mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 571bfcb213 | |||
| 6721444822 | |||
| ef1dc3407f | |||
| 1162f1e9f3 | |||
| 8d524e07f4 | |||
| f8ce56481f | |||
| 97d01e4b54 | |||
| 5980ce5e8d | |||
| 4cfbcde3de | |||
| c9ae34f225 | |||
| 0b30939b8f | |||
| 3e99bffe06 | |||
| 37da41da6c | |||
| b5a8a23b55 | |||
| 32888a90b3 | |||
| 50bf6a0ea1 | |||
| 3ea80830cf | |||
| d453dfb613 | |||
| bc2bf57908 | |||
| 18b28ce0cb | |||
| ce76c1381f | |||
| 91218e08f9 | |||
| 111b6819f0 | |||
| abc96e7424 | |||
| d6ef07e98d | |||
| 07cda5119f |
@@ -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@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 #v3.0.0
|
||||
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 #v3.1.0
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Enable auto-merge for minor and patch updates
|
||||
|
||||
@@ -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/DonutBrowser/logs/`
|
||||
- Windows: `%APPDATA%\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/DonutBrowser/logs/`
|
||||
- windows: `%APPDATA%\DonutBrowser\logs\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\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@a35b8a95c27d28e979a3826e1289d7ee87f40251 #v1.4.11
|
||||
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@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
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@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
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@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
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@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
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@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
Vendored
-1
@@ -191,7 +191,6 @@
|
||||
"osascript",
|
||||
"oscpu",
|
||||
"outpath",
|
||||
"OVPN",
|
||||
"pango",
|
||||
"passout",
|
||||
"patchelf",
|
||||
|
||||
@@ -26,7 +26,7 @@ donutbrowser/
|
||||
│ │ ├── api_server.rs # REST API (utoipa + axum)
|
||||
│ │ ├── mcp_server.rs # MCP protocol server
|
||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||
│ │ ├── vpn/ # WireGuard & OpenVPN tunnels
|
||||
│ │ ├── vpn/ # WireGuard tunnels
|
||||
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
|
||||
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
||||
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
|
||||
@@ -60,6 +60,16 @@ donutbrowser/
|
||||
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
|
||||
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
|
||||
|
||||
## Translations (mandatory)
|
||||
|
||||
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
|
||||
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
|
||||
- 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
|
||||
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
||||
|
||||
+128
@@ -1,6 +1,134 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
- chore: version bump
|
||||
- chore: i18n
|
||||
- chore: update flake.nix for v0.22.3 [skip ci] (#321)
|
||||
|
||||
|
||||
## v0.22.3 (2026-04-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- correct browser port mapping
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.22.2 [skip ci] (#315)
|
||||
|
||||
|
||||
## v0.22.2 (2026-04-27)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cookie management
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.22.1 [skip ci] (#313)
|
||||
|
||||
|
||||
## v0.22.1 (2026-04-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- link proper wayfern tos
|
||||
|
||||
### Refactoring
|
||||
|
||||
- vpn refresh and remove openvpn support
|
||||
|
||||
### Documentation
|
||||
|
||||
- update CHANGELOG.md and README.md for v0.22.0 [skip ci] (#306)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- chore: audit
|
||||
- chore: update flake.nix for v0.22.0 [skip ci] (#307)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(rust)(deps): bump the rust-dependencies group across 1 directory with 34 updates (#305)
|
||||
|
||||
|
||||
## v0.22.0 (2026-04-25)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- auth and wayfern
|
||||
- cdp gates cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: tests
|
||||
- chore:cargo audit
|
||||
- chore: version bump
|
||||
- chore: ignore .claude
|
||||
- chore: update flake.nix for v0.21.2 [skip ci] (#298)
|
||||
|
||||
|
||||
## v0.21.2 (2026-04-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
|
||||
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||
- **VPN support** — WireGuard and OpenVPN configs per profile
|
||||
- **VPN support** — WireGuard configs per profile
|
||||
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||
- **Profile groups** — organize profiles and apply bulk settings
|
||||
- **Import profiles** — migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -61,15 +61,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut-0.21.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut-0.21.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut-0.22.7-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut-0.22.7-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -160,6 +160,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>
|
||||
|
||||
@@ -4,7 +4,6 @@ extend-exclude = [
|
||||
"src-tauri/src/camoufox/data/*.xml",
|
||||
"src/i18n/locales/*.json",
|
||||
"src-tauri/build.rs",
|
||||
"src-tauri/tests/fixtures/test.ovpn",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
|
||||
@@ -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.21.2";
|
||||
releaseVersion = "0.22.7";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_amd64.AppImage";
|
||||
hash = "sha256-wHaH4CVKp7OkBQfohqA8+hU7jdYpvYj1DaqD1ow5yCg=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_amd64.AppImage";
|
||||
hash = "sha256-pnIiyXxCY/WxczM5IAjzCq+6C96oXOesmz27y78tJSI=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_aarch64.AppImage";
|
||||
hash = "sha256-OX3NyTKBYxoH4j+rmfhlNHmiTaQbrKCiFxtqODF/NKM=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_aarch64.AppImage";
|
||||
hash = "sha256-CyrujVE925Fr2G1U18PaklXCjKCDi+kOAkak7tZ8CW4=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+11
-8
@@ -2,14 +2,13 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "pnpm test:rust:unit && pnpm test:openvpn-e2e && pnpm test:sync-e2e",
|
||||
"test:openvpn-e2e": "node scripts/openvpn-test-harness.mjs",
|
||||
"test": "pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test:rust": "cd src-tauri && cargo test",
|
||||
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
|
||||
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
|
||||
@@ -17,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",
|
||||
@@ -46,7 +45,7 @@
|
||||
"@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/api": "~2.11.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "~2.5.0",
|
||||
@@ -76,7 +75,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tauri-apps/cli": "~2.10.1",
|
||||
"@tauri-apps/cli": "~2.11.0",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
@@ -92,10 +91,14 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0"
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
|
||||
"postcss@<8.5.10": ">=8.5.12",
|
||||
"fast-xml-parser@<5.7.0": ">=5.7.2",
|
||||
"fast-uri@<3.1.2": ">=3.1.2",
|
||||
"fast-xml-builder@<1.2.0": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
|
||||
Generated
+104
-107
@@ -7,6 +7,10 @@ settings:
|
||||
overrides:
|
||||
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||
postcss@<8.5.10: '>=8.5.12'
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
fast-uri@<3.1.2: '>=3.1.2'
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -52,8 +56,8 @@ importers:
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tauri-apps/api':
|
||||
specifier: ~2.10.1
|
||||
version: 2.10.1
|
||||
specifier: ~2.11.0
|
||||
version: 2.11.0
|
||||
'@tauri-apps/plugin-deep-link':
|
||||
specifier: ^2.4.7
|
||||
version: 2.4.7
|
||||
@@ -137,8 +141,8 @@ importers:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
'@tauri-apps/cli':
|
||||
specifier: ~2.10.1
|
||||
version: 2.10.1
|
||||
specifier: ~2.11.0
|
||||
version: 2.11.0
|
||||
'@types/color':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
@@ -1425,6 +1429,9 @@ packages:
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@nodable/entities@2.1.0':
|
||||
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
|
||||
|
||||
'@nuxt/opencollective@0.4.1':
|
||||
resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'}
|
||||
@@ -2673,82 +2680,82 @@ packages:
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tauri-apps/api@2.10.1':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
'@tauri-apps/api@2.11.0':
|
||||
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
|
||||
'@tauri-apps/cli-darwin-arm64@2.11.0':
|
||||
resolution: {integrity: sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.10.1':
|
||||
resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==}
|
||||
'@tauri-apps/cli-darwin-x64@2.11.0':
|
||||
resolution: {integrity: sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
|
||||
resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==}
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.0':
|
||||
resolution: {integrity: sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==}
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.11.0':
|
||||
resolution: {integrity: sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.11.0':
|
||||
resolution: {integrity: sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.11.0':
|
||||
resolution: {integrity: sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.0':
|
||||
resolution: {integrity: sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
|
||||
'@tauri-apps/cli-linux-x64-musl@2.11.0':
|
||||
resolution: {integrity: sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.11.0':
|
||||
resolution: {integrity: sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==}
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.11.0':
|
||||
resolution: {integrity: sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==}
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.11.0':
|
||||
resolution: {integrity: sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli@2.10.1':
|
||||
resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==}
|
||||
'@tauri-apps/cli@2.11.0':
|
||||
resolution: {integrity: sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3779,14 +3786,14 @@ packages:
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
|
||||
fast-xml-builder@1.1.4:
|
||||
resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
|
||||
fast-xml-builder@1.2.0:
|
||||
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
|
||||
|
||||
fast-xml-parser@5.5.8:
|
||||
resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==}
|
||||
fast-xml-parser@5.7.2:
|
||||
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
|
||||
hasBin: true
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
@@ -4690,8 +4697,8 @@ packages:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-expression-matcher@1.2.1:
|
||||
resolution: {integrity: sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==}
|
||||
path-expression-matcher@1.5.0:
|
||||
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
path-is-absolute@1.0.1:
|
||||
@@ -4740,16 +4747,8 @@ packages:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.8:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.9:
|
||||
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
|
||||
postcss@8.5.12:
|
||||
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
pretty-format@30.3.0:
|
||||
@@ -5131,8 +5130,8 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strnum@2.2.2:
|
||||
resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==}
|
||||
strnum@2.2.3:
|
||||
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
|
||||
|
||||
strtok3@10.3.5:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
@@ -5528,6 +5527,10 @@ packages:
|
||||
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
xml-naming@0.1.0:
|
||||
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6075,7 +6078,7 @@ snapshots:
|
||||
'@aws-sdk/xml-builder@3.972.16':
|
||||
dependencies:
|
||||
'@smithy/types': 4.13.1
|
||||
fast-xml-parser: 5.5.8
|
||||
fast-xml-parser: 5.7.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/lambda-invoke-store@0.2.4': {}
|
||||
@@ -7027,6 +7030,8 @@ snapshots:
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodable/entities@2.1.0': {}
|
||||
|
||||
'@nuxt/opencollective@0.4.1':
|
||||
dependencies:
|
||||
consola: 3.4.2
|
||||
@@ -8333,7 +8338,7 @@ snapshots:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.2.2
|
||||
'@tailwindcss/oxide': 4.2.2
|
||||
postcss: 8.5.8
|
||||
postcss: 8.5.12
|
||||
tailwindcss: 4.2.2
|
||||
|
||||
'@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
@@ -8344,74 +8349,74 @@ snapshots:
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tauri-apps/api@2.10.1': {}
|
||||
'@tauri-apps/api@2.11.0': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||
'@tauri-apps/cli-darwin-arm64@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.10.1':
|
||||
'@tauri-apps/cli-darwin-x64@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.10.1':
|
||||
'@tauri-apps/cli-linux-x64-musl@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli@2.10.1':
|
||||
'@tauri-apps/cli@2.11.0':
|
||||
optionalDependencies:
|
||||
'@tauri-apps/cli-darwin-arm64': 2.10.1
|
||||
'@tauri-apps/cli-darwin-x64': 2.10.1
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.10.1
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.10.1
|
||||
'@tauri-apps/cli-linux-riscv64-gnu': 2.10.1
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.10.1
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.10.1
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||
'@tauri-apps/cli-darwin-arm64': 2.11.0
|
||||
'@tauri-apps/cli-darwin-x64': 2.11.0
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.0
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.11.0
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.11.0
|
||||
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.0
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.11.0
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.11.0
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.11.0
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.0
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-deep-link@2.4.7':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.7.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-fs@2.5.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-log@2.8.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-opener@2.5.3':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
@@ -8808,7 +8813,7 @@ snapshots:
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
fast-uri: 3.1.2
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
@@ -9423,17 +9428,19 @@ snapshots:
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
fast-uri@3.1.2: {}
|
||||
|
||||
fast-xml-builder@1.1.4:
|
||||
fast-xml-builder@1.2.0:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.2.1
|
||||
path-expression-matcher: 1.5.0
|
||||
xml-naming: 0.1.0
|
||||
|
||||
fast-xml-parser@5.5.8:
|
||||
fast-xml-parser@5.7.2:
|
||||
dependencies:
|
||||
fast-xml-builder: 1.1.4
|
||||
path-expression-matcher: 1.2.1
|
||||
strnum: 2.2.2
|
||||
'@nodable/entities': 2.1.0
|
||||
fast-xml-builder: 1.2.0
|
||||
path-expression-matcher: 1.5.0
|
||||
strnum: 2.2.3
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
dependencies:
|
||||
@@ -10360,7 +10367,7 @@ snapshots:
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.10.17
|
||||
caniuse-lite: 1.0.30001787
|
||||
postcss: 8.4.31
|
||||
postcss: 8.5.12
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(react@19.2.4)
|
||||
@@ -10457,7 +10464,7 @@ snapshots:
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-expression-matcher@1.2.1: {}
|
||||
path-expression-matcher@1.5.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
|
||||
@@ -10491,19 +10498,7 @@ snapshots:
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.8:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.9:
|
||||
postcss@8.5.12:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
@@ -10992,7 +10987,7 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strnum@2.2.2: {}
|
||||
strnum@2.2.3: {}
|
||||
|
||||
strtok3@10.3.5:
|
||||
dependencies:
|
||||
@@ -11049,7 +11044,7 @@ snapshots:
|
||||
|
||||
tauri-plugin-macos-permissions-api@2.3.0:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
terser-webpack-plugin@5.4.0(webpack@5.105.4):
|
||||
dependencies:
|
||||
@@ -11298,7 +11293,7 @@ snapshots:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.9
|
||||
postcss: 8.5.12
|
||||
rollup: 4.60.1
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
@@ -11397,6 +11392,8 @@ snapshots:
|
||||
imurmurhash: 0.1.4
|
||||
signal-exit: 4.1.0
|
||||
|
||||
xml-naming@0.1.0: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OpenVPN E2E Test Harness
|
||||
*
|
||||
* This script:
|
||||
* 1. Skips unless explicitly enabled via DONUTBROWSER_RUN_OPENVPN_E2E=1
|
||||
* 2. Builds the Rust vpn_integration test binary without running it
|
||||
* 3. Runs the OpenVPN e2e test binary under sudo
|
||||
*
|
||||
* Usage: DONUTBROWSER_RUN_OPENVPN_E2E=1 node scripts/openvpn-test-harness.mjs
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||
const SRC_TAURI_DIR = path.join(ROOT_DIR, "src-tauri");
|
||||
const TEST_NAME = "test_openvpn_traffic_flows_through_donut_proxy";
|
||||
|
||||
function log(message) {
|
||||
console.log(`[openvpn-harness] ${message}`);
|
||||
}
|
||||
|
||||
function error(message) {
|
||||
console.error(`[openvpn-harness] ERROR: ${message}`);
|
||||
}
|
||||
|
||||
function shouldRun() {
|
||||
if (process.env.DONUTBROWSER_RUN_OPENVPN_E2E !== "1") {
|
||||
log("Skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.platform !== "linux") {
|
||||
log(`Skipping OpenVPN e2e test on unsupported platform: ${process.platform}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function buildTestBinary() {
|
||||
log("Building OpenVPN e2e test binary...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let executablePath = "";
|
||||
let stdoutBuffer = "";
|
||||
|
||||
const proc = spawn(
|
||||
"cargo",
|
||||
[
|
||||
"test",
|
||||
"--test",
|
||||
"vpn_integration",
|
||||
TEST_NAME,
|
||||
"--no-run",
|
||||
"--message-format=json",
|
||||
],
|
||||
{
|
||||
cwd: SRC_TAURI_DIR,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
|
||||
const parseBuffer = (flush = false) => {
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
const completeLines = flush ? lines : lines.slice(0, -1);
|
||||
stdoutBuffer = flush ? "" : lines.at(-1) ?? "";
|
||||
|
||||
for (const line of completeLines.filter(Boolean)) {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (message.reason === "compiler-artifact" && message.executable) {
|
||||
executablePath = message.executable;
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON lines.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdoutBuffer += data.toString();
|
||||
parseBuffer();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
parseBuffer(true);
|
||||
|
||||
if (code !== 0) {
|
||||
reject(new Error(`cargo test --no-run exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!executablePath) {
|
||||
reject(new Error("Could not determine the vpn_integration test binary path"));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(path.isAbsolute(executablePath) ? executablePath : path.resolve(SRC_TAURI_DIR, executablePath));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runOpenVpnE2e(executablePath) {
|
||||
log("Running OpenVPN e2e test under sudo...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"sudo",
|
||||
[
|
||||
"--preserve-env=CI,GITHUB_ACTIONS,VPN_TEST_OVPN_HOST,VPN_TEST_OVPN_PORT,DONUTBROWSER_RUN_OPENVPN_E2E",
|
||||
executablePath,
|
||||
TEST_NAME,
|
||||
"--exact",
|
||||
"--nocapture",
|
||||
],
|
||||
{
|
||||
cwd: SRC_TAURI_DIR,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
}
|
||||
);
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!shouldRun()) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const executablePath = await buildTestBinary();
|
||||
const exitCode = await runOpenVpnE2e(executablePath);
|
||||
process.exit(exitCode);
|
||||
} catch (err) {
|
||||
error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
// Wrapper that loads `.env` into process.env (without overwriting anything
|
||||
// already in the environment) and execs the given command. Used by the
|
||||
// `tauri` npm script so `pnpm tauri build` picks up APPLE_SIGNING_IDENTITY,
|
||||
// APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. without requiring direnv.
|
||||
//
|
||||
// Plain shell `source .env` works on macOS/Linux but not Windows; this
|
||||
// wrapper is platform-agnostic.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const envPath = resolve(projectRoot, ".env");
|
||||
|
||||
if (existsSync(envPath)) {
|
||||
const content = readFileSync(envPath, "utf8");
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let val = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
// Don't overwrite values already exported by the parent shell — direnv
|
||||
// / CI secrets / one-off `FOO=bar pnpm tauri ...` invocations win.
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [, , cmd, ...args] = process.argv;
|
||||
if (!cmd) {
|
||||
console.error("usage: run-with-env.mjs <command> [args...]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, { stdio: "inherit", shell: false });
|
||||
child.on("error", (err) => {
|
||||
console.error(`Failed to spawn ${cmd}:`, err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
} else {
|
||||
process.exit(code ?? 1);
|
||||
}
|
||||
});
|
||||
Generated
+322
-803
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.22.0"
|
||||
version = "0.22.7"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -51,7 +51,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"
|
||||
@@ -73,7 +73,7 @@ once_cell = "1"
|
||||
urlencoding = "2.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
axum = { version = "0.8.8", features = ["ws"] }
|
||||
axum = { version = "0.8.9", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
rand = "0.10.1"
|
||||
@@ -102,7 +102,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,8 +110,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"
|
||||
muda = "0.17"
|
||||
tray-icon = "0.24"
|
||||
tao = "0.35"
|
||||
image = "0.25"
|
||||
dirs = "6"
|
||||
|
||||
+334
-3
@@ -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)]
|
||||
@@ -130,6 +133,49 @@ struct UpdateProxyRequest {
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
struct ApiVpnResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
/// Always "WireGuard"
|
||||
vpn_type: String,
|
||||
created_at: i64,
|
||||
last_used: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct ApiVpnExportResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
/// Always "WireGuard"
|
||||
vpn_type: String,
|
||||
/// Raw `.conf` file content (decrypted)
|
||||
config_data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct ImportVpnRequest {
|
||||
/// Raw WireGuard `.conf` file content
|
||||
content: String,
|
||||
/// Original filename
|
||||
filename: String,
|
||||
/// Optional display name; defaults to filename-based name
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct CreateVpnRequest {
|
||||
name: String,
|
||||
/// Must be "WireGuard"
|
||||
vpn_type: String,
|
||||
config_data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct UpdateVpnRequest {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct DownloadBrowserRequest {
|
||||
browser: String,
|
||||
@@ -191,6 +237,12 @@ struct OpenUrlRequest {
|
||||
create_proxy,
|
||||
update_proxy,
|
||||
delete_proxy,
|
||||
get_vpns,
|
||||
get_vpn,
|
||||
import_vpn,
|
||||
create_vpn,
|
||||
update_vpn,
|
||||
delete_vpn,
|
||||
download_browser_api,
|
||||
get_browser_versions,
|
||||
check_browser_downloaded,
|
||||
@@ -207,6 +259,10 @@ struct OpenUrlRequest {
|
||||
ApiProxyResponse,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
ApiVpnResponse,
|
||||
ImportVpnRequest,
|
||||
CreateVpnRequest,
|
||||
UpdateVpnRequest,
|
||||
DownloadBrowserRequest,
|
||||
DownloadBrowserResponse,
|
||||
RunProfileResponse,
|
||||
@@ -219,6 +275,7 @@ struct OpenUrlRequest {
|
||||
(name = "groups", description = "Group management endpoints"),
|
||||
(name = "tags", description = "Tag management endpoints"),
|
||||
(name = "proxies", description = "Proxy management endpoints"),
|
||||
(name = "vpns", description = "VPN management endpoints"),
|
||||
(name = "browsers", description = "Browser management endpoints"),
|
||||
),
|
||||
modifiers(&SecurityAddon),
|
||||
@@ -311,6 +368,10 @@ impl ApiServer {
|
||||
.routes(routes!(get_tags))
|
||||
.routes(routes!(get_proxies, create_proxy))
|
||||
.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))
|
||||
.routes(routes!(get_extension_groups))
|
||||
@@ -495,6 +556,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();
|
||||
|
||||
@@ -551,6 +613,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 {
|
||||
@@ -605,7 +668,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(),
|
||||
@@ -653,6 +716,7 @@ async fn create_profile(
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules,
|
||||
vpn_id: profile.vpn_id,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -686,6 +750,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
|
||||
@@ -715,6 +785,21 @@ async fn update_profile(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(vpn_id) = request.vpn_id {
|
||||
let normalized = if vpn_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(vpn_id)
|
||||
};
|
||||
if profile_manager
|
||||
.update_profile_vpn(state.app_handle.clone(), &id, normalized)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(launch_hook) = request.launch_hook {
|
||||
let normalized = if launch_hook.trim().is_empty() {
|
||||
None
|
||||
@@ -1189,6 +1274,243 @@ async fn delete_proxy(
|
||||
}
|
||||
}
|
||||
|
||||
// API Handlers - VPNs
|
||||
|
||||
fn vpn_to_api_response(c: &crate::vpn::VpnConfig) -> ApiVpnResponse {
|
||||
ApiVpnResponse {
|
||||
id: c.id.clone(),
|
||||
name: c.name.clone(),
|
||||
vpn_type: c.vpn_type.to_string(),
|
||||
created_at: c.created_at,
|
||||
last_used: c.last_used,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_vpn_type(s: &str) -> Option<crate::vpn::VpnType> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"wireguard" | "wg" => Some(crate::vpn::VpnType::WireGuard),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/vpns",
|
||||
responses(
|
||||
(status = 200, description = "List of all VPN configurations", body = Vec<ApiVpnResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn get_vpns(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<ApiVpnResponse>>, StatusCode> {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let configs = storage
|
||||
.list_configs()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(configs.iter().map(vpn_to_api_response).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/vpns/{id}",
|
||||
params(("id" = String, Path, description = "VPN configuration ID")),
|
||||
responses(
|
||||
(status = 200, description = "VPN configuration details", body = ApiVpnResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "VPN configuration not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn get_vpn(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let configs = storage
|
||||
.list_configs()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
configs
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| Json(vpn_to_api_response(c)))
|
||||
.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",
|
||||
request_body = ImportVpnRequest,
|
||||
responses(
|
||||
(status = 200, description = "VPN configuration imported successfully", body = ApiVpnResponse),
|
||||
(status = 400, description = "Invalid or unrecognized VPN config"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn import_vpn(
|
||||
State(_state): State<ApiServerState>,
|
||||
Json(request): Json<ImportVpnRequest>,
|
||||
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
||||
let result = {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
storage.import_config(&request.content, &request.filename, request.name)
|
||||
};
|
||||
match result {
|
||||
Ok(config) => {
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
Ok(Json(vpn_to_api_response(&config)))
|
||||
}
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/vpns",
|
||||
request_body = CreateVpnRequest,
|
||||
responses(
|
||||
(status = 200, description = "VPN configuration created successfully", body = ApiVpnResponse),
|
||||
(status = 400, description = "Invalid VPN config or unknown vpn_type"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn create_vpn(
|
||||
State(_state): State<ApiServerState>,
|
||||
Json(request): Json<CreateVpnRequest>,
|
||||
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
||||
let vpn_type = parse_vpn_type(&request.vpn_type).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let result = {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
storage.create_config_manual(&request.name, vpn_type, &request.config_data)
|
||||
};
|
||||
match result {
|
||||
Ok(config) => {
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
Ok(Json(vpn_to_api_response(&config)))
|
||||
}
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/v1/vpns/{id}",
|
||||
params(("id" = String, Path, description = "VPN configuration ID")),
|
||||
request_body = UpdateVpnRequest,
|
||||
responses(
|
||||
(status = 200, description = "VPN configuration updated successfully", body = ApiVpnResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "VPN configuration not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn update_vpn(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateVpnRequest>,
|
||||
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
||||
let result = {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
storage.update_config_name(&id, &request.name)
|
||||
};
|
||||
match result {
|
||||
Ok(config) => {
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
Ok(Json(vpn_to_api_response(&config)))
|
||||
}
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/v1/vpns/{id}",
|
||||
params(("id" = String, Path, description = "VPN configuration ID")),
|
||||
responses(
|
||||
(status = 204, description = "VPN configuration deleted successfully"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "VPN configuration not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn delete_vpn(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(&id).await;
|
||||
|
||||
let result = {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
storage.delete_config(&id)
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
// Extension API endpoints
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -1331,8 +1653,17 @@ async fn run_profile(
|
||||
.await
|
||||
.map_err(|_| StatusCode::CONFLICT)?;
|
||||
|
||||
// Generate a random port for remote debugging
|
||||
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
|
||||
let remote_debugging_port = {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.port();
|
||||
drop(listener);
|
||||
port
|
||||
};
|
||||
|
||||
// Use the same launch method as the main app, but with remote debugging enabled
|
||||
match crate::browser_runner::launch_browser_profile_with_debugging(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,11 +11,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use muda::MenuEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tao::event::{Event, StartCause};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use tokio::runtime::Runtime;
|
||||
use tray_icon::menu::MenuEvent;
|
||||
use tray_icon::TrayIcon;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use tray_icon::{MouseButton, TrayIconEvent};
|
||||
|
||||
@@ -490,23 +490,10 @@ async fn main() {
|
||||
|
||||
let server =
|
||||
donutbrowser_lib::vpn::socks5_server::WireGuardSocks5Server::new(wg_config, port);
|
||||
if let Err(e) = server.run(id.clone()).await {
|
||||
log::error!("VPN worker failed: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
"openvpn" => {
|
||||
let ovpn_config = match donutbrowser_lib::vpn::parse_openvpn_config(&vpn_config_data) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse OpenVPN config: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let server =
|
||||
donutbrowser_lib::vpn::openvpn_socks5::OpenVpnSocks5Server::new(ovpn_config, port);
|
||||
if let Err(e) = server.run(id.clone()).await {
|
||||
if let Err(e) = server
|
||||
.run(id.clone(), config_path.map(std::path::PathBuf::from))
|
||||
.await
|
||||
{
|
||||
log::error!("VPN worker failed: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -50,20 +50,6 @@ pub mod chrome_decrypt {
|
||||
key
|
||||
}
|
||||
|
||||
/// Get the encryption key for Chrome cookies.
|
||||
///
|
||||
/// Wayfern stores `os_crypt_key` as a plain file inside the profile's
|
||||
/// user-data-dir on all platforms (see the wayfern patches for
|
||||
/// `os_crypt_mac.mm` and `os_crypt_linux.cc`). The file contains a
|
||||
/// base64-encoded 128-bit random value that is used as the PBKDF2
|
||||
/// password — not as the raw AES key — matching Chromium's
|
||||
/// `OSCryptImpl::DeriveKey` flow.
|
||||
///
|
||||
/// If the file is missing we return `None`. We must NEVER fall back to the
|
||||
/// real macOS Keychain or any other system credential store. Wayfern
|
||||
/// profiles are fully self-contained and reaching into another app's entry
|
||||
/// would trigger the macOS "confidential information stored in …" prompt
|
||||
/// and the "prevented from modifying other apps" warning.
|
||||
pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> {
|
||||
let key_file = profile_data_path.join("os_crypt_key");
|
||||
// Read as raw bytes and do NOT trim — Chromium's `ReadFileToString`
|
||||
@@ -186,32 +172,34 @@ impl CookieManager {
|
||||
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
|
||||
const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
|
||||
|
||||
/// Get the Chrome cookie encryption key for a Wayfern profile
|
||||
fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> {
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
chrome_decrypt::get_encryption_key(&profile_data_path)
|
||||
}
|
||||
|
||||
fn wayfern_cookie_path(profile_data_path: &Path) -> PathBuf {
|
||||
let default_dir = profile_data_path.join("Default");
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
default_dir.join("Network").join("Cookies")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
default_dir.join("Cookies")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the cookie database path for a profile (read-side: errors if missing).
|
||||
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
|
||||
match profile.browser.as_str() {
|
||||
"wayfern" => {
|
||||
let network_path = profile_data_path
|
||||
.join("Default")
|
||||
.join("Network")
|
||||
.join("Cookies");
|
||||
let legacy_path = profile_data_path.join("Default").join("Cookies");
|
||||
if network_path.exists() {
|
||||
Ok(network_path)
|
||||
} else if legacy_path.exists() {
|
||||
Ok(legacy_path)
|
||||
let path = Self::wayfern_cookie_path(&profile_data_path);
|
||||
if path.exists() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Cookie database not found at: {}",
|
||||
network_path.display()
|
||||
))
|
||||
Err(format!("Cookie database not found at: {}", path.display()))
|
||||
}
|
||||
}
|
||||
"camoufox" => {
|
||||
@@ -241,21 +229,11 @@ impl CookieManager {
|
||||
|
||||
match profile.browser.as_str() {
|
||||
"wayfern" => {
|
||||
let network_path = profile_data_path
|
||||
.join("Default")
|
||||
.join("Network")
|
||||
.join("Cookies");
|
||||
let legacy_path = profile_data_path.join("Default").join("Cookies");
|
||||
if network_path.exists() {
|
||||
Ok(network_path)
|
||||
} else if legacy_path.exists() {
|
||||
Ok(legacy_path)
|
||||
} else {
|
||||
let dir = network_path.parent().unwrap();
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create Network dir: {e}"))?;
|
||||
Self::create_empty_chrome_cookies_db(&network_path)?;
|
||||
Ok(network_path)
|
||||
let path = Self::wayfern_cookie_path(&profile_data_path);
|
||||
if !path.exists() {
|
||||
Self::create_empty_chrome_cookies_db(&path)?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
"camoufox" => {
|
||||
let path = profile_data_path.join("cookies.sqlite");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use muda::{Menu, MenuItem};
|
||||
use std::process::Command;
|
||||
use tray_icon::menu::{Menu, MenuItem};
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
|
||||
pub fn load_icon() -> Icon {
|
||||
|
||||
+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")
|
||||
|
||||
+31
-52
@@ -675,11 +675,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"))
|
||||
@@ -769,42 +775,6 @@ async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), Str
|
||||
}
|
||||
|
||||
// VPN commands
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VpnDependencyStatus {
|
||||
is_available: bool,
|
||||
requires_external_install: bool,
|
||||
missing_binary: bool,
|
||||
missing_windows_adapter: bool,
|
||||
dependency_check_failed: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_vpn_dependency_status(vpn_type: vpn::VpnType) -> Result<VpnDependencyStatus, String> {
|
||||
match vpn_type {
|
||||
vpn::VpnType::WireGuard => Ok(VpnDependencyStatus {
|
||||
is_available: true,
|
||||
requires_external_install: false,
|
||||
missing_binary: false,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: false,
|
||||
}),
|
||||
vpn::VpnType::OpenVPN => {
|
||||
let status = crate::vpn::openvpn_socks5::OpenVpnSocks5Server::dependency_status();
|
||||
let is_available =
|
||||
status.binary_found && !status.missing_windows_adapter && !status.dependency_check_failed;
|
||||
|
||||
Ok(VpnDependencyStatus {
|
||||
is_available,
|
||||
requires_external_install: true,
|
||||
missing_binary: !status.binary_found,
|
||||
missing_windows_adapter: status.missing_windows_adapter,
|
||||
dependency_check_failed: status.dependency_check_failed,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_vpn_config(
|
||||
content: String,
|
||||
@@ -1268,7 +1238,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(840.0, 500.0)
|
||||
.resizable(false)
|
||||
.fullscreen(false)
|
||||
.center()
|
||||
@@ -1918,21 +1888,31 @@ pub fn run() {
|
||||
// Start cloud auth background refresh loop
|
||||
let app_handle_cloud = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// On startup, refresh sync token and proxy if cloud auth is active.
|
||||
// On startup, refresh sync token, proxy config, and wayfern token in
|
||||
// PARALLEL. Previously they were awaited sequentially, so the wayfern
|
||||
// token request didn't even start until the earlier two API calls had
|
||||
// finished. Wayfern launch can race with this task — a few seconds of
|
||||
// serialized API calls translates directly into a slow first launch
|
||||
// because launch_wayfern blocks waiting for the token to land.
|
||||
// api_call_with_retry handles 401/refresh internally — no direct
|
||||
// refresh_access_token call needed.
|
||||
if cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
|
||||
log::warn!("Failed to refresh cloud sync token on startup: {e}");
|
||||
}
|
||||
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Request wayfern token on startup for paid users
|
||||
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to request wayfern token on startup: {e}");
|
||||
let sync_token_fut = async {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
|
||||
log::warn!("Failed to refresh cloud sync token on startup: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
let proxy_fut = async {
|
||||
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
};
|
||||
let wayfern_fut = async {
|
||||
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to request wayfern token on startup: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
|
||||
}
|
||||
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
|
||||
});
|
||||
@@ -2075,7 +2055,6 @@ pub fn run() {
|
||||
add_mcp_to_claude_code,
|
||||
remove_mcp_from_claude_code,
|
||||
// VPN commands
|
||||
get_vpn_dependency_status,
|
||||
import_vpn_config,
|
||||
list_vpn_configs,
|
||||
get_vpn_config,
|
||||
|
||||
@@ -848,17 +848,17 @@ impl McpServer {
|
||||
// VPN management tools
|
||||
McpTool {
|
||||
name: "import_vpn".to_string(),
|
||||
description: "Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration".to_string(),
|
||||
description: "Import a WireGuard (.conf) configuration".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Raw VPN config file content"
|
||||
"description": "Raw WireGuard config file content"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Original filename (.conf or .ovpn) for type detection"
|
||||
"description": "Original filename (.conf)"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
|
||||
@@ -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
|
||||
@@ -2095,17 +2100,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!(
|
||||
|
||||
@@ -16,7 +16,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// Combined read+write trait for tunnel target streams, allowing
|
||||
@@ -1232,8 +1231,49 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
|
||||
log::error!("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);
|
||||
@@ -1295,52 +1335,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:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+13
-197
@@ -11,8 +11,6 @@ pub enum VpnError {
|
||||
UnknownFormat,
|
||||
#[error("Invalid WireGuard config: {0}")]
|
||||
InvalidWireGuard(String),
|
||||
#[error("Invalid OpenVPN config: {0}")]
|
||||
InvalidOpenVpn(String),
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
#[error("Connection error: {0}")]
|
||||
@@ -31,14 +29,12 @@ pub enum VpnError {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VpnType {
|
||||
WireGuard,
|
||||
OpenVPN,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VpnType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VpnType::WireGuard => write!(f, "WireGuard"),
|
||||
VpnType::OpenVPN => write!(f, "OpenVPN"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,19 +68,6 @@ pub struct WireGuardConfig {
|
||||
pub preshared_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed OpenVPN configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenVpnConfig {
|
||||
pub raw_config: String,
|
||||
pub remote_host: String,
|
||||
pub remote_port: u16,
|
||||
pub protocol: String, // "udp" or "tcp"
|
||||
pub dev_type: String, // "tun" or "tap"
|
||||
pub has_inline_ca: bool,
|
||||
pub has_inline_cert: bool,
|
||||
pub has_inline_key: bool,
|
||||
}
|
||||
|
||||
/// Result of importing a VPN configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnImportResult {
|
||||
@@ -110,26 +93,16 @@ pub struct VpnStatus {
|
||||
pub fn detect_vpn_type(content: &str, filename: &str) -> Result<VpnType, VpnError> {
|
||||
let filename_lower = filename.to_lowercase();
|
||||
|
||||
// Check file extension first
|
||||
if filename_lower.ends_with(".conf") {
|
||||
// .conf could be WireGuard - check content
|
||||
if content.contains("[Interface]") && content.contains("[Peer]") {
|
||||
return Ok(VpnType::WireGuard);
|
||||
}
|
||||
}
|
||||
|
||||
if filename_lower.ends_with(".ovpn") {
|
||||
return Ok(VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
// Check content patterns
|
||||
if content.contains("[Interface]") && content.contains("PrivateKey") && content.contains("[Peer]")
|
||||
if filename_lower.ends_with(".conf")
|
||||
&& content.contains("[Interface]")
|
||||
&& content.contains("[Peer]")
|
||||
{
|
||||
return Ok(VpnType::WireGuard);
|
||||
}
|
||||
|
||||
if content.contains("remote ") && (content.contains("client") || content.contains("dev tun")) {
|
||||
return Ok(VpnType::OpenVPN);
|
||||
if content.contains("[Interface]") && content.contains("PrivateKey") && content.contains("[Peer]")
|
||||
{
|
||||
return Ok(VpnType::WireGuard);
|
||||
}
|
||||
|
||||
Err(VpnError::UnknownFormat)
|
||||
@@ -254,75 +227,6 @@ fn validate_wireguard_key(key: &str, field: &str) -> Result<(), VpnError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an OpenVPN configuration file
|
||||
pub fn parse_openvpn_config(content: &str) -> Result<OpenVpnConfig, VpnError> {
|
||||
let mut remote_host = String::new();
|
||||
let mut remote_port: u16 = 1194; // Default OpenVPN port
|
||||
let mut protocol = "udp".to_string();
|
||||
let mut dev_type = "tun".to_string();
|
||||
|
||||
let has_inline_ca = content.contains("<ca>") && content.contains("</ca>");
|
||||
let has_inline_cert = content.contains("<cert>") && content.contains("</cert>");
|
||||
let has_inline_key = content.contains("<key>") && content.contains("</key>");
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match parts[0] {
|
||||
"remote" => {
|
||||
if parts.len() >= 2 {
|
||||
remote_host = parts[1].to_string();
|
||||
}
|
||||
if let Some(port) = parts.get(2).and_then(|p| p.parse().ok()) {
|
||||
remote_port = port;
|
||||
}
|
||||
if parts.len() >= 4 {
|
||||
protocol = parts[3].to_string();
|
||||
}
|
||||
}
|
||||
"proto" if parts.len() >= 2 => {
|
||||
protocol = parts[1].to_string();
|
||||
}
|
||||
"port" => {
|
||||
if let Some(port) = parts.get(1).and_then(|p| p.parse().ok()) {
|
||||
remote_port = port;
|
||||
}
|
||||
}
|
||||
"dev" if parts.len() >= 2 => {
|
||||
dev_type = parts[1].to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if remote_host.is_empty() {
|
||||
return Err(VpnError::InvalidOpenVpn(
|
||||
"Missing 'remote' directive".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(OpenVpnConfig {
|
||||
raw_config: content.to_string(),
|
||||
remote_host,
|
||||
remote_port,
|
||||
protocol,
|
||||
dev_type,
|
||||
has_inline_ca,
|
||||
has_inline_cert,
|
||||
has_inline_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -336,15 +240,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_openvpn_by_extension() {
|
||||
let content = "client\nremote vpn.example.com 1194";
|
||||
assert_eq!(
|
||||
detect_vpn_type(content, "test.ovpn").unwrap(),
|
||||
VpnType::OpenVPN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_wireguard_by_content() {
|
||||
let content = "[Interface]\nPrivateKey = testkey123\nAddress = 10.0.0.2/24\n\n[Peer]\nPublicKey = peerkey456\nEndpoint = vpn.example.com:51820";
|
||||
@@ -354,21 +249,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_openvpn_by_content() {
|
||||
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
|
||||
assert_eq!(
|
||||
detect_vpn_type(content, "config").unwrap(),
|
||||
VpnType::OpenVPN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_unknown_format() {
|
||||
let content = "random text that is not a vpn config";
|
||||
assert!(detect_vpn_type(content, "random.txt").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_openvpn_content() {
|
||||
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
|
||||
assert!(detect_vpn_type(content, "test.ovpn").is_err());
|
||||
assert!(detect_vpn_type(content, "config").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wireguard_config() {
|
||||
let content = r#"
|
||||
@@ -444,81 +337,4 @@ Endpoint = 1.2.3.4:51820
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("PrivateKey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_config() {
|
||||
let content = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote vpn.example.com 1194
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
<ca>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...certificate data...
|
||||
-----END CERTIFICATE-----
|
||||
</ca>
|
||||
<cert>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...cert data...
|
||||
-----END CERTIFICATE-----
|
||||
</cert>
|
||||
<key>
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
...key data...
|
||||
-----END PRIVATE KEY-----
|
||||
</key>
|
||||
"#;
|
||||
|
||||
let config = parse_openvpn_config(content).unwrap();
|
||||
assert_eq!(config.remote_host, "vpn.example.com");
|
||||
assert_eq!(config.remote_port, 1194);
|
||||
assert_eq!(config.protocol, "udp");
|
||||
assert_eq!(config.dev_type, "tun");
|
||||
assert!(config.has_inline_ca);
|
||||
assert!(config.has_inline_cert);
|
||||
assert!(config.has_inline_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_config_minimal() {
|
||||
let content = r#"
|
||||
client
|
||||
remote vpn.example.com
|
||||
"#;
|
||||
|
||||
let config = parse_openvpn_config(content).unwrap();
|
||||
assert_eq!(config.remote_host, "vpn.example.com");
|
||||
assert_eq!(config.remote_port, 1194); // Default
|
||||
assert_eq!(config.protocol, "udp"); // Default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_config_with_port_and_proto() {
|
||||
let content = r#"
|
||||
client
|
||||
remote vpn.example.com 443 tcp
|
||||
"#;
|
||||
|
||||
let config = parse_openvpn_config(content).unwrap();
|
||||
assert_eq!(config.remote_host, "vpn.example.com");
|
||||
assert_eq!(config.remote_port, 443);
|
||||
assert_eq!(config.protocol, "tcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_missing_remote() {
|
||||
let content = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
"#;
|
||||
|
||||
let result = parse_openvpn_config(content);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("remote"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
//! VPN support module for WireGuard and OpenVPN configurations.
|
||||
//! VPN support module for WireGuard configurations.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - VPN config parsing (WireGuard .conf and OpenVPN .ovpn files)
|
||||
//! - WireGuard config parsing (`.conf` files)
|
||||
//! - Encrypted storage for VPN configurations
|
||||
//! - Tunnel management with userspace WireGuard (boringtun) and OpenVPN process management
|
||||
//! - Tunnel management with userspace WireGuard (boringtun) routed through smoltcp
|
||||
|
||||
mod config;
|
||||
mod openvpn;
|
||||
pub mod openvpn_socks5;
|
||||
pub mod socks5_server;
|
||||
mod storage;
|
||||
mod tunnel;
|
||||
mod wireguard;
|
||||
|
||||
pub use config::{
|
||||
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
|
||||
VpnError, VpnImportResult, VpnStatus, VpnType, WireGuardConfig,
|
||||
detect_vpn_type, parse_wireguard_config, VpnConfig, VpnError, VpnImportResult, VpnStatus,
|
||||
VpnType, WireGuardConfig,
|
||||
};
|
||||
pub use openvpn::OpenVpnTunnel;
|
||||
pub use storage::VpnStorage;
|
||||
pub use tunnel::{TunnelManager, VpnTunnel};
|
||||
pub use wireguard::WireGuardTunnel;
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
//! OpenVPN tunnel implementation using system openvpn binary.
|
||||
|
||||
use super::config::{OpenVpnConfig, VpnError, VpnStatus};
|
||||
use super::tunnel::VpnTunnel;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// OpenVPN tunnel implementation
|
||||
pub struct OpenVpnTunnel {
|
||||
vpn_id: String,
|
||||
config: OpenVpnConfig,
|
||||
process: Arc<Mutex<Option<Child>>>,
|
||||
config_file: Option<NamedTempFile>,
|
||||
connected: AtomicBool,
|
||||
connected_at: Option<i64>,
|
||||
bytes_sent: AtomicU64,
|
||||
bytes_received: AtomicU64,
|
||||
}
|
||||
|
||||
impl OpenVpnTunnel {
|
||||
/// Create a new OpenVPN tunnel
|
||||
pub fn new(vpn_id: String, config: OpenVpnConfig) -> Self {
|
||||
Self {
|
||||
vpn_id,
|
||||
config,
|
||||
process: Arc::new(Mutex::new(None)),
|
||||
config_file: None,
|
||||
connected: AtomicBool::new(false),
|
||||
connected_at: None,
|
||||
bytes_sent: AtomicU64::new(0),
|
||||
bytes_received: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the openvpn binary
|
||||
fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
|
||||
// Check common locations
|
||||
let locations = [
|
||||
"/usr/sbin/openvpn",
|
||||
"/usr/local/sbin/openvpn",
|
||||
"/opt/homebrew/bin/openvpn",
|
||||
"/usr/bin/openvpn",
|
||||
"C:\\Program Files\\OpenVPN\\bin\\openvpn.exe",
|
||||
"C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe",
|
||||
];
|
||||
|
||||
for loc in &locations {
|
||||
let path = PathBuf::from(loc);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find via which/where command
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("openvpn").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
if let Ok(output) = Command::new("where")
|
||||
.arg("openvpn")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(VpnError::Connection(
|
||||
"OpenVPN binary not found. Please install OpenVPN.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Write config to temporary file
|
||||
fn write_config_file(&mut self) -> Result<PathBuf, VpnError> {
|
||||
let temp_file =
|
||||
NamedTempFile::new().map_err(|e| VpnError::Io(std::io::Error::other(e.to_string())))?;
|
||||
|
||||
std::fs::write(temp_file.path(), &self.config.raw_config).map_err(VpnError::Io)?;
|
||||
|
||||
let path = temp_file.path().to_path_buf();
|
||||
self.config_file = Some(temp_file);
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Start the OpenVPN process
|
||||
async fn start_process(&mut self) -> Result<(), VpnError> {
|
||||
let openvpn_bin = Self::find_openvpn_binary()?;
|
||||
let config_path = self.write_config_file()?;
|
||||
|
||||
log::info!(
|
||||
"[vpn] Starting OpenVPN with config: {}",
|
||||
config_path.display()
|
||||
);
|
||||
|
||||
// Build command with common options
|
||||
let mut cmd = Command::new(&openvpn_bin);
|
||||
cmd
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.arg("--verb")
|
||||
.arg("3") // Verbosity level
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// On Unix, try to avoid requiring root if possible
|
||||
#[cfg(unix)]
|
||||
{
|
||||
cmd.arg("--script-security").arg("2");
|
||||
}
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
|
||||
|
||||
*self.process.lock().await = Some(child);
|
||||
|
||||
// Wait a bit and check if process is still running
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
let mut process_guard = self.process.lock().await;
|
||||
if let Some(ref mut child) = *process_guard {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
// Process exited early
|
||||
let mut error_msg = format!("OpenVPN exited with status: {status}");
|
||||
|
||||
// Try to get stderr output
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let lines: Vec<String> = reader.lines().map_while(Result::ok).take(5).collect();
|
||||
if !lines.is_empty() {
|
||||
error_msg.push_str(&format!("\nError: {}", lines.join("\n")));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(VpnError::Connection(error_msg));
|
||||
}
|
||||
Ok(None) => {
|
||||
// Still running, good
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Failed to check process status: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kill the OpenVPN process
|
||||
async fn kill_process(&mut self) -> Result<(), VpnError> {
|
||||
let mut process_guard = self.process.lock().await;
|
||||
|
||||
if let Some(mut child) = process_guard.take() {
|
||||
// Try graceful shutdown first
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::unistd::Pid;
|
||||
|
||||
if let Ok(pid) = child.id().try_into() {
|
||||
let _ = kill(Pid::from_raw(pid), Signal::SIGTERM);
|
||||
// Wait a bit for graceful shutdown
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Force kill if still running
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
// Clean up config file
|
||||
self.config_file = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VpnTunnel for OpenVpnTunnel {
|
||||
async fn connect(&mut self) -> Result<(), VpnError> {
|
||||
if self.connected.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Start OpenVPN process
|
||||
self.start_process().await?;
|
||||
|
||||
// Wait for connection to be established
|
||||
// Note: In a real implementation, we'd monitor the OpenVPN management interface
|
||||
// For now, we assume success if the process starts and runs for a bit
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
// Check if process is still running
|
||||
let process_guard = self.process.lock().await;
|
||||
if let Some(ref child) = *process_guard {
|
||||
let id = child.id();
|
||||
if id > 0 {
|
||||
self.connected.store(true, Ordering::Release);
|
||||
self.connected_at = Some(Utc::now().timestamp());
|
||||
log::info!("[vpn] OpenVPN tunnel {} connected (PID: {id})", self.vpn_id);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(VpnError::Connection(
|
||||
"Failed to establish OpenVPN connection".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), VpnError> {
|
||||
if !self.connected.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.kill_process().await?;
|
||||
|
||||
self.connected.store(false, Ordering::Release);
|
||||
self.connected_at = None;
|
||||
|
||||
log::info!("[vpn] OpenVPN tunnel {} disconnected", self.vpn_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn vpn_id(&self) -> &str {
|
||||
&self.vpn_id
|
||||
}
|
||||
|
||||
fn get_status(&self) -> VpnStatus {
|
||||
VpnStatus {
|
||||
connected: self.is_connected(),
|
||||
vpn_id: self.vpn_id.clone(),
|
||||
connected_at: self.connected_at,
|
||||
bytes_sent: Some(self.bytes_sent.load(Ordering::Relaxed)),
|
||||
bytes_received: Some(self.bytes_received.load(Ordering::Relaxed)),
|
||||
last_handshake: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_sent(&self) -> u64 {
|
||||
self.bytes_sent.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn bytes_received(&self) -> u64 {
|
||||
self.bytes_received.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OpenVpnTunnel {
|
||||
fn drop(&mut self) {
|
||||
// Clean up process on drop (synchronously)
|
||||
if let Ok(mut guard) = self.process.try_lock() {
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_config() -> OpenVpnConfig {
|
||||
OpenVpnConfig {
|
||||
raw_config: "client\nremote test.example.com 1194\ndev tun".to_string(),
|
||||
remote_host: "test.example.com".to_string(),
|
||||
remote_port: 1194,
|
||||
protocol: "udp".to_string(),
|
||||
dev_type: "tun".to_string(),
|
||||
has_inline_ca: false,
|
||||
has_inline_cert: false,
|
||||
has_inline_key: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_tunnel_creation() {
|
||||
let config = create_test_config();
|
||||
let tunnel = OpenVpnTunnel::new("test-ovpn-1".to_string(), config);
|
||||
|
||||
assert_eq!(tunnel.vpn_id(), "test-ovpn-1");
|
||||
assert!(!tunnel.is_connected());
|
||||
assert_eq!(tunnel.bytes_sent(), 0);
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_status() {
|
||||
let config = create_test_config();
|
||||
let tunnel = OpenVpnTunnel::new("test-ovpn-2".to_string(), config);
|
||||
|
||||
let status = tunnel.get_status();
|
||||
assert!(!status.connected);
|
||||
assert_eq!(status.vpn_id, "test-ovpn-2");
|
||||
assert!(status.connected_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_openvpn_binary_format() {
|
||||
// This test just checks that the function doesn't panic
|
||||
// It may or may not find openvpn depending on the system
|
||||
let result = OpenVpnTunnel::find_openvpn_binary();
|
||||
// Just check that it returns a valid Result
|
||||
match result {
|
||||
Ok(path) => assert!(!path.as_os_str().is_empty()),
|
||||
Err(e) => assert!(e.to_string().contains("not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,811 +0,0 @@
|
||||
use super::config::{OpenVpnConfig, VpnError};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{lookup_host, TcpListener, TcpSocket, TcpStream};
|
||||
|
||||
const OPENVPN_CONNECT_TIMEOUT_SECS: u64 = 90;
|
||||
|
||||
enum SocksTarget {
|
||||
Address(SocketAddr),
|
||||
Domain(String, u16),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct OpenVpnDependencyStatus {
|
||||
pub binary_found: bool,
|
||||
pub missing_windows_adapter: bool,
|
||||
pub dependency_check_failed: bool,
|
||||
}
|
||||
|
||||
pub struct OpenVpnSocks5Server {
|
||||
config: OpenVpnConfig,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl OpenVpnSocks5Server {
|
||||
pub fn new(config: OpenVpnConfig, port: u16) -> Self {
|
||||
Self { config, port }
|
||||
}
|
||||
|
||||
fn read_log_tail(path: &Path, lines: usize) -> String {
|
||||
std::fs::read_to_string(path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.rev()
|
||||
.take(lines)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn extract_vpn_ip(line: &str) -> Option<Ipv4Addr> {
|
||||
for field in line.split(',') {
|
||||
let trimmed = field.trim();
|
||||
if let Ok(ip) = trimmed.parse::<Ipv4Addr>() {
|
||||
if ip.is_private() && !ip.is_loopback() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn log_indicates_connected(log_content: &str) -> bool {
|
||||
log_content.contains("Initialization Sequence Completed")
|
||||
}
|
||||
|
||||
fn log_indicates_failure(log_content: &str) -> bool {
|
||||
log_content.contains("AUTH_FAILED")
|
||||
|| log_content.contains("Exiting due to fatal error")
|
||||
|| log_content.contains("Fatal error")
|
||||
|| log_content.contains("Options error")
|
||||
|| log_content.contains("Exiting")
|
||||
}
|
||||
|
||||
fn has_config_directive(config: &str, directive: &str) -> bool {
|
||||
config.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty()
|
||||
&& !trimmed.starts_with('#')
|
||||
&& !trimmed.starts_with(';')
|
||||
&& trimmed.starts_with(directive)
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_config_directive(config: &str, directive: &str) -> String {
|
||||
config
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with(';')
|
||||
|| !trimmed.starts_with(directive)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_runtime_config(&self) -> String {
|
||||
let mut runtime_config = self.config.raw_config.clone();
|
||||
|
||||
runtime_config = Self::strip_config_directive(&runtime_config, "redirect-gateway");
|
||||
runtime_config = Self::strip_config_directive(&runtime_config, "block-outside-dns");
|
||||
runtime_config = Self::strip_config_directive(&runtime_config, "dhcp-option");
|
||||
|
||||
if !runtime_config.contains("pull-filter ignore \"redirect-gateway\"") {
|
||||
runtime_config.push_str("\npull-filter ignore \"redirect-gateway\"\n");
|
||||
}
|
||||
if !runtime_config.contains("pull-filter ignore \"block-outside-dns\"") {
|
||||
runtime_config.push_str("pull-filter ignore \"block-outside-dns\"\n");
|
||||
}
|
||||
if !runtime_config.contains("pull-filter ignore \"dhcp-option\"") {
|
||||
runtime_config.push_str("pull-filter ignore \"dhcp-option\"\n");
|
||||
}
|
||||
|
||||
if !Self::has_config_directive(&runtime_config, "route 0.0.0.0") {
|
||||
runtime_config.push_str("\nroute 0.0.0.0 0.0.0.0 vpn_gateway 9999\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if Self::has_config_directive(&runtime_config, "dev-node") {
|
||||
runtime_config = runtime_config
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with(';')
|
||||
|| !trimmed.starts_with("dev-node")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if !Self::has_config_directive(&runtime_config, "disable-dco") {
|
||||
runtime_config.push_str("\ndisable-dco\n");
|
||||
}
|
||||
|
||||
if self.config.dev_type.starts_with("tun")
|
||||
&& !Self::has_config_directive(&runtime_config, "windows-driver")
|
||||
{
|
||||
runtime_config.push_str("\nwindows-driver wintun\n");
|
||||
}
|
||||
}
|
||||
|
||||
runtime_config
|
||||
}
|
||||
|
||||
pub(crate) fn dependency_status() -> OpenVpnDependencyStatus {
|
||||
let Ok(openvpn_bin) = Self::find_openvpn_binary() else {
|
||||
return OpenVpnDependencyStatus {
|
||||
binary_found: false,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: false,
|
||||
};
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
match Self::windows_openvpn_has_adapter(&openvpn_bin) {
|
||||
Ok(has_adapter) => OpenVpnDependencyStatus {
|
||||
binary_found: true,
|
||||
missing_windows_adapter: !has_adapter,
|
||||
dependency_check_failed: false,
|
||||
},
|
||||
Err(_) => OpenVpnDependencyStatus {
|
||||
binary_found: true,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = openvpn_bin;
|
||||
OpenVpnDependencyStatus {
|
||||
binary_found: true,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
|
||||
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
|
||||
let path = PathBuf::from(path);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Configured OpenVPN binary does not exist: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let locations = [
|
||||
"/usr/sbin/openvpn",
|
||||
"/usr/local/sbin/openvpn",
|
||||
"/opt/homebrew/bin/openvpn",
|
||||
"/usr/bin/openvpn",
|
||||
"C:\\Program Files\\OpenVPN\\bin\\openvpn.exe",
|
||||
"C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe",
|
||||
];
|
||||
|
||||
for loc in &locations {
|
||||
let path = PathBuf::from(loc);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("openvpn").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
if let Ok(output) = Command::new("where")
|
||||
.arg("openvpn")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(VpnError::Connection(
|
||||
"OpenVPN binary not found. Please install OpenVPN.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn openvpn_supports_management(openvpn_bin: &Path) -> bool {
|
||||
let mut command = Command::new(openvpn_bin);
|
||||
command.arg("--version");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let Ok(output) = command.output() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let version_text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
!version_text.contains("enable_management=no")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn windows_openvpn_has_adapter(openvpn_bin: &Path) -> Result<bool, VpnError> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let output = Command::new(openvpn_bin)
|
||||
.arg("--show-adapters")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to inspect OpenVPN adapters: {e}")))?;
|
||||
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(
|
||||
text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.any(|line| !line.is_empty() && !line.starts_with("Available adapters")),
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_vpn_ip_from_log(log_content: &str) -> Option<Ipv4Addr> {
|
||||
for line in log_content.lines() {
|
||||
if let Some(ip) = Self::extract_vpn_ip(line) {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(position) = line.find("ifconfig ") {
|
||||
let after = &line[position + "ifconfig ".len()..];
|
||||
if let Some(ip_str) = after
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.or_else(|| after.split(',').next())
|
||||
{
|
||||
if let Ok(ip) = ip_str.parse::<Ipv4Addr>() {
|
||||
if ip.is_private() && !ip.is_loopback() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn wait_for_openvpn_ready_via_management(
|
||||
child: &mut std::process::Child,
|
||||
mgmt_port: u16,
|
||||
log_path: &Path,
|
||||
) -> Result<Option<Ipv4Addr>, VpnError> {
|
||||
let deadline =
|
||||
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
|
||||
|
||||
let mgmt_stream = loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Timed out connecting to OpenVPN management interface. Last OpenVPN output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited (status: {}) before the tunnel was established. Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
match TcpStream::connect(("127.0.0.1", mgmt_port)).await {
|
||||
Ok(stream) => break stream,
|
||||
Err(_) => tokio::time::sleep(tokio::time::Duration::from_millis(500)).await,
|
||||
}
|
||||
};
|
||||
|
||||
let (mgmt_reader, mut mgmt_writer) = mgmt_stream.into_split();
|
||||
let _ = mgmt_writer.write_all(b"state on\nstate\n").await;
|
||||
|
||||
let mut lines = BufReader::new(mgmt_reader).lines();
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
|
||||
interval.tick().await;
|
||||
|
||||
let mut vpn_ip = None;
|
||||
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Timed out waiting for OpenVPN to reach CONNECTED state. Last OpenVPN output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
line_result = lines.next_line() => {
|
||||
match line_result {
|
||||
Ok(Some(line)) => {
|
||||
if let Some(ip) = Self::extract_vpn_ip(&line) {
|
||||
vpn_ip = Some(ip);
|
||||
}
|
||||
|
||||
if line.contains(",CONNECTED,") {
|
||||
break;
|
||||
}
|
||||
|
||||
if line.contains("AUTH_FAILED") {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN authentication failed. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
if line.contains(",EXITING,") || line.contains(">FATAL:") {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN is exiting. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN management connection closed before CONNECTED state. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
let _ = mgmt_writer.write_all(b"state\n").await;
|
||||
|
||||
let log_path = log_path.to_path_buf();
|
||||
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok);
|
||||
|
||||
if let Some(content) = log_content {
|
||||
if Self::log_indicates_connected(&content) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vpn_ip.is_none() {
|
||||
if let Ok(log_content) = std::fs::read_to_string(log_path) {
|
||||
vpn_ip = Self::extract_vpn_ip_from_log(&log_content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vpn_ip)
|
||||
}
|
||||
|
||||
async fn wait_for_openvpn_ready_via_log(
|
||||
child: &mut std::process::Child,
|
||||
log_path: &Path,
|
||||
) -> Result<Option<Ipv4Addr>, VpnError> {
|
||||
let deadline =
|
||||
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
|
||||
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Timed out waiting for OpenVPN to connect. Last OpenVPN output:\n{}",
|
||||
Self::read_log_tail(log_path, 40)
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(log_path, 40)
|
||||
)));
|
||||
}
|
||||
|
||||
let log_path_buf = log_path.to_path_buf();
|
||||
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path_buf))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_default();
|
||||
|
||||
if Self::log_indicates_connected(&log_content) {
|
||||
return Ok(Self::extract_vpn_ip_from_log(&log_content));
|
||||
}
|
||||
|
||||
if Self::log_indicates_failure(&log_content) {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN reported a fatal error while connecting. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 40)
|
||||
)));
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_target(
|
||||
target: SocksTarget,
|
||||
vpn_bind_ip: Ipv4Addr,
|
||||
) -> Result<(TcpStream, SocketAddr), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut addresses = match target {
|
||||
SocksTarget::Address(addr) => vec![addr],
|
||||
SocksTarget::Domain(host, port) => {
|
||||
let mut resolved = lookup_host((host.as_str(), port))
|
||||
.await?
|
||||
.collect::<Vec<_>>();
|
||||
resolved.sort_by_key(|addr| if addr.is_ipv4() { 0 } else { 1 });
|
||||
resolved
|
||||
}
|
||||
};
|
||||
|
||||
if addresses.is_empty() {
|
||||
return Err("No addresses resolved for SOCKS5 target".into());
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for address in addresses.drain(..) {
|
||||
let socket = if address.is_ipv4() {
|
||||
let socket = TcpSocket::new_v4()?;
|
||||
if !vpn_bind_ip.is_unspecified() {
|
||||
socket.bind(SocketAddr::new(IpAddr::V4(vpn_bind_ip), 0))?;
|
||||
}
|
||||
socket
|
||||
} else {
|
||||
TcpSocket::new_v6()?
|
||||
};
|
||||
|
||||
match socket.connect(address).await {
|
||||
Ok(stream) => return Ok((stream, address)),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(
|
||||
last_error
|
||||
.map(|error| error.into())
|
||||
.unwrap_or_else(|| "Failed to connect to any resolved SOCKS5 target".into()),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
|
||||
let openvpn_bin = Self::find_openvpn_binary()?;
|
||||
let supports_management = Self::openvpn_supports_management(&openvpn_bin);
|
||||
|
||||
#[cfg(windows)]
|
||||
if !Self::windows_openvpn_has_adapter(&openvpn_bin)? {
|
||||
return Err(VpnError::Connection(
|
||||
"OpenVPN requires a TAP/Wintun/ovpn-dco adapter on Windows, but none were found. Install or provision an adapter before connecting.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id));
|
||||
std::fs::write(&config_path, self.build_runtime_config()).map_err(VpnError::Io)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
let mgmt_port = if supports_management {
|
||||
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
|
||||
let port = mgmt_listener
|
||||
.local_addr()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
|
||||
.port();
|
||||
drop(mgmt_listener);
|
||||
Some(port)
|
||||
} else {
|
||||
log::info!(
|
||||
"[vpn-worker] OpenVPN build does not support management; using log-based readiness"
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
let openvpn_log_path = std::env::temp_dir().join(format!("openvpn-{}.log", config_id));
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&openvpn_log_path)
|
||||
.map_err(VpnError::Io)?;
|
||||
|
||||
let mut cmd = Command::new(&openvpn_bin);
|
||||
cmd.arg("--config").arg(&config_path);
|
||||
if let Some(mgmt_port) = mgmt_port {
|
||||
cmd
|
||||
.arg("--management")
|
||||
.arg("127.0.0.1")
|
||||
.arg(mgmt_port.to_string());
|
||||
}
|
||||
cmd
|
||||
.arg("--verb")
|
||||
.arg("3")
|
||||
.stdout(
|
||||
log_file
|
||||
.try_clone()
|
||||
.map(Stdio::from)
|
||||
.map_err(VpnError::Io)?,
|
||||
)
|
||||
.stderr(Stdio::from(log_file));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
cmd.arg("--disable-dco");
|
||||
if self.config.dev_type.starts_with("tun") {
|
||||
cmd.arg("--windows-driver").arg("wintun");
|
||||
}
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let _ = std::fs::remove_file(&config_path);
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited immediately (status: {}). Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(&openvpn_log_path, 20)
|
||||
)));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&config_path);
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Failed to check OpenVPN status: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let vpn_bind_ip = if let Some(mgmt_port) = mgmt_port {
|
||||
Self::wait_for_openvpn_ready_via_management(&mut child, mgmt_port, &openvpn_log_path).await?
|
||||
} else {
|
||||
Self::wait_for_openvpn_ready_via_log(&mut child, &openvpn_log_path).await?
|
||||
}
|
||||
.unwrap_or(Ipv4Addr::UNSPECIFIED);
|
||||
let vpn_bind_ip = Arc::new(vpn_bind_ip);
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", self.port))
|
||||
.await
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?;
|
||||
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
|
||||
.port();
|
||||
|
||||
if let Some(mut worker_config) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
|
||||
worker_config.local_port = Some(actual_port);
|
||||
worker_config.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
|
||||
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&worker_config);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[vpn-worker] OpenVPN SOCKS5 server listening on 127.0.0.1:{}",
|
||||
actual_port
|
||||
);
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((client, _)) => {
|
||||
let bind_ip = vpn_bind_ip.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = Self::handle_socks5_client(client, bind_ip).await;
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("[vpn-worker] Accept error: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_socks5_client(
|
||||
mut client: TcpStream,
|
||||
vpn_bind_ip: Arc<Ipv4Addr>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut greeting = [0u8; 2];
|
||||
if let Err(error) = client.read_exact(&mut greeting).await {
|
||||
if error.kind() != std::io::ErrorKind::UnexpectedEof {
|
||||
log::debug!("[socks5] Failed to read greeting header: {}", error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if greeting[0] != 0x05 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut methods = vec![0u8; greeting[1] as usize];
|
||||
if let Err(error) = client.read_exact(&mut methods).await {
|
||||
if error.kind() != std::io::ErrorKind::UnexpectedEof {
|
||||
log::debug!("[socks5] Failed to read methods list: {}", error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
client.write_all(&[0x05, 0x00]).await?;
|
||||
|
||||
let mut request_header = [0u8; 4];
|
||||
if let Err(error) = client.read_exact(&mut request_header).await {
|
||||
if error.kind() != std::io::ErrorKind::UnexpectedEof {
|
||||
log::debug!("[socks5] Failed to read request header: {}", error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request_header[0] != 0x05 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request_header[1] != 0x01 {
|
||||
let _ = client
|
||||
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let target = match request_header[3] {
|
||||
0x01 => {
|
||||
let mut addr_port = [0u8; 6];
|
||||
client.read_exact(&mut addr_port).await?;
|
||||
SocksTarget::Address(SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(
|
||||
addr_port[0],
|
||||
addr_port[1],
|
||||
addr_port[2],
|
||||
addr_port[3],
|
||||
)),
|
||||
u16::from_be_bytes([addr_port[4], addr_port[5]]),
|
||||
))
|
||||
}
|
||||
0x03 => {
|
||||
let mut len = [0u8; 1];
|
||||
client.read_exact(&mut len).await?;
|
||||
if len[0] == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut domain = vec![0u8; len[0] as usize];
|
||||
client.read_exact(&mut domain).await?;
|
||||
|
||||
let mut port = [0u8; 2];
|
||||
client.read_exact(&mut port).await?;
|
||||
|
||||
SocksTarget::Domain(
|
||||
String::from_utf8_lossy(&domain).to_string(),
|
||||
u16::from_be_bytes(port),
|
||||
)
|
||||
}
|
||||
0x04 => {
|
||||
let mut addr_port = [0u8; 18];
|
||||
client.read_exact(&mut addr_port).await?;
|
||||
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&addr_port[..16]);
|
||||
|
||||
SocksTarget::Address(SocketAddr::new(
|
||||
IpAddr::V6(std::net::Ipv6Addr::from(octets)),
|
||||
u16::from_be_bytes([addr_port[16], addr_port[17]]),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
let _ = client
|
||||
.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
match Self::connect_target(target, *vpn_bind_ip).await {
|
||||
Ok((upstream, _address)) => {
|
||||
client
|
||||
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
|
||||
.await?;
|
||||
|
||||
let (mut client_read, mut client_write) = client.into_split();
|
||||
let (mut upstream_read, mut upstream_write) = upstream.into_split();
|
||||
|
||||
let client_to_upstream = tokio::io::copy(&mut client_read, &mut upstream_write);
|
||||
let upstream_to_client = tokio::io::copy(&mut upstream_read, &mut client_write);
|
||||
let _ = tokio::try_join!(client_to_upstream, upstream_to_client)?;
|
||||
}
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"[socks5] Failed to connect through OpenVPN tunnel: {}",
|
||||
error
|
||||
);
|
||||
client
|
||||
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_openvpn_binary_format() {
|
||||
let result = OpenVpnSocks5Server::find_openvpn_binary();
|
||||
match result {
|
||||
Ok(path) => assert!(!path.as_os_str().is_empty()),
|
||||
Err(e) => assert!(e.to_string().contains("not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,25 @@ impl WgDevice {
|
||||
let mut dst = vec![0u8; ip_packet.len() + 256];
|
||||
let mut tunn = self.tunn.lock().unwrap();
|
||||
let result = tunn.encapsulate(&ip_packet, &mut dst);
|
||||
if let TunnResult::WriteToNetwork(packet) = result {
|
||||
let _ = self.udp_socket.send_to(packet, self.peer_addr);
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(packet) => {
|
||||
if let Err(e) = self.udp_socket.send_to(packet, self.peer_addr) {
|
||||
log::error!("[wg] udp send_to failed: {e}");
|
||||
}
|
||||
}
|
||||
TunnResult::Done => {
|
||||
// boringtun has nothing to send right now (e.g. handshake not yet
|
||||
// complete); silently drop. smoltcp will retransmit.
|
||||
}
|
||||
TunnResult::Err(e) => {
|
||||
log::error!(
|
||||
"[wg] encapsulate error for {}B IP packet: {e:?}",
|
||||
ip_packet.len()
|
||||
);
|
||||
}
|
||||
TunnResult::WriteToTunnelV4(_, _) | TunnResult::WriteToTunnelV6(_, _) => {
|
||||
log::error!("[wg] encapsulate returned unexpected WriteToTunnel — bug?");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +330,11 @@ impl WireGuardSocks5Server {
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
|
||||
pub async fn run(
|
||||
self,
|
||||
config_id: String,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
) -> Result<(), VpnError> {
|
||||
let peer_addr = self.resolve_endpoint()?;
|
||||
let mut tunn = self.create_tunnel()?;
|
||||
|
||||
@@ -371,11 +392,37 @@ impl WireGuardSocks5Server {
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
|
||||
.port();
|
||||
|
||||
// Update config with actual port and local_url
|
||||
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
|
||||
// Update config with actual port and local_url. Prefer the explicit
|
||||
// config path the worker was started with — see issue #287, where
|
||||
// get_storage_dir() in the worker process resolved to a different
|
||||
// directory than in the parent (Qubes/sandboxed Linux), causing the
|
||||
// write-back to land in the wrong place and the parent to time out.
|
||||
let updated = match &config_path {
|
||||
Some(path) => crate::vpn_worker_storage::get_vpn_worker_config_from_path(path)
|
||||
.or_else(|| crate::vpn_worker_storage::get_vpn_worker_config(&config_id)),
|
||||
None => crate::vpn_worker_storage::get_vpn_worker_config(&config_id),
|
||||
};
|
||||
if let Some(mut wc) = updated {
|
||||
wc.local_port = Some(actual_port);
|
||||
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
|
||||
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
|
||||
let result = match &config_path {
|
||||
Some(path) => crate::vpn_worker_storage::save_vpn_worker_config_to_path(&wc, path)
|
||||
.map_err(|e| e.to_string()),
|
||||
None => crate::vpn_worker_storage::save_vpn_worker_config(&wc).map_err(|e| e.to_string()),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
log::error!(
|
||||
"[vpn-worker] Failed to write back local_url to config: {} (path={:?})",
|
||||
e,
|
||||
config_path
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::error!(
|
||||
"[vpn-worker] Could not load worker config for write-back (id={}, path={:?})",
|
||||
config_id,
|
||||
config_path
|
||||
);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
|
||||
@@ -161,7 +161,17 @@ impl VpnStorage {
|
||||
let content = fs::read_to_string(&self.storage_path)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to read storage file: {e}")))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
// Drop entries whose vpn_type isn't recognized by the current build (e.g.
|
||||
// legacy "OpenVPN" entries after support was removed). Filtering at JSON
|
||||
// level keeps the rest of the file deserializable instead of the whole
|
||||
// load failing on a single unknown variant.
|
||||
let mut value: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}")))?;
|
||||
if let Some(arr) = value.get_mut("configs").and_then(|v| v.as_array_mut()) {
|
||||
arr.retain(|c| c.get("vpn_type").and_then(|t| t.as_str()) == Some("WireGuard"));
|
||||
}
|
||||
|
||||
serde_json::from_value(value)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}")))
|
||||
}
|
||||
|
||||
@@ -328,14 +338,10 @@ impl VpnStorage {
|
||||
vpn_type: VpnType,
|
||||
config_data: &str,
|
||||
) -> Result<VpnConfig, VpnError> {
|
||||
// Validate the config by parsing it
|
||||
match vpn_type {
|
||||
VpnType::WireGuard => {
|
||||
super::parse_wireguard_config(config_data)?;
|
||||
}
|
||||
VpnType::OpenVPN => {
|
||||
super::parse_openvpn_config(config_data)?;
|
||||
}
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
@@ -392,20 +398,15 @@ impl VpnStorage {
|
||||
) -> Result<VpnConfig, VpnError> {
|
||||
let vpn_type = super::detect_vpn_type(content, filename)?;
|
||||
|
||||
// Validate the config by parsing it
|
||||
match vpn_type {
|
||||
VpnType::WireGuard => {
|
||||
super::parse_wireguard_config(content)?;
|
||||
}
|
||||
VpnType::OpenVPN => {
|
||||
super::parse_openvpn_config(content)?;
|
||||
}
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let display_name = name.unwrap_or_else(|| {
|
||||
// Generate name from filename
|
||||
let base = filename.trim_end_matches(".conf").trim_end_matches(".ovpn");
|
||||
let base = filename.trim_end_matches(".conf");
|
||||
format!("{} ({})", base, vpn_type)
|
||||
});
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
@@ -491,7 +492,7 @@ mod tests {
|
||||
let config2 = VpnConfig {
|
||||
id: "id-2".to_string(),
|
||||
name: "VPN 2".to_string(),
|
||||
vpn_type: VpnType::OpenVPN,
|
||||
vpn_type: VpnType::WireGuard,
|
||||
config_data: "secret2".to_string(),
|
||||
created_at: 2000,
|
||||
last_used: Some(3000),
|
||||
|
||||
@@ -9,7 +9,6 @@ use std::process::Stdio;
|
||||
|
||||
const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100;
|
||||
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 30_000;
|
||||
const OPENVPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 100_000;
|
||||
|
||||
async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool {
|
||||
let Some(port) = config.local_port else {
|
||||
@@ -44,13 +43,8 @@ fn read_worker_log(id: &str) -> String {
|
||||
|
||||
async fn wait_for_vpn_worker_ready(
|
||||
id: &str,
|
||||
vpn_type: &str,
|
||||
) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||
let startup_timeout = if vpn_type == "openvpn" {
|
||||
tokio::time::Duration::from_millis(OPENVPN_WORKER_STARTUP_TIMEOUT_MS)
|
||||
} else {
|
||||
tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS)
|
||||
};
|
||||
let startup_timeout = tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS);
|
||||
let startup_deadline = tokio::time::Instant::now() + startup_timeout;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(
|
||||
@@ -124,7 +118,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
return wait_for_vpn_worker_ready(&existing.id, &existing.vpn_type).await;
|
||||
return wait_for_vpn_worker_ready(&existing.id).await;
|
||||
}
|
||||
}
|
||||
// Worker config exists but process is dead, clean up
|
||||
@@ -141,10 +135,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
.map_err(|e| format!("Failed to load VPN config: {e}"))?
|
||||
};
|
||||
|
||||
let vpn_type_str = match vpn_config.vpn_type {
|
||||
crate::vpn::VpnType::WireGuard => "wireguard",
|
||||
crate::vpn::VpnType::OpenVPN => "openvpn",
|
||||
};
|
||||
let vpn_type_str = "wireguard";
|
||||
|
||||
// Write decrypted config to a temp file
|
||||
let config_file_path = std::env::temp_dir()
|
||||
@@ -270,7 +261,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
drop(child);
|
||||
}
|
||||
|
||||
wait_for_vpn_worker_ready(&id, vpn_type_str).await
|
||||
wait_for_vpn_worker_ready(&id).await
|
||||
}
|
||||
|
||||
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::proxy_storage::get_storage_dir;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnWorkerConfig {
|
||||
@@ -36,12 +37,34 @@ pub fn save_vpn_worker_config(config: &VpnWorkerConfig) -> Result<(), Box<dyn st
|
||||
fs::create_dir_all(&storage_dir)?;
|
||||
|
||||
let file_path = storage_dir.join(format!("vpn_worker_{}.json", config.id));
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&file_path, content)?;
|
||||
save_vpn_worker_config_to_path(config, &file_path)
|
||||
}
|
||||
|
||||
/// Write a worker config to a specific path. Used by detached worker
|
||||
/// processes that already know their config file path (passed via
|
||||
/// `--config-path`) and must write back to the same location regardless of
|
||||
/// how `get_storage_dir()` resolves in the worker process — which can
|
||||
/// differ from the parent on Linux distros that sandbox the GUI (Qubes,
|
||||
/// flatpak, etc.) and is the cause of issue #287.
|
||||
pub fn save_vpn_worker_config_to_path(
|
||||
config: &VpnWorkerConfig,
|
||||
path: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a worker config from a specific path. Counterpart to
|
||||
/// `save_vpn_worker_config_to_path`.
|
||||
pub fn get_vpn_worker_config_from_path(path: &Path) -> Option<VpnWorkerConfig> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
pub fn get_vpn_worker_config(id: &str) -> Option<VpnWorkerConfig> {
|
||||
let storage_dir = get_storage_dir();
|
||||
let file_path = storage_dir.join(format!("vpn_worker_{}.json", id));
|
||||
|
||||
@@ -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.0",
|
||||
"version": "0.22.7",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
@@ -42,11 +42,11 @@
|
||||
"linux": {
|
||||
"deb": {
|
||||
"desktopTemplate": "donutbrowser.desktop",
|
||||
"depends": ["xdg-utils"]
|
||||
"depends": ["xdg-utils", "libxdo3"]
|
||||
},
|
||||
"rpm": {
|
||||
"desktopTemplate": "donutbrowser.desktop",
|
||||
"depends": ["xdg-utils"]
|
||||
"depends": ["xdg-utils", "libxdo"]
|
||||
},
|
||||
"appimage": {
|
||||
"files": {
|
||||
|
||||
Vendored
-39
@@ -1,39 +0,0 @@
|
||||
# Sample OpenVPN configuration for testing
|
||||
# This is NOT a real configuration - for unit test purposes only
|
||||
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote vpn.example.com 1194
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
verb 3
|
||||
|
||||
<ca>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBojCCAUigAwIBAgIJAKPGF0Tc8XJaMAoGCCqGSM49BAMCMBkxFzAVBgNVBAMM
|
||||
DnRlc3QtY2EtZXhhbXBsZTAeFw0yMzAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBa
|
||||
MBkxFzAVBgNVBAMMDnRlc3QtY2EtZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49
|
||||
AwEHA0IABHfakeZYe3R6uCZoL5DqbZkW8mBVKnIYMrIIKV4FPYO9V1YL8V3Z9QC
|
||||
TEST_CERTIFICATE_DATA_NOT_REAL_EXAMPLE_ONLY
|
||||
-----END CERTIFICATE-----
|
||||
</ca>
|
||||
|
||||
<cert>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBojCCAUigAwIBAgIJAKPGF0Tc8XJbMAoGCCqGSM49BAMCMBkxFzAVBgNVBAMM
|
||||
DnRlc3QtY2xpZW50LWV4YW1wbGUwHhcNMjMwMTAxMDAwMDAwWhcNMjUwMTAxMDAw
|
||||
MDAwWjAZMRcwFQYDVQQDDA50ZXN0LWNsaWVudC1leGFtcGxlMFkwEwYHKoZIzj0C
|
||||
AQYIKoZIzj0DAQcDQgAE
|
||||
TEST_CLIENT_CERT_DATA_NOT_REAL_EXAMPLE_ONLY
|
||||
-----END CERTIFICATE-----
|
||||
</cert>
|
||||
|
||||
<key>
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZFG/NKjHmTJBNcuH
|
||||
TEST_PRIVATE_KEY_DATA_NOT_REAL_EXAMPLE_ONLY
|
||||
-----END PRIVATE KEY-----
|
||||
</key>
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Test harness for VPN integration tests.
|
||||
//!
|
||||
//! This module provides Docker-based test infrastructure for WireGuard and OpenVPN tests.
|
||||
//! This module provides Docker-based test infrastructure for WireGuard tests.
|
||||
//! In CI environments, it uses pre-configured service containers.
|
||||
//! In local development, it spawns Docker containers on demand.
|
||||
//!
|
||||
@@ -13,10 +13,7 @@ use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const WIREGUARD_IMAGE: &str = "linuxserver/wireguard:latest";
|
||||
const OPENVPN_IMAGE: &str = "kylemanna/openvpn:latest";
|
||||
const WG_CONTAINER: &str = "donut-wg-test";
|
||||
const OVPN_CONTAINER: &str = "donut-ovpn-test";
|
||||
const OVPN_VOLUME: &str = "donut-ovpn-test-data";
|
||||
|
||||
/// Check if running in CI environment
|
||||
pub fn is_ci() -> bool {
|
||||
@@ -27,10 +24,6 @@ fn has_external_wireguard_service() -> bool {
|
||||
std::env::var("VPN_TEST_WG_HOST").is_ok()
|
||||
}
|
||||
|
||||
fn has_external_openvpn_service() -> bool {
|
||||
std::env::var("VPN_TEST_OVPN_HOST").is_ok()
|
||||
}
|
||||
|
||||
/// Check if Docker is available
|
||||
pub fn is_docker_available() -> bool {
|
||||
Command::new("docker")
|
||||
@@ -165,166 +158,10 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Start an OpenVPN test server and return client config
|
||||
pub async fn start_openvpn_server() -> Result<OpenVpnTestConfig, String> {
|
||||
if has_external_openvpn_service() {
|
||||
let host = std::env::var("VPN_TEST_OVPN_HOST").unwrap_or_else(|_| "localhost".into());
|
||||
let port = std::env::var("VPN_TEST_OVPN_PORT").unwrap_or_else(|_| "1194".into());
|
||||
|
||||
return get_ci_openvpn_config(&host, &port);
|
||||
}
|
||||
|
||||
if !is_docker_available() {
|
||||
return Err("Docker is not available for local testing".to_string());
|
||||
}
|
||||
|
||||
// Stop any existing container
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", OVPN_CONTAINER])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("docker")
|
||||
.args(["volume", "rm", "-f", OVPN_VOLUME])
|
||||
.output();
|
||||
|
||||
let create_volume = Command::new("docker")
|
||||
.args(["volume", "create", OVPN_VOLUME])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to create OpenVPN test volume: {e}"))?;
|
||||
if !create_volume.status.success() {
|
||||
return Err(format!(
|
||||
"Failed to create OpenVPN test volume: {}",
|
||||
String::from_utf8_lossy(&create_volume.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let genconfig = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
"-e",
|
||||
"EASYRSA_BATCH=1",
|
||||
OPENVPN_IMAGE,
|
||||
"ovpn_genconfig",
|
||||
"-u",
|
||||
"udp://127.0.0.1",
|
||||
"-s",
|
||||
"10.9.0.0/24",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to generate OpenVPN config: {e}"))?;
|
||||
if !genconfig.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN config generation failed: {}",
|
||||
String::from_utf8_lossy(&genconfig.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let init_pki = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
"-e",
|
||||
"EASYRSA_BATCH=1",
|
||||
OPENVPN_IMAGE,
|
||||
"ovpn_initpki",
|
||||
"nopass",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to initialize OpenVPN PKI: {e}"))?;
|
||||
if !init_pki.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN PKI initialization failed: {}",
|
||||
String::from_utf8_lossy(&init_pki.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let build_client = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
"-e",
|
||||
"EASYRSA_BATCH=1",
|
||||
OPENVPN_IMAGE,
|
||||
"easyrsa",
|
||||
"build-client-full",
|
||||
"donut-test-client",
|
||||
"nopass",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to build OpenVPN client certificate: {e}"))?;
|
||||
if !build_client.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN client certificate build failed: {}",
|
||||
String::from_utf8_lossy(&build_client.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let start_server = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
OVPN_CONTAINER,
|
||||
"--cap-add=NET_ADMIN",
|
||||
"-p",
|
||||
"1194:1194/udp",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
OPENVPN_IMAGE,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to start OpenVPN container: {e}"))?;
|
||||
if !start_server.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN container start failed: {}",
|
||||
String::from_utf8_lossy(&start_server.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
let client_config = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
OPENVPN_IMAGE,
|
||||
"ovpn_getclient",
|
||||
"donut-test-client",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to fetch OpenVPN client config: {e}"))?;
|
||||
if !client_config.status.success() {
|
||||
return Err(format!(
|
||||
"Failed to read OpenVPN client config: {}",
|
||||
String::from_utf8_lossy(&client_config.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let raw_config = String::from_utf8_lossy(&client_config.stdout).to_string();
|
||||
Ok(OpenVpnTestConfig {
|
||||
raw_config,
|
||||
remote_host: "127.0.0.1".to_string(),
|
||||
remote_port: 1194,
|
||||
protocol: "udp".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop all VPN test servers
|
||||
pub async fn stop_vpn_servers() {
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", WG_CONTAINER, OVPN_CONTAINER])
|
||||
.output();
|
||||
let _ = Command::new("docker")
|
||||
.args(["volume", "rm", "-f", OVPN_VOLUME])
|
||||
.args(["rm", "-f", WG_CONTAINER])
|
||||
.output();
|
||||
}
|
||||
|
||||
@@ -343,14 +180,6 @@ pub struct WireGuardTestConfig {
|
||||
pub server_tunnel_ip: String,
|
||||
}
|
||||
|
||||
/// OpenVPN test configuration
|
||||
pub struct OpenVpnTestConfig {
|
||||
pub raw_config: String,
|
||||
pub remote_host: String,
|
||||
pub remote_port: u16,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// Parse WireGuard test config from INI content
|
||||
fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, String> {
|
||||
let mut private_key = String::new();
|
||||
@@ -436,7 +265,7 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
|
||||
|
||||
Ok(WireGuardTestConfig {
|
||||
private_key,
|
||||
address: "10.0.0.2/24".to_string(),
|
||||
address: std::env::var("VPN_TEST_WG_ADDRESS").unwrap_or_else(|_| "10.0.0.2/24".to_string()),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
peer_public_key: public_key,
|
||||
peer_endpoint: format!("{host}:{port}"),
|
||||
@@ -446,35 +275,3 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
|
||||
.unwrap_or_else(|_| "10.0.0.1".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get OpenVPN config from CI environment
|
||||
fn get_ci_openvpn_config(host: &str, port: &str) -> Result<OpenVpnTestConfig, String> {
|
||||
if let Ok(raw_config) = std::env::var("VPN_TEST_OVPN_RAW_CONFIG") {
|
||||
return Ok(OpenVpnTestConfig {
|
||||
raw_config,
|
||||
remote_host: host.to_string(),
|
||||
remote_port: port.parse().unwrap_or(1194),
|
||||
protocol: "udp".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw_config = format!(
|
||||
r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote {host} {port}
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
"#
|
||||
);
|
||||
|
||||
Ok(OpenVpnTestConfig {
|
||||
raw_config,
|
||||
remote_host: host.to_string(),
|
||||
remote_port: port.parse().unwrap_or(1194),
|
||||
protocol: "udp".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ mod test_harness;
|
||||
|
||||
use common::TestUtils;
|
||||
use donutbrowser_lib::vpn::{
|
||||
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
|
||||
VpnStorage, VpnType, WireGuardConfig,
|
||||
detect_vpn_type, parse_wireguard_config, VpnConfig, VpnStorage, VpnType, WireGuardConfig,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
@@ -45,27 +44,6 @@ fn test_wireguard_config_import() {
|
||||
assert_eq!(wg.persistent_keepalive, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_config_import() {
|
||||
let config = include_str!("fixtures/test.ovpn");
|
||||
let result = parse_openvpn_config(config);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse OpenVPN config: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let ovpn = result.unwrap();
|
||||
assert_eq!(ovpn.remote_host, "vpn.example.com");
|
||||
assert_eq!(ovpn.remote_port, 1194);
|
||||
assert_eq!(ovpn.protocol, "udp");
|
||||
assert_eq!(ovpn.dev_type, "tun");
|
||||
assert!(ovpn.has_inline_ca);
|
||||
assert!(ovpn.has_inline_cert);
|
||||
assert!(ovpn.has_inline_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_wireguard_by_extension() {
|
||||
let content = "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = peer";
|
||||
@@ -75,15 +53,6 @@ fn test_detect_vpn_type_wireguard_by_extension() {
|
||||
assert_eq!(result.unwrap(), VpnType::WireGuard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_openvpn_by_extension() {
|
||||
let content = "client\nremote vpn.example.com 1194";
|
||||
let result = detect_vpn_type(content, "my-vpn.ovpn");
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_wireguard_by_content() {
|
||||
let content = r#"
|
||||
@@ -101,20 +70,6 @@ Endpoint = 1.2.3.4:51820
|
||||
assert_eq!(result.unwrap(), VpnType::WireGuard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_openvpn_by_content() {
|
||||
let content = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote vpn.server.com 443
|
||||
"#;
|
||||
let result = detect_vpn_type(content, "config.txt");
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_unknown() {
|
||||
let content = "this is just some random text that is not a vpn config";
|
||||
@@ -123,6 +78,13 @@ fn test_detect_vpn_type_unknown() {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_openvpn_content() {
|
||||
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
|
||||
assert!(detect_vpn_type(content, "old.ovpn").is_err());
|
||||
assert!(detect_vpn_type(content, "config.txt").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wireguard_config_missing_private_key() {
|
||||
let config = r#"
|
||||
@@ -154,32 +116,6 @@ Address = 10.0.0.2/24
|
||||
assert!(err.contains("PublicKey") || err.contains("Peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_config_missing_remote() {
|
||||
let config = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
"#;
|
||||
let result = parse_openvpn_config(config);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_config_with_port_in_remote() {
|
||||
let config = "client\nremote server.example.com 443 tcp";
|
||||
let result = parse_openvpn_config(config);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let ovpn = result.unwrap();
|
||||
assert_eq!(ovpn.remote_host, "server.example.com");
|
||||
assert_eq!(ovpn.remote_port, 443);
|
||||
assert_eq!(ovpn.protocol, "tcp");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storage Tests
|
||||
// ============================================================================
|
||||
@@ -228,16 +164,11 @@ fn test_vpn_storage_list() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let storage = create_test_storage(&temp_dir);
|
||||
|
||||
// Save two configs
|
||||
for i in 1..=2 {
|
||||
let config = VpnConfig {
|
||||
id: format!("list-test-{i}"),
|
||||
name: format!("VPN {i}"),
|
||||
vpn_type: if i == 1 {
|
||||
VpnType::WireGuard
|
||||
} else {
|
||||
VpnType::OpenVPN
|
||||
},
|
||||
vpn_type: VpnType::WireGuard,
|
||||
config_data: "secret data".to_string(),
|
||||
created_at: 1000 * i as i64,
|
||||
last_used: None,
|
||||
@@ -250,7 +181,6 @@ fn test_vpn_storage_list() {
|
||||
let list = storage.list_configs().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
|
||||
// Config data should be empty in listing
|
||||
for cfg in &list {
|
||||
assert!(cfg.config_data.is_empty());
|
||||
}
|
||||
@@ -297,6 +227,52 @@ fn test_vpn_storage_import() {
|
||||
assert!(!imported.id.is_empty());
|
||||
}
|
||||
|
||||
/// Existing OpenVPN entries on disk should be silently dropped at load time
|
||||
/// after support was removed. Stored configs are encrypted at rest, so we
|
||||
/// build the on-disk JSON by hand instead of going through `save_config`.
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_vpn_storage_drops_legacy_openvpn_entries() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let storage_path = temp_dir.path().join("vpn_configs.json");
|
||||
std::fs::write(
|
||||
&storage_path,
|
||||
r#"{
|
||||
"version": 1,
|
||||
"configs": [
|
||||
{
|
||||
"id": "wg-keep",
|
||||
"name": "Keep me",
|
||||
"vpn_type": "WireGuard",
|
||||
"encrypted_data": "",
|
||||
"nonce": "",
|
||||
"created_at": 1,
|
||||
"last_used": null,
|
||||
"sync_enabled": false,
|
||||
"last_sync": null
|
||||
},
|
||||
{
|
||||
"id": "ovpn-drop",
|
||||
"name": "Drop me",
|
||||
"vpn_type": "OpenVPN",
|
||||
"encrypted_data": "",
|
||||
"nonce": "",
|
||||
"created_at": 2,
|
||||
"last_used": null,
|
||||
"sync_enabled": false,
|
||||
"last_sync": null
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let storage = create_test_storage(&temp_dir);
|
||||
let configs = storage.list_configs().unwrap();
|
||||
let ids: Vec<_> = configs.iter().map(|c| c.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["wg-keep"]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
@@ -309,13 +285,9 @@ fn create_test_storage(temp_dir: &tempfile::TempDir) -> VpnStorage {
|
||||
// Connection Tests (require Docker)
|
||||
// ============================================================================
|
||||
|
||||
/// These tests require Docker to be available.
|
||||
/// They are automatically skipped if Docker is not installed.
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_wireguard_tunnel_init() {
|
||||
// This test only verifies tunnel creation, not actual connection
|
||||
let config = WireGuardConfig {
|
||||
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
|
||||
address: "10.0.0.2/24".to_string(),
|
||||
@@ -337,30 +309,6 @@ async fn test_wireguard_tunnel_init() {
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_openvpn_tunnel_init() {
|
||||
// This test only verifies tunnel creation, not actual connection
|
||||
let config = OpenVpnConfig {
|
||||
raw_config: "client\nremote localhost 1194".to_string(),
|
||||
remote_host: "localhost".to_string(),
|
||||
remote_port: 1194,
|
||||
protocol: "udp".to_string(),
|
||||
dev_type: "tun".to_string(),
|
||||
has_inline_ca: false,
|
||||
has_inline_cert: false,
|
||||
has_inline_key: false,
|
||||
};
|
||||
|
||||
use donutbrowser_lib::vpn::{OpenVpnTunnel, VpnTunnel};
|
||||
|
||||
let tunnel = OpenVpnTunnel::new("test-ovpn".to_string(), config);
|
||||
assert_eq!(tunnel.vpn_id(), "test-ovpn");
|
||||
assert!(!tunnel.is_connected());
|
||||
assert_eq!(tunnel.bytes_sent(), 0);
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_tunnel_manager() {
|
||||
@@ -565,45 +513,6 @@ fn build_wireguard_config(config: &test_harness::WireGuardTestConfig) -> String
|
||||
)
|
||||
}
|
||||
|
||||
fn openvpn_client_available() -> bool {
|
||||
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
|
||||
return PathBuf::from(path).exists();
|
||||
}
|
||||
|
||||
std::process::Command::new(if cfg!(windows) { "where" } else { "which" })
|
||||
.arg("openvpn")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn openvpn_adapter_available() -> bool {
|
||||
let openvpn = std::process::Command::new("openvpn")
|
||||
.arg("--show-adapters")
|
||||
.output();
|
||||
|
||||
openvpn
|
||||
.ok()
|
||||
.map(|output| {
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.any(|line| !line.is_empty() && !line.starts_with("Available adapters"))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn openvpn_adapter_available() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn start_proxy_with_upstream(
|
||||
binary_path: &PathBuf,
|
||||
upstream_proxy: &str,
|
||||
@@ -750,10 +659,6 @@ async fn run_proxy_feature_suite(
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Test HTTP traffic through the tunnel to the internal HTTP server running
|
||||
// inside the WireGuard container. This avoids depending on internet access
|
||||
// from Docker (macOS Docker Desktop can't reliably NAT WireGuard tunnel
|
||||
// traffic through to the internet).
|
||||
let internal_url = format!("http://{}:8080/", server_tunnel_ip);
|
||||
let internal_host = format!("{}:8080", server_tunnel_ip);
|
||||
let http_response =
|
||||
@@ -790,7 +695,6 @@ async fn run_proxy_feature_suite(
|
||||
|
||||
stop_proxy(binary_path, &proxy.id).await?;
|
||||
|
||||
// DNS blocklist test: blocklist the tunnel server IP so it gets rejected
|
||||
let blocklist_file = tempfile::NamedTempFile::new()?;
|
||||
std::fs::write(blocklist_file.path(), format!("{server_tunnel_ip}\n"))?;
|
||||
let blocked_proxy = start_proxy_with_upstream(
|
||||
@@ -896,56 +800,3 @@ async fn test_wireguard_traffic_flows_through_donut_proxy(
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_openvpn_traffic_flows_through_donut_proxy(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let _env = TestEnvGuard::new()?;
|
||||
cleanup_runtime().await;
|
||||
|
||||
if std::env::var("DONUTBROWSER_RUN_OPENVPN_E2E")
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= Some("1")
|
||||
{
|
||||
eprintln!("skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !test_harness::is_docker_available() {
|
||||
eprintln!("skipping OpenVPN e2e test because Docker is unavailable");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !openvpn_client_available() {
|
||||
eprintln!("skipping OpenVPN e2e test because the OpenVPN client binary is unavailable");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !openvpn_adapter_available() {
|
||||
eprintln!("skipping OpenVPN e2e test because no Windows OpenVPN adapter is available");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let binary_path = ensure_donut_proxy_binary().await?;
|
||||
let ovpn_config = match test_harness::start_openvpn_server().await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("skipping OpenVPN e2e test: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let vpn_config = new_test_vpn_config("OpenVPN E2E", VpnType::OpenVPN, ovpn_config.raw_config);
|
||||
{
|
||||
let storage = donutbrowser_lib::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage.save_config(&vpn_config)?;
|
||||
}
|
||||
|
||||
// OpenVPN test uses the server's tunnel IP for internal-only traffic.
|
||||
// The OpenVPN server's subnet is 10.9.0.0/24, server at 10.9.0.1.
|
||||
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id, "10.9.0.1").await;
|
||||
cleanup_runtime().await;
|
||||
result
|
||||
}
|
||||
|
||||
+176
-115
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
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 { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
@@ -11,6 +12,7 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog";
|
||||
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
|
||||
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
|
||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||
@@ -67,6 +69,7 @@ interface PendingUrl {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
// Mount global version update listener/toasts
|
||||
useVersionUpdater();
|
||||
|
||||
@@ -195,6 +198,7 @@ export default function Home() {
|
||||
useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
|
||||
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
|
||||
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
|
||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||
@@ -392,21 +396,32 @@ export default function Home() {
|
||||
}
|
||||
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
|
||||
|
||||
const checkNextPermission = useCallback(() => {
|
||||
try {
|
||||
if (!isMicrophoneAccessGranted) {
|
||||
setCurrentPermissionType("microphone");
|
||||
setPermissionDialogOpen(true);
|
||||
} else if (!isCameraAccessGranted) {
|
||||
setCurrentPermissionType("camera");
|
||||
setPermissionDialogOpen(true);
|
||||
} else {
|
||||
setPermissionDialogOpen(false);
|
||||
const checkNextPermission = useCallback(
|
||||
(justGranted?: PermissionType) => {
|
||||
try {
|
||||
// Treat the just-granted permission as already granted even if our
|
||||
// own usePermissions instance hasn't observed it yet — it polls on a
|
||||
// 5 s cadence and would otherwise leave the dialog stuck on the
|
||||
// permission the user just successfully granted.
|
||||
const micGranted =
|
||||
isMicrophoneAccessGranted || justGranted === "microphone";
|
||||
const camGranted = isCameraAccessGranted || justGranted === "camera";
|
||||
|
||||
if (!micGranted) {
|
||||
setCurrentPermissionType("microphone");
|
||||
setPermissionDialogOpen(true);
|
||||
} else if (!camGranted) {
|
||||
setCurrentPermissionType("camera");
|
||||
setPermissionDialogOpen(true);
|
||||
} else {
|
||||
setPermissionDialogOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check next permission:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check next permission:", error);
|
||||
}
|
||||
}, [isMicrophoneAccessGranted, isCameraAccessGranted]);
|
||||
},
|
||||
[isMicrophoneAccessGranted, isCameraAccessGranted],
|
||||
);
|
||||
|
||||
const listenForUrlEvents = useCallback(async () => {
|
||||
try {
|
||||
@@ -428,9 +443,7 @@ export default function Home() {
|
||||
"Received show create profile dialog request:",
|
||||
event.payload,
|
||||
);
|
||||
showErrorToast(
|
||||
"No profiles available. Please create a profile first before opening URLs.",
|
||||
);
|
||||
showErrorToast(t("errors.noProfilesForUrl"));
|
||||
setCreateProfileDialogOpen(true);
|
||||
});
|
||||
|
||||
@@ -455,7 +468,7 @@ export default function Home() {
|
||||
} catch (error) {
|
||||
console.error("Failed to setup URL listener:", error);
|
||||
}
|
||||
}, [handleUrlOpen]);
|
||||
}, [handleUrlOpen, t]);
|
||||
|
||||
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForCamoufoxConfig(profile);
|
||||
@@ -474,12 +487,14 @@ export default function Home() {
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to update camoufox config:", err);
|
||||
showErrorToast(
|
||||
`Failed to update camoufox config: ${JSON.stringify(err)}`,
|
||||
t("errors.updateCamoufoxConfigFailed", {
|
||||
error: JSON.stringify(err),
|
||||
}),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleSaveWayfernConfig = useCallback(
|
||||
@@ -494,12 +509,12 @@ export default function Home() {
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to update wayfern config:", err);
|
||||
showErrorToast(
|
||||
`Failed to update wayfern config: ${JSON.stringify(err)}`,
|
||||
t("errors.updateWayfernConfigFailed", { error: JSON.stringify(err) }),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleCreateProfile = useCallback(
|
||||
@@ -553,84 +568,92 @@ export default function Home() {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (error) {
|
||||
showErrorToast(
|
||||
`Failed to create profile: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
t("errors.createProfileFailed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[selectedGroupId],
|
||||
[selectedGroupId, t],
|
||||
);
|
||||
|
||||
const launchProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
const launchProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
|
||||
// Show one-time warning about window resizing for fingerprinted browsers
|
||||
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
|
||||
try {
|
||||
const dismissed = await invoke<boolean>(
|
||||
"get_window_resize_warning_dismissed",
|
||||
);
|
||||
if (!dismissed) {
|
||||
const proceed = await new Promise<boolean>((resolve) => {
|
||||
windowResizeWarningResolver.current = resolve;
|
||||
setWindowResizeWarningBrowserType(profile.browser);
|
||||
setWindowResizeWarningOpen(true);
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
// Show one-time warning about window resizing for fingerprinted browsers
|
||||
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
|
||||
try {
|
||||
const dismissed = await invoke<boolean>(
|
||||
"get_window_resize_warning_dismissed",
|
||||
);
|
||||
if (!dismissed) {
|
||||
const proceed = await new Promise<boolean>((resolve) => {
|
||||
windowResizeWarningResolver.current = resolve;
|
||||
setWindowResizeWarningBrowserType(profile.browser);
|
||||
setWindowResizeWarningOpen(true);
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check window resize warning:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check window resize warning:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to launch browser: ${errorMessage}`);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
try {
|
||||
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(
|
||||
t("errors.launchBrowserFailed", { error: errorMessage }),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
|
||||
setCloneProfile(profile);
|
||||
}, []);
|
||||
|
||||
const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Attempting to delete profile:", profile.name);
|
||||
const handleDeleteProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
console.log("Attempting to delete profile:", profile.name);
|
||||
|
||||
try {
|
||||
// First check if the browser is running for this profile
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
try {
|
||||
// First check if the browser is running for this profile
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
|
||||
if (isRunning) {
|
||||
if (isRunning) {
|
||||
showErrorToast(t("errors.cannotDeleteRunningProfile"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to delete the profile
|
||||
await invoke("delete_profile", { profileId: profile.id });
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
console.log("Profile deleted successfully");
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete profile:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(
|
||||
"Cannot delete profile while browser is running. Please stop the browser first.",
|
||||
t("errors.deleteProfileFailed", { error: errorMessage }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to delete the profile
|
||||
await invoke("delete_profile", { profileId: profile.id });
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
console.log("Profile deleted successfully");
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete profile:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to delete profile: ${errorMessage}`);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleRenameProfile = useCallback(
|
||||
async (profileId: string, newName: string) => {
|
||||
@@ -639,28 +662,33 @@ export default function Home() {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to rename profile:", err);
|
||||
showErrorToast(`Failed to rename profile: ${JSON.stringify(err)}`);
|
||||
showErrorToast(
|
||||
t("errors.renameProfileFailed", { error: JSON.stringify(err) }),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleKillProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Starting kill for profile:", profile.name);
|
||||
const handleKillProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
console.log("Starting kill for profile:", profile.name);
|
||||
|
||||
try {
|
||||
await invoke("kill_browser_profile", { profile });
|
||||
console.log("Successfully killed profile:", profile.name);
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to kill browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to kill browser: ${errorMessage}`);
|
||||
// Re-throw the error so the table component can handle loading state cleanup
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
try {
|
||||
await invoke("kill_browser_profile", { profile });
|
||||
console.log("Successfully killed profile:", profile.name);
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to kill browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(t("errors.killBrowserFailed", { error: errorMessage }));
|
||||
// Re-throw the error so the table component can handle loading state cleanup
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleDeleteSelectedProfiles = useCallback(
|
||||
async (profileIds: string[]) => {
|
||||
@@ -670,11 +698,13 @@ export default function Home() {
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete selected profiles:", err);
|
||||
showErrorToast(
|
||||
`Failed to delete selected profiles: ${JSON.stringify(err)}`,
|
||||
t("errors.deleteSelectedProfilesFailed", {
|
||||
error: JSON.stringify(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => {
|
||||
@@ -701,12 +731,14 @@ export default function Home() {
|
||||
} catch (error) {
|
||||
console.error("Failed to delete selected profiles:", error);
|
||||
showErrorToast(
|
||||
`Failed to delete selected profiles: ${JSON.stringify(error)}`,
|
||||
t("errors.deleteSelectedProfilesFailed", {
|
||||
error: JSON.stringify(error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
}, [selectedProfiles]);
|
||||
}, [selectedProfiles, t]);
|
||||
|
||||
const handleBulkGroupAssignment = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
@@ -749,14 +781,12 @@ export default function Home() {
|
||||
(p.browser === "wayfern" || p.browser === "camoufox"),
|
||||
);
|
||||
if (eligibleProfiles.length === 0) {
|
||||
showErrorToast(
|
||||
"Cookie copy only works with Wayfern and Camoufox profiles",
|
||||
);
|
||||
showErrorToast(t("errors.cookieCopyUnsupportedBrowser"));
|
||||
return;
|
||||
}
|
||||
setSelectedProfilesForCookies(eligibleProfiles.map((p) => p.id));
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, [selectedProfiles, profiles]);
|
||||
}, [selectedProfiles, profiles, t]);
|
||||
|
||||
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
|
||||
setSelectedProfilesForCookies([profile.id]);
|
||||
@@ -804,10 +834,10 @@ export default function Home() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast("Failed to update sync settings");
|
||||
showErrorToast(t("errors.updateSyncSettingsFailed"));
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -825,19 +855,22 @@ export default function Home() {
|
||||
const { profile_id, status, error, profile_name } = event.payload;
|
||||
const toastId = `sync-${profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile_name || profile?.name || "Unknown";
|
||||
const name =
|
||||
profile_name || profile?.name || t("common.labels.unknownProfile");
|
||||
|
||||
if (status === "synced") {
|
||||
dismissToast(toastId);
|
||||
if (profilesWithTransfer.has(profile_id)) {
|
||||
profilesWithTransfer.delete(profile_id);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
showSuccessToast(t("sync.toast.profileSynced", { name }));
|
||||
}
|
||||
} else if (status === "error") {
|
||||
dismissToast(toastId);
|
||||
profilesWithTransfer.delete(profile_id);
|
||||
showErrorToast(
|
||||
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
|
||||
error
|
||||
? t("sync.toast.profileSyncFailedWithError", { name, error })
|
||||
: t("sync.toast.profileSyncFailed", { name }),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -857,7 +890,10 @@ export default function Home() {
|
||||
const payload = event.payload;
|
||||
const toastId = `sync-${payload.profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === payload.profile_id);
|
||||
const name = payload.profile_name || profile?.name || "Unknown";
|
||||
const name =
|
||||
payload.profile_name ||
|
||||
profile?.name ||
|
||||
t("common.labels.unknownProfile");
|
||||
|
||||
if (
|
||||
payload.phase === "started" ||
|
||||
@@ -889,7 +925,7 @@ export default function Home() {
|
||||
if (unlistenStatus) unlistenStatus();
|
||||
if (unlistenProgress) unlistenProgress();
|
||||
};
|
||||
}, [profiles]);
|
||||
}, [profiles, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for startup default browser prompt
|
||||
@@ -1047,7 +1083,7 @@ export default function Home() {
|
||||
|
||||
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">
|
||||
<main className="flex flex-col items-center w-full max-w-4xl px-3">
|
||||
<div className="w-full">
|
||||
<HomeHeader
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
@@ -1272,9 +1308,13 @@ export default function Home() {
|
||||
setShowBulkDeleteConfirmation(false);
|
||||
}}
|
||||
onConfirm={confirmBulkDelete}
|
||||
title="Delete Selected Profiles"
|
||||
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
|
||||
confirmButtonText={`Delete ${selectedProfiles.length} Profile${selectedProfiles.length !== 1 ? "s" : ""}`}
|
||||
title={t("profiles.bulkDelete.title")}
|
||||
description={t("profiles.bulkDelete.description", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
confirmButtonText={t("profiles.bulkDelete.confirmButton", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
isLoading={isBulkDeleting}
|
||||
profileIds={selectedProfiles}
|
||||
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
|
||||
@@ -1289,8 +1329,29 @@ export default function Home() {
|
||||
setSyncAllDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
onLoginStarted={() => {
|
||||
// Hand the verify step off to its own dialog. We close this one
|
||||
// first so the verify dialog isn't stacked on top of it (and
|
||||
// can't end up stacked on top of the profile selector either).
|
||||
setSyncConfigDialogOpen(false);
|
||||
setDeviceCodeDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Only render while no profile-selector flow is in progress, so the
|
||||
verify dialog never lands on top of a deep-link-triggered selector. */}
|
||||
{pendingUrls.length === 0 && (
|
||||
<DeviceCodeVerifyDialog
|
||||
isOpen={deviceCodeDialogOpen}
|
||||
onClose={(loginOccurred) => {
|
||||
setDeviceCodeDialogOpen(false);
|
||||
if (loginOccurred) {
|
||||
setSyncAllDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SyncAllDialog
|
||||
isOpen={syncAllDialogOpen}
|
||||
onClose={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -51,6 +52,7 @@ export function CamoufoxConfigDialog({
|
||||
isRunning = false,
|
||||
crossOsUnlocked = false,
|
||||
}: CamoufoxConfigDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Use union type to support both Camoufox and Wayfern configs
|
||||
const [config, setConfig] = useState<CamoufoxConfig | WayfernConfig>(() => ({
|
||||
geoip: true,
|
||||
@@ -93,9 +95,8 @@ export function CamoufoxConfigDialog({
|
||||
JSON.parse(config.fingerprint);
|
||||
} catch (_error) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error("Invalid fingerprint configuration", {
|
||||
description:
|
||||
"The fingerprint configuration contains invalid JSON. Please check your advanced settings.",
|
||||
toast.error(t("camoufoxDialog.invalidFingerprint"), {
|
||||
description: t("camoufoxDialog.invalidFingerprintDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -112,9 +113,11 @@ export function CamoufoxConfigDialog({
|
||||
} catch (error) {
|
||||
console.error("Failed to save config:", error);
|
||||
const { toast } = await import("sonner");
|
||||
toast.error("Failed to save configuration", {
|
||||
toast.error(t("camoufoxDialog.saveFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("camoufoxDialog.unknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -149,8 +152,15 @@ export function CamoufoxConfigDialog({
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{isRunning ? "View" : "Configure"} Fingerprint Settings -{" "}
|
||||
{profile.name} ({browserName})
|
||||
{isRunning
|
||||
? t("camoufoxDialog.titleView", {
|
||||
name: profile.name,
|
||||
browser: browserName,
|
||||
})
|
||||
: t("camoufoxDialog.titleConfigure", {
|
||||
name: profile.name,
|
||||
browser: browserName,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -185,7 +195,7 @@ export function CamoufoxConfigDialog({
|
||||
|
||||
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{isRunning ? "Close" : "Cancel"}
|
||||
{isRunning ? t("common.buttons.close") : t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
{!isRunning && (
|
||||
<LoadingButton
|
||||
@@ -193,7 +203,7 @@ export function CamoufoxConfigDialog({
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function CloneProfileDialog({
|
||||
onCloneComplete?.();
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to clone profile: ${errorMessage}`);
|
||||
showErrorToast(t("errors.cloneProfileFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,6 +23,7 @@ export function CommercialTrialModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: CommercialTrialModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isAcknowledging, setIsAcknowledging] = useState(false);
|
||||
|
||||
const handleAcknowledge = useCallback(async () => {
|
||||
@@ -31,14 +33,16 @@ export function CommercialTrialModal({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to acknowledge trial expiration:", error);
|
||||
showErrorToast("Failed to save acknowledgment", {
|
||||
showErrorToast(t("commercialTrial.failed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("commercialTrial.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsAcknowledging(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [onClose, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
@@ -55,17 +59,15 @@ export function CommercialTrialModal({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Commercial Trial Expired</DialogTitle>
|
||||
<DialogTitle>{t("commercialTrial.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your 2-week commercial trial period has ended.
|
||||
{t("commercialTrial.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If you are using Donut Browser for business purposes, you need to
|
||||
purchase a commercial license to continue. You can still use it for
|
||||
personal use for free.
|
||||
{t("commercialTrial.body")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +76,7 @@ export function CommercialTrialModal({
|
||||
onClick={handleAcknowledge}
|
||||
isLoading={isAcknowledging}
|
||||
>
|
||||
I Understand
|
||||
{t("commercialTrial.understandButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
@@ -66,6 +67,7 @@ export function CookieCopyDialog({
|
||||
runningProfiles,
|
||||
onCopyComplete,
|
||||
}: CookieCopyDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sourceProfileId, setSourceProfileId] = useState<string | null>(null);
|
||||
const [cookieData, setCookieData] = useState<CookieReadResult | null>(null);
|
||||
const [isLoadingCookies, setIsLoadingCookies] = useState(false);
|
||||
@@ -243,10 +245,11 @@ export function CookieCopyDialog({
|
||||
runningProfiles.has(p.id),
|
||||
);
|
||||
if (runningTargets.length > 0) {
|
||||
const names = runningTargets.map((p) => p.name).join(", ");
|
||||
toast.error(
|
||||
`Cannot copy cookies: ${runningTargets.map((p) => p.name).join(", ")} ${
|
||||
runningTargets.length === 1 ? "is" : "are"
|
||||
} still running`,
|
||||
runningTargets.length === 1
|
||||
? t("cookies.copy.cannotCopyRunningOne", { names })
|
||||
: t("cookies.copy.cannotCopyRunningMany", { names }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -277,10 +280,15 @@ export function CookieCopyDialog({
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(`Some errors occurred: ${errors.join(", ")}`);
|
||||
toast.error(
|
||||
t("cookies.copy.someErrors", { errors: errors.join(", ") }),
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Successfully copied ${totalCopied + totalReplaced} cookies (${totalReplaced} replaced)`,
|
||||
t("cookies.copy.successMessage", {
|
||||
copied: totalCopied + totalReplaced,
|
||||
replaced: totalReplaced,
|
||||
}),
|
||||
);
|
||||
onCopyComplete?.();
|
||||
onClose();
|
||||
@@ -288,7 +296,9 @@ export function CookieCopyDialog({
|
||||
} catch (err) {
|
||||
console.error("Failed to copy cookies:", err);
|
||||
toast.error(
|
||||
`Failed to copy cookies: ${err instanceof Error ? err.message : String(err)}`,
|
||||
t("cookies.copy.failedMessage", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
@@ -300,6 +310,7 @@ export function CookieCopyDialog({
|
||||
buildSelectedCookies,
|
||||
onCopyComplete,
|
||||
onClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -325,23 +336,30 @@ export function CookieCopyDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuCookie className="w-5 h-5" />
|
||||
Copy Cookies
|
||||
{t("cookies.copy.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy cookies from a source profile to {selectedProfiles.length}{" "}
|
||||
selected profile{selectedProfiles.length !== 1 ? "s" : ""}.
|
||||
{selectedProfiles.length === 1
|
||||
? t("cookies.copy.dialogDescription_one", {
|
||||
count: selectedProfiles.length,
|
||||
})
|
||||
: t("cookies.copy.dialogDescription_other", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Source Profile</Label>
|
||||
<Label>{t("cookies.copy.sourceProfile")}</Label>
|
||||
<Select
|
||||
value={sourceProfileId ?? undefined}
|
||||
onValueChange={handleSourceChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a profile to copy cookies from" />
|
||||
<SelectValue
|
||||
placeholder={t("cookies.copy.sourcePlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eligibleSourceProfiles.map((profile) => {
|
||||
@@ -358,7 +376,7 @@ export function CookieCopyDialog({
|
||||
<span>{profile.name}</span>
|
||||
{isRunning && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(running)
|
||||
{t("cookies.copy.running")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -370,13 +388,17 @@ export function CookieCopyDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Target Profiles ({targetProfiles.length})</Label>
|
||||
<Label>
|
||||
{t("cookies.copy.targetProfiles", {
|
||||
count: targetProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<div className="p-2 bg-muted rounded-md max-h-20 overflow-y-auto">
|
||||
{targetProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sourceProfileId
|
||||
? "No other Wayfern/Camoufox profiles selected"
|
||||
: "Select a source profile first"}
|
||||
? t("cookies.copy.noOtherTargets")
|
||||
: t("cookies.copy.selectSourceFirst")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -388,7 +410,7 @@ export function CookieCopyDialog({
|
||||
{p.name}
|
||||
{runningProfiles.has(p.id) && (
|
||||
<span className="text-xs text-destructive">
|
||||
(running)
|
||||
{t("cookies.copy.running")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -402,11 +424,13 @@ export function CookieCopyDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Select Cookies{" "}
|
||||
{t("cookies.copy.selectCookies")}{" "}
|
||||
{cookieData && (
|
||||
<span className="text-muted-foreground">
|
||||
({selectedCookieCount} of {cookieData.total_count}{" "}
|
||||
selected)
|
||||
{t("cookies.copy.selectionStatus", {
|
||||
selected: selectedCookieCount,
|
||||
total: cookieData.total_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -415,7 +439,7 @@ export function CookieCopyDialog({
|
||||
<div className="relative">
|
||||
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search domains or cookies..."
|
||||
placeholder={t("cookies.copy.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -435,8 +459,8 @@ export function CookieCopyDialog({
|
||||
) : filteredDomains.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No matching cookies found"
|
||||
: "No cookies found"}
|
||||
? t("cookies.copy.noMatching")
|
||||
: t("cookies.copy.noFound")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
@@ -457,8 +481,7 @@ export function CookieCopyDialog({
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Existing cookies with the same name and domain will be replaced.
|
||||
Other cookies will be kept.
|
||||
{t("cookies.copy.replaceNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -470,15 +493,22 @@ export function CookieCopyDialog({
|
||||
onClick={onClose}
|
||||
disabled={isCopying}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isCopying}
|
||||
onClick={() => void handleCopy()}
|
||||
disabled={!canCopy}
|
||||
>
|
||||
Copy {selectedCookieCount > 0 ? `${selectedCookieCount} ` : ""}
|
||||
Cookie{selectedCookieCount !== 1 ? "s" : ""}
|
||||
{selectedCookieCount === 0
|
||||
? t("cookies.copy.copyButtonEmpty")
|
||||
: selectedCookieCount === 1
|
||||
? t("cookies.copy.copyButton_one", {
|
||||
count: selectedCookieCount,
|
||||
})
|
||||
: t("cookies.copy.copyButton_other", {
|
||||
count: selectedCookieCount,
|
||||
})}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -122,6 +123,7 @@ export function CookieManagementDialog({
|
||||
profile,
|
||||
initialTab = "import",
|
||||
}: CookieManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Import state
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
@@ -171,13 +173,15 @@ export function CookieManagementDialog({
|
||||
setExportSelection(initSelectionFromCookieData(result));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Failed to load cookies: ${err instanceof Error ? err.message : String(err)}`,
|
||||
t("cookies.management.loadFailed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingExportCookies(false);
|
||||
}
|
||||
},
|
||||
[exportCookieData],
|
||||
[exportCookieData, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -220,19 +224,22 @@ export function CookieManagementDialog({
|
||||
[resetImportState, resetExportState],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, []);
|
||||
const handleFileRead = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error(t("cookies.management.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!fileContent || !profile) return;
|
||||
@@ -297,14 +304,14 @@ export function CookieManagementDialog({
|
||||
}
|
||||
|
||||
await writeTextFile(filePath, content);
|
||||
toast.success("Cookies exported successfully");
|
||||
toast.success(t("cookies.export.success"));
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [profile, format, getSelectedCookies, handleClose]);
|
||||
}, [profile, format, getSelectedCookies, handleClose, t]);
|
||||
|
||||
const toggleDomain = useCallback(
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
@@ -385,7 +392,7 @@ export function CookieManagementDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cookie Management</DialogTitle>
|
||||
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
@@ -394,15 +401,19 @@ export function CookieManagementDialog({
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="import">Import</TabsTrigger>
|
||||
<TabsTrigger value="export">Export</TabsTrigger>
|
||||
<TabsTrigger value="import">
|
||||
{t("cookies.management.tabImport")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="export">
|
||||
{t("cookies.management.tabExport")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="import" className="space-y-4 mt-4">
|
||||
{!fileContent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Import cookies from a Netscape or JSON format file.
|
||||
{t("cookies.management.importDescription")}
|
||||
</p>
|
||||
<div
|
||||
role="button"
|
||||
@@ -420,9 +431,11 @@ export function CookieManagementDialog({
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a cookie file
|
||||
{t("cookies.management.dropPrompt")}
|
||||
<br />
|
||||
<span className="text-xs">(.txt, .cookies, or .json)</span>
|
||||
<span className="text-xs">
|
||||
{t("cookies.management.fileFormats")}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="cookie-file-input"
|
||||
@@ -445,20 +458,22 @@ export function CookieManagementDialog({
|
||||
<div>
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{cookieCount} cookies found
|
||||
{t("cookies.management.cookiesFound", {
|
||||
count: cookieCount,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={resetImportState}>
|
||||
Back
|
||||
{t("cookies.management.backButton")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={cookieCount === 0}
|
||||
>
|
||||
Import
|
||||
{t("cookies.management.importButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,17 +483,23 @@ export function CookieManagementDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-success/10">
|
||||
<div className="font-medium text-success">
|
||||
Successfully imported {importResult.cookies_imported}{" "}
|
||||
cookies ({importResult.cookies_replaced} replaced)
|
||||
{t("cookies.management.importedSuccess", {
|
||||
imported: importResult.cookies_imported,
|
||||
replaced: importResult.cookies_replaced,
|
||||
})}
|
||||
</div>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{importResult.errors.length} line(s) skipped
|
||||
{t("cookies.management.linesSkipped", {
|
||||
count: importResult.errors.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("cookies.management.doneButton")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -486,7 +507,7 @@ export function CookieManagementDialog({
|
||||
|
||||
<TabsContent value="export" className="space-y-3 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Format</Label>
|
||||
<Label>{t("cookies.export.formatLabel")}</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => {
|
||||
@@ -497,8 +518,12 @@ export function CookieManagementDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="netscape">Netscape TXT</SelectItem>
|
||||
<SelectItem value="json">
|
||||
{t("cookies.export.json")}
|
||||
</SelectItem>
|
||||
<SelectItem value="netscape">
|
||||
{t("cookies.export.netscape")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -506,11 +531,13 @@ export function CookieManagementDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Cookies{" "}
|
||||
{t("cookies.management.cookiesLabel")}{" "}
|
||||
{exportCookieData && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({selectedExportCount} of {exportCookieData.total_count}{" "}
|
||||
selected)
|
||||
{t("cookies.management.selectionStatus", {
|
||||
selected: selectedExportCount,
|
||||
total: exportCookieData.total_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -521,8 +548,8 @@ export function CookieManagementDialog({
|
||||
onClick={toggleSelectAll}
|
||||
>
|
||||
{selectedExportCount === exportCookieData.total_count
|
||||
? "Deselect all"
|
||||
: "Select all"}
|
||||
? t("cookies.management.deselectAll")
|
||||
: t("cookies.management.selectAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -533,7 +560,7 @@ export function CookieManagementDialog({
|
||||
</div>
|
||||
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
|
||||
No cookies found in this profile
|
||||
{t("cookies.management.noCookies")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[200px] border rounded-md">
|
||||
@@ -556,14 +583,14 @@ export function CookieManagementDialog({
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isExporting}
|
||||
onClick={() => void handleExport()}
|
||||
disabled={selectedExportCount === 0}
|
||||
>
|
||||
Export
|
||||
{t("cookies.management.exportButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ export function CreateGroupDialog({
|
||||
onClose,
|
||||
onGroupCreated,
|
||||
}: CreateGroupDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -42,20 +44,20 @@ export function CreateGroupDialog({
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group created successfully");
|
||||
toast.success(t("groups.createSuccess"));
|
||||
onGroupCreated(newGroup);
|
||||
setGroupName("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to create group";
|
||||
err instanceof Error ? err.message : t("groups.createFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [groupName, onGroupCreated, onClose]);
|
||||
}, [groupName, onGroupCreated, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setGroupName("");
|
||||
@@ -67,18 +69,16 @@ export function CreateGroupDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new group to organize your browser profiles.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("groups.createTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("groups.createDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Label htmlFor="group-name">{t("groups.form.name")}</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
placeholder={t("groups.form.namePlaceholder")}
|
||||
value={groupName}
|
||||
onChange={(e) => {
|
||||
setGroupName(e.target.value);
|
||||
@@ -105,14 +105,14 @@ export function CreateGroupDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isCreating}
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!groupName.trim()}
|
||||
>
|
||||
Create
|
||||
{t("common.buttons.create")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -537,7 +537,7 @@ export function CreateProfileDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-full max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-md max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>
|
||||
{currentStep === "browser-selection"
|
||||
@@ -625,10 +625,10 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
Regular Browsers
|
||||
{t("createProfile.regular.title")}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose from supported regular browsers
|
||||
{t("createProfile.regular.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -655,7 +655,7 @@ export function CreateProfileDialog({
|
||||
{browser.label}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Regular Browser
|
||||
{t("createProfile.regular.badge")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -672,7 +672,9 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Label htmlFor="profile-name">
|
||||
{t("createProfile.profileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
@@ -688,7 +690,9 @@ export function CreateProfileDialog({
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
placeholder={t(
|
||||
"createProfile.profileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -722,7 +726,7 @@ export function CreateProfileDialog({
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<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>
|
||||
)}
|
||||
@@ -739,7 +743,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -748,8 +752,9 @@ export function CreateProfileDialog({
|
||||
!getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<p className="text-sm text-warning">
|
||||
Wayfern is not available on your platform
|
||||
yet.
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Wayfern",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -760,11 +765,12 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("wayfern");
|
||||
return `Wayfern version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")
|
||||
?.version,
|
||||
})}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -779,8 +785,8 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("wayfern")
|
||||
? "Downloading..."
|
||||
: "Download"}
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -789,20 +795,22 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
isBrowserVersionAvailable("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("wayfern");
|
||||
return `✓ Wayfern version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("wayfern");
|
||||
return `Downloading Wayfern version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -826,7 +834,7 @@ export function CreateProfileDialog({
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<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>
|
||||
)}
|
||||
@@ -843,7 +851,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -852,8 +860,9 @@ export function CreateProfileDialog({
|
||||
!getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<p className="text-sm text-warning">
|
||||
Camoufox is not available on your platform
|
||||
yet.
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Camoufox",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -864,11 +873,12 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Camoufox version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -883,8 +893,8 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? "Downloading..."
|
||||
: "Download"}
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -893,20 +903,23 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
isBrowserVersionAvailable("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `✓ Camoufox version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Downloading Camoufox version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -940,7 +953,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>
|
||||
)}
|
||||
@@ -971,13 +984,15 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -992,7 +1007,7 @@ export function CreateProfileDialog({
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
{t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1005,26 +1020,31 @@ export function CreateProfileDialog({
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t(
|
||||
"createProfile.version.latestAvailable",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestDownloading",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1035,7 +1055,7 @@ export function CreateProfileDialog({
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy / VPN</Label>
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1044,7 +1064,8 @@ export function CreateProfileDialog({
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
<GoPlus className="mr-1 w-3 h-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
@@ -1061,20 +1082,23 @@ export function CreateProfileDialog({
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
return t("createProfile.proxy.noProxy");
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
v.id === selectedProxyId.slice(4),
|
||||
);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
? `WG — ${vpn.name}`
|
||||
: t("createProfile.proxy.noProxy");
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
return (
|
||||
proxy?.name ??
|
||||
t("createProfile.proxy.noProxy")
|
||||
);
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -1084,10 +1108,14 @@ export function CreateProfileDialog({
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"createProfile.proxy.search",
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
{t("createProfile.proxy.notFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
@@ -1105,7 +1133,7 @@ export function CreateProfileDialog({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("common.labels.none")}
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
@@ -1154,9 +1182,7 @@ export function CreateProfileDialog({
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
WG
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
@@ -1169,8 +1195,7 @@ export function CreateProfileDialog({
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1267,7 +1292,9 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Label htmlFor="profile-name">
|
||||
{t("createProfile.profileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
@@ -1283,7 +1310,9 @@ export function CreateProfileDialog({
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
placeholder={t(
|
||||
"createProfile.profileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1312,7 +1341,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1325,13 +1354,15 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -1346,7 +1377,7 @@ export function CreateProfileDialog({
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
{t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1357,24 +1388,30 @@ export function CreateProfileDialog({
|
||||
) &&
|
||||
isBrowserVersionAvailable(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t(
|
||||
"createProfile.version.latestAvailable",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestDownloading",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(selectedBrowser)
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1384,7 +1421,7 @@ export function CreateProfileDialog({
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy / VPN</Label>
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1393,7 +1430,8 @@ export function CreateProfileDialog({
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
<GoPlus className="mr-1 w-3 h-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
@@ -1410,20 +1448,23 @@ export function CreateProfileDialog({
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
return t("createProfile.proxy.noProxy");
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
v.id === selectedProxyId.slice(4),
|
||||
);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
? `WG — ${vpn.name}`
|
||||
: t("createProfile.proxy.noProxy");
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
return (
|
||||
proxy?.name ??
|
||||
t("createProfile.proxy.noProxy")
|
||||
);
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -1433,10 +1474,14 @@ export function CreateProfileDialog({
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"createProfile.proxy.search",
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
{t("createProfile.proxy.notFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
@@ -1454,7 +1499,7 @@ export function CreateProfileDialog({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("common.labels.none")}
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
@@ -1503,9 +1548,7 @@ export function CreateProfileDialog({
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
WG
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
@@ -1518,8 +1561,7 @@ export function CreateProfileDialog({
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1553,19 +1595,19 @@ export function CreateProfileDialog({
|
||||
{currentStep === "browser-config" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleBack}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={handleCreate}
|
||||
isLoading={isCreating}
|
||||
disabled={isCreateDisabled}
|
||||
>
|
||||
Create
|
||||
{t("common.buttons.create")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
*/
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: TODO */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuCheckCheck,
|
||||
LuDownload,
|
||||
@@ -214,6 +215,7 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
}
|
||||
|
||||
export function UnifiedToast(props: ToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const { title, description, type, action, onCancel } = props;
|
||||
const stage = "stage" in props ? props.stage : undefined;
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
@@ -231,7 +233,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="Cancel"
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<LuX className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -292,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)} /{" "}
|
||||
@@ -347,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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Table } from "@tanstack/react-table";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -134,6 +135,7 @@ interface DataTableActionBarSelectionProps<TData> {
|
||||
function DataTableActionBarSelection<TData>({
|
||||
table,
|
||||
}: DataTableActionBarSelectionProps<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const onClearSelection = React.useCallback(() => {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}, [table]);
|
||||
@@ -141,7 +143,9 @@ function DataTableActionBarSelection<TData>({
|
||||
return (
|
||||
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
{t("dataTableActionBar.selected", {
|
||||
count: table.getFilteredSelectedRowModel().rows.length,
|
||||
})}
|
||||
</span>
|
||||
<div className="mr-1 ml-2 h-4 w-px bg-border" />
|
||||
<Tooltip>
|
||||
@@ -159,9 +163,9 @@ function DataTableActionBarSelection<TData>({
|
||||
sideOffset={10}
|
||||
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
|
||||
>
|
||||
<p>Clear selection</p>
|
||||
<p>{t("dataTableActionBar.clearSelection")}</p>
|
||||
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
|
||||
<abbr title="Escape" className="no-underline">
|
||||
<abbr title={t("common.keys.escape")} className="no-underline">
|
||||
Esc
|
||||
</abbr>
|
||||
</kbd>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -29,11 +30,12 @@ export function DeleteConfirmationDialog({
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmButtonText = "Delete",
|
||||
confirmButtonText,
|
||||
isLoading = false,
|
||||
profileIds,
|
||||
profiles = [],
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
};
|
||||
@@ -47,7 +49,7 @@ export function DeleteConfirmationDialog({
|
||||
{profileIds && profileIds.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Profiles to be deleted:
|
||||
{t("deleteDialog.profilesToDelete")}
|
||||
</p>
|
||||
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
@@ -71,14 +73,14 @@ export function DeleteConfirmationDialog({
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
onClick={() => void handleConfirm()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{confirmButtonText}
|
||||
{confirmButtonText ?? t("common.buttons.delete")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -56,11 +56,13 @@ export function DeleteGroupDialog({
|
||||
setAssociatedProfiles(groupProfiles);
|
||||
} catch (err) {
|
||||
console.error("Failed to load associated profiles:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load profiles");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("groups.loadProfilesFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [group]);
|
||||
}, [group, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && group) {
|
||||
@@ -90,19 +92,19 @@ export function DeleteGroupDialog({
|
||||
// Delete the group
|
||||
await invoke("delete_profile_group", { groupId: group.id });
|
||||
|
||||
toast.success("Group deleted successfully");
|
||||
toast.success(t("groups.deleteSuccess"));
|
||||
onGroupDeleted();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to delete group";
|
||||
err instanceof Error ? err.message : t("groups.deleteFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose]);
|
||||
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -115,17 +117,14 @@ export function DeleteGroupDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the group
|
||||
"{group?.name}".
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("groups.deleteTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("groups.deleteDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading associated profiles...
|
||||
{t("groups.loadingProfiles")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -133,7 +132,9 @@ export function DeleteGroupDialog({
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Associated Profiles ({associatedProfiles.length})
|
||||
{t("groups.associatedProfiles", {
|
||||
count: associatedProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
||||
<div className="space-y-1">
|
||||
@@ -147,7 +148,7 @@ export function DeleteGroupDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>What should happen to these profiles?</Label>
|
||||
<Label>{t("groups.whatToDoWithProfiles")}</Label>
|
||||
<RadioGroup
|
||||
value={deleteAction}
|
||||
onValueChange={(value) => {
|
||||
@@ -166,7 +167,7 @@ export function DeleteGroupDialog({
|
||||
htmlFor="delete"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
Delete profiles along with the group
|
||||
{t("groups.deleteAlongWithGroup")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
@@ -176,7 +177,7 @@ export function DeleteGroupDialog({
|
||||
|
||||
{associatedProfiles.length === 0 && !isLoading && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This group has no associated profiles.
|
||||
{t("groups.noAssociatedProfiles")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -195,7 +196,7 @@ export function DeleteGroupDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
@@ -203,10 +204,9 @@ export function DeleteGroupDialog({
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete Group
|
||||
{deleteAction === "delete" &&
|
||||
associatedProfiles.length > 0 &&
|
||||
" & Profiles"}
|
||||
{deleteAction === "delete" && associatedProfiles.length > 0
|
||||
? t("groups.deleteGroupAndProfiles")
|
||||
: t("groups.deleteGroup")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
|
||||
interface DeviceCodeVerifyDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: (loginOccurred?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated dialog for pasting and verifying the cloud device-link code.
|
||||
* Opens after the user clicks "Login" in the sync config dialog so the
|
||||
* verify step is a focused step on its own — and so it doesn't visually
|
||||
* stack with other dialogs (e.g. the profile selector triggered by a
|
||||
* deep link) sharing the same view.
|
||||
*/
|
||||
export function DeviceCodeVerifyDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: DeviceCodeVerifyDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { exchangeDeviceCode } = useCloudAuth();
|
||||
const [linkCode, setLinkCode] = useState("");
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
// Reset the field when the dialog reopens so a stale code from a
|
||||
// previous attempt doesn't auto-populate.
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLinkCode("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleVerify = async () => {
|
||||
const trimmed = linkCode.trim();
|
||||
if (!trimmed) return;
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await exchangeDeviceCode(trimmed);
|
||||
showSuccessToast(t("sync.cloud.loginSuccess"));
|
||||
try {
|
||||
await invoke("restart_sync_service");
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
console.error("Device-code exchange failed:", error);
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sync.cloud.verifyAndLogin")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sync.cloud.deviceLinkInstructions")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="device-link-code">
|
||||
{t("sync.cloud.linkCodeLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="device-link-code"
|
||||
placeholder={t("sync.cloud.linkCodePlaceholder")}
|
||||
value={linkCode}
|
||||
onChange={(e) => {
|
||||
setLinkCode(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && linkCode.trim()) {
|
||||
void handleVerify();
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={() => void handleVerify()}
|
||||
isLoading={isVerifying}
|
||||
disabled={!linkCode.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isVerifying
|
||||
? t("sync.cloud.loggingIn")
|
||||
: t("sync.cloud.verifyAndLogin")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ export function EditGroupDialog({
|
||||
group,
|
||||
onGroupUpdated,
|
||||
}: EditGroupDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -54,19 +56,19 @@ export function EditGroupDialog({
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group updated successfully");
|
||||
toast.success(t("groups.updateSuccess"));
|
||||
onGroupUpdated(updatedGroup);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to update group";
|
||||
err instanceof Error ? err.message : t("groups.updateFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [group, groupName, onGroupUpdated, onClose]);
|
||||
}, [group, groupName, onGroupUpdated, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -77,18 +79,16 @@ export function EditGroupDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the name of the group "{group?.name}".
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("groups.editTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("groups.editDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Label htmlFor="group-name">{t("groups.form.name")}</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
placeholder={t("groups.form.namePlaceholder")}
|
||||
value={groupName}
|
||||
onChange={(e) => {
|
||||
setGroupName(e.target.value);
|
||||
@@ -115,14 +115,14 @@ export function EditGroupDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isUpdating}
|
||||
onClick={() => void handleUpdate()}
|
||||
disabled={!groupName.trim() || groupName === group?.name}
|
||||
>
|
||||
Update Group
|
||||
{t("groups.edit")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -674,6 +681,7 @@ export function ExtensionManagementDialog({
|
||||
const syncDot = getSyncStatusDot(
|
||||
ext,
|
||||
extSyncStatus[ext.id],
|
||||
t,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
@@ -840,6 +848,7 @@ export function ExtensionManagementDialog({
|
||||
const groupSyncDot = getSyncStatusDot(
|
||||
group,
|
||||
extSyncStatus[group.id],
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -995,7 +1004,7 @@ export function ExtensionManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.editGroup")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1003,87 +1012,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 +1128,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 +1136,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
|
||||
|
||||
@@ -57,11 +57,13 @@ export function GroupAssignmentDialog({
|
||||
setGroups(groupList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("groupManagement.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
@@ -73,7 +75,8 @@ export function GroupAssignmentDialog({
|
||||
});
|
||||
|
||||
const groupName = selectedGroupId
|
||||
? groups.find((g) => g.id === selectedGroupId)?.name || "Unknown Group"
|
||||
? groups.find((g) => g.id === selectedGroupId)?.name ||
|
||||
t("groups.unknownGroup")
|
||||
: t("groups.defaultGroup");
|
||||
|
||||
toast.success(
|
||||
@@ -89,7 +92,7 @@ export function GroupAssignmentDialog({
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign profiles to group";
|
||||
: t("groupAssignment.failedFallback");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -116,15 +119,21 @@ export function GroupAssignmentDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Group</DialogTitle>
|
||||
<DialogTitle>{t("groupAssignment.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProfiles.length} selected profile(s) to a group.
|
||||
{selectedProfiles.length === 1
|
||||
? t("groupAssignment.description_one", {
|
||||
count: selectedProfiles.length,
|
||||
})
|
||||
: t("groupAssignment.description_other", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Selected Profiles:</Label>
|
||||
<Label>{t("groupAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
@@ -145,7 +154,9 @@ export function GroupAssignmentDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="group-select">Assign to Group:</Label>
|
||||
<Label htmlFor="group-select">
|
||||
{t("groupAssignment.assignGroupLabel")}
|
||||
</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -154,12 +165,13 @@ export function GroupAssignmentDialog({
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Create Group
|
||||
<GoPlus className="mr-1 w-3 h-3" />{" "}
|
||||
{t("groupManagement.createGroup")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
{t("groupManagement.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
@@ -169,7 +181,7 @@ export function GroupAssignmentDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a group" />
|
||||
<SelectValue placeholder={t("groupAssignment.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
@@ -198,14 +210,14 @@ export function GroupAssignmentDialog({
|
||||
onClick={onClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isAssigning}
|
||||
onClick={() => void handleAssign()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Assign
|
||||
{t("groupAssignment.assignButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -156,7 +156,7 @@ export function GroupBadges({
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
role="region"
|
||||
aria-label="Profile groups"
|
||||
aria-label={t("groups.profileGroupsAriaLabel")}
|
||||
className={`flex gap-2 overflow-x-auto pb-2 -mb-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
onScroll={checkScrollPosition}
|
||||
onMouseDown={handleMouseDown}
|
||||
|
||||
@@ -44,37 +44,46 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
group: GroupWithCount,
|
||||
liveStatus: SyncStatus | undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
errorMessage?: string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: t("syncTooltips.syncing"),
|
||||
animate: true,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: group.last_sync
|
||||
? `Synced ${new Date(group.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("syncTooltips.syncedAt", {
|
||||
time: new Date(group.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("syncTooltips.synced"),
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Waiting to sync",
|
||||
tooltip: t("syncTooltips.waiting"),
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
tooltip: errorMessage
|
||||
? t("syncTooltips.errorWith", { error: errorMessage })
|
||||
: t("syncTooltips.error"),
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Not synced",
|
||||
tooltip: t("syncTooltips.notSynced"),
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
@@ -165,11 +174,13 @@ export function GroupManagementDialog({
|
||||
setGroupInUse(inUse);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("groupManagement.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleGroupCreated = useCallback(
|
||||
(_newGroup: ProfileGroup) => {
|
||||
@@ -210,18 +221,24 @@ export function GroupManagementDialog({
|
||||
groupId: group.id,
|
||||
enabled: !group.sync_enabled,
|
||||
});
|
||||
showSuccessToast(group.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
showSuccessToast(
|
||||
group.sync_enabled
|
||||
? t("proxies.management.syncDisabled")
|
||||
: t("proxies.management.syncEnabled"),
|
||||
);
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [group.id]: false }));
|
||||
}
|
||||
},
|
||||
[loadGroups],
|
||||
[loadGroups, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -244,7 +261,7 @@ export function GroupManagementDialog({
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<Label>{t("groupManagement.groupsLabel")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
@@ -253,7 +270,7 @@ export function GroupManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
{t("proxies.management.create")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
@@ -266,7 +283,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">
|
||||
@@ -278,10 +295,16 @@ export function GroupManagementDialog({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="w-20">
|
||||
{t("groupManagement.profilesCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -289,6 +312,7 @@ export function GroupManagementDialog({
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
t,
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
@@ -332,14 +356,13 @@ export function GroupManagementDialog({
|
||||
<TooltipContent>
|
||||
{groupInUse[group.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
{t("groupManagement.syncCannotDisable")}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
? t("proxies.management.disableSync")
|
||||
: t("proxies.management.enableSync")}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
@@ -360,7 +383,9 @@ export function GroupManagementDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
<p>
|
||||
{t("groupManagement.editGroupTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -376,7 +401,9 @@ export function GroupManagementDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
<p>
|
||||
{t("groupManagement.deleteGroupTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -393,7 +420,7 @@ export function GroupManagementDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -272,7 +272,7 @@ const HomeHeader = ({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex gap-2 items-center h-[36px]"
|
||||
className="flex gap-2 items-center h-[36px] border-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<GoKebabHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -49,6 +50,7 @@ export function ImportProfileDialog({
|
||||
onClose,
|
||||
crossOsUnlocked,
|
||||
}: ImportProfileDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
|
||||
[],
|
||||
);
|
||||
@@ -103,11 +105,11 @@ export function ImportProfileDialog({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to detect existing profiles:", error);
|
||||
toast.error("Failed to detect existing browser profiles");
|
||||
toast.error(t("importProfile.detectFailed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const selectedProfile = detectedProfiles.find(
|
||||
(p) => p.path === selectedDetectedProfile,
|
||||
@@ -118,7 +120,7 @@ export function ImportProfileDialog({
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Select Browser Profile Folder",
|
||||
title: t("importProfile.selectFolderTitle"),
|
||||
});
|
||||
|
||||
if (selected && typeof selected === "string") {
|
||||
@@ -126,7 +128,7 @@ export function ImportProfileDialog({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open folder dialog:", error);
|
||||
toast.error("Failed to open folder dialog");
|
||||
toast.error(t("importProfile.folderDialogFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,14 +139,14 @@ export function ImportProfileDialog({
|
||||
|
||||
if (importMode === "auto-detect") {
|
||||
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
|
||||
toast.error("Please select a profile and provide a name");
|
||||
toast.error(t("importProfile.selectAndName"));
|
||||
return;
|
||||
}
|
||||
const profile = detectedProfiles.find(
|
||||
(p) => p.path === selectedDetectedProfile,
|
||||
);
|
||||
if (!profile) {
|
||||
toast.error("Selected profile not found");
|
||||
toast.error(t("importProfile.profileNotFound"));
|
||||
return;
|
||||
}
|
||||
sourcePath = profile.path;
|
||||
@@ -156,7 +158,7 @@ export function ImportProfileDialog({
|
||||
!manualProfilePath.trim() ||
|
||||
!manualProfileName.trim()
|
||||
) {
|
||||
toast.error("Please fill in all fields");
|
||||
toast.error(t("importProfile.fillFields"));
|
||||
return;
|
||||
}
|
||||
sourcePath = manualProfilePath.trim();
|
||||
@@ -180,7 +182,9 @@ export function ImportProfileDialog({
|
||||
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
|
||||
});
|
||||
|
||||
toast.success(`Successfully imported profile "${newProfileName}"`);
|
||||
toast.success(
|
||||
t("importProfile.importedSuccess", { name: newProfileName }),
|
||||
);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to import profile:", error);
|
||||
@@ -190,13 +194,13 @@ export function ImportProfileDialog({
|
||||
if (errorMessage.includes("No downloaded versions found")) {
|
||||
const browserDisplayName = getBrowserDisplayName(browserType);
|
||||
toast.error(
|
||||
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
|
||||
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
||||
{
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to import profile: ${errorMessage}`);
|
||||
toast.error(t("importProfile.importFailed", { error: errorMessage }));
|
||||
}
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
@@ -214,6 +218,7 @@ export function ImportProfileDialog({
|
||||
wayfernConfig,
|
||||
onClose,
|
||||
selectedProfile,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -290,7 +295,7 @@ export function ImportProfileDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Import Browser Profile</DialogTitle>
|
||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
|
||||
@@ -305,7 +310,7 @@ export function ImportProfileDialog({
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Auto-Detect
|
||||
{t("importProfile.autoDetect")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant={importMode === "manual" ? "default" : "outline"}
|
||||
@@ -315,30 +320,29 @@ export function ImportProfileDialog({
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Manual Import
|
||||
{t("importProfile.manualImport")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{importMode === "auto-detect" && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
Detected Browser Profiles
|
||||
{t("importProfile.detectedProfilesTitle")}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Scanning for browser profiles...
|
||||
{t("importProfile.scanning")}
|
||||
</p>
|
||||
</div>
|
||||
) : detectedProfiles.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No browser profiles found on your system.
|
||||
{t("importProfile.noneFound")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Try the manual import option if you have profiles in
|
||||
custom locations.
|
||||
{t("importProfile.noneFoundHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -348,7 +352,7 @@ export function ImportProfileDialog({
|
||||
htmlFor="detected-profile-select"
|
||||
className="mb-2"
|
||||
>
|
||||
Select Profile:
|
||||
{t("importProfile.selectProfile")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedDetectedProfile ?? undefined}
|
||||
@@ -357,7 +361,11 @@ export function ImportProfileDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="detected-profile-select">
|
||||
<SelectValue placeholder="Choose a detected profile" />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"importProfile.selectProfilePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{detectedProfiles.map((profile) => {
|
||||
@@ -395,11 +403,15 @@ export function ImportProfileDialog({
|
||||
{selectedProfile && (
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Path:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("importProfile.pathLabel")}
|
||||
</span>{" "}
|
||||
{selectedProfile.path}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Browser:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("importProfile.browserLabel")}
|
||||
</span>{" "}
|
||||
{getBrowserDisplayName(selectedProfile.browser)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -407,7 +419,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div>
|
||||
<Label htmlFor="auto-profile-name" className="mb-2">
|
||||
New Profile Name:
|
||||
{t("importProfile.newProfileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="auto-profile-name"
|
||||
@@ -415,7 +427,9 @@ export function ImportProfileDialog({
|
||||
onChange={(e) => {
|
||||
setAutoDetectProfileName(e.target.value);
|
||||
}}
|
||||
placeholder="Enter a name for the imported profile"
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -425,12 +439,14 @@ export function ImportProfileDialog({
|
||||
|
||||
{importMode === "manual" && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Manual Profile Import</h3>
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.manualTitle")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="manual-browser-select" className="mb-2">
|
||||
Browser Type:
|
||||
{t("importProfile.browserType")}
|
||||
</Label>
|
||||
<Select
|
||||
value={manualBrowserType ?? undefined}
|
||||
@@ -443,8 +459,8 @@ export function ImportProfileDialog({
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingSupport
|
||||
? "Loading browsers..."
|
||||
: "Select browser type"
|
||||
? t("importProfile.loadingBrowsers")
|
||||
: t("importProfile.selectBrowserType")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
@@ -468,7 +484,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-path" className="mb-2">
|
||||
Profile Folder Path:
|
||||
{t("importProfile.profileFolderPath")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -477,19 +493,21 @@ export function ImportProfileDialog({
|
||||
onChange={(e) => {
|
||||
setManualProfilePath(e.target.value);
|
||||
}}
|
||||
placeholder="Enter the full path to the profile folder"
|
||||
placeholder={t(
|
||||
"importProfile.profileFolderPlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseFolder()}
|
||||
title="Browse for folder"
|
||||
title={t("importProfile.browseFolderTitle")}
|
||||
>
|
||||
<FaFolder className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Example paths:
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application
|
||||
Support/Firefox/Profiles/xxx.default
|
||||
@@ -502,7 +520,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-name" className="mb-2">
|
||||
New Profile Name:
|
||||
{t("importProfile.newProfileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-profile-name"
|
||||
@@ -510,7 +528,9 @@ export function ImportProfileDialog({
|
||||
onChange={(e) => {
|
||||
setManualProfileName(e.target.value);
|
||||
}}
|
||||
placeholder="Enter a name for the imported profile"
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,14 +543,16 @@ export function ImportProfileDialog({
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
This profile will be imported as a{" "}
|
||||
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "}
|
||||
profile.
|
||||
{t("importProfile.importedAs", {
|
||||
browser: getBrowserDisplayName(currentMappedBrowser),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Proxy (Optional)</Label>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.proxyOptional")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedProxyId ?? "none"}
|
||||
onValueChange={(value) => {
|
||||
@@ -538,10 +560,12 @@ export function ImportProfileDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
<SelectValue placeholder={t("importProfile.noProxy")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noProxy")}
|
||||
</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
@@ -580,7 +604,7 @@ export function ImportProfileDialog({
|
||||
{currentStep === "select" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
disabled={!canProceedToNext}
|
||||
@@ -588,7 +612,7 @@ export function ImportProfileDialog({
|
||||
setCurrentStep("configure");
|
||||
}}
|
||||
>
|
||||
Next
|
||||
{t("importProfile.nextButton")}
|
||||
</RippleButton>
|
||||
</>
|
||||
) : (
|
||||
@@ -599,7 +623,7 @@ export function ImportProfileDialog({
|
||||
setCurrentStep("select");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
@@ -607,7 +631,7 @@ export function ImportProfileDialog({
|
||||
void handleImport();
|
||||
}}
|
||||
>
|
||||
Import
|
||||
{t("importProfile.importButton")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function IntegrationsDialog({
|
||||
settings: { ...settings, api_enabled: true },
|
||||
});
|
||||
setSettings(next);
|
||||
showSuccessToast(`API server started on port ${port}`);
|
||||
showSuccessToast(t("integrations.apiStarted", { port }));
|
||||
} else {
|
||||
await invoke("stop_api_server");
|
||||
setApiServerPort(null);
|
||||
@@ -156,12 +156,13 @@ export function IntegrationsDialog({
|
||||
settings: { ...settings, api_enabled: false, api_token: null },
|
||||
});
|
||||
setSettings(next);
|
||||
showSuccessToast("API server stopped");
|
||||
showSuccessToast(t("integrations.apiStopped"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle API:", e);
|
||||
showErrorToast("Failed to toggle API server", {
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
showErrorToast(t("integrations.apiToggleFailed"), {
|
||||
description:
|
||||
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
@@ -178,7 +179,7 @@ export function IntegrationsDialog({
|
||||
});
|
||||
setSettings(next);
|
||||
void loadMcpConfig();
|
||||
showSuccessToast(`MCP server started on port ${port}`);
|
||||
showSuccessToast(t("integrations.mcpStarted", { port }));
|
||||
} else {
|
||||
await invoke("stop_mcp_server");
|
||||
const next = await invoke<AppSettings>("save_app_settings", {
|
||||
@@ -186,12 +187,13 @@ export function IntegrationsDialog({
|
||||
});
|
||||
setSettings(next);
|
||||
setMcpConfig(null);
|
||||
showSuccessToast("MCP server stopped");
|
||||
showSuccessToast(t("integrations.mcpStopped"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle MCP server:", e);
|
||||
showErrorToast("Failed to toggle MCP server", {
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
showErrorToast(t("integrations.mcpToggleFailed"), {
|
||||
description:
|
||||
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsMcpStarting(false);
|
||||
@@ -207,14 +209,14 @@ export function IntegrationsDialog({
|
||||
>
|
||||
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>Integrations</DialogTitle>
|
||||
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<Tabs defaultValue="api" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="api">Local API</TabsTrigger>
|
||||
<TabsTrigger value="mcp">MCP (AI Assistants)</TabsTrigger>
|
||||
<TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger>
|
||||
<TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="api" className="space-y-4 mt-4">
|
||||
@@ -230,10 +232,10 @@ export function IntegrationsDialog({
|
||||
htmlFor="api-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable Local API Server
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow managing profiles, groups, and proxies via REST API.
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,7 +243,9 @@ export function IntegrationsDialog({
|
||||
{settings.api_enabled && (
|
||||
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Port</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -251,8 +255,10 @@ export function IntegrationsDialog({
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast("Invalid port", {
|
||||
description: "Port must be between 1 and 65535",
|
||||
showErrorToast(t("integrations.apiInvalidPort"), {
|
||||
description: t(
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -270,20 +276,28 @@ export function IntegrationsDialog({
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(`Port ${port} is already in use`, {
|
||||
description: `Server started on fallback port ${actualPort}`,
|
||||
});
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
`API server running on port ${actualPort}`,
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast("Failed to start API server", {
|
||||
showErrorToast(t("integrations.apiStartFailed"), {
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Unknown error",
|
||||
: t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
@@ -315,7 +329,7 @@ export function IntegrationsDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Authentication Token
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -343,11 +357,13 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage="Token copied"
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include in Authorization header: Bearer {"<token>"}
|
||||
{t("integrations.apiTokenHint", {
|
||||
tokenSlot: "<token>",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,13 +383,13 @@ export function IntegrationsDialog({
|
||||
htmlFor="mcp-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable MCP Server (Model Context Protocol)
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow AI assistants like Claude Desktop to control browsers.
|
||||
{t("integrations.mcpEnableDescription")}
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-warning">
|
||||
(Accept Wayfern terms in Settings first)
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ export function LaunchOnLoginDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LaunchOnLoginDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isEnabling, setIsEnabling] = useState(false);
|
||||
const [isDeclining, setIsDeclining] = useState(false);
|
||||
|
||||
@@ -29,18 +31,18 @@ export function LaunchOnLoginDialog({
|
||||
setIsEnabling(true);
|
||||
try {
|
||||
await invoke("enable_launch_on_login");
|
||||
showSuccessToast("Launch on login enabled");
|
||||
showSuccessToast(t("launchOnLogin.enableSuccess"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to enable launch on login:", error);
|
||||
showErrorToast("Failed to enable launch on login", {
|
||||
showErrorToast(t("launchOnLogin.enableFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsEnabling(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [onClose, t]);
|
||||
|
||||
const handleDecline = useCallback(async () => {
|
||||
setIsDeclining(true);
|
||||
@@ -49,14 +51,14 @@ export function LaunchOnLoginDialog({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to decline launch on login:", error);
|
||||
showErrorToast("Failed to save preference", {
|
||||
showErrorToast(t("launchOnLogin.declineFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsDeclining(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [onClose, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
@@ -73,11 +75,11 @@ export function LaunchOnLoginDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Launch on Login?</DialogTitle>
|
||||
<DialogTitle>{t("launchOnLogin.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Running in the background helps keep your proxies and browsers alive.
|
||||
{t("launchOnLogin.description")}
|
||||
</p>
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||
@@ -86,14 +88,16 @@ export function LaunchOnLoginDialog({
|
||||
onClick={handleDecline}
|
||||
disabled={isEnabling || isDeclining}
|
||||
>
|
||||
{isDeclining ? "..." : "Don't Ask Again"}
|
||||
{isDeclining
|
||||
? t("launchOnLogin.declining")
|
||||
: t("launchOnLogin.declineButton")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleEnable}
|
||||
isLoading={isEnabling}
|
||||
disabled={isDeclining}
|
||||
>
|
||||
Enable
|
||||
{t("launchOnLogin.enableButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
@@ -29,6 +30,7 @@ export function LocationProxyDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LocationProxyDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [countries, setCountries] = useState<LocationItem[]>([]);
|
||||
const [regions, setRegions] = useState<LocationItem[]>([]);
|
||||
const [cities, setCities] = useState<LocationItem[]>([]);
|
||||
@@ -68,12 +70,12 @@ export function LocationProxyDialog({
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch countries:", err);
|
||||
toast.error("Failed to load countries");
|
||||
toast.error(t("locationProxy.loadFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCountries(false);
|
||||
});
|
||||
}, [isOpen]);
|
||||
}, [isOpen, t]);
|
||||
|
||||
// Fetch regions when country changes
|
||||
useEffect(() => {
|
||||
@@ -188,13 +190,13 @@ export function LocationProxyDialog({
|
||||
city: selectedCity || null,
|
||||
isp: selectedIsp || null,
|
||||
});
|
||||
toast.success("Location proxy created");
|
||||
toast.success(t("locationProxy.createSuccess"));
|
||||
await emit("stored-proxies-changed");
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create location proxy:", error);
|
||||
toast.error(
|
||||
typeof error === "string" ? error : "Failed to create location proxy",
|
||||
typeof error === "string" ? error : t("locationProxy.createFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
@@ -206,6 +208,7 @@ export function LocationProxyDialog({
|
||||
selectedIsp,
|
||||
proxyName,
|
||||
handleClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
const countryOptions = countries.map((c) => ({
|
||||
@@ -224,9 +227,9 @@ export function LocationProxyDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Location Proxy</DialogTitle>
|
||||
<DialogTitle>{t("locationProxy.titleCreate")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a geo-targeted proxy with a 24-hour sticky session
|
||||
{t("locationProxy.descriptionCreate")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -234,7 +237,7 @@ export function LocationProxyDialog({
|
||||
{/* Country - always visible */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
Country (required)
|
||||
{t("locationProxy.countryLabel")}
|
||||
{isLoadingCountries && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -242,9 +245,11 @@ export function LocationProxyDialog({
|
||||
value={selectedCountry}
|
||||
onValueChange={setSelectedCountry}
|
||||
placeholder={
|
||||
isLoadingCountries ? "Loading countries..." : "Select country"
|
||||
isLoadingCountries
|
||||
? t("locationProxy.loadingCountries")
|
||||
: t("locationProxy.selectCountryPh")
|
||||
}
|
||||
searchPlaceholder="Search countries..."
|
||||
searchPlaceholder={t("locationProxy.searchCountries")}
|
||||
disabled={isLoadingCountries}
|
||||
/>
|
||||
</div>
|
||||
@@ -252,7 +257,7 @@ export function LocationProxyDialog({
|
||||
{/* Region - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
Region (optional)
|
||||
{t("locationProxy.regionLabel")}
|
||||
{isLoadingRegions && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -261,14 +266,14 @@ export function LocationProxyDialog({
|
||||
onValueChange={setSelectedRegion}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingRegions
|
||||
? "Loading regions..."
|
||||
? t("locationProxy.loadingRegions")
|
||||
: regionOptions.length === 0
|
||||
? "No regions available"
|
||||
: "Select region"
|
||||
? t("locationProxy.noRegions")
|
||||
: t("locationProxy.selectRegion")
|
||||
}
|
||||
searchPlaceholder="Search regions..."
|
||||
searchPlaceholder={t("locationProxy.searchRegions")}
|
||||
disabled={!selectedCountry || isLoadingRegions}
|
||||
/>
|
||||
</div>
|
||||
@@ -276,7 +281,7 @@ export function LocationProxyDialog({
|
||||
{/* City - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
City (optional)
|
||||
{t("locationProxy.cityLabel")}
|
||||
{isLoadingCities && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -285,14 +290,14 @@ export function LocationProxyDialog({
|
||||
onValueChange={setSelectedCity}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingCities
|
||||
? "Loading cities..."
|
||||
? t("locationProxy.loadingCities")
|
||||
: cityOptions.length === 0
|
||||
? "No cities available"
|
||||
: "Select city"
|
||||
? t("locationProxy.noCities")
|
||||
: t("locationProxy.selectCity")
|
||||
}
|
||||
searchPlaceholder="Search cities..."
|
||||
searchPlaceholder={t("locationProxy.searchCities")}
|
||||
disabled={!selectedCountry || isLoadingCities}
|
||||
/>
|
||||
</div>
|
||||
@@ -300,7 +305,7 @@ export function LocationProxyDialog({
|
||||
{/* ISP - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
ISP (optional)
|
||||
{t("locationProxy.ispLabel")}
|
||||
{isLoadingIsps && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -309,40 +314,42 @@ export function LocationProxyDialog({
|
||||
onValueChange={setSelectedIsp}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingIsps
|
||||
? "Loading ISPs..."
|
||||
? t("locationProxy.loadingIsps")
|
||||
: ispOptions.length === 0
|
||||
? "No ISPs available"
|
||||
: "Select ISP"
|
||||
? t("locationProxy.noIsps")
|
||||
: t("locationProxy.selectIsp")
|
||||
}
|
||||
searchPlaceholder="Search ISPs..."
|
||||
searchPlaceholder={t("locationProxy.searchIsps")}
|
||||
disabled={!selectedCountry || isLoadingIsps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Label>{t("locationProxy.nameLabel")}</Label>
|
||||
<Input
|
||||
value={proxyName}
|
||||
onChange={(e) => {
|
||||
setProxyName(e.target.value);
|
||||
}}
|
||||
placeholder="Proxy name"
|
||||
placeholder={t("locationProxy.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedCountry || !proxyName.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create"}
|
||||
{isCreating
|
||||
? t("locationProxy.creatingButton")
|
||||
: t("locationProxy.createButton")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"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";
|
||||
import {
|
||||
@@ -20,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({
|
||||
@@ -29,7 +37,9 @@ export function PermissionDialog({
|
||||
permissionType,
|
||||
onPermissionGranted,
|
||||
}: PermissionDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const [isWaitingForGrant, setIsWaitingForGrant] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const {
|
||||
requestPermission,
|
||||
@@ -55,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) {
|
||||
@@ -74,18 +140,18 @@ export function PermissionDialog({
|
||||
const getPermissionTitle = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone Access Required";
|
||||
return t("permissionDialog.titleMicrophone");
|
||||
case "camera":
|
||||
return "Camera Access Required";
|
||||
return t("permissionDialog.titleCamera");
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionDescription = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Donut Browser needs access to your microphone to enable microphone functionality in web browsers. Each website that wants to use your microphone will still ask for your permission individually.";
|
||||
return t("permissionDialog.descMicrophone");
|
||||
case "camera":
|
||||
return "Donut Browser needs access to your camera to enable camera functionality in web browsers. Each website that wants to use your camera will still ask for your permission individually.";
|
||||
return t("permissionDialog.descCamera");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,15 +159,28 @@ export function PermissionDialog({
|
||||
setIsRequesting(true);
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionTitle(permissionType).replace(
|
||||
" Required",
|
||||
"",
|
||||
)} permission requested`,
|
||||
);
|
||||
// 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("Failed to request permission");
|
||||
showErrorToast(t("permissionDialog.requestFailed"));
|
||||
} finally {
|
||||
setIsRequesting(false);
|
||||
}
|
||||
@@ -128,33 +207,29 @@ 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">
|
||||
✅ Permission granted! Browsers launched from Donut Browser can
|
||||
now access your {permissionType}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-warning/10 rounded-lg">
|
||||
<p className="text-sm text-warning">
|
||||
⚠️ Permission not granted. Click the button below to request
|
||||
access to your {permissionType}.
|
||||
{permissionType === "microphone"
|
||||
? t("permissionDialog.notGrantedMicrophone")
|
||||
: t("permissionDialog.notGrantedCamera")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{isCurrentPermissionGranted ? "Done" : "Cancel"}
|
||||
<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);
|
||||
@@ -162,7 +237,7 @@ export function PermissionDialog({
|
||||
}}
|
||||
className="min-w-24"
|
||||
>
|
||||
Grant Access
|
||||
{t("permissionDialog.grantAccessButton")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -236,6 +236,7 @@ function getProfileSyncStatusDot(
|
||||
| "error"
|
||||
| "disabled"
|
||||
| undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
errorMessage?: string,
|
||||
): SyncStatusDot | null {
|
||||
const encrypted = profile.sync_mode === "Encrypted";
|
||||
@@ -249,14 +250,14 @@ function getProfileSyncStatusDot(
|
||||
case "syncing":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Syncing...",
|
||||
tooltip: t("profileTable.syncTooltipSyncing"),
|
||||
animate: true,
|
||||
encrypted,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Close the profile to sync",
|
||||
tooltip: t("profileTable.syncTooltipCloseToSync"),
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
@@ -264,15 +265,19 @@ function getProfileSyncStatusDot(
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: profile.last_sync
|
||||
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("profileTable.syncTooltipSyncedAt", {
|
||||
time: new Date(profile.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("profileTable.syncTooltipSynced"),
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
tooltip: errorMessage
|
||||
? t("profileTable.syncTooltipErrorWith", { error: errorMessage })
|
||||
: t("profileTable.syncTooltipError"),
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
@@ -280,7 +285,9 @@ function getProfileSyncStatusDot(
|
||||
if (profile.last_sync) {
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
|
||||
tooltip: t("profileTable.syncTooltipDisabledWithLast", {
|
||||
time: formatRelativeTime(profile.last_sync),
|
||||
}),
|
||||
animate: false,
|
||||
encrypted: false,
|
||||
};
|
||||
@@ -313,6 +320,7 @@ const TagsCell = React.memo<{
|
||||
setOpenTagsEditorFor,
|
||||
setTagsOverrides,
|
||||
}) => {
|
||||
const { t: translate } = useTranslation();
|
||||
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.id)
|
||||
? tagsOverrides[profile.id]
|
||||
: (profile.tags ?? []);
|
||||
@@ -475,7 +483,9 @@ const TagsCell = React.memo<{
|
||||
</Badge>
|
||||
))}
|
||||
{effectiveTags.length === 0 && (
|
||||
<span className="text-muted-foreground">No tags</span>
|
||||
<span className="text-muted-foreground">
|
||||
{translate("profileTable.noTags")}
|
||||
</span>
|
||||
)}
|
||||
{hiddenCount > 0 && (
|
||||
<Badge variant="outline" className="px-2 py-0 text-xs">
|
||||
@@ -526,7 +536,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!",
|
||||
@@ -630,6 +644,7 @@ const NoteCell = React.memo<{
|
||||
setOpenNoteEditorFor,
|
||||
setNoteOverrides,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const effectiveNote: string | null = Object.hasOwn(
|
||||
noteOverrides,
|
||||
profile.id,
|
||||
@@ -745,14 +760,14 @@ const NoteCell = React.memo<{
|
||||
!effectiveNote && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{effectiveNote ? trimmedNote : "No Note"}
|
||||
{effectiveNote ? trimmedNote : t("profiles.note.empty")}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent className="max-w-[320px]">
|
||||
<p className="whitespace-pre-wrap wrap-break-word">
|
||||
{effectiveNote ?? "No Note"}
|
||||
{effectiveNote ?? t("profiles.note.empty")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
@@ -789,7 +804,7 @@ const NoteCell = React.memo<{
|
||||
void onNoteChange(noteValue);
|
||||
setOpenNoteEditorFor(null);
|
||||
}}
|
||||
placeholder="Add a note..."
|
||||
placeholder={t("profiles.note.placeholder")}
|
||||
className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0"
|
||||
style={{
|
||||
overflow: "auto",
|
||||
@@ -892,6 +907,13 @@ export function ProfilesDataTable({
|
||||
}
|
||||
setRowSelection(newSelection);
|
||||
prevSelectedProfilesRef.current = selectedProfiles;
|
||||
// When the parent clears the selection (e.g. after a bulk action like
|
||||
// delete / move-to-group), collapse the checkbox column back to icons.
|
||||
// Otherwise the row checkboxes stay visible and only revert after the
|
||||
// user clicks one — which the per-checkbox handler resets.
|
||||
if (selectedProfiles.length === 0) {
|
||||
setShowCheckboxes(false);
|
||||
}
|
||||
}
|
||||
}, [selectedProfiles]);
|
||||
|
||||
@@ -1334,12 +1356,14 @@ export function ProfilesDataTable({
|
||||
setRenameError(null);
|
||||
} catch (error) {
|
||||
setRenameError(
|
||||
error instanceof Error ? error.message : "Failed to rename profile",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("errors.renameProfileFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsRenamingSaving(false);
|
||||
}
|
||||
}, [profileToRename, newProfileName, onRenameProfile]);
|
||||
}, [profileToRename, newProfileName, onRenameProfile, t]);
|
||||
|
||||
// Cancel inline rename on outside click
|
||||
React.useEffect(() => {
|
||||
@@ -1661,7 +1685,7 @@ export function ProfilesDataTable({
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleToggleAll(!!value);
|
||||
}}
|
||||
aria-label="Select all"
|
||||
aria-label={t("common.aria.selectAll")}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</span>
|
||||
@@ -1707,7 +1731,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label="Select profile"
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
|
||||
@@ -1745,7 +1769,7 @@ export function ProfilesDataTable({
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleCheckboxChange(profile.id, !!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
aria-label={t("common.aria.selectRow")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</span>
|
||||
@@ -1793,7 +1817,7 @@ export function ProfilesDataTable({
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleCheckboxChange(profile.id, !!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
aria-label={t("common.aria.selectRow")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</span>
|
||||
@@ -1814,7 +1838,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label="Select profile"
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
{IconComponent && (
|
||||
@@ -1833,6 +1857,7 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 100,
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -1951,7 +1976,7 @@ export function ProfilesDataTable({
|
||||
size="sm"
|
||||
disabled={!canLaunch || isLaunching || isStopping}
|
||||
className={cn(
|
||||
"min-w-[70px] h-7",
|
||||
"min-w-[80px] h-7 px-3",
|
||||
!canLaunch && "opacity-50 cursor-not-allowed",
|
||||
canLaunch && "cursor-pointer",
|
||||
isFollower && "border-accent",
|
||||
@@ -1967,9 +1992,9 @@ export function ProfilesDataTable({
|
||||
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : isRunning ? (
|
||||
"Stop"
|
||||
meta.t("profiles.actions.stop")
|
||||
) : (
|
||||
"Launch"
|
||||
meta.t("profiles.actions.launch")
|
||||
)}
|
||||
</RippleButton>
|
||||
</span>
|
||||
@@ -1986,7 +2011,9 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
size: 130,
|
||||
header: ({ column, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1995,7 +2022,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" ? (
|
||||
@@ -2124,7 +2151,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
header: "Tags",
|
||||
size: 110,
|
||||
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;
|
||||
@@ -2153,7 +2184,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "note",
|
||||
header: "Note",
|
||||
size: 110,
|
||||
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;
|
||||
@@ -2180,7 +2215,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "proxy",
|
||||
header: "Proxy / VPN",
|
||||
size: 130,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profiles.table.proxy");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -2218,12 +2257,8 @@ export function ProfilesDataTable({
|
||||
? effectiveVpn.name
|
||||
: effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: "Not Selected";
|
||||
const vpnBadge = effectiveVpn
|
||||
? effectiveVpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"
|
||||
: null;
|
||||
: meta.t("profiles.table.notSelected");
|
||||
const vpnBadge = effectiveVpn ? "WG" : null;
|
||||
const tooltipText = hasAssignment ? displayName : null;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
||||
@@ -2303,8 +2338,8 @@ export function ProfilesDataTable({
|
||||
<CommandInput
|
||||
placeholder={
|
||||
meta.canCreateLocationProxy
|
||||
? "Search proxies, VPNs, or countries..."
|
||||
: "Search proxies or VPNs..."
|
||||
? t("createProfile.proxy.searchWithCountries")
|
||||
: t("createProfile.proxy.search")
|
||||
}
|
||||
onFocus={() => {
|
||||
if (meta.canCreateLocationProxy)
|
||||
@@ -2312,7 +2347,9 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{t("createProfile.proxy.notFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -2328,7 +2365,7 @@ export function ProfilesDataTable({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("common.labels.none")}
|
||||
</CommandItem>
|
||||
{meta.storedProxies
|
||||
.filter(
|
||||
@@ -2361,7 +2398,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}
|
||||
@@ -2385,7 +2422,7 @@ export function ProfilesDataTable({
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
WG
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
@@ -2394,7 +2431,9 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{meta.canCreateLocationProxy &&
|
||||
meta.countries.length > 0 && (
|
||||
<CommandGroup heading="Create by country">
|
||||
<CommandGroup
|
||||
heading={t("profileTable.createByCountryHeading")}
|
||||
>
|
||||
{meta.countries
|
||||
.filter(
|
||||
(c) =>
|
||||
@@ -2470,6 +2509,7 @@ export function ProfilesDataTable({
|
||||
const dot = getProfileSyncStatusDot(
|
||||
profile,
|
||||
liveStatus,
|
||||
meta.t,
|
||||
syncEntry?.error,
|
||||
);
|
||||
if (!dot) return null;
|
||||
@@ -2511,7 +2551,9 @@ export function ProfilesDataTable({
|
||||
setProfileForInfoDialog(profile);
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Profile info</span>
|
||||
<span className="sr-only">
|
||||
{t("profiles.aria.profileInfo")}
|
||||
</span>
|
||||
<LuInfo className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2555,7 +2597,7 @@ export function ProfilesDataTable({
|
||||
platform === "macos" ? "h-[340px]" : "h-[280px]",
|
||||
)}
|
||||
>
|
||||
<Table className="overflow-visible">
|
||||
<Table className="overflow-visible table-fixed">
|
||||
<TableHeader className="overflow-visible">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="overflow-visible">
|
||||
@@ -2630,7 +2672,7 @@ export function ProfilesDataTable({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No profiles found.
|
||||
{t("profiles.table.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -2643,9 +2685,11 @@ export function ProfilesDataTable({
|
||||
setProfileToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Profile"
|
||||
description={`This action cannot be undone. This will permanently delete the profile "${profileToDelete?.name}" and all its associated data.`}
|
||||
confirmButtonText="Delete Profile"
|
||||
title={t("profiles.delete.title")}
|
||||
description={t("profiles.delete.description", {
|
||||
profileName: profileToDelete?.name ?? "",
|
||||
})}
|
||||
confirmButtonText={t("profiles.delete.confirmButton")}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
{profileForInfoDialog &&
|
||||
@@ -2704,7 +2748,7 @@ export function ProfilesDataTable({
|
||||
<DataTableActionBarSelection table={table} />
|
||||
{onBulkGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign to Group"
|
||||
tooltip={t("profiles.actionBar.assignToGroup")}
|
||||
onClick={onBulkGroupAssignment}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2713,7 +2757,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkProxyAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign Proxy"
|
||||
tooltip={t("profiles.actionBar.assignProxy")}
|
||||
onClick={onBulkProxyAssignment}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2722,7 +2766,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkExtensionGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign Extension Group"
|
||||
tooltip={t("profiles.actionBar.assignExtensionGroup")}
|
||||
onClick={onBulkExtensionGroupAssignment}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2731,7 +2775,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkCopyCookies && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Copy Cookies"
|
||||
tooltip={t("profiles.actionBar.copyCookies")}
|
||||
onClick={onBulkCopyCookies}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2740,7 +2784,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkDelete && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Delete"
|
||||
tooltip={t("common.buttons.delete")}
|
||||
onClick={onBulkDelete}
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"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";
|
||||
import {
|
||||
@@ -47,8 +48,17 @@ export function ProfileSelectorDialog({
|
||||
runningProfiles: externalRunningProfiles,
|
||||
isUpdating,
|
||||
}: 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;
|
||||
@@ -146,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]);
|
||||
@@ -159,17 +165,19 @@ export function ProfileSelectorDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Choose Profile</DialogTitle>
|
||||
<DialogTitle>{t("profileSelector.chooseProfileTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{url && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm font-medium">Opening URL:</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
{t("profileSelector.openingUrl")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
successMessage="URL copied to clipboard!"
|
||||
successMessage={t("profileSelector.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 text-sm break-all rounded bg-muted">
|
||||
@@ -179,15 +187,16 @@ export function ProfileSelectorDialog({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-select">Select Profile:</Label>
|
||||
<Label htmlFor="profile-select">
|
||||
{t("profileSelector.selectProfileLabel")}
|
||||
</Label>
|
||||
{profiles.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No profiles available. Please create a profile first.
|
||||
{t("profileSelector.noneAvailableShort")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Close this dialog and create a profile from the main window to
|
||||
get started.
|
||||
{t("profileSelector.noneAvailableLong")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -196,7 +205,9 @@ export function ProfileSelectorDialog({
|
||||
onValueChange={setSelectedProfile}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a profile" />
|
||||
<SelectValue
|
||||
placeholder={t("profileSelector.chooseAProfile")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => {
|
||||
@@ -241,12 +252,12 @@ export function ProfileSelectorDialog({
|
||||
</Badge>
|
||||
{hasProxy(profile) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Proxy
|
||||
{t("profileSelector.badgeProxy")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Running
|
||||
{t("profileSelector.badgeRunning")}
|
||||
</Badge>
|
||||
)}
|
||||
{!canUseForLinks && (
|
||||
@@ -254,7 +265,7 @@ export function ProfileSelectorDialog({
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
>
|
||||
Unavailable
|
||||
{t("profileSelector.badgeUnavailable")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -275,7 +286,7 @@ export function ProfileSelectorDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -289,7 +300,7 @@ export function ProfileSelectorDialog({
|
||||
!canOpenWithSelectedProfile()
|
||||
}
|
||||
>
|
||||
Open
|
||||
{t("profileSelector.openButton")}
|
||||
</LoadingButton>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -53,6 +54,7 @@ export function ProxyAssignmentDialog({
|
||||
storedProxies = [],
|
||||
vpnConfigs = [],
|
||||
}: ProxyAssignmentDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
|
||||
"none",
|
||||
@@ -84,7 +86,7 @@ export function ProxyAssignmentDialog({
|
||||
});
|
||||
|
||||
if (validProfiles.length === 0) {
|
||||
setError("No valid profiles selected.");
|
||||
setError(t("proxyAssignment.noValidProfiles"));
|
||||
setIsAssigning(false);
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export function ProxyAssignmentDialog({
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign proxy/VPN to profiles";
|
||||
: t("proxyAssignment.failedFallback");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -124,6 +126,7 @@ export function ProxyAssignmentDialog({
|
||||
profiles,
|
||||
onAssignmentComplete,
|
||||
onClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,16 +141,21 @@ export function ProxyAssignmentDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Proxy / VPN</DialogTitle>
|
||||
<DialogTitle>{t("proxyAssignment.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign a proxy or VPN to {selectedProfiles.length} selected
|
||||
profile(s).
|
||||
{selectedProfiles.length === 1
|
||||
? t("proxyAssignment.description_one", {
|
||||
count: selectedProfiles.length,
|
||||
})
|
||||
: t("proxyAssignment.description_other", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Selected Profiles:</Label>
|
||||
<Label>{t("proxyAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
@@ -166,7 +174,9 @@ export function ProxyAssignmentDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
|
||||
<Label htmlFor="proxy-vpn-select">
|
||||
{t("proxyAssignment.assignProxyVpnLabel")}
|
||||
</Label>
|
||||
<Popover open={proxyPopoverOpen} onOpenChange={setProxyPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -176,26 +186,29 @@ export function ProxyAssignmentDialog({
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{(() => {
|
||||
if (selectionType === "none") return "None";
|
||||
if (selectionType === "none")
|
||||
return t("proxyAssignment.noneOption");
|
||||
if (selectionType === "vpn") {
|
||||
const vpn = vpnConfigs.find((v) => v.id === selectedId);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "None";
|
||||
? `WG — ${vpn.name}`
|
||||
: t("proxyAssignment.noneOption");
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedId,
|
||||
);
|
||||
return proxy ? proxy.name : "None";
|
||||
return proxy ? proxy.name : t("proxyAssignment.noneOption");
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandInput
|
||||
placeholder={t("proxyAssignment.searchPlaceholder")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandEmpty>{t("proxyAssignment.notFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -212,7 +225,7 @@ export function ProxyAssignmentDialog({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("proxyAssignment.noneOption")}
|
||||
</CommandItem>
|
||||
{storedProxies
|
||||
.filter(
|
||||
@@ -242,7 +255,9 @@ export function ProxyAssignmentDialog({
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
<CommandGroup
|
||||
heading={t("proxyAssignment.vpnGroupHeading")}
|
||||
>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
@@ -264,7 +279,7 @@ export function ProxyAssignmentDialog({
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
WG
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
@@ -290,13 +305,13 @@ export function ProxyAssignmentDialog({
|
||||
onClick={onClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isAssigning}
|
||||
onClick={() => void handleAssign()}
|
||||
>
|
||||
Assign
|
||||
{t("proxyAssignment.assignButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { FlagIcon } from "@/components/flag-icon";
|
||||
@@ -35,6 +36,7 @@ export function ProxyCheckButton({
|
||||
disabled = false,
|
||||
setCheckingProfileId,
|
||||
}: ProxyCheckButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [localResult, setLocalResult] = React.useState<
|
||||
ProxyCheckResult | undefined
|
||||
>(cachedResult);
|
||||
@@ -60,11 +62,13 @@ export function ProxyCheckButton({
|
||||
if (result.city) locationParts.push(result.city);
|
||||
if (result.country) locationParts.push(result.country);
|
||||
const location =
|
||||
locationParts.length > 0 ? locationParts.join(", ") : "Unknown";
|
||||
locationParts.length > 0
|
||||
? locationParts.join(", ")
|
||||
: t("proxyCheck.unknownLocation");
|
||||
|
||||
toast.success(
|
||||
<div className="flex flex-col">
|
||||
Your proxy location is:
|
||||
{t("proxyCheck.locationToast")}
|
||||
<div className="flex items-center whitespace-nowrap">
|
||||
{location}
|
||||
{result.country_code && (
|
||||
@@ -79,7 +83,7 @@ export function ProxyCheckButton({
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Proxy check failed: ${errorMessage}`);
|
||||
toast.error(t("proxyCheck.failed", { error: errorMessage }));
|
||||
|
||||
// Save failed check result
|
||||
const failedResult: ProxyCheckResult = {
|
||||
@@ -102,6 +106,7 @@ export function ProxyCheckButton({
|
||||
onCheckComplete,
|
||||
onCheckFailed,
|
||||
setCheckingProfileId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const isCurrentlyChecking = checkingProfileId === profileId;
|
||||
@@ -133,7 +138,7 @@ export function ProxyCheckButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentlyChecking ? (
|
||||
<p>Checking proxy...</p>
|
||||
<p>{t("proxyCheck.tooltipChecking")}</p>
|
||||
) : result?.is_valid ? (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1">
|
||||
@@ -141,24 +146,28 @@ export function ProxyCheckButton({
|
||||
<FlagIcon countryCode={result.country_code} />
|
||||
)}
|
||||
{[result.city, result.country].filter(Boolean).join(", ") ||
|
||||
"Unknown"}
|
||||
t("proxyCheck.unknownLocation")}
|
||||
</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
IP: {result.ip}
|
||||
{t("proxyCheck.tooltipIp", { ip: result.ip })}
|
||||
</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
{t("proxyCheck.tooltipChecked", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : result && !result.is_valid ? (
|
||||
<div>
|
||||
<p>Proxy check failed</p>
|
||||
<p>{t("proxyCheck.tooltipFailedTitle")}</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Failed {formatRelativeTime(result.timestamp)}
|
||||
{t("proxyCheck.tooltipFailed", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Check proxy validity</p>
|
||||
<p>{t("proxyCheck.tooltipDefault")}</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuCopy, LuDownload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ interface ProxyExportDialogProps {
|
||||
}
|
||||
|
||||
export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [format, setFormat] = useState<"json" | "txt">("json");
|
||||
const [exportContent, setExportContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -35,12 +37,12 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
setExportContent(content);
|
||||
} catch (error) {
|
||||
console.error("Failed to export proxies:", error);
|
||||
toast.error("Failed to export proxies");
|
||||
toast.error(t("proxies.exportDialog.failed"));
|
||||
setExportContent("");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [format]);
|
||||
}, [format, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -52,15 +54,15 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(exportContent);
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
toast.success(t("toasts.success.copied"));
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
toast.error("Failed to copy to clipboard");
|
||||
toast.error(t("toasts.error.copyFailed"));
|
||||
}
|
||||
}, [exportContent]);
|
||||
}, [exportContent, t]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const filename = format === "json" ? "proxies.json" : "proxies.txt";
|
||||
@@ -76,8 +78,8 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Downloaded ${filename}`);
|
||||
}, [format, exportContent]);
|
||||
toast.success(t("proxies.exportDialog.downloaded", { filename }));
|
||||
}, [format, exportContent, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setFormat("json");
|
||||
@@ -90,15 +92,15 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Proxies</DialogTitle>
|
||||
<DialogTitle>{t("proxies.exportDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Export your proxy configurations to a file
|
||||
{t("proxies.exportDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Export Format</Label>
|
||||
<Label>{t("proxies.exportDialog.format")}</Label>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => {
|
||||
@@ -109,24 +111,24 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="json" id="format-json" />
|
||||
<Label htmlFor="format-json" className="cursor-pointer">
|
||||
JSON
|
||||
{t("proxies.exportDialog.json")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="txt" id="format-txt" />
|
||||
<Label htmlFor="format-txt" className="cursor-pointer">
|
||||
TXT (URL format)
|
||||
{t("proxies.exportDialog.txt")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<Label>{t("proxies.exportDialog.preview")}</Label>
|
||||
<ScrollArea className="h-[200px] border rounded-md bg-muted/30">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : exportContent ? (
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
@@ -134,7 +136,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
No proxies to export
|
||||
{t("proxies.exportDialog.noProxies")}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
@@ -143,7 +145,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
@@ -156,7 +158,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
) : (
|
||||
<LuCopy className="w-4 h-4" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied
|
||||
? t("proxies.exportDialog.copied")
|
||||
: t("common.buttons.copy")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={handleDownload}
|
||||
@@ -164,7 +168,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Download
|
||||
{t("common.buttons.download")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -83,14 +83,12 @@ export function ProxyFormDialog({
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
|
||||
toast.error(t("proxies.form.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.host.trim() || !form.port) {
|
||||
toast.error(
|
||||
t("proxies.form.hostPortRequired", "Host and port are required"),
|
||||
);
|
||||
toast.error(t("proxies.form.hostPortRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,12 +96,7 @@ export function ProxyFormDialog({
|
||||
form.proxy_type === "ss" &&
|
||||
(!form.username.trim() || !form.password.trim())
|
||||
) {
|
||||
toast.error(
|
||||
t(
|
||||
"proxies.form.ssCipherRequired",
|
||||
"Cipher and password are required for Shadowsocks",
|
||||
),
|
||||
);
|
||||
toast.error(t("proxies.form.ssCipherRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +129,7 @@ export function ProxyFormDialog({
|
||||
console.error("Failed to save proxy:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to save proxy: ${errorMessage}`);
|
||||
toast.error(t("proxies.form.saveFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -189,7 +182,7 @@ export function ProxyFormDialog({
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select proxy type" />
|
||||
<SelectValue placeholder={t("proxies.form.selectType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5", "ss"].map((type) => (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -39,6 +40,7 @@ interface AmbiguousProxy {
|
||||
}
|
||||
|
||||
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]);
|
||||
@@ -52,7 +54,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
null,
|
||||
);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [namePrefix, setNamePrefix] = useState("Imported");
|
||||
const [namePrefix, setNamePrefix] = useState(
|
||||
t("proxies.importDialog.namePrefixDefault"),
|
||||
);
|
||||
|
||||
const os = getCurrentOS();
|
||||
const modKey = os === "macos" ? "⌘" : "Ctrl";
|
||||
@@ -65,8 +69,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
setInvalidProxies([]);
|
||||
setImportResult(null);
|
||||
setIsImporting(false);
|
||||
setNamePrefix("Imported");
|
||||
}, []);
|
||||
setNamePrefix(t("proxies.importDialog.namePrefixDefault"));
|
||||
}, [t]);
|
||||
|
||||
const processContent = useCallback(
|
||||
async (content: string, isJson: boolean, _filename = "") => {
|
||||
@@ -116,19 +120,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
} else if (parsed.length > 0) {
|
||||
setStep("preview");
|
||||
} else {
|
||||
toast.error("No valid proxies found in the file");
|
||||
toast.error(t("proxies.importDialog.noValidProxies"));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process content:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to process file",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.importDialog.fileProcessError"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
@@ -140,11 +146,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
void processContent(content, isJson, file.name);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
toast.error(t("proxies.importDialog.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[processContent],
|
||||
[processContent, t],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -160,10 +166,10 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a .json or .txt file");
|
||||
toast.error(t("proxies.importDialog.wrongFileType"));
|
||||
}
|
||||
},
|
||||
[handleFileRead],
|
||||
[handleFileRead, t],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -206,7 +212,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
"import_proxies_from_parsed",
|
||||
{
|
||||
parsedProxies,
|
||||
namePrefix: namePrefix.trim() || "Imported",
|
||||
namePrefix:
|
||||
namePrefix.trim() || t("proxies.importDialog.namePrefixDefault"),
|
||||
},
|
||||
);
|
||||
setImportResult(result);
|
||||
@@ -215,12 +222,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
} catch (error) {
|
||||
console.error("Failed to import proxies:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import proxies",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.importDialog.failed"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [parsedProxies, namePrefix]);
|
||||
}, [parsedProxies, namePrefix, t]);
|
||||
|
||||
const handleAmbiguousFormatSelect = useCallback(
|
||||
(index: number, format: string) => {
|
||||
@@ -273,13 +282,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Proxies</DialogTitle>
|
||||
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" && "Import proxies from a JSON or TXT file"}
|
||||
{step === "preview" && "Review the proxies to import"}
|
||||
{step === "ambiguous" &&
|
||||
"Some proxies have ambiguous formats. Please select the correct format."}
|
||||
{step === "result" && "Import completed"}
|
||||
{step === "dropzone" && t("proxies.importDialog.descDropzone")}
|
||||
{step === "preview" && t("proxies.importDialog.descPreview")}
|
||||
{step === "ambiguous" && t("proxies.importDialog.descAmbiguous")}
|
||||
{step === "result" && t("proxies.importDialog.descResult")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -309,9 +317,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Drop a proxy config file
|
||||
{t("proxies.importDialog.dropzonePrompt")}
|
||||
<br />
|
||||
<span className="text-xs">(.json, .txt)</span>
|
||||
<span className="text-xs">
|
||||
{t("proxies.importDialog.dropzoneFormats")}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="proxy-file-input"
|
||||
@@ -326,7 +336,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Paste from clipboard with {modKey}+V
|
||||
{t("proxies.importDialog.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -334,27 +344,35 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "preview" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name-prefix">Name Prefix</Label>
|
||||
<Label htmlFor="name-prefix">
|
||||
{t("proxies.importDialog.namePrefix")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name-prefix"
|
||||
placeholder="Imported"
|
||||
placeholder={t("proxies.importDialog.namePrefixDefault")}
|
||||
value={namePrefix}
|
||||
onChange={(e) => {
|
||||
setNamePrefix(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Proxies will be named "{namePrefix || "Imported"} Proxy
|
||||
1", "{namePrefix || "Imported"} Proxy 2", etc.
|
||||
{t("proxies.importDialog.namePrefixHint", {
|
||||
prefix:
|
||||
namePrefix || t("proxies.importDialog.namePrefixDefault"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Proxies to import ({parsedProxies.length})
|
||||
{t("proxies.importDialog.proxiesToImport", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
{invalidProxies.length > 0 && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({invalidProxies.length} invalid)
|
||||
{t("proxies.importDialog.invalidCount", {
|
||||
count: invalidProxies.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -387,8 +405,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "ambiguous" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The following proxies have an ambiguous format. Please select the
|
||||
correct interpretation for each.
|
||||
{t("proxies.importDialog.ambiguousIntro")}
|
||||
</p>
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<div className="p-3 space-y-4">
|
||||
@@ -430,14 +447,18 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Imported:</span>
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.imported")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-success">
|
||||
{importResult.imported_count}
|
||||
</span>
|
||||
</div>
|
||||
{importResult.skipped_count > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Skipped (duplicates):</span>
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.skippedDuplicates")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-warning">
|
||||
{importResult.skipped_count}
|
||||
</span>
|
||||
@@ -445,7 +466,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
)}
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Errors:</span>
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.errors")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-destructive">
|
||||
{importResult.errors.length}
|
||||
</span>
|
||||
@@ -455,7 +478,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Errors</Label>
|
||||
<Label>{t("proxies.importDialog.errors")}</Label>
|
||||
<ScrollArea className="h-[100px] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
{importResult.errors.map((error, i) => (
|
||||
@@ -476,21 +499,23 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={parsedProxies.length === 0}
|
||||
>
|
||||
Import {parsedProxies.length} Proxies
|
||||
{t("proxies.importDialog.importButton", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
@@ -498,19 +523,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "ambiguous" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={handleResolveAmbiguous}
|
||||
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
|
||||
>
|
||||
Continue
|
||||
{t("proxies.importDialog.continueButton")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("proxies.importDialog.doneButton")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
@@ -51,37 +52,46 @@ 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,
|
||||
errorMessage?: 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("syncTooltips.syncing"),
|
||||
animate: true,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: item.last_sync
|
||||
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("syncTooltips.syncedAt", {
|
||||
time: new Date(item.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("syncTooltips.synced"),
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Waiting to sync",
|
||||
tooltip: t("syncTooltips.waiting"),
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
tooltip: errorMessage
|
||||
? t("syncTooltips.errorWith", { error: errorMessage })
|
||||
: t("syncTooltips.error"),
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Not synced",
|
||||
tooltip: t("syncTooltips.notSynced"),
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
@@ -96,6 +106,7 @@ export function ProxyManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ProxyManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Proxy state
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
@@ -260,16 +271,16 @@ export function ProxyManagementDialog({
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id });
|
||||
toast.success("Proxy deleted successfully");
|
||||
toast.success(t("proxies.management.deleteSuccess"));
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete proxy:", error);
|
||||
toast.error("Failed to delete proxy");
|
||||
toast.error(t("proxies.management.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setProxyToDelete(null);
|
||||
}
|
||||
}, [proxyToDelete]);
|
||||
}, [proxyToDelete, t]);
|
||||
|
||||
const handleCreateProxy = useCallback(() => {
|
||||
setEditingProxy(null);
|
||||
@@ -286,24 +297,33 @@ export function ProxyManagementDialog({
|
||||
setEditingProxy(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleSync = useCallback(async (proxy: StoredProxy) => {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true }));
|
||||
try {
|
||||
await invoke("set_proxy_sync_enabled", {
|
||||
proxyId: proxy.id,
|
||||
enabled: !proxy.sync_enabled,
|
||||
});
|
||||
showSuccessToast(proxy.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false }));
|
||||
}
|
||||
}, []);
|
||||
const handleToggleSync = useCallback(
|
||||
async (proxy: StoredProxy) => {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true }));
|
||||
try {
|
||||
await invoke("set_proxy_sync_enabled", {
|
||||
proxyId: proxy.id,
|
||||
enabled: !proxy.sync_enabled,
|
||||
});
|
||||
showSuccessToast(
|
||||
proxy.sync_enabled
|
||||
? t("proxies.management.syncDisabled")
|
||||
: t("proxies.management.syncEnabled"),
|
||||
);
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false }));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// VPN handlers
|
||||
const handleDeleteVpn = useCallback((vpn: VpnConfig) => {
|
||||
@@ -315,16 +335,16 @@ export function ProxyManagementDialog({
|
||||
setIsDeletingVpn(true);
|
||||
try {
|
||||
await invoke("delete_vpn_config", { vpnId: vpnToDelete.id });
|
||||
toast.success("VPN deleted successfully");
|
||||
toast.success(t("vpns.management.deleteSuccess"));
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete VPN:", error);
|
||||
toast.error("Failed to delete VPN");
|
||||
toast.error(t("vpns.management.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeletingVpn(false);
|
||||
setVpnToDelete(null);
|
||||
}
|
||||
}, [vpnToDelete]);
|
||||
}, [vpnToDelete, t]);
|
||||
|
||||
const handleCreateVpn = useCallback(() => {
|
||||
setEditingVpn(null);
|
||||
@@ -341,33 +361,42 @@ export function ProxyManagementDialog({
|
||||
setEditingVpn(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleVpnSync = useCallback(async (vpn: VpnConfig) => {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
|
||||
try {
|
||||
await invoke("set_vpn_sync_enabled", {
|
||||
vpnId: vpn.id,
|
||||
enabled: !vpn.sync_enabled,
|
||||
});
|
||||
showSuccessToast(vpn.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle VPN sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
|
||||
}
|
||||
}, []);
|
||||
const handleToggleVpnSync = useCallback(
|
||||
async (vpn: VpnConfig) => {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
|
||||
try {
|
||||
await invoke("set_vpn_sync_enabled", {
|
||||
vpnId: vpn.id,
|
||||
enabled: !vpn.sync_enabled,
|
||||
});
|
||||
showSuccessToast(
|
||||
vpn.sync_enabled
|
||||
? t("proxies.management.syncDisabled")
|
||||
: t("proxies.management.syncEnabled"),
|
||||
);
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle VPN sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Proxies & VPNs</DialogTitle>
|
||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage your proxy and VPN configurations for reuse across profiles
|
||||
{t("proxies.management.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -375,14 +404,14 @@ export function ProxyManagementDialog({
|
||||
<Tabs defaultValue="proxies">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="proxies" className="flex-1">
|
||||
Proxies
|
||||
{t("proxies.management.tabProxies")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vpns" className="flex-1">
|
||||
VPNs
|
||||
{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">
|
||||
@@ -395,7 +424,7 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
@@ -407,7 +436,7 @@ export function ProxyManagementDialog({
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Export
|
||||
{t("common.buttons.export")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -417,183 +446,202 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
{t("proxies.management.create")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
{t("proxies.management.loading")}
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No proxies created yet. Create your first proxy using the
|
||||
button above.
|
||||
{t("proxies.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
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>
|
||||
Sync cannot be disabled while this
|
||||
proxy is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</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>
|
||||
<p>Edit proxy</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>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1
|
||||
? "s"
|
||||
: ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</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>
|
||||
</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">
|
||||
@@ -606,7 +654,7 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
<RippleButton
|
||||
@@ -615,165 +663,180 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
{t("proxies.management.create")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading VPNs...
|
||||
{t("vpns.management.loading")}
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No VPN configs created yet. Import or create one using the
|
||||
buttons above.
|
||||
{t("vpns.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-16">Type</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnConfigs.map((vpn) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
vpn,
|
||||
vpnSyncStatus[vpn.id],
|
||||
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">
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
</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>
|
||||
Sync cannot be disabled while this
|
||||
VPN is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</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>Edit VPN</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>
|
||||
Cannot delete: in use by{" "}
|
||||
{vpnUsage[vpn.id]} profile
|
||||
{vpnUsage[vpn.id] > 1 ? "s" : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete VPN</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>
|
||||
@@ -783,7 +846,7 @@ export function ProxyManagementDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -800,9 +863,11 @@ export function ProxyManagementDialog({
|
||||
setProxyToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Proxy"
|
||||
description={`This action cannot be undone. This will permanently delete the proxy "${proxyToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
title={t("proxies.management.deleteTitle")}
|
||||
description={t("proxies.management.deleteDescription", {
|
||||
name: proxyToDelete?.name ?? "",
|
||||
})}
|
||||
confirmButtonText={t("common.buttons.delete")}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
<ProxyImportDialog
|
||||
@@ -828,9 +893,11 @@ export function ProxyManagementDialog({
|
||||
setVpnToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDeleteVpn}
|
||||
title="Delete VPN"
|
||||
description={`This action cannot be undone. This will permanently delete the VPN "${vpnToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
title={t("vpns.management.deleteTitle")}
|
||||
description={t("vpns.management.deleteDescription", {
|
||||
name: vpnToDelete?.name ?? "",
|
||||
})}
|
||||
confirmButtonText={t("common.buttons.delete")}
|
||||
isLoading={isDeletingVpn}
|
||||
/>
|
||||
<VpnImportDialog
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -37,11 +38,14 @@ export function ReleaseTypeSelector({
|
||||
availableReleaseTypes,
|
||||
isDownloading,
|
||||
onDownload,
|
||||
placeholder = "Select release type...",
|
||||
placeholder,
|
||||
showDownloadButton = true,
|
||||
downloadedVersions = [],
|
||||
}: ReleaseTypeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const effectivePlaceholder =
|
||||
placeholder ?? t("releaseTypeSelector.placeholder");
|
||||
|
||||
const releaseOptions = [
|
||||
...(availableReleaseTypes.stable
|
||||
@@ -64,9 +68,9 @@ export function ReleaseTypeSelector({
|
||||
|
||||
const selectedDisplayText = selectedReleaseType
|
||||
? selectedReleaseType === "stable"
|
||||
? "Stable"
|
||||
: "Nightly"
|
||||
: placeholder;
|
||||
? t("releaseTypeSelector.stable")
|
||||
: t("releaseTypeSelector.nightly")
|
||||
: effectivePlaceholder;
|
||||
|
||||
const selectedVersion =
|
||||
selectedReleaseType === "stable"
|
||||
@@ -95,7 +99,9 @@ export function ReleaseTypeSelector({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandEmpty>No release types available.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{t("releaseTypeSelector.noReleaseTypes")}
|
||||
</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{releaseOptions.map((option) => {
|
||||
@@ -130,7 +136,7 @@ export function ReleaseTypeSelector({
|
||||
<span className="capitalize">{option.type}</span>
|
||||
{option.type === "nightly" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Nightly
|
||||
{t("releaseTypeSelector.nightly")}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
@@ -138,7 +144,7 @@ export function ReleaseTypeSelector({
|
||||
</Badge>
|
||||
{isDownloaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Downloaded
|
||||
{t("releaseTypeSelector.downloaded")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,7 +168,7 @@ export function ReleaseTypeSelector({
|
||||
</Badge>
|
||||
{downloadedVersions.includes(releaseOptions[0].version) && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Downloaded
|
||||
{t("releaseTypeSelector.downloaded")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -182,7 +188,9 @@ export function ReleaseTypeSelector({
|
||||
className="w-full"
|
||||
>
|
||||
<LuDownload className="mr-2 w-4 h-4" />
|
||||
{isDownloading ? "Downloading..." : "Download Browser"}
|
||||
{isDownloading
|
||||
? t("releaseTypeSelector.downloading")
|
||||
: t("releaseTypeSelector.downloadBrowser")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+138
-130
@@ -165,34 +165,46 @@ export function SettingsDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPermissionDisplayName = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone";
|
||||
case "camera":
|
||||
return "Camera";
|
||||
}
|
||||
}, []);
|
||||
const getPermissionDisplayName = useCallback(
|
||||
(type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return t("settings.permissions.microphone");
|
||||
case "camera":
|
||||
return t("settings.permissions.camera");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const getStatusBadge = useCallback((isGranted: boolean) => {
|
||||
if (isGranted) {
|
||||
return (
|
||||
<Badge variant="default" className="text-success-foreground bg-success">
|
||||
Granted
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">Not Granted</Badge>;
|
||||
}, []);
|
||||
const getStatusBadge = useCallback(
|
||||
(isGranted: boolean) => {
|
||||
if (isGranted) {
|
||||
return (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-success-foreground bg-success"
|
||||
>
|
||||
{t("common.status.granted")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">{t("common.status.notGranted")}</Badge>;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const getPermissionDescription = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Access to microphone for browser applications";
|
||||
case "camera":
|
||||
return "Access to camera for browser applications";
|
||||
}
|
||||
}, []);
|
||||
const getPermissionDescription = useCallback(
|
||||
(type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return t("settings.permissions.microphoneDescription");
|
||||
case "camera":
|
||||
return t("settings.permissions.cameraDescription");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -332,15 +344,15 @@ export function SettingsDialog({
|
||||
// Don't show immediate success toast - let the version update progress events handle it
|
||||
} catch (error) {
|
||||
console.error("Failed to clear cache:", error);
|
||||
showErrorToast("Failed to clear cache", {
|
||||
showErrorToast(t("settings.advanced.clearCacheFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error instanceof Error ? error.message : t("common.errors.unknown"),
|
||||
duration: 4000,
|
||||
});
|
||||
} finally {
|
||||
setIsClearingCache(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleRequestPermission = useCallback(
|
||||
async (permissionType: PermissionType) => {
|
||||
@@ -348,7 +360,9 @@ export function SettingsDialog({
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionDisplayName(permissionType)} access requested`,
|
||||
t("settings.permissions.accessRequested", {
|
||||
permission: getPermissionDisplayName(permissionType),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
@@ -356,7 +370,7 @@ export function SettingsDialog({
|
||||
setRequestingPermission(null);
|
||||
}
|
||||
},
|
||||
[getPermissionDisplayName, requestPermission],
|
||||
[getPermissionDisplayName, requestPermission, t],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
@@ -394,7 +408,12 @@ export function SettingsDialog({
|
||||
// Update settings with any generated tokens
|
||||
setSettings(savedSettings);
|
||||
settingsToSave = savedSettings;
|
||||
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
|
||||
// Pass the actual theme value through. Calling setTheme("dark") here
|
||||
// when the user is on "custom" pushes the provider state to "dark",
|
||||
// which triggers its clear-custom-vars effect and wipes the CSS
|
||||
// variables we set just below — that's the bug where saving a custom
|
||||
// theme made it disappear until the app was restarted.
|
||||
setTheme(settings.theme);
|
||||
|
||||
// Apply or clear custom variables only on Save
|
||||
if (settings.theme === "custom") {
|
||||
@@ -525,7 +544,7 @@ export function SettingsDialog({
|
||||
checkDefaultBrowserStatus().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}, 500); // Check every 500ms
|
||||
}, 2000);
|
||||
|
||||
// Cleanup interval on component unmount or dialog close
|
||||
return () => {
|
||||
@@ -592,11 +611,13 @@ export function SettingsDialog({
|
||||
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
|
||||
{/* Appearance Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Appearance</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.appearance.title")}
|
||||
</Label>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="theme-select" className="text-sm">
|
||||
Theme
|
||||
{t("settings.appearance.theme")}
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.theme}
|
||||
@@ -614,20 +635,29 @@ export function SettingsDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-select">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
<SelectValue
|
||||
placeholder={t("settings.appearance.selectTheme")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
<SelectItem value="light">
|
||||
{t("settings.appearance.light")}
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
{t("settings.appearance.dark")}
|
||||
</SelectItem>
|
||||
<SelectItem value="system">
|
||||
{t("settings.appearance.system")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("common.labels.custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred theme or follow your system settings.
|
||||
Custom theme changes are applied only when you save.
|
||||
{t("settings.appearance.themeDescription")}
|
||||
</p>
|
||||
|
||||
{settings.theme === "custom" && (
|
||||
@@ -637,7 +667,7 @@ export function SettingsDialog({
|
||||
htmlFor="theme-preset-select"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Theme Preset
|
||||
{t("settings.appearance.themePreset")}
|
||||
</Label>
|
||||
<Select
|
||||
value={customThemeState.selectedThemeId ?? "custom"}
|
||||
@@ -659,7 +689,11 @@ export function SettingsDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-preset-select">
|
||||
<SelectValue placeholder="Select a theme preset" />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"settings.appearance.selectThemePreset",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THEMES.map((theme) => (
|
||||
@@ -667,12 +701,16 @@ export function SettingsDialog({
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Your Own</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("settings.appearance.yourOwn")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium">Custom Colors</div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("settings.appearance.customColors")}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{THEME_VARIABLES.map(({ key, label }) => {
|
||||
const colorValue =
|
||||
@@ -744,11 +782,13 @@ export function SettingsDialog({
|
||||
|
||||
{/* Language Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Language</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.language.title")}
|
||||
</Label>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="language-select" className="text-sm">
|
||||
Interface Language
|
||||
{t("settings.language.interface")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedLanguage ?? "system"}
|
||||
@@ -758,10 +798,14 @@ export function SettingsDialog({
|
||||
disabled={isLanguageLoading}
|
||||
>
|
||||
<SelectTrigger id="language-select">
|
||||
<SelectValue placeholder="Select language" />
|
||||
<SelectValue
|
||||
placeholder={t("settings.language.selectLanguage")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">System Default</SelectItem>
|
||||
<SelectItem value="system">
|
||||
{t("settings.language.systemDefault")}
|
||||
</SelectItem>
|
||||
{supportedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.nativeName} ({lang.name})
|
||||
@@ -772,7 +816,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>
|
||||
|
||||
@@ -781,10 +825,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>
|
||||
|
||||
@@ -800,13 +846,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>
|
||||
)}
|
||||
@@ -815,12 +860,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">
|
||||
@@ -878,17 +923,18 @@ export function SettingsDialog({
|
||||
|
||||
{/* Integrations Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Integrations</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.integrations.title")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure Local API and MCP (Model Context Protocol) for
|
||||
integrating with external tools and AI assistants.
|
||||
{t("settings.integrations.description")}
|
||||
</p>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onIntegrationsOpen}
|
||||
>
|
||||
Open Integrations Settings
|
||||
{t("integrations.openSettings")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
@@ -912,33 +958,24 @@ 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">
|
||||
@@ -952,10 +989,7 @@ export function SettingsDialog({
|
||||
setE2eError("");
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"settings.encryption.changePassword",
|
||||
"Change Password",
|
||||
)}
|
||||
{t("settings.encryption.changePassword")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -964,21 +998,13 @@ export function SettingsDialog({
|
||||
try {
|
||||
await invoke("delete_e2e_password");
|
||||
setHasE2ePassword(false);
|
||||
showSuccessToast(
|
||||
t(
|
||||
"settings.encryption.removed",
|
||||
"Encryption password removed",
|
||||
),
|
||||
);
|
||||
showSuccessToast(t("settings.encryption.removed"));
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"settings.encryption.removePassword",
|
||||
"Remove Password",
|
||||
)}
|
||||
{t("settings.encryption.removePassword")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -986,10 +1012,7 @@ export function SettingsDialog({
|
||||
<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);
|
||||
@@ -998,10 +1021,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);
|
||||
@@ -1017,21 +1037,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);
|
||||
@@ -1043,10 +1053,7 @@ export function SettingsDialog({
|
||||
setE2ePassword("");
|
||||
setE2ePasswordConfirm("");
|
||||
showSuccessToast(
|
||||
t(
|
||||
"settings.encryption.passwordSaved",
|
||||
"Encryption password set",
|
||||
),
|
||||
t("settings.encryption.passwordSaved"),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
@@ -1055,7 +1062,7 @@ export function SettingsDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.setPassword", "Set Password")}
|
||||
{t("settings.encryption.setPassword")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1064,28 +1071,29 @@ export function SettingsDialog({
|
||||
{/* Commercial License Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
Commercial License
|
||||
{t("settings.commercial.title")}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
|
||||
{trialStatus?.type === "Active" ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Trial: {trialStatus.days_remaining} days,{" "}
|
||||
{trialStatus.hours_remaining} hours remaining
|
||||
{t("settings.commercial.trialActive", {
|
||||
days: trialStatus.days_remaining,
|
||||
hours: trialStatus.hours_remaining,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Commercial use is free during the trial period
|
||||
{t("settings.commercial.trialActiveDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-warning">
|
||||
Trial expired
|
||||
{t("settings.commercial.trialExpired")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Personal use remains free. Commercial use requires a
|
||||
license.
|
||||
{t("settings.commercial.trialExpiredDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1094,7 +1102,9 @@ export function SettingsDialog({
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.advanced.title")}
|
||||
</Label>
|
||||
|
||||
{!isLinux && (
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg border">
|
||||
@@ -1129,13 +1139,11 @@ 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>
|
||||
|
||||
@@ -1151,7 +1159,7 @@ export function SettingsDialog({
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
@@ -1162,7 +1170,7 @@ export function SettingsDialog({
|
||||
}}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
Save Settings
|
||||
{t("common.buttons.saveSettings")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -74,6 +74,7 @@ function ObjectEditor({
|
||||
title,
|
||||
readOnly = false,
|
||||
}: ObjectEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [jsonString, setJsonString] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,7 +112,7 @@ function ObjectEditor({
|
||||
onChange={(e) => {
|
||||
handleChange(e.target.value);
|
||||
}}
|
||||
placeholder={`Enter ${title} as JSON`}
|
||||
placeholder={t("fingerprint.enterAsJson", { title })}
|
||||
className="font-mono text-sm"
|
||||
rows={6}
|
||||
disabled={readOnly}
|
||||
@@ -465,7 +466,9 @@ export function SharedCamoufoxConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Intel Mac OS X 10.15"
|
||||
placeholder={t(
|
||||
"config.camoufox.fingerprint.osCpuPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -904,7 +907,9 @@ export function SharedCamoufoxConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., llvmpipe, or similar"
|
||||
placeholder={t(
|
||||
"config.camoufox.fingerprint.webglRendererPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1010,7 +1015,7 @@ export function SharedCamoufoxConfigForm({
|
||||
selected.map((s: Option) => s.value),
|
||||
);
|
||||
}}
|
||||
placeholder="Add fonts..."
|
||||
placeholder={t("fingerprint.addFontsPlaceholder")}
|
||||
creatable
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -126,7 +134,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!serverUrl) {
|
||||
showErrorToast("Please enter a server URL");
|
||||
showErrorToast(t("sync.config.serverUrlRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,18 +145,18 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const response = await fetch(healthUrl);
|
||||
if (response.ok) {
|
||||
setConnectionStatus("connected");
|
||||
showSuccessToast("Connection successful!");
|
||||
showSuccessToast(t("sync.config.connectionSuccess"));
|
||||
} else {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast("Server responded with an error");
|
||||
showErrorToast(t("sync.config.serverError"));
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast("Failed to connect to server");
|
||||
showErrorToast(t("sync.config.connectFailed"));
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [serverUrl]);
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -162,15 +170,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
showSuccessToast("Sync settings saved");
|
||||
showSuccessToast(t("sync.config.settingsSaved"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save sync settings:", error);
|
||||
showErrorToast("Failed to save settings");
|
||||
showErrorToast(t("sync.config.saveFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [serverUrl, token, onClose]);
|
||||
}, [serverUrl, token, onClose, t]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -187,44 +195,27 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setServerUrl("");
|
||||
setToken("");
|
||||
setConnectionStatus("unknown");
|
||||
showSuccessToast("Sync disconnected");
|
||||
showSuccessToast(t("sync.config.disconnected"));
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
showErrorToast("Failed to disconnect");
|
||||
showErrorToast(t("sync.config.disconnectFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
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" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -152,6 +153,7 @@ export function TrafficDetailsDialog({
|
||||
profileId,
|
||||
profileName,
|
||||
}: TrafficDetailsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
|
||||
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
|
||||
|
||||
@@ -211,7 +213,9 @@ export function TrafficDetailsDialog({
|
||||
{payload.map((entry) => (
|
||||
<p key={String(entry.dataKey)} className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{entry.dataKey === "sent" ? "↑ Sent: " : "↓ Received: "}
|
||||
{entry.dataKey === "sent"
|
||||
? t("traffic.tooltipSent")
|
||||
: t("traffic.tooltipReceived")}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatBytesPerSecond(
|
||||
@@ -223,7 +227,7 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
// Top domains sorted by total traffic
|
||||
@@ -255,7 +259,7 @@ export function TrafficDetailsDialog({
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Traffic Details
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="text-muted-foreground font-normal ml-2">
|
||||
— {profileName}
|
||||
@@ -269,7 +273,9 @@ export function TrafficDetailsDialog({
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium">Bandwidth Over Time</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => {
|
||||
@@ -277,19 +283,21 @@ export function TrafficDetailsDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] h-8">
|
||||
<SelectValue placeholder="Time period" />
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1m">Last 1 min</SelectItem>
|
||||
<SelectItem value="5m">Last 5 min</SelectItem>
|
||||
<SelectItem value="30m">Last 30 min</SelectItem>
|
||||
<SelectItem value="1h">Last 1 hour</SelectItem>
|
||||
<SelectItem value="2h">Last 2 hours</SelectItem>
|
||||
<SelectItem value="4h">Last 4 hours</SelectItem>
|
||||
<SelectItem value="1d">Last 1 day</SelectItem>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||
<SelectItem value="all">All time</SelectItem>
|
||||
<SelectItem value="1m">{t("traffic.last1m")}</SelectItem>
|
||||
<SelectItem value="5m">{t("traffic.last5m")}</SelectItem>
|
||||
<SelectItem value="30m">{t("traffic.last30m")}</SelectItem>
|
||||
<SelectItem value="1h">{t("traffic.last1h")}</SelectItem>
|
||||
<SelectItem value="2h">{t("traffic.last2h")}</SelectItem>
|
||||
<SelectItem value="4h">{t("traffic.last4h")}</SelectItem>
|
||||
<SelectItem value="1d">{t("traffic.last1d")}</SelectItem>
|
||||
<SelectItem value="7d">{t("traffic.last7d")}</SelectItem>
|
||||
<SelectItem value="30d">{t("traffic.last30d")}</SelectItem>
|
||||
<SelectItem value="all">{t("traffic.allTime")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -393,7 +401,9 @@ export function TrafficDetailsDialog({
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Sent</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLegend")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
@@ -401,7 +411,7 @@ export function TrafficDetailsDialog({
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Received
|
||||
{t("traffic.receivedLegend")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,7 +421,12 @@ export function TrafficDetailsDialog({
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sent ({timePeriod === "all" ? "total" : timePeriod})
|
||||
{t("traffic.sentLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-1">
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
@@ -419,7 +434,12 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Received ({timePeriod === "all" ? "total" : timePeriod})
|
||||
{t("traffic.receivedLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-2">
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
@@ -427,7 +447,12 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requests ({timePeriod === "all" ? "total" : timePeriod})
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
@@ -438,38 +463,50 @@ export function TrafficDetailsDialog({
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t pt-4">
|
||||
<div>
|
||||
<span className="font-medium">All-time traffic:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeTraffic")}
|
||||
</span>{" "}
|
||||
{formatBytes(
|
||||
(stats?.total_bytes_sent ?? 0) +
|
||||
(stats?.total_bytes_received ?? 0),
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">All-time requests:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeRequests")}
|
||||
</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer about proxy/VPN traffic calculation */}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Note: If you are using a proxy, VPN, or similar service, your
|
||||
provider may calculate traffic differently due to encryption
|
||||
overhead and protocol differences.
|
||||
{t("traffic.proxyDisclaimer")}
|
||||
</p>
|
||||
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Top Domains by Traffic (
|
||||
{timePeriod === "all" ? "all time" : timePeriod})
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||
<span>Domain</span>
|
||||
<span className="text-right">Requests</span>
|
||||
<span className="text-right">Sent</span>
|
||||
<span className="text-right">Received</span>
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnSent")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnReceived")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
@@ -503,14 +540,22 @@ export function TrafficDetailsDialog({
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Top Domains by Requests (
|
||||
{timePeriod === "all" ? "all time" : timePeriod})
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||
<span>Domain</span>
|
||||
<span className="text-right">Requests</span>
|
||||
<span className="text-right">Total Traffic</span>
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnTotal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
@@ -543,7 +588,7 @@ export function TrafficDetailsDialog({
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Unique IPs ({stats.unique_ips.length})
|
||||
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
||||
</h3>
|
||||
<div className="border rounded-md p-3 max-h-[120px] overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -563,10 +608,8 @@ export function TrafficDetailsDialog({
|
||||
{/* No data state */}
|
||||
{!stats && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No traffic data available for this profile.</p>
|
||||
<p className="text-sm mt-1">
|
||||
Traffic data will appear after you launch the profile.
|
||||
</p>
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="text-sm mt-1">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPipette } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -366,12 +367,13 @@ export const ColorPickerOutput = ({
|
||||
className: _className,
|
||||
...props
|
||||
}: ColorPickerOutputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { mode, setMode } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Select onValueChange={setMode} value={mode}>
|
||||
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
|
||||
<SelectValue placeholder="Mode" />
|
||||
<SelectValue placeholder={t("common.labels.mode")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formats.map((format) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -39,13 +40,18 @@ export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search...",
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
className,
|
||||
disabled,
|
||||
}: ComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const resolvedPlaceholder = placeholder ?? t("common.buttons.select");
|
||||
const resolvedSearchPlaceholder =
|
||||
searchPlaceholder ?? t("common.buttons.search");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -58,15 +64,15 @@ export function Combobox({
|
||||
>
|
||||
{value
|
||||
? options.find((option) => option.value === value)?.label
|
||||
: placeholder}
|
||||
: resolvedPlaceholder}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandInput placeholder={resolvedSearchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No option found.</CommandEmpty>
|
||||
<CommandEmpty>{t("common.noResults")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
@@ -100,77 +106,3 @@ export function Combobox({
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
value: "next.js",
|
||||
label: "Next.js",
|
||||
},
|
||||
{
|
||||
value: "sveltekit",
|
||||
label: "SvelteKit",
|
||||
},
|
||||
{
|
||||
value: "nuxt.js",
|
||||
label: "Nuxt.js",
|
||||
},
|
||||
{
|
||||
value: "remix",
|
||||
label: "Remix",
|
||||
},
|
||||
{
|
||||
value: "astro",
|
||||
label: "Astro",
|
||||
},
|
||||
];
|
||||
|
||||
export function ComboboxDemo() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState("");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{value
|
||||
? frameworks.find((framework) => framework.value === value)?.label
|
||||
: "Select framework..."}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search framework..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No framework found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{frameworks.map((framework) => (
|
||||
<CommandItem
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === framework.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{framework.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSearch } from "react-icons/lu";
|
||||
|
||||
import {
|
||||
@@ -30,19 +31,23 @@ function Command({
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t("common.commandPalette.title");
|
||||
const resolvedDescription =
|
||||
description ?? t("common.commandPalette.description");
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
<DialogTitle>{resolvedTitle}</DialogTitle>
|
||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
@@ -111,7 +116,7 @@ function CommandGroup({
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-x-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium overflow-y-scroll",
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-x-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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,9 +49,11 @@ 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 ${
|
||||
copied ? "scale-0" : "scale-100"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RxCross2 } from "react-icons/rx";
|
||||
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
@@ -115,6 +116,7 @@ function DialogContent({
|
||||
transition = { type: "spring", stiffness: 150, damping: 25 },
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const initialRotation =
|
||||
from === "bottom" || from === "left" ? "20deg" : "-20deg";
|
||||
const isVertical = from === "top" || from === "bottom";
|
||||
@@ -158,7 +160,7 @@ function DialogContent({
|
||||
}}
|
||||
transition={transition}
|
||||
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}
|
||||
@@ -166,7 +168,7 @@ function DialogContent({
|
||||
{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">Close</span>
|
||||
<span className="sr-only">{t("common.buttons.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</motion.div>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -28,6 +29,7 @@ export function VpnCheckButton({
|
||||
setCheckingVpnId,
|
||||
disabled = false,
|
||||
}: VpnCheckButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [result, setResult] = React.useState<ProxyCheckResult | undefined>();
|
||||
|
||||
const handleCheck = React.useCallback(async () => {
|
||||
@@ -41,14 +43,14 @@ export function VpnCheckButton({
|
||||
setResult(checkResult);
|
||||
|
||||
if (checkResult.is_valid) {
|
||||
toast.success(`VPN "${vpnName}" configuration is valid`);
|
||||
toast.success(t("vpnCheck.valid", { name: vpnName }));
|
||||
} else {
|
||||
toast.error(`VPN "${vpnName}" configuration is invalid`);
|
||||
toast.error(t("vpnCheck.invalid", { name: vpnName }));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`VPN check failed: ${errorMessage}`);
|
||||
toast.error(t("vpnCheck.failed", { error: errorMessage }));
|
||||
|
||||
setResult({
|
||||
ip: "",
|
||||
@@ -58,7 +60,7 @@ export function VpnCheckButton({
|
||||
} finally {
|
||||
setCheckingVpnId(null);
|
||||
}
|
||||
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId]);
|
||||
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId, t]);
|
||||
|
||||
const isCurrentlyChecking = checkingVpnId === vpnId;
|
||||
|
||||
@@ -85,23 +87,27 @@ export function VpnCheckButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentlyChecking ? (
|
||||
<p>Checking VPN config...</p>
|
||||
<p>{t("vpnCheck.tooltipChecking")}</p>
|
||||
) : result?.is_valid ? (
|
||||
<div className="space-y-1">
|
||||
<p>Configuration valid</p>
|
||||
<p>{t("vpnCheck.tooltipValid")}</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
{t("vpnCheck.tooltipChecked", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : result && !result.is_valid ? (
|
||||
<div>
|
||||
<p>Configuration invalid</p>
|
||||
<p>{t("vpnCheck.tooltipInvalid")}</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
{t("vpnCheck.tooltipChecked", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Check VPN config validity</p>
|
||||
<p>{t("vpnCheck.tooltipDefault")}</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
+179
-384
@@ -6,7 +6,6 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,15 +18,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { VpnConfig, VpnType } from "@/types";
|
||||
import type { VpnConfig } from "@/types";
|
||||
|
||||
interface VpnFormDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -48,19 +39,6 @@ interface WireGuardFormData {
|
||||
presharedKey: string;
|
||||
}
|
||||
|
||||
interface OpenVpnFormData {
|
||||
name: string;
|
||||
rawConfig: string;
|
||||
}
|
||||
|
||||
interface VpnDependencyStatus {
|
||||
isAvailable: boolean;
|
||||
requiresExternalInstall: boolean;
|
||||
missingBinary: boolean;
|
||||
missingWindowsAdapter: boolean;
|
||||
dependencyCheckFailed: boolean;
|
||||
}
|
||||
|
||||
const defaultWireGuardForm: WireGuardFormData = {
|
||||
name: "",
|
||||
privateKey: "",
|
||||
@@ -74,11 +52,6 @@ const defaultWireGuardForm: WireGuardFormData = {
|
||||
presharedKey: "",
|
||||
};
|
||||
|
||||
const defaultOpenVpnForm: OpenVpnFormData = {
|
||||
name: "",
|
||||
rawConfig: "",
|
||||
};
|
||||
|
||||
function buildWireGuardConfig(form: WireGuardFormData): string {
|
||||
const lines: string[] = ["[Interface]"];
|
||||
lines.push(`PrivateKey = ${form.privateKey.trim()}`);
|
||||
@@ -104,61 +77,23 @@ export function VpnFormDialog({
|
||||
}: VpnFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [vpnType, setVpnType] = useState<VpnType>("WireGuard");
|
||||
const [wireGuardForm, setWireGuardForm] =
|
||||
useState<WireGuardFormData>(defaultWireGuardForm);
|
||||
const [openVpnForm, setOpenVpnForm] =
|
||||
useState<OpenVpnFormData>(defaultOpenVpnForm);
|
||||
const [vpnDependencyStatus, setVpnDependencyStatus] =
|
||||
useState<VpnDependencyStatus | null>(null);
|
||||
|
||||
const resetForms = useCallback(() => {
|
||||
setVpnType("WireGuard");
|
||||
setWireGuardForm(defaultWireGuardForm);
|
||||
setOpenVpnForm(defaultOpenVpnForm);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingVpn) {
|
||||
setVpnType(editingVpn.vpn_type);
|
||||
if (editingVpn.vpn_type === "WireGuard") {
|
||||
setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name });
|
||||
} else {
|
||||
setOpenVpnForm({ name: editingVpn.name, rawConfig: "" });
|
||||
}
|
||||
setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name });
|
||||
} else {
|
||||
resetForms();
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingVpn, resetForms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setVpnDependencyStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void invoke<VpnDependencyStatus>("get_vpn_dependency_status", { vpnType })
|
||||
.then((status) => {
|
||||
if (!cancelled) {
|
||||
setVpnDependencyStatus(status);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load VPN dependency status:", error);
|
||||
if (!cancelled) {
|
||||
setVpnDependencyStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, vpnType]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
@@ -167,13 +102,10 @@ export function VpnFormDialog({
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (editingVpn) {
|
||||
const name =
|
||||
vpnType === "WireGuard"
|
||||
? wireGuardForm.name.trim()
|
||||
: openVpnForm.name.trim();
|
||||
const name = wireGuardForm.name.trim();
|
||||
|
||||
if (!name) {
|
||||
toast.error("VPN name is required");
|
||||
toast.error(t("vpns.form.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,92 +116,61 @@ export function VpnFormDialog({
|
||||
name,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("VPN updated successfully");
|
||||
toast.success(t("vpns.form.updated"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to update VPN: ${errorMessage}`);
|
||||
toast.error(t("vpns.form.updateFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (vpnType === "WireGuard") {
|
||||
const { name, privateKey, address, peerPublicKey, peerEndpoint } =
|
||||
wireGuardForm;
|
||||
const { name, privateKey, address, peerPublicKey, peerEndpoint } =
|
||||
wireGuardForm;
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
if (!privateKey.trim()) {
|
||||
toast.error("Private key is required");
|
||||
return;
|
||||
}
|
||||
if (!address.trim()) {
|
||||
toast.error("Address is required");
|
||||
return;
|
||||
}
|
||||
if (!peerPublicKey.trim()) {
|
||||
toast.error("Peer public key is required");
|
||||
return;
|
||||
}
|
||||
if (!peerEndpoint.trim()) {
|
||||
toast.error("Peer endpoint is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const configData = buildWireGuardConfig(wireGuardForm);
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "WireGuard",
|
||||
configData,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("WireGuard VPN created successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to create VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
const { name, rawConfig } = openVpnForm;
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
if (!rawConfig.trim()) {
|
||||
toast.error("OpenVPN config content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "OpenVPN",
|
||||
configData: rawConfig,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("OpenVPN configuration created successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to create VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
if (!name.trim()) {
|
||||
toast.error(t("vpns.form.nameRequired"));
|
||||
return;
|
||||
}
|
||||
}, [editingVpn, vpnType, wireGuardForm, openVpnForm, onClose]);
|
||||
if (!privateKey.trim()) {
|
||||
toast.error(t("vpns.form.privateKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!address.trim()) {
|
||||
toast.error(t("vpns.form.addressRequired"));
|
||||
return;
|
||||
}
|
||||
if (!peerPublicKey.trim()) {
|
||||
toast.error(t("vpns.form.peerPublicKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!peerEndpoint.trim()) {
|
||||
toast.error(t("vpns.form.peerEndpointRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const configData = buildWireGuardConfig(wireGuardForm);
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "WireGuard",
|
||||
configData,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success(t("vpns.form.created"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t("vpns.form.createFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [editingVpn, wireGuardForm, onClose, t]);
|
||||
|
||||
const updateWireGuard = useCallback(
|
||||
(field: keyof WireGuardFormData, value: string) => {
|
||||
@@ -278,54 +179,12 @@ export function VpnFormDialog({
|
||||
[],
|
||||
);
|
||||
|
||||
const updateOpenVpn = useCallback(
|
||||
(field: keyof OpenVpnFormData, value: string) => {
|
||||
setOpenVpnForm((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dialogTitle = editingVpn
|
||||
? "Edit VPN"
|
||||
: vpnType === "WireGuard"
|
||||
? "Create WireGuard VPN"
|
||||
: "Create OpenVPN Configuration";
|
||||
|
||||
? t("vpns.form.titleEdit")
|
||||
: t("vpns.form.titleCreate");
|
||||
const dialogDescription = editingVpn
|
||||
? "Update the name of your VPN configuration."
|
||||
: vpnType === "WireGuard"
|
||||
? "Enter your WireGuard interface and peer details."
|
||||
: "Paste your .ovpn configuration file content.";
|
||||
|
||||
let dependencyWarningTitle: string | null = null;
|
||||
let dependencyWarningDescription: string | null = null;
|
||||
|
||||
if (
|
||||
vpnType === "OpenVPN" &&
|
||||
vpnDependencyStatus?.requiresExternalInstall &&
|
||||
!vpnDependencyStatus.isAvailable
|
||||
) {
|
||||
if (vpnDependencyStatus.missingBinary) {
|
||||
dependencyWarningTitle = t("vpnForm.dependencies.openVpnMissingTitle");
|
||||
dependencyWarningDescription = t(
|
||||
"vpnForm.dependencies.openVpnMissingDescription",
|
||||
);
|
||||
} else if (vpnDependencyStatus.missingWindowsAdapter) {
|
||||
dependencyWarningTitle = t(
|
||||
"vpnForm.dependencies.openVpnAdapterMissingTitle",
|
||||
);
|
||||
dependencyWarningDescription = t(
|
||||
"vpnForm.dependencies.openVpnAdapterMissingDescription",
|
||||
);
|
||||
} else if (vpnDependencyStatus.dependencyCheckFailed) {
|
||||
dependencyWarningTitle = t(
|
||||
"vpnForm.dependencies.openVpnCheckFailedTitle",
|
||||
);
|
||||
dependencyWarningDescription = t(
|
||||
"vpnForm.dependencies.openVpnCheckFailedDescription",
|
||||
);
|
||||
}
|
||||
}
|
||||
? t("vpns.form.descEdit")
|
||||
: t("vpns.form.descCreate");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -337,221 +196,155 @@ export function VpnFormDialog({
|
||||
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
<div className="grid gap-4 py-2">
|
||||
{dependencyWarningTitle && dependencyWarningDescription && (
|
||||
<Alert className="border-warning/50 bg-warning/10">
|
||||
<AlertTitle className="text-warning">
|
||||
{dependencyWarningTitle}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-warning">
|
||||
{dependencyWarningDescription}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-name">{t("vpns.form.name")}</Label>
|
||||
<Input
|
||||
id="wg-name"
|
||||
value={wireGuardForm.name}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("name", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.namePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<div className="grid gap-2">
|
||||
<Label>VPN Type</Label>
|
||||
<Select
|
||||
value={vpnType}
|
||||
onValueChange={(value) => {
|
||||
setVpnType(value as VpnType);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select VPN type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="WireGuard">WireGuard</SelectItem>
|
||||
<SelectItem value="OpenVPN">OpenVPN</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vpnType === "WireGuard" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-name">Name</Label>
|
||||
<Label htmlFor="wg-private-key">
|
||||
{t("vpns.form.privateKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-name"
|
||||
value={wireGuardForm.name}
|
||||
id="wg-private-key"
|
||||
value={wireGuardForm.privateKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("name", e.target.value);
|
||||
updateWireGuard("privateKey", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Home WireGuard"
|
||||
placeholder={t("vpns.form.privateKeyPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-private-key">Private Key</Label>
|
||||
<Input
|
||||
id="wg-private-key"
|
||||
value={wireGuardForm.privateKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("privateKey", e.target.value);
|
||||
}}
|
||||
placeholder="Base64-encoded private key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-address">Address</Label>
|
||||
<Input
|
||||
id="wg-address"
|
||||
value={wireGuardForm.address}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("address", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 10.0.0.2/24"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-dns">DNS (optional)</Label>
|
||||
<Input
|
||||
id="wg-dns"
|
||||
value={wireGuardForm.dns}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("dns", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 1.1.1.1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-mtu">MTU (optional)</Label>
|
||||
<Input
|
||||
id="wg-mtu"
|
||||
type="number"
|
||||
value={wireGuardForm.mtu}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("mtu", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 1420"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-public-key">
|
||||
Peer Public Key
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-public-key"
|
||||
value={wireGuardForm.peerPublicKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerPublicKey", e.target.value);
|
||||
}}
|
||||
placeholder="Base64-encoded peer public key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-endpoint">Peer Endpoint</Label>
|
||||
<Input
|
||||
id="wg-peer-endpoint"
|
||||
value={wireGuardForm.peerEndpoint}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerEndpoint", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. vpn.example.com:51820"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-allowed-ips">Allowed IPs</Label>
|
||||
<Input
|
||||
id="wg-allowed-ips"
|
||||
value={wireGuardForm.allowedIps}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("allowedIps", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 0.0.0.0/0, ::/0"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-keepalive">
|
||||
Persistent Keepalive (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-keepalive"
|
||||
type="number"
|
||||
value={wireGuardForm.persistentKeepalive}
|
||||
onChange={(e) => {
|
||||
updateWireGuard(
|
||||
"persistentKeepalive",
|
||||
e.target.value,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g. 25"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-preshared-key">
|
||||
Preshared Key (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-preshared-key"
|
||||
value={wireGuardForm.presharedKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("presharedKey", e.target.value);
|
||||
}}
|
||||
placeholder="Base64-encoded preshared key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{vpnType === "OpenVPN" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ovpn-name">Name</Label>
|
||||
<Label htmlFor="wg-address">{t("vpns.form.address")}</Label>
|
||||
<Input
|
||||
id="ovpn-name"
|
||||
value={openVpnForm.name}
|
||||
id="wg-address"
|
||||
value={wireGuardForm.address}
|
||||
onChange={(e) => {
|
||||
updateOpenVpn("name", e.target.value);
|
||||
updateWireGuard("address", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Work OpenVPN"
|
||||
placeholder={t("vpns.form.addressPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ovpn-config">Raw Config</Label>
|
||||
<Textarea
|
||||
id="ovpn-config"
|
||||
value={openVpnForm.rawConfig}
|
||||
<Label htmlFor="wg-dns">{t("vpns.form.dnsOptional")}</Label>
|
||||
<Input
|
||||
id="wg-dns"
|
||||
value={wireGuardForm.dns}
|
||||
onChange={(e) => {
|
||||
updateOpenVpn("rawConfig", e.target.value);
|
||||
updateWireGuard("dns", e.target.value);
|
||||
}}
|
||||
placeholder="Paste your .ovpn file content here..."
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder={t("vpns.form.dnsPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-mtu">{t("vpns.form.mtuOptional")}</Label>
|
||||
<Input
|
||||
id="wg-mtu"
|
||||
type="number"
|
||||
value={wireGuardForm.mtu}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("mtu", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.mtuPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-public-key">
|
||||
{t("vpns.form.peerPublicKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-public-key"
|
||||
value={wireGuardForm.peerPublicKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerPublicKey", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.peerPublicKeyPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-endpoint">
|
||||
{t("vpns.form.peerEndpoint")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-endpoint"
|
||||
value={wireGuardForm.peerEndpoint}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerEndpoint", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.peerEndpointPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-allowed-ips">
|
||||
{t("vpns.form.allowedIps")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-allowed-ips"
|
||||
value={wireGuardForm.allowedIps}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("allowedIps", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.allowedIpsPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-keepalive">
|
||||
{t("vpns.form.keepaliveOptional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-keepalive"
|
||||
type="number"
|
||||
value={wireGuardForm.persistentKeepalive}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("persistentKeepalive", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.keepalivePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-preshared-key">
|
||||
{t("vpns.form.presharedKeyOptional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-preshared-key"
|
||||
value={wireGuardForm.presharedKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("presharedKey", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.presharedKeyPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -563,10 +356,12 @@ export function VpnFormDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton isLoading={isSubmitting} onClick={handleSubmit}>
|
||||
{editingVpn ? "Update VPN" : "Create VPN"}
|
||||
{editingVpn
|
||||
? t("vpns.form.updateButton")
|
||||
: t("vpns.form.createButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuShield, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -52,21 +53,11 @@ const detectVpnType = (
|
||||
endpoint: endpointMatch ? endpointMatch[1] : null,
|
||||
};
|
||||
}
|
||||
if (
|
||||
lowerFilename.endsWith(".ovpn") ||
|
||||
(content.includes("remote ") &&
|
||||
(content.includes("client") || content.includes("dev tun")))
|
||||
) {
|
||||
const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i);
|
||||
const endpoint = remoteMatch
|
||||
? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}`
|
||||
: null;
|
||||
return { isVpn: true, type: "OpenVPN", endpoint };
|
||||
}
|
||||
return { isVpn: false, type: null, endpoint: null };
|
||||
};
|
||||
|
||||
export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
|
||||
@@ -92,25 +83,28 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
const processContent = useCallback((content: string, filename: string) => {
|
||||
const detection = detectVpnType(content, filename);
|
||||
if (!detection.isVpn) {
|
||||
toast.error("Content does not appear to be a valid VPN configuration");
|
||||
return;
|
||||
}
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: detection.type,
|
||||
endpoint: detection.endpoint,
|
||||
});
|
||||
const baseName = filename
|
||||
.replace(/\.(conf|ovpn)$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${detection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
}, []);
|
||||
const processContent = useCallback(
|
||||
(content: string, filename: string) => {
|
||||
const detection = detectVpnType(content, filename);
|
||||
if (!detection.isVpn) {
|
||||
toast.error(t("vpns.import.invalidContent"));
|
||||
return;
|
||||
}
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: detection.type,
|
||||
endpoint: detection.endpoint,
|
||||
});
|
||||
const baseName = filename
|
||||
.replace(/\.conf$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${detection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
(file: File) => {
|
||||
@@ -120,11 +114,11 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
processContent(content, file.name);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
toast.error(t("vpns.import.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[processContent],
|
||||
[processContent, t],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -132,16 +126,14 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const validFile = files.find(
|
||||
(f) => f.name.endsWith(".conf") || f.name.endsWith(".ovpn"),
|
||||
);
|
||||
const validFile = files.find((f) => f.name.endsWith(".conf"));
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a .conf or .ovpn file");
|
||||
toast.error(t("vpns.import.wrongFileType"));
|
||||
}
|
||||
},
|
||||
[handleFileRead],
|
||||
[handleFileRead, t],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -186,23 +178,22 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import VPN config",
|
||||
error instanceof Error ? error.message : t("vpns.import.failedGeneric"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [vpnPreview, vpnName]);
|
||||
}, [vpnPreview, vpnName, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import VPN Config</DialogTitle>
|
||||
<DialogTitle>{t("vpns.import.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" &&
|
||||
"Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration file"}
|
||||
{step === "vpn-preview" && "Review the VPN configuration to import"}
|
||||
{step === "vpn-result" && "VPN import completed"}
|
||||
{step === "dropzone" && t("vpns.import.descDropzone")}
|
||||
{step === "vpn-preview" && t("vpns.import.descPreview")}
|
||||
{step === "vpn-result" && t("vpns.import.descResult")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -230,16 +221,12 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Drop a VPN config file here or click to browse
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
(.conf for WireGuard, .ovpn for OpenVPN)
|
||||
</span>
|
||||
{t("vpns.import.dropzonePrompt")}
|
||||
</p>
|
||||
<input
|
||||
id="vpn-file-input"
|
||||
type="file"
|
||||
accept=".conf,.ovpn"
|
||||
accept=".conf"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -249,7 +236,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Paste from clipboard with {modKey}+V
|
||||
{t("vpns.import.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -260,21 +247,25 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<LuShield className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{vpnPreview.detectedType} Configuration
|
||||
{t("vpns.import.configurationLabel", {
|
||||
type: vpnPreview.detectedType,
|
||||
})}
|
||||
</div>
|
||||
{vpnPreview.endpoint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Endpoint: {vpnPreview.endpoint}
|
||||
{t("vpns.import.endpointLabel", {
|
||||
endpoint: vpnPreview.endpoint,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpn-name">VPN Name</Label>
|
||||
<Label htmlFor="vpn-name">{t("vpns.import.vpnNameLabel")}</Label>
|
||||
<Input
|
||||
id="vpn-name"
|
||||
placeholder="My VPN"
|
||||
placeholder={t("vpns.import.vpnNamePlaceholder")}
|
||||
value={vpnName}
|
||||
onChange={(e) => {
|
||||
setVpnName(e.target.value);
|
||||
@@ -283,7 +274,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Config Preview</Label>
|
||||
<Label>{t("vpns.import.configPreview")}</Label>
|
||||
<ScrollArea className="h-[150px] border rounded-md">
|
||||
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{vpnPreview.content.slice(0, 1000)}
|
||||
@@ -304,7 +295,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<LuShield className="w-8 h-8 text-success" />
|
||||
<div>
|
||||
<div className="font-medium text-success">
|
||||
VPN Imported Successfully
|
||||
{t("vpns.import.importedSuccess")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vpnImportResult.name} ({vpnImportResult.vpn_type})
|
||||
@@ -314,7 +305,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-destructive">
|
||||
Import Failed
|
||||
{t("vpns.import.importFailed")}
|
||||
</div>
|
||||
<div className="text-sm text-destructive">
|
||||
{vpnImportResult.error}
|
||||
@@ -328,26 +319,28 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
Import VPN
|
||||
{t("vpns.import.importButton")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("vpns.import.doneButton")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -316,7 +316,9 @@ export function WayfernConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Win32, MacIntel, Linux x86_64"
|
||||
placeholder={t(
|
||||
"config.wayfern.fingerprint.platformPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -755,7 +757,9 @@ export function WayfernConfigForm({
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 300 for EST (UTC-5)"
|
||||
placeholder={t(
|
||||
"config.wayfern.fingerprint.timezoneOffsetPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -841,7 +845,9 @@ export function WayfernConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Intel(R) HD Graphics"
|
||||
placeholder={t(
|
||||
"config.wayfern.fingerprint.webglRendererPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -880,7 +886,7 @@ export function WayfernConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="Enter a seed string for canvas fingerprint"
|
||||
placeholder={t("fingerprint.canvasNoiseSeedPlaceholder")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fingerprint.canvasNoiseSeedDescription")}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,24 +23,25 @@ export function WayfernTermsDialog({
|
||||
isOpen,
|
||||
onAccepted,
|
||||
}: WayfernTermsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isAccepting, setIsAccepting] = useState(false);
|
||||
|
||||
const handleAccept = useCallback(async () => {
|
||||
setIsAccepting(true);
|
||||
try {
|
||||
await invoke("accept_wayfern_terms");
|
||||
showSuccessToast("Terms accepted successfully");
|
||||
showSuccessToast(t("wayfernTerms.acceptSuccess"));
|
||||
onAccepted();
|
||||
} catch (error) {
|
||||
console.error("Failed to accept terms:", error);
|
||||
showErrorToast("Failed to accept terms", {
|
||||
showErrorToast(t("wayfernTerms.acceptFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error ? error.message : t("wayfernTerms.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsAccepting(false);
|
||||
}
|
||||
}, [onAccepted]);
|
||||
}, [onAccepted, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
@@ -56,33 +58,30 @@ export function WayfernTermsDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wayfern Terms and Conditions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Before using Donut Browser, you must read and agree to Wayfern's
|
||||
Terms and Conditions.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("wayfernTerms.title")}</DialogTitle>
|
||||
<DialogDescription>{t("wayfernTerms.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please review the Terms and Conditions at:
|
||||
{t("wayfernTerms.reviewLabel")}
|
||||
</p>
|
||||
<a
|
||||
href="https://wayfern.com/terms-and-conditions"
|
||||
href="https://wayfern.com/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm font-medium block"
|
||||
>
|
||||
https://wayfern.com/terms-and-conditions
|
||||
https://wayfern.com/tos
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
By clicking "I Accept", you agree to be bound by these terms.
|
||||
{t("wayfernTerms.agreeNotice")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<LoadingButton onClick={handleAccept} isLoading={isAccepting}>
|
||||
I Accept
|
||||
{t("wayfernTerms.acceptButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user