mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-05 04:27:49 +02:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 547fb0bed6 | |||
| c8c2419ff1 | |||
| 35723de96a | |||
| cb8093fbde | |||
| 749b439d6d | |||
| e49b0b30a1 | |||
| e388e2e85a | |||
| decfdfcfc7 | |||
| c516999f7a | |||
| 1099459dbb | |||
| a3514df0d4 | |||
| 0102cb6c06 | |||
| 612c6610ce | |||
| ba750a3401 | |||
| d0e3e15fd3 | |||
| 248927ae6f | |||
| 6d71dbc62c | |||
| 3f0029c778 | |||
| fff1fe7087 | |||
| 1c971c664f | |||
| 0788797e3f | |||
| 8c338515b7 | |||
| a8c179fca7 | |||
| d0f436ce2d | |||
| 4019701186 | |||
| 53f85abe24 | |||
| 2aafb4c7a4 | |||
| 00d5c655dc | |||
| b12a704d9f | |||
| 0e134fd145 | |||
| adcdc91de2 | |||
| 880014d4c4 | |||
| 71f367f0ae | |||
| 001a292185 |
@@ -197,6 +197,7 @@ These are frequently overlooked issues that make UI look unprofessional:
|
||||
Before delivering UI code, verify these items:
|
||||
|
||||
### Visual Quality
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] Brand logos are correct (verified from Simple Icons)
|
||||
@@ -204,24 +205,28 @@ Before delivering UI code, verify these items:
|
||||
- [ ] Use theme colors directly (bg-primary) not var() wrapper
|
||||
|
||||
### Interaction
|
||||
|
||||
- [ ] All clickable elements have `cursor-pointer`
|
||||
- [ ] Hover states provide clear visual feedback
|
||||
- [ ] Transitions are smooth (150-300ms)
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
|
||||
### Light/Dark Mode
|
||||
|
||||
- [ ] Light mode text has sufficient contrast (4.5:1 minimum)
|
||||
- [ ] Glass/transparent elements visible in light mode
|
||||
- [ ] Borders visible in both modes
|
||||
- [ ] Test both modes before delivery
|
||||
|
||||
### Layout
|
||||
|
||||
- [ ] Floating elements have proper spacing from edges
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] Responsive at 320px, 768px, 1024px, 1440px
|
||||
- [ ] No horizontal scroll on mobile
|
||||
|
||||
### Accessibility
|
||||
|
||||
- [ ] All images have alt text
|
||||
- [ ] Form inputs have labels
|
||||
- [ ] Color is not the only indicator
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||
env:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a #v2.5.0
|
||||
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 #v3.0.0
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Enable auto-merge for minor and patch updates
|
||||
|
||||
@@ -30,13 +30,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f #v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 #v3
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 #v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
echo "Tags: ${TAGS}"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 #v6
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 #v7.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./donut-sync/Dockerfile
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -30,9 +30,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
run: |
|
||||
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues" \
|
||||
--jq "map(select(.user.login == \"$ISSUE_AUTHOR\" and .number != ${{ github.event.issue.number }})) | length" \
|
||||
--paginate || echo "0")
|
||||
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues?state=all&creator=$ISSUE_AUTHOR&per_page=100" \
|
||||
--jq "[.[] | select(.number != ${{ github.event.issue.number }}) ] | length" \
|
||||
|| echo "0")
|
||||
|
||||
if [ "$ISSUE_COUNT" = "0" ]; then
|
||||
echo "is_first_time=true" >> $GITHUB_OUTPUT
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
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. Use them to give specific, actionable advice.\n\nAnalyze the issue and produce a single comment. Format:\n\n1. One sentence acknowledging the issue.\n2. **Possible cause** - Based on the source code, briefly explain what might be going wrong and which files are involved. Be specific (mention file names, function names, line ranges if possible).\n3. **Action items** - What specific info is missing or what the user should try. Only include items that are actually missing.\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`).\n4. Suggest a label: `Label: bug` or `Label: enhancement` on its own line.\n\nRules:\n- Be brief but specific. Reference actual code when possible.\n- If the issue already has everything needed, just acknowledge it and point to the likely cause.\n- Never exceed 15 lines.")
|
||||
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",
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -324,10 +324,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@4ee426ba549131c4903a71dfb6259200467aca81 #v1.2.27
|
||||
uses: anomalyco/opencode/github@54443bfb7e090ec3130dc972e689a3e5cc55a7f9 #v1.3.3
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
scan-scheduled:
|
||||
name: Scheduled Security Scan
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
scan-pr:
|
||||
name: PR Security Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
+112
-18
@@ -20,7 +20,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
@@ -225,6 +225,44 @@ jobs:
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Create portable Windows ZIP
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
PORTABLE_DIR="Donut-Portable"
|
||||
mkdir -p "$PORTABLE_DIR"
|
||||
|
||||
# Copy main executable
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
|
||||
|
||||
# Copy sidecar binaries
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
|
||||
|
||||
# Copy WebView2Loader if present
|
||||
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
|
||||
fi
|
||||
|
||||
# Create .portable marker
|
||||
touch "$PORTABLE_DIR/.portable"
|
||||
|
||||
# Create ZIP
|
||||
7z a "Donut_${VERSION}_x64-portable.zip" "$PORTABLE_DIR"
|
||||
|
||||
- name: Upload portable ZIP to release
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
gh release upload "$TAG" "Donut_${VERSION}_x64-portable.zip" --clobber
|
||||
|
||||
- name: Clean up Apple certificate
|
||||
if: matrix.platform == 'macos-latest' && always()
|
||||
run: |
|
||||
@@ -239,7 +277,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -346,7 +384,7 @@ jobs:
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](${BASE}/Donut_${VERSION}_x64-setup.exe)
|
||||
[Download Windows Installer (x64)](${BASE}/Donut_${VERSION}_x64-setup.exe) · [Portable (x64)](${BASE}/Donut_${VERSION}_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -390,7 +428,7 @@ jobs:
|
||||
--body "Automated update of CHANGELOG.md and README.md download links for ${TAG}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
gh pr merge "$BRANCH" --auto --squash
|
||||
gh pr merge "$BRANCH" --squash --admin
|
||||
fi
|
||||
|
||||
- name: Update release notes
|
||||
@@ -402,26 +440,82 @@ jobs:
|
||||
|
||||
notify-discord:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
needs: [release, changelog]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog summary
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
PREV_TAG=$(git tag --sort=-version:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -v "^${TAG}$" \
|
||||
| head -n 1)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
|
||||
|
||||
CHANGES=""
|
||||
while IFS= read -r msg; do
|
||||
[ -z "$msg" ] && continue
|
||||
case "$msg" in
|
||||
feat\(*\):*|feat:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
|
||||
fix\(*\):*|fix:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
|
||||
refactor\(*\):*|refactor:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
|
||||
perf\(*\):*|perf:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
|
||||
esac
|
||||
done < <(git log --pretty=format:"%s" "${PREV_TAG}..${TAG}" --no-merges)
|
||||
|
||||
# Truncate to fit Discord embed (max 4096 chars)
|
||||
if [ ${#CHANGES} -gt 3900 ]; then
|
||||
CHANGES="${CHANGES:0:3900}\n..."
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGES" ]; then
|
||||
CHANGES="See the full changelog on GitHub."
|
||||
fi
|
||||
|
||||
printf '%s' "$CHANGES" > /tmp/discord-changes.txt
|
||||
|
||||
- name: Send Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_STABLE_WEBHOOK_URL }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${TAG}"
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${VERSION}"
|
||||
CHANGES=$(cat /tmp/discord-changes.txt)
|
||||
|
||||
curl -fsSL -H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"embeds\": [{
|
||||
\"title\": \"Donut Browser ${VERSION} Released\",
|
||||
\"url\": \"${RELEASE_URL}\",
|
||||
\"description\": \"A new stable release of Donut Browser is available.\",
|
||||
\"color\": 5814783
|
||||
# Build JSON with jq to handle escaping
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "Donut Browser ${VERSION} Released" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg changes "$CHANGES" \
|
||||
--arg dl_mac_arm "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_aarch64.dmg" \
|
||||
--arg dl_mac_intel "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64.dmg" \
|
||||
--arg dl_win "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64-setup.exe" \
|
||||
--arg dl_linux "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_amd64.AppImage" \
|
||||
'{
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $changes,
|
||||
color: 5814783,
|
||||
fields: [
|
||||
{ name: "Download", value: ("[macOS (Apple Silicon)](" + $dl_mac_arm + ") · [macOS (Intel)](" + $dl_mac_intel + ")\n[Windows x64](" + $dl_win + ") · [Linux x64](" + $dl_linux + ")"), inline: false }
|
||||
],
|
||||
footer: { text: "donutbrowser.com" }
|
||||
}]
|
||||
}" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
}')
|
||||
|
||||
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
|
||||
|
||||
deploy-website:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
@@ -447,7 +541,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
|
||||
@@ -516,4 +610,4 @@ jobs:
|
||||
--body "Automated update of flake.nix with new AppImage hashes for v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
gh pr merge "$BRANCH" --auto --squash
|
||||
gh pr merge "$BRANCH" --squash --admin
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
@@ -235,6 +235,34 @@ jobs:
|
||||
prerelease: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Create portable Windows ZIP
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
PORTABLE_DIR="Donut-Portable"
|
||||
mkdir -p "$PORTABLE_DIR"
|
||||
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
|
||||
|
||||
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
|
||||
fi
|
||||
|
||||
touch "$PORTABLE_DIR/.portable"
|
||||
|
||||
7z a "Donut_x64-portable.zip" "$PORTABLE_DIR"
|
||||
|
||||
- name: Upload portable ZIP to release
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NIGHTLY_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
run: |
|
||||
gh release upload "$NIGHTLY_TAG" "Donut_x64-portable.zip" --clobber
|
||||
|
||||
- name: Clean up Apple certificate
|
||||
if: matrix.platform == 'macos-latest' && always()
|
||||
run: |
|
||||
@@ -248,7 +276,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
@@ -364,14 +392,24 @@ jobs:
|
||||
run: |
|
||||
COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/nightly"
|
||||
COMMIT_URL="https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}"
|
||||
|
||||
curl -fsSL -H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"embeds\": [{
|
||||
\"title\": \"Donut Browser Nightly Updated\",
|
||||
\"url\": \"${RELEASE_URL}\",
|
||||
\"description\": \"A new nightly build is available (${COMMIT_SHORT}).\",
|
||||
\"color\": 16752128
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "Donut Browser Nightly (${COMMIT_SHORT})" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg commit_url "$COMMIT_URL" \
|
||||
--arg commit_short "$COMMIT_SHORT" \
|
||||
'{
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
color: 16752128,
|
||||
fields: [
|
||||
{ name: "Commit", value: ("[" + $commit_short + "](" + $commit_url + ")"), inline: true },
|
||||
{ name: "Download", value: ("[Nightly Release](" + $url + ")"), inline: true }
|
||||
],
|
||||
footer: { text: "donutbrowser.com" }
|
||||
}]
|
||||
}" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
}')
|
||||
|
||||
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
|
||||
|
||||
@@ -21,6 +21,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d #v1.44.0
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project Guidelines
|
||||
|
||||
> **IMPORTANT**: CLAUDE.md and AGENTS.md must always be identical. If you update one, update the other.
|
||||
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
|
||||
> After significant changes (new modules, renamed files, new directories), re-evaluate the Repository Structure below and update it if needed.
|
||||
|
||||
## Repository Structure
|
||||
@@ -84,4 +84,5 @@ donutbrowser/
|
||||
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
|
||||
|
||||
## Proprietary Changes
|
||||
|
||||
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## v0.18.1 (2026-03-24)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- run docker workflow on release
|
||||
|
||||
### Documentation
|
||||
|
||||
- agents.md
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: require ai disclosure
|
||||
- chore: redeploy web on new release
|
||||
- chore: fix e2e in pr requests
|
||||
- chore: issues get stale after 30 days
|
||||
- chore: better issue validation
|
||||
- chore: update flake.nix for v0.18.0 [skip ci] (#247)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# Project Guidelines
|
||||
|
||||
> **IMPORTANT**: CLAUDE.md and AGENTS.md must always be identical. If you update one, update the other.
|
||||
> After significant changes (new modules, renamed files, new directories), re-evaluate the Repository Structure below and update it if needed.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
donutbrowser/
|
||||
├── src/ # Next.js frontend
|
||||
│ ├── app/ # App router (page.tsx, layout.tsx)
|
||||
│ ├── components/ # 50+ React components (dialogs, tables, UI)
|
||||
│ ├── hooks/ # Event-driven React hooks
|
||||
│ ├── i18n/locales/ # Translations (en, es, fr, ja, pt, ru, zh)
|
||||
│ ├── lib/ # Utilities (themes, toast, browser-utils)
|
||||
│ └── types.ts # Shared TypeScript interfaces
|
||||
├── src-tauri/ # Rust backend (Tauri)
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs # Tauri command registration (100+ commands)
|
||||
│ │ ├── browser_runner.rs # Profile launch/kill orchestration
|
||||
│ │ ├── browser.rs # Browser trait & launch logic
|
||||
│ │ ├── profile/ # Profile CRUD (manager.rs, types.rs)
|
||||
│ │ ├── proxy_manager.rs # Proxy lifecycle & connection testing
|
||||
│ │ ├── proxy_server.rs # Local proxy binary (donut-proxy)
|
||||
│ │ ├── proxy_storage.rs # Proxy config persistence (JSON files)
|
||||
│ │ ├── api_server.rs # REST API (utoipa + axum)
|
||||
│ │ ├── mcp_server.rs # MCP protocol server
|
||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||
│ │ ├── vpn/ # WireGuard & OpenVPN tunnels
|
||||
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
|
||||
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
||||
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
|
||||
│ │ ├── downloader.rs # Browser binary downloader
|
||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||
│ │ ├── settings_manager.rs # App settings persistence
|
||||
│ │ ├── cookie_manager.rs # Cookie import/export
|
||||
│ │ ├── extension_manager.rs # Browser extension management
|
||||
│ │ ├── group_manager.rs # Profile group management
|
||||
│ │ ├── synchronizer.rs # Real-time profile synchronizer
|
||||
│ │ ├── daemon/ # Background daemon + tray icon (currently disabled)
|
||||
│ │ └── cloud_auth.rs # Cloud authentication
|
||||
│ ├── tests/ # Integration tests
|
||||
│ └── Cargo.toml # Rust dependencies
|
||||
├── donut-sync/ # NestJS sync server (self-hostable)
|
||||
│ └── src/ # Controllers, services, auth, S3 sync
|
||||
├── docs/ # Documentation (self-hosting guide)
|
||||
├── flake.nix # Nix development environment
|
||||
└── .github/workflows/ # CI/CD pipelines
|
||||
```
|
||||
|
||||
## Testing and Quality
|
||||
|
||||
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
|
||||
- Always run this command before finishing a task to ensure the application isn't broken
|
||||
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Don't leave comments that don't add value
|
||||
- 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
|
||||
|
||||
## Singletons
|
||||
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
||||
|
||||
## UI Theming
|
||||
|
||||
- Never use hardcoded Tailwind color classes (e.g., `text-red-500`, `bg-green-600`, `border-yellow-400`). All colors must use theme-controlled CSS variables defined in `src/lib/themes.ts`
|
||||
- Available semantic color classes:
|
||||
- `background`, `foreground` — page/container background and text
|
||||
- `card`, `card-foreground` — card surfaces
|
||||
- `popover`, `popover-foreground` — dropdown/popover surfaces
|
||||
- `primary`, `primary-foreground` — primary actions
|
||||
- `secondary`, `secondary-foreground` — secondary actions
|
||||
- `muted`, `muted-foreground` — muted/disabled elements
|
||||
- `accent`, `accent-foreground` — accent highlights
|
||||
- `destructive`, `destructive-foreground` — errors, danger, delete actions
|
||||
- `success`, `success-foreground` — success states, valid indicators
|
||||
- `warning`, `warning-foreground` — warnings, caution messages
|
||||
- `border` — borders
|
||||
- `chart-1` through `chart-5` — data visualization
|
||||
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
|
||||
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
|
||||
|
||||
## Proprietary Changes
|
||||
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
|
||||
@@ -27,6 +27,7 @@ Or enter the dev shell: `nix develop`
|
||||
### Manual Setup
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js (see `.node-version`)
|
||||
- pnpm
|
||||
- Rust + Cargo (latest stable)
|
||||
@@ -47,6 +48,7 @@ pnpm format && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
This runs:
|
||||
|
||||
- **Biome** — JS/TS linting and formatting
|
||||
- **Clippy + rustfmt** — Rust linting and formatting
|
||||
- **typos** — Spellcheck (allowlist in `_typos.toml`)
|
||||
|
||||
@@ -16,11 +16,8 @@
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
|
||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://app.codacy.com/gh/zhom/donutbrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade">
|
||||
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81"/>
|
||||
</a>
|
||||
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
|
||||
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security"/>
|
||||
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security" alt="FOSSA Security Status"/>
|
||||
</a>
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
|
||||
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
|
||||
@@ -45,18 +42,16 @@
|
||||
- **Default browser** — set Donut as your default browser and choose which profile opens each link
|
||||
- **Cloud sync** — sync profiles, proxies, and groups across devices (self-hostable)
|
||||
- **E2E encryption** — optional end-to-end encrypted sync with a password only you know
|
||||
- **Zero telemetry** — no tracking, no fingerprinting of your device, fully auditable open source code
|
||||
- **Cross-platform** — macOS, Linux, and Windows
|
||||
- **Zero telemetry** — no tracking or device fingerprinting
|
||||
|
||||
## Install
|
||||
|
||||
<!-- install-links-start -->
|
||||
|
||||
### macOS
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -66,16 +61,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_x64-setup.exe)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_x64-setup.exe)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut-0.17.6-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut-0.17.6-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_aarch64.AppImage) |
|
||||
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut-0.18.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut-0.18.1-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -146,6 +140,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>Hassiy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/yb403">
|
||||
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
|
||||
<br />
|
||||
<sub><b>yb403</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/drunkod">
|
||||
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
||||
|
||||
@@ -17,4 +17,5 @@ COPY --from=builder /build/node_modules/ node_modules/
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 12342
|
||||
|
||||
USER node
|
||||
CMD ["node", "dist/main"]
|
||||
|
||||
+9
-11
@@ -2,8 +2,6 @@
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
@@ -28,33 +26,33 @@
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ pnpm run start
|
||||
pnpm run start
|
||||
|
||||
# watch mode
|
||||
$ pnpm run start:dev
|
||||
pnpm run start:dev
|
||||
|
||||
# production mode
|
||||
$ pnpm run start:prod
|
||||
pnpm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ pnpm run test
|
||||
pnpm run test
|
||||
|
||||
# e2e tests
|
||||
$ pnpm run test:e2e
|
||||
pnpm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ pnpm run test:cov
|
||||
pnpm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
@@ -64,8 +62,8 @@ When you're ready to deploy your NestJS application to production, there are som
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ pnpm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
pnpm install -g @nestjs/mau
|
||||
mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1015.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
||||
"@aws-sdk/client-s3": "^3.1019.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1019.0",
|
||||
"@nestjs/common": "^11.1.17",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.17",
|
||||
|
||||
@@ -27,7 +27,7 @@ export class AuthGuard implements CanActivate {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException(
|
||||
"Missing or invalid authorization header",
|
||||
);
|
||||
@@ -38,7 +38,7 @@ export class AuthGuard implements CanActivate {
|
||||
// Try SYNC_TOKEN first (self-hosted mode)
|
||||
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
|
||||
if (expectedToken && token === expectedToken) {
|
||||
(request as any).user = {
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "self-hosted",
|
||||
prefix: "",
|
||||
teamPrefix: null,
|
||||
@@ -55,7 +55,7 @@ export class AuthGuard implements CanActivate {
|
||||
algorithms: ["RS256"],
|
||||
}) as jwt.JwtPayload;
|
||||
|
||||
(request as any).user = {
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "cloud",
|
||||
prefix: decoded.prefix || `users/${decoded.sub}/`,
|
||||
teamPrefix: decoded.teamPrefix || null,
|
||||
|
||||
@@ -39,7 +39,7 @@ export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
private getUserContext(req: Request): UserContext {
|
||||
return (req as any).user as UserContext;
|
||||
return (req as unknown as Record<string, unknown>).user as UserContext;
|
||||
}
|
||||
|
||||
@Post("stat")
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"types": ["jest", "node"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
|
||||
@@ -94,17 +94,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.18.0";
|
||||
releaseVersion = "0.18.1";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.0/Donut_0.18.0_amd64.AppImage";
|
||||
hash = "sha256-xsN6FIkuGYPhxdX3hjQ+Ku+iVEoo721NqamOsNc3Wa8=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.AppImage";
|
||||
hash = "sha256-+twOKfcM5qdV3+415/PecdQUgTTe+9xwL7/qu4kCxQI=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.0/Donut_0.18.0_aarch64.AppImage";
|
||||
hash = "sha256-UqdIVGd3DNI5nzePDvfewHsFiUE93Lgck9evNlHlDAo=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.AppImage";
|
||||
hash = "sha256-/Fj2euuxKzP6DxcV7sqShsNr6sy7Ck1iERtYcMt2hZQ=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./dist/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
+6
-6
@@ -57,23 +57,23 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^25.10.5",
|
||||
"lucide-react": "^0.577.0",
|
||||
"i18next": "^26.0.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "^16.2.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.6.2",
|
||||
"react-i18next": "^17.0.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "3.8.0",
|
||||
"recharts": "3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@biomejs/biome": "2.4.9",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tauri-apps/cli": "~2.10.1",
|
||||
"@types/color": "^4.2.1",
|
||||
@@ -86,7 +86,7 @@
|
||||
"tailwindcss": "^4.2.2",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3"
|
||||
"typescript": "~6.0.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.1",
|
||||
"lint-staged": {
|
||||
|
||||
Generated
+201
-188
@@ -84,11 +84,11 @@ importers:
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0
|
||||
i18next:
|
||||
specifier: ^25.10.5
|
||||
version: 25.10.5(typescript@5.9.3)
|
||||
specifier: ^26.0.0
|
||||
version: 26.0.0(typescript@6.0.2)
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.4)
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0(react@19.2.4)
|
||||
motion:
|
||||
specifier: ^12.38.0
|
||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -108,14 +108,14 @@ importers:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
react-i18next:
|
||||
specifier: ^16.6.2
|
||||
version: 16.6.2(i18next@25.10.5(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
specifier: ^17.0.0
|
||||
version: 17.0.0(i18next@26.0.0(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2)
|
||||
react-icons:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0(react@19.2.4)
|
||||
recharts:
|
||||
specifier: 3.8.0
|
||||
version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1)
|
||||
specifier: 3.8.1
|
||||
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -127,8 +127,8 @@ importers:
|
||||
version: 2.3.0
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.4.8
|
||||
version: 2.4.8
|
||||
specifier: 2.4.9
|
||||
version: 2.4.9
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
@@ -161,22 +161,22 @@ importers:
|
||||
version: 4.2.2
|
||||
ts-unused-exports:
|
||||
specifier: ^11.0.1
|
||||
version: 11.0.1(typescript@5.9.3)
|
||||
version: 11.0.1(typescript@6.0.2)
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
typescript:
|
||||
specifier: ~5.9.3
|
||||
version: 5.9.3
|
||||
specifier: ~6.0.2
|
||||
version: 6.0.2
|
||||
|
||||
donut-sync:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1015.0
|
||||
version: 3.1015.0
|
||||
specifier: ^3.1019.0
|
||||
version: 3.1019.0
|
||||
'@aws-sdk/s3-request-presigner':
|
||||
specifier: ^3.1015.0
|
||||
version: 3.1015.0
|
||||
specifier: ^3.1019.0
|
||||
version: 3.1019.0
|
||||
'@nestjs/common':
|
||||
specifier: ^11.1.17
|
||||
version: 11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -308,48 +308,48 @@ packages:
|
||||
'@aws-crypto/util@5.2.0':
|
||||
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
|
||||
|
||||
'@aws-sdk/client-s3@3.1015.0':
|
||||
resolution: {integrity: sha512-yo+Y+/fq5/E684SynTRO+VA3a+98MeE/hs7J52XpNI5SchOCSrLhLtcDKVASlGhHQdNLGLzblRgps1OZaf8sbA==}
|
||||
'@aws-sdk/client-s3@3.1019.0':
|
||||
resolution: {integrity: sha512-0pb9x7PPhS4oEi4c0rL3vzQQoXA4cWKtPuGga/UfVYLZ68yrqdq0NDKg0fr55qzdhNvWFCpmGx73g9Iyy03kkA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.973.24':
|
||||
resolution: {integrity: sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw==}
|
||||
'@aws-sdk/core@3.973.25':
|
||||
resolution: {integrity: sha512-TNrx7eq6nKNOO62HWPqoBqPLXEkW6nLZQGwjL6lq1jZtigWYbK1NbCnT7mKDzbLMHZfuOECUt3n6CzxjUW9HWQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/crc64-nvme@3.972.5':
|
||||
resolution: {integrity: sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.22':
|
||||
resolution: {integrity: sha512-cXp0VTDWT76p3hyK5D51yIKEfpf6/zsUvMfaB8CkyqadJxMQ8SbEeVroregmDlZbtG31wkj9ei0WnftmieggLg==}
|
||||
'@aws-sdk/credential-provider-env@3.972.23':
|
||||
resolution: {integrity: sha512-EamaclJcCEaPHp6wiVknNMM2RlsPMjAHSsYSFLNENBM8Wz92QPc6cOn3dif6vPDQt0Oo4IEghDy3NMDCzY/IvA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.24':
|
||||
resolution: {integrity: sha512-h694K7+tRuepSRJr09wTvQfaEnjzsKZ5s7fbESrVds02GT/QzViJ94/HCNwM7bUfFxqpPXHxulZfL6Cou0dwPg==}
|
||||
'@aws-sdk/credential-provider-http@3.972.25':
|
||||
resolution: {integrity: sha512-qPymamdPcLp6ugoVocG1y5r69ScNiRzb0hogX25/ij+Wz7c7WnsgjLTaz7+eB5BfRxeyUwuw5hgULMuwOGOpcw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.972.24':
|
||||
resolution: {integrity: sha512-O46fFmv0RDFWiWEA9/e6oW92BnsyAXuEgTTasxHligjn2RCr9L/DK773m/NoFaL3ZdNAUz8WxgxunleMnHAkeQ==}
|
||||
'@aws-sdk/credential-provider-ini@3.972.26':
|
||||
resolution: {integrity: sha512-xKxEAMuP6GYx2y5GET+d3aGEroax3AgGfwBE65EQAUe090lzyJ/RzxPX9s8v7Z6qAk0XwfQl+LrmH05X7YvTeg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.24':
|
||||
resolution: {integrity: sha512-sIk8oa6AzDoUhxsR11svZESqvzGuXesw62Rl2oW6wguZx8i9cdGCvkFg+h5K7iucUZP8wyWibUbJMc+J66cu5g==}
|
||||
'@aws-sdk/credential-provider-login@3.972.26':
|
||||
resolution: {integrity: sha512-EFcM8RM3TUxnZOfMJo++3PnyxFu1fL/huzmn3Vh+8IWRgqZawUD3cRwwOr+/4bE9DpyHaLOWFAjY0lfK5X9ZkQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.25':
|
||||
resolution: {integrity: sha512-m7dR0Dsva2P+VUpL+VkC0WwiDby5pgmWXkRVDB5rlwv0jXJrQJf7YMtCoM8Wjk0H9jPeCYOxOXXcIgp/qp5Alg==}
|
||||
'@aws-sdk/credential-provider-node@3.972.27':
|
||||
resolution: {integrity: sha512-jXpxSolfFnPVj6GCTtx3xIdWNoDR7hYC/0SbetGZxOC9UnNmipHeX1k6spVstf7eWJrMhXNQEgXC0pD1r5tXIg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.22':
|
||||
resolution: {integrity: sha512-Os32s8/4gTZjBk5BtoS/cuTILaj+K72d0dVG7TCJX/fC4598cxwLDmf1AEHEpER5oL3K//yETjvFaz0V8oO5Xw==}
|
||||
'@aws-sdk/credential-provider-process@3.972.23':
|
||||
resolution: {integrity: sha512-IL/TFW59++b7MpHserjUblGrdP5UXy5Ekqqx1XQkERXBFJcZr74I7VaSrQT5dxdRMU16xGK4L0RQ5fQG1pMgnA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.972.24':
|
||||
resolution: {integrity: sha512-PaFv7snEfypU2yXkpvfyWgddEbDLtgVe51wdZlinhc2doubBjUzJZZpgwuF2Jenl1FBydMhNpMjD6SBUM3qdSA==}
|
||||
'@aws-sdk/credential-provider-sso@3.972.26':
|
||||
resolution: {integrity: sha512-c6ghvRb6gTlMznWhGxn/bpVCcp0HRaz4DobGVD9kI4vwHq186nU2xN/S7QGkm0lo0H2jQU8+dgpUFLxfTcwCOg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.24':
|
||||
resolution: {integrity: sha512-J6H4R1nvr3uBTqD/EeIPAskrBtET4WFfNhpFySr2xW7bVZOXpQfPjrLSIx65jcNjBmLXzWq8QFLdVoGxiGG/SA==}
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.26':
|
||||
resolution: {integrity: sha512-cXcS3+XD3iwhoXkM44AmxjmbcKueoLCINr1e+IceMmCySda5ysNIfiGBGe9qn5EMiQ9Jd7pP0AGFtcd6OV3Lvg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.972.8':
|
||||
@@ -360,8 +360,8 @@ packages:
|
||||
resolution: {integrity: sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-flexible-checksums@3.974.4':
|
||||
resolution: {integrity: sha512-fhCbZXPAyy8btnNbnBlR7Cc1nD54cETSvGn2wey71ehsM89AKPO8Dpco9DBAAgvrUdLrdHQepBXcyX4vxC5OwA==}
|
||||
'@aws-sdk/middleware-flexible-checksums@3.974.5':
|
||||
resolution: {integrity: sha512-SPSvF0G1t8m8CcB0L+ClNFszzQOvXaxmRj25oRWDf6aU+TuN2PXPFAJ9A6lt1IvX4oGAqqbTdMPTYs/SSHUYYQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-host-header@3.972.8':
|
||||
@@ -376,40 +376,40 @@ packages:
|
||||
resolution: {integrity: sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-recursion-detection@3.972.8':
|
||||
resolution: {integrity: sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==}
|
||||
'@aws-sdk/middleware-recursion-detection@3.972.9':
|
||||
resolution: {integrity: sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.24':
|
||||
resolution: {integrity: sha512-4sXxVC/enYgMkZefNMOzU6C6KtAXEvwVJLgNcUx1dvROH6GvKB5Sm2RGnGzTp0/PwkibIyMw4kOzF8tbLfaBAQ==}
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.26':
|
||||
resolution: {integrity: sha512-5q7UGSTtt7/KF0Os8wj2VZtlLxeWJVb0e2eDrDJlWot2EIxUNKDDMPFq/FowUqrwZ40rO2bu6BypxaKNvQhI+g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-ssec@3.972.8':
|
||||
resolution: {integrity: sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-user-agent@3.972.25':
|
||||
resolution: {integrity: sha512-QxiMPofvOt8SwSynTOmuZfvvPM1S9QfkESBxB22NMHTRXCJhR5BygLl8IXfC4jELiisQgwsgUby21GtXfX3f/g==}
|
||||
'@aws-sdk/middleware-user-agent@3.972.26':
|
||||
resolution: {integrity: sha512-AilFIh4rI/2hKyyGN6XrB0yN96W2o7e7wyrPWCM6QjZM1mcC/pVkW3IWWRvuBWMpVP8Fg+rMpbzeLQ6dTM4gig==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.996.14':
|
||||
resolution: {integrity: sha512-fSESKvh1VbfjtV3QMnRkCPZWkUbQof6T/DOpiLp33yP2wA+rbwwnZeG3XT3Ekljgw2I8X4XaQPnw+zSR8yxJ5Q==}
|
||||
'@aws-sdk/nested-clients@3.996.16':
|
||||
resolution: {integrity: sha512-L7Qzoj/qQU1cL5GnYLQP5LbI+wlLCLoINvcykR3htKcQ4tzrPf2DOs72x933BM7oArYj1SKrkb2lGlsJHIic3g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/region-config-resolver@3.972.9':
|
||||
resolution: {integrity: sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng==}
|
||||
'@aws-sdk/region-config-resolver@3.972.10':
|
||||
resolution: {integrity: sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/s3-request-presigner@3.1015.0':
|
||||
resolution: {integrity: sha512-N8Axxt3VNXPPnujakUfwm5SvyoE+4dqeIdfPr2EXLgV8vruerHuH9fb9/Dr1lGYeaRjM161ye2d3Ko4TB7oZLg==}
|
||||
'@aws-sdk/s3-request-presigner@3.1019.0':
|
||||
resolution: {integrity: sha512-KFv5UaIORIF6MTmEc79MQTPQSnRZjUmOIaOzXn9g6ujtViQLIrNYJiaSmVw8LqK1ebcndS6L6s4bBdLd9AQVJA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.12':
|
||||
resolution: {integrity: sha512-abRObSqjVeKUUHIZfAp78PTYrEsxCgVKDs/YET357pzT5C02eDDEvmWyeEC2wglWcYC4UTbBFk22gd2YJUlCQg==}
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.14':
|
||||
resolution: {integrity: sha512-4nZSrBr1NO+48HCM/6BRU8mnRjuHZjcpziCvLXZk5QVftwWz5Mxqbhwdz4xf7WW88buaTB8uRO2MHklSX1m0vg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1015.0':
|
||||
resolution: {integrity: sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ==}
|
||||
'@aws-sdk/token-providers@3.1019.0':
|
||||
resolution: {integrity: sha512-OF+2RfRmUKyjzrRWlDcyju3RBsuqcrYDQ8TwrJg8efcOotMzuZN4U9mpVTIdATpmEc4lWNZBMSjPzrGm6JPnAQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.973.6':
|
||||
@@ -435,8 +435,8 @@ packages:
|
||||
'@aws-sdk/util-user-agent-browser@3.972.8':
|
||||
resolution: {integrity: sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==}
|
||||
|
||||
'@aws-sdk/util-user-agent-node@3.973.11':
|
||||
resolution: {integrity: sha512-1qdXbXo2s5MMLpUvw00284LsbhtlQ4ul7Zzdn5n+7p4WVgCMLqhxImpHIrjSoc72E/fyc4Wq8dLtUld2Gsh+lA==}
|
||||
'@aws-sdk/util-user-agent-node@3.973.12':
|
||||
resolution: {integrity: sha512-8phW0TS8ntENJgDcFewYT/Q8dOmarpvSxEjATu2GUBAutiHr++oEGCiBUwxslCMNvwW2cAPZNT53S/ym8zm/gg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
aws-crt: '>=1.0.0'
|
||||
@@ -444,8 +444,8 @@ packages:
|
||||
aws-crt:
|
||||
optional: true
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.15':
|
||||
resolution: {integrity: sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==}
|
||||
'@aws-sdk/xml-builder@3.972.16':
|
||||
resolution: {integrity: sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws/lambda-invoke-store@0.2.4':
|
||||
@@ -621,59 +621,59 @@ packages:
|
||||
'@bcoe/v8-coverage@0.2.3':
|
||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||
|
||||
'@biomejs/biome@2.4.8':
|
||||
resolution: {integrity: sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==}
|
||||
'@biomejs/biome@2.4.9':
|
||||
resolution: {integrity: sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.4.8':
|
||||
resolution: {integrity: sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==}
|
||||
'@biomejs/cli-darwin-arm64@2.4.9':
|
||||
resolution: {integrity: sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.4.8':
|
||||
resolution: {integrity: sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==}
|
||||
'@biomejs/cli-darwin-x64@2.4.9':
|
||||
resolution: {integrity: sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.8':
|
||||
resolution: {integrity: sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==}
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.9':
|
||||
resolution: {integrity: sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.8':
|
||||
resolution: {integrity: sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==}
|
||||
'@biomejs/cli-linux-arm64@2.4.9':
|
||||
resolution: {integrity: sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.8':
|
||||
resolution: {integrity: sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==}
|
||||
'@biomejs/cli-linux-x64-musl@2.4.9':
|
||||
resolution: {integrity: sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.8':
|
||||
resolution: {integrity: sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==}
|
||||
'@biomejs/cli-linux-x64@2.4.9':
|
||||
resolution: {integrity: sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.8':
|
||||
resolution: {integrity: sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==}
|
||||
'@biomejs/cli-win32-arm64@2.4.9':
|
||||
resolution: {integrity: sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.4.8':
|
||||
resolution: {integrity: sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==}
|
||||
'@biomejs/cli-win32-x64@2.4.9':
|
||||
resolution: {integrity: sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -3980,10 +3980,10 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
i18next@25.10.5:
|
||||
resolution: {integrity: sha512-jRnF7eRNsdcnh7AASSgaU3lj/8lJZuHkfsouetnLEDH0xxE1vVi7qhiJ9RhdSPUyzg4ltb7P7aXsFlTk9sxL2w==}
|
||||
i18next@26.0.0:
|
||||
resolution: {integrity: sha512-+Dg27j7VH40WRy+Q010hj3jxlxDb3qFkA7EwuK4kx0+glLxEqZsxdB8MqliPBkTrXEuNlEfeosM3lucAHK+0tQ==}
|
||||
peerDependencies:
|
||||
typescript: ^5
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
@@ -4434,8 +4434,8 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lucide-react@0.577.0:
|
||||
resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==}
|
||||
lucide-react@1.7.0:
|
||||
resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
@@ -4735,6 +4735,10 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
picomatch@4.0.4:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pirates@4.0.7:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -4803,14 +4807,14 @@ packages:
|
||||
react-fast-compare@3.2.2:
|
||||
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
||||
|
||||
react-i18next@16.6.2:
|
||||
resolution: {integrity: sha512-/S/GPzElTqEi5o2kzd0/O2627hPDmE6OGhJCCwCfUaQ3syyu+kaYH8/PYFtZeWc25NzfxTN/2fD1QjvrTgrFfA==}
|
||||
react-i18next@17.0.0:
|
||||
resolution: {integrity: sha512-L7aqwOePCExt6nlF7000lN2YKWnR7IpSpQId9sj01798Xn3LAncBdTHKl9lA/nr+YrG78BTqWPJxq9mlrrmH7Q==}
|
||||
peerDependencies:
|
||||
i18next: '>= 25.6.2'
|
||||
i18next: '>= 25.10.10'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
typescript: ^5
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
@@ -4881,8 +4885,8 @@ packages:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
recharts@3.8.0:
|
||||
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
|
||||
recharts@3.8.1:
|
||||
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -5341,6 +5345,11 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
typescript@6.0.2:
|
||||
resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uglify-js@3.19.3:
|
||||
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
@@ -5659,29 +5668,29 @@ snapshots:
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-s3@3.1015.0':
|
||||
'@aws-sdk/client-s3@3.1019.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha1-browser': 5.2.0
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/credential-provider-node': 3.972.25
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/credential-provider-node': 3.972.27
|
||||
'@aws-sdk/middleware-bucket-endpoint': 3.972.8
|
||||
'@aws-sdk/middleware-expect-continue': 3.972.8
|
||||
'@aws-sdk/middleware-flexible-checksums': 3.974.4
|
||||
'@aws-sdk/middleware-flexible-checksums': 3.974.5
|
||||
'@aws-sdk/middleware-host-header': 3.972.8
|
||||
'@aws-sdk/middleware-location-constraint': 3.972.8
|
||||
'@aws-sdk/middleware-logger': 3.972.8
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.8
|
||||
'@aws-sdk/middleware-sdk-s3': 3.972.24
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.9
|
||||
'@aws-sdk/middleware-sdk-s3': 3.972.26
|
||||
'@aws-sdk/middleware-ssec': 3.972.8
|
||||
'@aws-sdk/middleware-user-agent': 3.972.25
|
||||
'@aws-sdk/region-config-resolver': 3.972.9
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.12
|
||||
'@aws-sdk/middleware-user-agent': 3.972.26
|
||||
'@aws-sdk/region-config-resolver': 3.972.10
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.14
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@aws-sdk/util-endpoints': 3.996.5
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.8
|
||||
'@aws-sdk/util-user-agent-node': 3.973.11
|
||||
'@aws-sdk/util-user-agent-node': 3.973.12
|
||||
'@smithy/config-resolver': 4.4.13
|
||||
'@smithy/core': 3.23.12
|
||||
'@smithy/eventstream-serde-browser': 4.2.12
|
||||
@@ -5719,10 +5728,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/core@3.973.24':
|
||||
'@aws-sdk/core@3.973.25':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@aws-sdk/xml-builder': 3.972.15
|
||||
'@aws-sdk/xml-builder': 3.972.16
|
||||
'@smithy/core': 3.23.12
|
||||
'@smithy/node-config-provider': 4.3.12
|
||||
'@smithy/property-provider': 4.2.12
|
||||
@@ -5740,17 +5749,17 @@ snapshots:
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.22':
|
||||
'@aws-sdk/credential-provider-env@3.972.23':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/property-provider': 4.2.12
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.24':
|
||||
'@aws-sdk/credential-provider-http@3.972.25':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/fetch-http-handler': 5.3.15
|
||||
'@smithy/node-http-handler': 4.5.0
|
||||
@@ -5761,16 +5770,16 @@ snapshots:
|
||||
'@smithy/util-stream': 4.5.20
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.972.24':
|
||||
'@aws-sdk/credential-provider-ini@3.972.26':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/credential-provider-env': 3.972.22
|
||||
'@aws-sdk/credential-provider-http': 3.972.24
|
||||
'@aws-sdk/credential-provider-login': 3.972.24
|
||||
'@aws-sdk/credential-provider-process': 3.972.22
|
||||
'@aws-sdk/credential-provider-sso': 3.972.24
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.24
|
||||
'@aws-sdk/nested-clients': 3.996.14
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/credential-provider-env': 3.972.23
|
||||
'@aws-sdk/credential-provider-http': 3.972.25
|
||||
'@aws-sdk/credential-provider-login': 3.972.26
|
||||
'@aws-sdk/credential-provider-process': 3.972.23
|
||||
'@aws-sdk/credential-provider-sso': 3.972.26
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.26
|
||||
'@aws-sdk/nested-clients': 3.996.16
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/credential-provider-imds': 4.2.12
|
||||
'@smithy/property-provider': 4.2.12
|
||||
@@ -5780,10 +5789,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.24':
|
||||
'@aws-sdk/credential-provider-login@3.972.26':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/nested-clients': 3.996.14
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/nested-clients': 3.996.16
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/property-provider': 4.2.12
|
||||
'@smithy/protocol-http': 5.3.12
|
||||
@@ -5793,14 +5802,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.25':
|
||||
'@aws-sdk/credential-provider-node@3.972.27':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.22
|
||||
'@aws-sdk/credential-provider-http': 3.972.24
|
||||
'@aws-sdk/credential-provider-ini': 3.972.24
|
||||
'@aws-sdk/credential-provider-process': 3.972.22
|
||||
'@aws-sdk/credential-provider-sso': 3.972.24
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.24
|
||||
'@aws-sdk/credential-provider-env': 3.972.23
|
||||
'@aws-sdk/credential-provider-http': 3.972.25
|
||||
'@aws-sdk/credential-provider-ini': 3.972.26
|
||||
'@aws-sdk/credential-provider-process': 3.972.23
|
||||
'@aws-sdk/credential-provider-sso': 3.972.26
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.26
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/credential-provider-imds': 4.2.12
|
||||
'@smithy/property-provider': 4.2.12
|
||||
@@ -5810,20 +5819,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.22':
|
||||
'@aws-sdk/credential-provider-process@3.972.23':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/property-provider': 4.2.12
|
||||
'@smithy/shared-ini-file-loader': 4.4.7
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.972.24':
|
||||
'@aws-sdk/credential-provider-sso@3.972.26':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/nested-clients': 3.996.14
|
||||
'@aws-sdk/token-providers': 3.1015.0
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/nested-clients': 3.996.16
|
||||
'@aws-sdk/token-providers': 3.1019.0
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/property-provider': 4.2.12
|
||||
'@smithy/shared-ini-file-loader': 4.4.7
|
||||
@@ -5832,10 +5841,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.24':
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.26':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/nested-clients': 3.996.14
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/nested-clients': 3.996.16
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/property-provider': 4.2.12
|
||||
'@smithy/shared-ini-file-loader': 4.4.7
|
||||
@@ -5861,12 +5870,12 @@ snapshots:
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-flexible-checksums@3.974.4':
|
||||
'@aws-sdk/middleware-flexible-checksums@3.974.5':
|
||||
dependencies:
|
||||
'@aws-crypto/crc32': 5.2.0
|
||||
'@aws-crypto/crc32c': 5.2.0
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/crc64-nvme': 3.972.5
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/is-array-buffer': 4.2.2
|
||||
@@ -5897,7 +5906,7 @@ snapshots:
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-recursion-detection@3.972.8':
|
||||
'@aws-sdk/middleware-recursion-detection@3.972.9':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@aws/lambda-invoke-store': 0.2.4
|
||||
@@ -5905,9 +5914,9 @@ snapshots:
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.24':
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.26':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@aws-sdk/util-arn-parser': 3.972.3
|
||||
'@smithy/core': 3.23.12
|
||||
@@ -5928,9 +5937,9 @@ snapshots:
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-user-agent@3.972.25':
|
||||
'@aws-sdk/middleware-user-agent@3.972.26':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@aws-sdk/util-endpoints': 3.996.5
|
||||
'@smithy/core': 3.23.12
|
||||
@@ -5939,20 +5948,20 @@ snapshots:
|
||||
'@smithy/util-retry': 4.2.12
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/nested-clients@3.996.14':
|
||||
'@aws-sdk/nested-clients@3.996.16':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/middleware-host-header': 3.972.8
|
||||
'@aws-sdk/middleware-logger': 3.972.8
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.8
|
||||
'@aws-sdk/middleware-user-agent': 3.972.25
|
||||
'@aws-sdk/region-config-resolver': 3.972.9
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.9
|
||||
'@aws-sdk/middleware-user-agent': 3.972.26
|
||||
'@aws-sdk/region-config-resolver': 3.972.10
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@aws-sdk/util-endpoints': 3.996.5
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.8
|
||||
'@aws-sdk/util-user-agent-node': 3.973.11
|
||||
'@aws-sdk/util-user-agent-node': 3.973.12
|
||||
'@smithy/config-resolver': 4.4.13
|
||||
'@smithy/core': 3.23.12
|
||||
'@smithy/fetch-http-handler': 5.3.15
|
||||
@@ -5982,7 +5991,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/region-config-resolver@3.972.9':
|
||||
'@aws-sdk/region-config-resolver@3.972.10':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/config-resolver': 4.4.13
|
||||
@@ -5990,9 +5999,9 @@ snapshots:
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/s3-request-presigner@3.1015.0':
|
||||
'@aws-sdk/s3-request-presigner@3.1019.0':
|
||||
dependencies:
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.12
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.14
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@aws-sdk/util-format-url': 3.972.8
|
||||
'@smithy/middleware-endpoint': 4.4.27
|
||||
@@ -6001,19 +6010,19 @@ snapshots:
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.12':
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.14':
|
||||
dependencies:
|
||||
'@aws-sdk/middleware-sdk-s3': 3.972.24
|
||||
'@aws-sdk/middleware-sdk-s3': 3.972.26
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/protocol-http': 5.3.12
|
||||
'@smithy/signature-v4': 5.3.12
|
||||
'@smithy/types': 4.13.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1015.0':
|
||||
'@aws-sdk/token-providers@3.1019.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.24
|
||||
'@aws-sdk/nested-clients': 3.996.14
|
||||
'@aws-sdk/core': 3.973.25
|
||||
'@aws-sdk/nested-clients': 3.996.16
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/property-provider': 4.2.12
|
||||
'@smithy/shared-ini-file-loader': 4.4.7
|
||||
@@ -6057,16 +6066,16 @@ snapshots:
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-user-agent-node@3.973.11':
|
||||
'@aws-sdk/util-user-agent-node@3.973.12':
|
||||
dependencies:
|
||||
'@aws-sdk/middleware-user-agent': 3.972.25
|
||||
'@aws-sdk/middleware-user-agent': 3.972.26
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/node-config-provider': 4.3.12
|
||||
'@smithy/types': 4.13.1
|
||||
'@smithy/util-config-provider': 4.2.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.15':
|
||||
'@aws-sdk/xml-builder@3.972.16':
|
||||
dependencies:
|
||||
'@smithy/types': 4.13.1
|
||||
fast-xml-parser: 5.5.8
|
||||
@@ -6265,39 +6274,39 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3': {}
|
||||
|
||||
'@biomejs/biome@2.4.8':
|
||||
'@biomejs/biome@2.4.9':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.4.8
|
||||
'@biomejs/cli-darwin-x64': 2.4.8
|
||||
'@biomejs/cli-linux-arm64': 2.4.8
|
||||
'@biomejs/cli-linux-arm64-musl': 2.4.8
|
||||
'@biomejs/cli-linux-x64': 2.4.8
|
||||
'@biomejs/cli-linux-x64-musl': 2.4.8
|
||||
'@biomejs/cli-win32-arm64': 2.4.8
|
||||
'@biomejs/cli-win32-x64': 2.4.8
|
||||
'@biomejs/cli-darwin-arm64': 2.4.9
|
||||
'@biomejs/cli-darwin-x64': 2.4.9
|
||||
'@biomejs/cli-linux-arm64': 2.4.9
|
||||
'@biomejs/cli-linux-arm64-musl': 2.4.9
|
||||
'@biomejs/cli-linux-x64': 2.4.9
|
||||
'@biomejs/cli-linux-x64-musl': 2.4.9
|
||||
'@biomejs/cli-win32-arm64': 2.4.9
|
||||
'@biomejs/cli-win32-x64': 2.4.9
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.4.8':
|
||||
'@biomejs/cli-darwin-arm64@2.4.9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.4.8':
|
||||
'@biomejs/cli-darwin-x64@2.4.9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.8':
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.8':
|
||||
'@biomejs/cli-linux-arm64@2.4.9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.8':
|
||||
'@biomejs/cli-linux-x64-musl@2.4.9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.8':
|
||||
'@biomejs/cli-linux-x64@2.4.9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.8':
|
||||
'@biomejs/cli-win32-arm64@2.4.9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.4.8':
|
||||
'@biomejs/cli-win32-x64@2.4.9':
|
||||
optional: true
|
||||
|
||||
'@borewit/text-codec@0.2.2': {}
|
||||
@@ -9439,9 +9448,9 @@ snapshots:
|
||||
dependencies:
|
||||
bser: 2.1.1
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
|
||||
file-type@21.3.2:
|
||||
dependencies:
|
||||
@@ -9637,11 +9646,11 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
i18next@25.10.5(typescript@5.9.3):
|
||||
i18next@26.0.0(typescript@6.0.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
typescript: 6.0.2
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
@@ -10235,7 +10244,7 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lucide-react@0.577.0(react@19.2.4):
|
||||
lucide-react@1.7.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
@@ -10483,6 +10492,8 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
pkg-dir@4.2.0:
|
||||
@@ -10601,16 +10612,16 @@ snapshots:
|
||||
|
||||
react-fast-compare@3.2.2: {}
|
||||
|
||||
react-i18next@16.6.2(i18next@25.10.5(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
|
||||
react-i18next@17.0.0(i18next@26.0.0(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 25.10.5(typescript@5.9.3)
|
||||
i18next: 26.0.0(typescript@6.0.2)
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
typescript: 5.9.3
|
||||
typescript: 6.0.2
|
||||
|
||||
react-icons@5.6.0(react@19.2.4):
|
||||
dependencies:
|
||||
@@ -10664,7 +10675,7 @@ snapshots:
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1):
|
||||
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
|
||||
clsx: 2.1.1
|
||||
@@ -11070,8 +11081,8 @@ snapshots:
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tmpl@1.0.5: {}
|
||||
|
||||
@@ -11135,11 +11146,11 @@ snapshots:
|
||||
v8-compile-cache-lib: 3.0.1
|
||||
yn: 3.1.1
|
||||
|
||||
ts-unused-exports@11.0.1(typescript@5.9.3):
|
||||
ts-unused-exports@11.0.1(typescript@6.0.2):
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
tsconfig-paths: 3.15.0
|
||||
typescript: 5.9.3
|
||||
typescript: 6.0.2
|
||||
|
||||
tsconfig-paths-webpack-plugin@4.2.0:
|
||||
dependencies:
|
||||
@@ -11186,6 +11197,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
typescript@6.0.2: {}
|
||||
|
||||
uglify-js@3.19.3:
|
||||
optional: true
|
||||
|
||||
@@ -11286,8 +11299,8 @@ snapshots:
|
||||
vite@7.0.6(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.8
|
||||
rollup: 4.60.0
|
||||
tinyglobby: 0.2.15
|
||||
|
||||
Generated
+142
-37
@@ -158,7 +158,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -169,7 +169,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -820,6 +820,15 @@ dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
|
||||
dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
@@ -920,9 +929,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.57"
|
||||
version = "1.2.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -1461,6 +1470,17 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
@@ -1619,7 +1639,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1705,7 +1725,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"boringtun",
|
||||
"bzip2",
|
||||
"bzip2 0.6.1",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
@@ -1750,7 +1770,7 @@ dependencies = [
|
||||
"smoltcp",
|
||||
"sys-locale",
|
||||
"sysinfo",
|
||||
"tao",
|
||||
"tao 0.35.0",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -1825,9 +1845,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.7"
|
||||
version = "3.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f"
|
||||
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
@@ -1956,7 +1976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3485,12 +3505,27 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.12"
|
||||
@@ -3519,9 +3554,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
@@ -3770,9 +3805,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
@@ -3954,9 +3989,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
@@ -4024,7 +4059,7 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -4126,6 +4161,16 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
@@ -4219,8 +4264,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-location",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
@@ -4345,7 +4409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5568,9 +5632,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.40.0"
|
||||
version = "1.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
|
||||
checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"borsh",
|
||||
@@ -5580,6 +5644,7 @@ dependencies = [
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5607,7 +5672,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6140,9 +6205,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simd_helpers"
|
||||
@@ -6222,7 +6287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6528,6 +6593,46 @@ dependencies = [
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
"dispatch2",
|
||||
"dlopen2",
|
||||
"dpi",
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk-sys",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tao-macros"
|
||||
version = "0.1.3"
|
||||
@@ -6890,7 +6995,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"softbuffer",
|
||||
"tao",
|
||||
"tao 0.34.8",
|
||||
"tauri-runtime",
|
||||
"tauri-utils",
|
||||
"url",
|
||||
@@ -6956,10 +7061,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7519,7 +7624,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7607,9 +7712,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-vo"
|
||||
@@ -7767,9 +7872,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -8117,7 +8222,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8646,7 +8751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9069,7 +9174,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"bzip2 0.5.2",
|
||||
"constant_time_eq 0.3.1",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
@@ -9167,9 +9272,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.14"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -64,7 +64,7 @@ flate2 = "1"
|
||||
lzma-rs = "0"
|
||||
msi-extract = "0"
|
||||
|
||||
uuid = { version = "1.20", features = ["v4", "serde"] }
|
||||
uuid = { version = "1.23", features = ["v4", "serde"] }
|
||||
url = "2.5"
|
||||
blake3 = "1"
|
||||
globset = "0.4"
|
||||
@@ -111,7 +111,7 @@ smoltcp = { version = "0.13", default-features = false, features = ["std", "medi
|
||||
# Daemon dependencies (tray icon)
|
||||
tray-icon = "0.21"
|
||||
muda = "0.17"
|
||||
tao = "0.34"
|
||||
tao = "0.35"
|
||||
image = "0.25"
|
||||
dirs = "6"
|
||||
crossbeam-channel = "0.5"
|
||||
|
||||
@@ -611,6 +611,7 @@ async fn create_profile(
|
||||
wayfern_config,
|
||||
request.group_id.clone(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1604,6 +1604,10 @@ rm "{}"
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
||||
if crate::app_dirs::is_portable() {
|
||||
log::info!("App auto-updates disabled in portable mode");
|
||||
return Ok(None);
|
||||
}
|
||||
// The disable_auto_updates setting controls app self-updates only
|
||||
let disabled = crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
|
||||
@@ -3,11 +3,29 @@ use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static BASE_DIRS: OnceLock<BaseDirs> = OnceLock::new();
|
||||
static PORTABLE_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
|
||||
fn base_dirs() -> &'static BaseDirs {
|
||||
BASE_DIRS.get_or_init(|| BaseDirs::new().expect("Failed to get base directories"))
|
||||
}
|
||||
|
||||
/// Returns the portable base directory if a `.portable` marker exists next to the executable.
|
||||
fn portable_dir() -> Option<&'static PathBuf> {
|
||||
PORTABLE_DIR
|
||||
.get_or_init(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|p| p.to_path_buf()))
|
||||
.filter(|dir| dir.join(".portable").exists())
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Returns true if the app is running in portable mode.
|
||||
pub fn is_portable() -> bool {
|
||||
portable_dir().is_some()
|
||||
}
|
||||
|
||||
pub fn app_name() -> &'static str {
|
||||
if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
@@ -28,6 +46,10 @@ pub fn data_dir() -> PathBuf {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
if let Some(dir) = portable_dir() {
|
||||
return dir.join("data");
|
||||
}
|
||||
|
||||
base_dirs().data_local_dir().join(app_name())
|
||||
}
|
||||
|
||||
@@ -43,6 +65,10 @@ pub fn cache_dir() -> PathBuf {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
if let Some(dir) = portable_dir() {
|
||||
return dir.join("cache");
|
||||
}
|
||||
|
||||
base_dirs().cache_dir().join(app_name())
|
||||
}
|
||||
|
||||
@@ -78,6 +104,10 @@ pub fn extensions_dir() -> PathBuf {
|
||||
data_dir().join("extensions")
|
||||
}
|
||||
|
||||
pub fn dns_blocklist_dir() -> PathBuf {
|
||||
cache_dir().join("dns_blocklists")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
@@ -162,6 +192,7 @@ mod tests {
|
||||
assert!(proxy_workers_dir().ends_with("proxy_workers"));
|
||||
assert!(vpn_dir().ends_with("vpn"));
|
||||
assert!(extensions_dir().ends_with("extensions"));
|
||||
assert!(dns_blocklist_dir().ends_with("dns_blocklists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -699,6 +699,7 @@ mod tests {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,11 @@ async fn main() {
|
||||
Arg::new("bypass-rules")
|
||||
.long("bypass-rules")
|
||||
.help("JSON array of bypass rules (hostnames, IPs, or regex patterns)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("blocklist-file")
|
||||
.long("blocklist-file")
|
||||
.help("Path to DNS blocklist file (one domain per line)"),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
@@ -235,8 +240,17 @@ async fn main() {
|
||||
.get_one::<String>("bypass-rules")
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
||||
|
||||
match start_proxy_process_with_profile(upstream_url, port, profile_id, bypass_rules).await {
|
||||
match start_proxy_process_with_profile(
|
||||
upstream_url,
|
||||
port,
|
||||
profile_id,
|
||||
bypass_rules,
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
// Output the configuration as JSON for the Rust side to parse
|
||||
// Use println! here because this needs to go to stdout for parsing
|
||||
|
||||
@@ -1216,6 +1216,7 @@ mod tests {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
let path = profile.get_profile_data_path(&profiles_dir);
|
||||
|
||||
@@ -38,6 +38,26 @@ impl BrowserRunner {
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
/// Resolve the DNS blocklist level to a cached file path.
|
||||
/// If a level is set but the cache is missing, fetches on demand (blocks until done).
|
||||
async fn resolve_blocklist_file(
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(ref level_str) = profile.dns_blocklist else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if level == crate::dns_blocklist::BlocklistLevel::None {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?;
|
||||
Ok(Some(path.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
||||
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
|
||||
/// Resolve proxy settings for a profile, returning an error for dynamic proxy failures.
|
||||
@@ -168,6 +188,7 @@ impl BrowserRunner {
|
||||
// Start the proxy and get local proxy settings
|
||||
// If proxy startup fails, DO NOT launch Camoufox - it requires local proxy
|
||||
let profile_id_str = profile.id.to_string();
|
||||
let blocklist_file = Self::resolve_blocklist_file(profile).await?;
|
||||
let local_proxy = PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
@@ -175,6 +196,7 @@ impl BrowserRunner {
|
||||
0, // Use 0 as temporary PID, will be updated later
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -427,6 +449,7 @@ impl BrowserRunner {
|
||||
// Start the proxy and get local proxy settings
|
||||
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
|
||||
let profile_id_str = profile.id.to_string();
|
||||
let blocklist_file = Self::resolve_blocklist_file(profile).await?;
|
||||
let local_proxy = PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
@@ -434,6 +457,7 @@ impl BrowserRunner {
|
||||
0, // Use 0 as temporary PID, will be updated later
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -751,6 +775,9 @@ impl BrowserRunner {
|
||||
let profile_id_str = profile.id.to_string();
|
||||
|
||||
// Start local proxy - if this fails, DO NOT launch browser
|
||||
let blocklist_file = Self::resolve_blocklist_file(profile)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
let internal_proxy = PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
@@ -758,6 +785,7 @@ impl BrowserRunner {
|
||||
temp_pid,
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -2280,6 +2308,7 @@ pub async fn launch_browser_profile(
|
||||
|
||||
// Always start a local proxy, even if there's no upstream proxy
|
||||
// This allows for traffic monitoring and future features
|
||||
let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?;
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
@@ -2287,6 +2316,7 @@ pub async fn launch_browser_profile(
|
||||
temp_pid,
|
||||
Some(&profile_id_str),
|
||||
profile_for_launch.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -340,6 +340,9 @@ pub fn is_autostart_enabled() -> bool {
|
||||
}
|
||||
|
||||
pub fn get_data_dir() -> Option<PathBuf> {
|
||||
if crate::app_dirs::is_portable() {
|
||||
return Some(crate::app_dirs::data_dir());
|
||||
}
|
||||
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
|
||||
Some(proj_dirs.data_dir().to_path_buf())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::app_dirs;
|
||||
|
||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(43200); // 12 hours
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BlocklistLevel {
|
||||
#[default]
|
||||
None,
|
||||
Light,
|
||||
Normal,
|
||||
Pro,
|
||||
ProPlus,
|
||||
Ultimate,
|
||||
}
|
||||
|
||||
impl BlocklistLevel {
|
||||
pub fn parse_level(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"light" => Some(Self::Light),
|
||||
"normal" => Some(Self::Normal),
|
||||
"pro" => Some(Self::Pro),
|
||||
"pro_plus" => Some(Self::ProPlus),
|
||||
"ultimate" => Some(Self::Ultimate),
|
||||
"none" => Some(Self::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Light => "light",
|
||||
Self::Normal => "normal",
|
||||
Self::Pro => "pro",
|
||||
Self::ProPlus => "pro_plus",
|
||||
Self::Ultimate => "ultimate",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None",
|
||||
Self::Light => "Light",
|
||||
Self::Normal => "Normal",
|
||||
Self::Pro => "Pro",
|
||||
Self::ProPlus => "Pro++",
|
||||
Self::Ultimate => "Ultimate",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Light => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
|
||||
}
|
||||
Self::Normal => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt")
|
||||
}
|
||||
Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"),
|
||||
Self::ProPlus => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt")
|
||||
}
|
||||
Self::Ultimate => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Light => Some("light.txt"),
|
||||
Self::Normal => Some("multi.txt"),
|
||||
Self::Pro => Some("pro.txt"),
|
||||
Self::ProPlus => Some("pro.plus.txt"),
|
||||
Self::Ultimate => Some("ultimate.txt"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_downloadable() -> &'static [BlocklistLevel] {
|
||||
&[
|
||||
Self::Light,
|
||||
Self::Normal,
|
||||
Self::Pro,
|
||||
Self::ProPlus,
|
||||
Self::Ultimate,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlocklistCacheStatus {
|
||||
pub level: String,
|
||||
pub display_name: String,
|
||||
pub entry_count: usize,
|
||||
pub file_size_bytes: u64,
|
||||
pub last_updated: Option<u64>,
|
||||
pub is_fresh: bool,
|
||||
pub is_cached: bool,
|
||||
}
|
||||
|
||||
pub struct BlocklistManager;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
}
|
||||
|
||||
impl BlocklistManager {
|
||||
pub fn instance() -> &'static BlocklistManager {
|
||||
&BLOCKLIST_MANAGER
|
||||
}
|
||||
|
||||
fn cache_dir() -> PathBuf {
|
||||
app_dirs::dns_blocklist_dir()
|
||||
}
|
||||
|
||||
pub fn cached_file_path(level: BlocklistLevel) -> Option<PathBuf> {
|
||||
level.filename().map(|f| Self::cache_dir().join(f))
|
||||
}
|
||||
|
||||
pub fn is_cache_fresh(level: BlocklistLevel) -> bool {
|
||||
let Some(path) = Self::cached_file_path(level) else {
|
||||
return false;
|
||||
};
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
match std::fs::metadata(&path).and_then(|m| m.modified()) {
|
||||
Ok(modified) => SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.map(|age| age < REFRESH_INTERVAL)
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||
let url = level
|
||||
.url()
|
||||
.ok_or_else(|| format!("No URL for level {:?}", level))?;
|
||||
let path =
|
||||
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
||||
|
||||
let cache_dir = Self::cache_dir();
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
||||
|
||||
log::info!(
|
||||
"[dns-blocklist] Fetching {} from {}",
|
||||
level.display_name(),
|
||||
url
|
||||
);
|
||||
|
||||
let response = HTTP_CLIENT
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {} when fetching {}", response.status(), url));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
|
||||
// Write atomically: write to temp file, then rename
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
std::fs::write(&tmp_path, &body).map_err(|e| format!("Failed to write blocklist: {e}"))?;
|
||||
std::fs::rename(&tmp_path, &path).map_err(|e| format!("Failed to rename blocklist: {e}"))?;
|
||||
|
||||
let entry_count = body
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
|
||||
.count();
|
||||
log::info!(
|
||||
"[dns-blocklist] Cached {} ({} domains)",
|
||||
level.display_name(),
|
||||
entry_count
|
||||
);
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||
if let Some(path) = Self::cached_file_path(level) {
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
Self::fetch_blocklist(level).await
|
||||
}
|
||||
|
||||
pub async fn refresh_all_stale(&self) {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
if !Self::is_cache_fresh(level) {
|
||||
if let Err(e) = Self::fetch_blocklist(level).await {
|
||||
log::error!(
|
||||
"[dns-blocklist] Failed to refresh {}: {e}",
|
||||
level.display_name()
|
||||
);
|
||||
let _ = crate::events::emit(
|
||||
"dns-blocklist-refresh-failed",
|
||||
serde_json::json!({
|
||||
"level": level.as_str(),
|
||||
"error": e,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> {
|
||||
Self::cached_file_path(level).filter(|p| p.exists())
|
||||
}
|
||||
|
||||
pub fn get_cache_status() -> Vec<BlocklistCacheStatus> {
|
||||
BlocklistLevel::all_downloadable()
|
||||
.iter()
|
||||
.map(|&level| {
|
||||
let path = Self::cached_file_path(level);
|
||||
let metadata = path.as_ref().and_then(|p| std::fs::metadata(p).ok());
|
||||
let is_cached = metadata.is_some();
|
||||
|
||||
let entry_count = if is_cached {
|
||||
path
|
||||
.as_ref()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.map(|content| {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let file_size_bytes = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
let last_updated = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
|
||||
BlocklistCacheStatus {
|
||||
level: level.as_str().to_string(),
|
||||
display_name: level.display_name().to_string(),
|
||||
entry_count,
|
||||
file_size_bytes,
|
||||
last_updated,
|
||||
is_fresh: Self::is_cache_fresh(level),
|
||||
is_cached,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref BLOCKLIST_MANAGER: BlocklistManager = BlocklistManager;
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_dns_blocklist_cache_status() -> Result<Vec<BlocklistCacheStatus>, String> {
|
||||
Ok(BlocklistManager::get_cache_status())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh_dns_blocklists() -> Result<(), String> {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
BlocklistManager::fetch_blocklist(level).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_level_roundtrip() {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
let s = level.as_str();
|
||||
let parsed = BlocklistLevel::parse_level(s);
|
||||
assert_eq!(parsed, Some(level), "Roundtrip failed for {s}");
|
||||
}
|
||||
assert_eq!(
|
||||
BlocklistLevel::parse_level("none"),
|
||||
Some(BlocklistLevel::None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_level_urls_all_present() {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
assert!(
|
||||
level.url().is_some(),
|
||||
"{} should have a URL",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
level.filename().is_some(),
|
||||
"{} should have a filename",
|
||||
level.as_str()
|
||||
);
|
||||
}
|
||||
assert!(BlocklistLevel::None.url().is_none());
|
||||
assert!(BlocklistLevel::None.filename().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_status_returns_all_levels() {
|
||||
let statuses = BlocklistManager::get_cache_status();
|
||||
assert_eq!(statuses.len(), 5);
|
||||
assert_eq!(statuses[0].level, "light");
|
||||
assert_eq!(statuses[1].level, "normal");
|
||||
assert_eq!(statuses[2].level, "pro");
|
||||
assert_eq!(statuses[3].level, "pro_plus");
|
||||
assert_eq!(statuses[4].level, "ultimate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_fresh_returns_false_when_missing() {
|
||||
assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::Light));
|
||||
assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::None));
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,7 @@ mod tests {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +314,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_recover_ephemeral_dirs() {
|
||||
let base = get_ephemeral_base_dir().unwrap();
|
||||
let test_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
+22
-4
@@ -19,6 +19,7 @@ mod browser_version_manager;
|
||||
pub mod camoufox;
|
||||
mod camoufox_manager;
|
||||
mod default_browser;
|
||||
pub mod dns_blocklist;
|
||||
mod downloaded_browsers_registry;
|
||||
mod downloader;
|
||||
mod ephemeral_dirs;
|
||||
@@ -65,9 +66,9 @@ use browser_runner::{
|
||||
|
||||
use profile::manager::{
|
||||
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
|
||||
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_note,
|
||||
update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
|
||||
update_wayfern_config,
|
||||
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_dns_blocklist,
|
||||
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
|
||||
update_profile_tags, update_profile_vpn, update_wayfern_config,
|
||||
};
|
||||
|
||||
use browser_version_manager::{
|
||||
@@ -85,7 +86,7 @@ use downloader::{cancel_download, download_browser};
|
||||
|
||||
use settings_manager::{
|
||||
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
|
||||
get_sync_settings, get_system_language, get_table_sorting_settings,
|
||||
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
|
||||
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
|
||||
save_table_sorting_settings, should_show_launch_on_login_prompt,
|
||||
};
|
||||
@@ -1132,6 +1133,7 @@ async fn generate_sample_fingerprint(
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
if browser == "camoufox" {
|
||||
@@ -1462,6 +1464,17 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
// DNS blocklist refresh task (every 12 hours)
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let manager = dns_blocklist::BlocklistManager::instance();
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
|
||||
interval.tick().await; // Skip the immediate first tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
manager.refresh_all_stale().await;
|
||||
}
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let updater = app_auto_updater::AppAutoUpdater::instance();
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
|
||||
@@ -1805,6 +1818,7 @@ pub fn run() {
|
||||
update_profile_tags,
|
||||
update_profile_note,
|
||||
update_profile_proxy_bypass_rules,
|
||||
update_profile_dns_blocklist,
|
||||
check_browser_status,
|
||||
kill_browser_profile,
|
||||
rename_profile,
|
||||
@@ -1816,6 +1830,7 @@ pub fn run() {
|
||||
get_table_sorting_settings,
|
||||
save_table_sorting_settings,
|
||||
get_system_language,
|
||||
get_system_info,
|
||||
dismiss_window_resize_warning,
|
||||
get_window_resize_warning_dismissed,
|
||||
clear_all_version_cache_and_refetch,
|
||||
@@ -1951,6 +1966,9 @@ pub fn run() {
|
||||
synchronizer::stop_sync_session,
|
||||
synchronizer::remove_sync_follower,
|
||||
synchronizer::get_sync_sessions,
|
||||
// DNS blocklist commands
|
||||
dns_blocklist::get_dns_blocklist_cache_status,
|
||||
dns_blocklist::refresh_dns_blocklists,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::settings_manager::SettingsManager;
|
||||
use crate::wayfern_terms::WayfernTermsManager;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
@@ -1008,6 +1009,36 @@ impl McpServer {
|
||||
"required": ["profile_id", "rules"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_profile_dns_blocklist".to_string(),
|
||||
description:
|
||||
"Update the DNS blocklist level for a profile. Blocks ads, trackers, and malware domains at the proxy level."
|
||||
.to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile to update"
|
||||
},
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": ["none", "light", "normal", "pro", "pro_plus", "ultimate"],
|
||||
"description": "DNS blocklist level. 'none' disables blocking."
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "level"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_dns_blocklist_status".to_string(),
|
||||
description: "Get the cache status of all DNS blocklist tiers including entry counts and freshness.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "list_extensions".to_string(),
|
||||
description: "List all managed browser extensions. Requires Pro subscription.".to_string(),
|
||||
@@ -1481,6 +1512,9 @@ impl McpServer {
|
||||
.handle_update_profile_proxy_bypass_rules(&arguments)
|
||||
.await
|
||||
}
|
||||
// DNS blocklist management
|
||||
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(&arguments).await,
|
||||
"get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await,
|
||||
// Extension management
|
||||
"list_extensions" => self.handle_list_extensions().await,
|
||||
"list_extension_groups" => self.handle_list_extension_groups().await,
|
||||
@@ -1805,6 +1839,7 @@ impl McpServer {
|
||||
let mut profile = ProfileManager::instance()
|
||||
.create_profile_with_group(
|
||||
app_handle, name, browser, version, "stable", proxy_id, None, None, None, group_id, false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
@@ -3118,6 +3153,61 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_update_profile_dns_blocklist(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
|
||||
let level = arguments
|
||||
.get("level")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing level".to_string(),
|
||||
})?;
|
||||
|
||||
let dns_blocklist = if level == "none" {
|
||||
None
|
||||
} else {
|
||||
Some(level.to_string())
|
||||
};
|
||||
|
||||
let profile = ProfileManager::instance()
|
||||
.update_profile_dns_blocklist(profile_id, dns_blocklist)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update DNS blocklist: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"DNS blocklist updated for profile '{}': {}",
|
||||
profile.name,
|
||||
level
|
||||
)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_dns_blocklist_status(&self) -> Result<serde_json::Value, McpError> {
|
||||
let statuses = crate::dns_blocklist::BlocklistManager::get_cache_status();
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&statuses).unwrap_or_default()
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_extensions(&self) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
|
||||
@@ -50,6 +50,7 @@ impl ProfileManager {
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
@@ -158,6 +159,7 @@ impl ProfileManager {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -257,6 +259,7 @@ impl ProfileManager {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -310,6 +313,7 @@ impl ProfileManager {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist,
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -760,6 +764,30 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn update_profile_dns_blocklist(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.dns_blocklist = dns_blocklist;
|
||||
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn delete_multiple_profiles(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -902,6 +930,7 @@ impl ProfileManager {
|
||||
proxy_bypass_rules: source.proxy_bypass_rules,
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: source.dns_blocklist,
|
||||
};
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
@@ -1957,6 +1986,7 @@ pub async fn create_browser_profile_with_group(
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
@@ -1972,6 +2002,7 @@ pub async fn create_browser_profile_with_group(
|
||||
wayfern_config,
|
||||
group_id,
|
||||
ephemeral,
|
||||
dns_blocklist,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
||||
@@ -2047,6 +2078,17 @@ pub fn update_profile_proxy_bypass_rules(
|
||||
.map_err(|e| format!("Failed to update proxy bypass rules: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_dns_blocklist(
|
||||
profile_id: String,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_dns_blocklist(&profile_id, dns_blocklist)
|
||||
.map_err(|e| format!("Failed to update DNS blocklist: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_browser_status(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -2085,6 +2127,7 @@ pub async fn create_browser_profile_new(
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: Option<bool>,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let fingerprint_os = camoufox_config
|
||||
.as_ref()
|
||||
@@ -2112,6 +2155,7 @@ pub async fn create_browser_profile_new(
|
||||
wayfern_config,
|
||||
group_id,
|
||||
ephemeral.unwrap_or(false),
|
||||
dns_blocklist,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub enum SyncMode {
|
||||
Encrypted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct BrowserProfile {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
@@ -65,6 +65,8 @@ pub struct BrowserProfile {
|
||||
pub created_by_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_by_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dns_blocklist: Option<String>,
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -582,6 +582,7 @@ impl ProfileImporter {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -660,6 +661,7 @@ impl ProfileImporter {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -709,6 +711,7 @@ impl ProfileImporter {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
self.profile_manager.save_profile(&profile)?;
|
||||
|
||||
@@ -77,6 +77,7 @@ pub struct ProxyInfo {
|
||||
pub local_port: u16,
|
||||
// Optional profile ID to which this proxy instance is logically tied
|
||||
pub profile_id: Option<String>,
|
||||
pub blocklist_file: Option<String>,
|
||||
}
|
||||
|
||||
// Proxy check result cache
|
||||
@@ -1675,6 +1676,7 @@ impl ProxyManager {
|
||||
browser_pid: u32,
|
||||
profile_id: Option<&str>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
) -> Result<ProxySettings, String> {
|
||||
if let Some(name) = profile_id {
|
||||
// Check if we have an active proxy recorded for this profile
|
||||
@@ -1802,6 +1804,11 @@ impl ProxyManager {
|
||||
proxy_cmd = proxy_cmd.arg("--bypass-rules").arg(rules_json);
|
||||
}
|
||||
|
||||
// Add blocklist file path if provided
|
||||
if let Some(ref path) = blocklist_file {
|
||||
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
||||
}
|
||||
|
||||
// Execute the command and wait for it to complete
|
||||
// The donut-proxy binary should start the worker and then exit
|
||||
let output = proxy_cmd
|
||||
@@ -1847,6 +1854,7 @@ impl ProxyManager {
|
||||
.unwrap_or_else(|| "DIRECT".to_string()),
|
||||
local_port,
|
||||
profile_id: profile_id.map(|s| s.to_string()),
|
||||
blocklist_file: blocklist_file.clone(),
|
||||
};
|
||||
|
||||
// Wait for the local proxy port to be ready to accept connections
|
||||
@@ -2345,6 +2353,7 @@ mod tests {
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: (8000 + i) as u16,
|
||||
profile_id: None,
|
||||
blocklist_file: None,
|
||||
};
|
||||
|
||||
// Add proxy
|
||||
@@ -2671,6 +2680,7 @@ mod tests {
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: port,
|
||||
profile_id: profile_id.map(|s| s.to_string()),
|
||||
blocklist_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2898,6 +2908,7 @@ mod tests {
|
||||
pid: Some(live_pid),
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
};
|
||||
let dead_config = ProxyConfig {
|
||||
id: dead_id.clone(),
|
||||
@@ -2908,6 +2919,7 @@ mod tests {
|
||||
pid: Some(dead_pid),
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
};
|
||||
|
||||
save_proxy_config(&live_config).unwrap();
|
||||
@@ -2946,6 +2958,7 @@ mod tests {
|
||||
pid: Some(12345),
|
||||
profile_id: Some("prof_abc".to_string()),
|
||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||
blocklist_file: None,
|
||||
};
|
||||
|
||||
// Save
|
||||
@@ -3064,6 +3077,7 @@ mod tests {
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: 9201,
|
||||
profile_id: Some("profile_alpha".to_string()),
|
||||
blocklist_file: None,
|
||||
};
|
||||
let info_b = ProxyInfo {
|
||||
id: "px_shared_b".to_string(),
|
||||
@@ -3073,6 +3087,7 @@ mod tests {
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: 9202,
|
||||
profile_id: Some("profile_beta".to_string()),
|
||||
blocklist_file: None,
|
||||
};
|
||||
|
||||
pm.insert_active_proxy(3001, info_a);
|
||||
@@ -3260,6 +3275,7 @@ mod tests {
|
||||
pid: Some(dead_pid),
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
};
|
||||
save_proxy_config(&config).unwrap();
|
||||
|
||||
@@ -3432,6 +3448,7 @@ mod tests {
|
||||
upstream_type: ptype.to_string(),
|
||||
local_port: 9300 + i as u16,
|
||||
profile_id: Some(format!("profile_{ptype}")),
|
||||
blocklist_file: None,
|
||||
};
|
||||
pm.insert_active_proxy(4000 + i as u32, info);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub async fn start_proxy_process(
|
||||
upstream_url: Option<String>,
|
||||
port: Option<u16>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new()).await
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None).await
|
||||
}
|
||||
|
||||
pub async fn start_proxy_process_with_profile(
|
||||
@@ -20,6 +20,7 @@ pub async fn start_proxy_process_with_profile(
|
||||
port: Option<u16>,
|
||||
profile_id: Option<String>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
let id = generate_proxy_id();
|
||||
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
||||
@@ -33,7 +34,8 @@ pub async fn start_proxy_process_with_profile(
|
||||
|
||||
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
|
||||
.with_profile_id(profile_id.clone())
|
||||
.with_bypass_rules(bypass_rules);
|
||||
.with_bypass_rules(bypass_rules)
|
||||
.with_blocklist_file(blocklist_file);
|
||||
save_proxy_config(&config)?;
|
||||
|
||||
// Log profile_id for debugging
|
||||
|
||||
@@ -7,6 +7,7 @@ use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use regex_lite::Regex;
|
||||
use std::collections::HashSet;
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
@@ -51,6 +52,58 @@ impl BypassMatcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlocklistMatcher {
|
||||
domains: Arc<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl Default for BlocklistMatcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BlocklistMatcher {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
domains: Arc::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let domains: HashSet<String> = content
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with('#') && !line.trim().is_empty())
|
||||
.map(|line| line.trim().to_lowercase())
|
||||
.collect();
|
||||
log::info!("[blocklist] Loaded {} domains from {}", domains.len(), path);
|
||||
Ok(Self {
|
||||
domains: Arc::new(domains),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_blocked(&self, host: &str) -> bool {
|
||||
if self.domains.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let host_lower = host.to_lowercase();
|
||||
// Exact match
|
||||
if self.domains.contains(host_lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
// Suffix matching: check parent domains (like uBlock)
|
||||
let mut start = 0;
|
||||
while let Some(dot_pos) = host_lower[start..].find('.') {
|
||||
start += dot_pos + 1;
|
||||
if self.domains.contains(&host_lower[start..]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper stream that counts bytes read and written
|
||||
struct CountingStream<S> {
|
||||
inner: S,
|
||||
@@ -167,20 +220,22 @@ async fn handle_request(
|
||||
req: Request<hyper::body::Incoming>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
// Handle CONNECT method for HTTPS tunneling
|
||||
if req.method() == Method::CONNECT {
|
||||
return handle_connect(req, upstream_url, bypass_matcher).await;
|
||||
return handle_connect(req, upstream_url, bypass_matcher, blocklist_matcher).await;
|
||||
}
|
||||
|
||||
// Handle regular HTTP requests
|
||||
handle_http(req, upstream_url, bypass_matcher).await
|
||||
handle_http(req, upstream_url, bypass_matcher, blocklist_matcher).await
|
||||
}
|
||||
|
||||
async fn handle_connect(
|
||||
req: Request<hyper::body::Incoming>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
let authority = req.uri().authority().cloned();
|
||||
|
||||
@@ -196,6 +251,14 @@ async fn handle_connect(
|
||||
(&target_addr[..], 443)
|
||||
};
|
||||
|
||||
// Block if domain is in the DNS blocklist (before any connection)
|
||||
if blocklist_matcher.is_blocked(target_host) {
|
||||
log::debug!("[blocklist] Blocked CONNECT to {}", target_host);
|
||||
let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist")));
|
||||
*response.status_mut() = StatusCode::FORBIDDEN;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// If no upstream proxy, or bypass rule matches, connect directly
|
||||
if upstream_url.is_none()
|
||||
|| upstream_url
|
||||
@@ -711,6 +774,7 @@ async fn handle_http(
|
||||
req: Request<hyper::body::Incoming>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
// Extract domain for traffic tracking
|
||||
let domain = req
|
||||
@@ -719,6 +783,14 @@ async fn handle_http(
|
||||
.map(|h| h.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Block if domain is in the DNS blocklist (before any connection)
|
||||
if blocklist_matcher.is_blocked(&domain) {
|
||||
log::debug!("[blocklist] Blocked HTTP request to {}", domain);
|
||||
let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist")));
|
||||
*response.status_mut() = StatusCode::FORBIDDEN;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
log::error!(
|
||||
"DEBUG: Handling HTTP request: {} {} (host: {:?})",
|
||||
req.method(),
|
||||
@@ -888,6 +960,7 @@ pub async fn handle_proxy_connection(
|
||||
mut stream: tokio::net::TcpStream,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
@@ -942,8 +1015,14 @@ pub async fn handle_proxy_connection(
|
||||
}
|
||||
}
|
||||
|
||||
let _ =
|
||||
handle_connect_from_buffer(stream, full_request, upstream_url, bypass_matcher).await;
|
||||
let _ = handle_connect_from_buffer(
|
||||
stream,
|
||||
full_request,
|
||||
upstream_url,
|
||||
bypass_matcher,
|
||||
blocklist_matcher,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -955,8 +1034,14 @@ pub async fn handle_proxy_connection(
|
||||
inner: stream,
|
||||
};
|
||||
let io = TokioIo::new(prepended_reader);
|
||||
let service =
|
||||
service_fn(move |req| handle_request(req, upstream_url.clone(), bypass_matcher.clone()));
|
||||
let service = service_fn(move |req| {
|
||||
handle_request(
|
||||
req,
|
||||
upstream_url.clone(),
|
||||
bypass_matcher.clone(),
|
||||
blocklist_matcher.clone(),
|
||||
)
|
||||
});
|
||||
|
||||
let _ = http1::Builder::new().serve_connection(io, service).await;
|
||||
}
|
||||
@@ -1128,6 +1213,17 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
});
|
||||
|
||||
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
|
||||
let blocklist_matcher = if let Some(ref path) = config.blocklist_file {
|
||||
match BlocklistMatcher::from_file(path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::error!("[blocklist] Failed to load from {}: {}", path, e);
|
||||
BlocklistMatcher::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BlocklistMatcher::new()
|
||||
};
|
||||
|
||||
// Keep the runtime alive with an infinite loop
|
||||
// This ensures the process doesn't exit even if there are no active connections
|
||||
@@ -1136,8 +1232,9 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
Ok((stream, _peer_addr)) => {
|
||||
let upstream = upstream_url.clone();
|
||||
let matcher = bypass_matcher.clone();
|
||||
let blocker = blocklist_matcher.clone();
|
||||
tokio::task::spawn(async move {
|
||||
handle_proxy_connection(stream, upstream, matcher).await;
|
||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1155,6 +1252,7 @@ async fn handle_connect_from_buffer(
|
||||
request_buffer: Vec<u8>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Parse the CONNECT request from the buffer
|
||||
let request_str = String::from_utf8_lossy(&request_buffer);
|
||||
@@ -1185,6 +1283,15 @@ async fn handle_connect_from_buffer(
|
||||
(target, 443)
|
||||
};
|
||||
|
||||
// Block if domain is in the DNS blocklist (before any connection)
|
||||
if blocklist_matcher.is_blocked(target_host) {
|
||||
log::debug!("[blocklist] Blocked CONNECT tunnel to {}", target_host);
|
||||
let _ = client_stream
|
||||
.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 24\r\n\r\nBlocked by DNS blocklist")
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Record domain access in traffic tracker
|
||||
let domain = target_host.to_string();
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
@@ -1362,3 +1469,106 @@ async fn handle_connect_from_buffer(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_exact_match() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
let mut domains = HashSet::new();
|
||||
domains.insert("example.com".to_string());
|
||||
domains.insert("tracker.net".to_string());
|
||||
matcher.domains = Arc::new(domains);
|
||||
|
||||
assert!(matcher.is_blocked("example.com"));
|
||||
assert!(matcher.is_blocked("tracker.net"));
|
||||
assert!(!matcher.is_blocked("safe.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_subdomain_match() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
let mut domains = HashSet::new();
|
||||
domains.insert("example.com".to_string());
|
||||
matcher.domains = Arc::new(domains);
|
||||
|
||||
assert!(matcher.is_blocked("foo.example.com"));
|
||||
assert!(matcher.is_blocked("bar.baz.example.com"));
|
||||
assert!(matcher.is_blocked("a.b.c.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_no_false_positives() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
let mut domains = HashSet::new();
|
||||
domains.insert("example.com".to_string());
|
||||
matcher.domains = Arc::new(domains);
|
||||
|
||||
// "notexample.com" should NOT match "example.com"
|
||||
assert!(!matcher.is_blocked("notexample.com"));
|
||||
assert!(!matcher.is_blocked("myexample.com"));
|
||||
// But subdomain should
|
||||
assert!(matcher.is_blocked("sub.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_empty_blocks_nothing() {
|
||||
let matcher = BlocklistMatcher::new();
|
||||
assert!(!matcher.is_blocked("anything.com"));
|
||||
assert!(!matcher.is_blocked("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_case_insensitive() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
let mut domains = HashSet::new();
|
||||
domains.insert("example.com".to_string());
|
||||
matcher.domains = Arc::new(domains);
|
||||
|
||||
assert!(matcher.is_blocked("EXAMPLE.COM"));
|
||||
assert!(matcher.is_blocked("Example.Com"));
|
||||
assert!(matcher.is_blocked("FOO.EXAMPLE.COM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_from_file() {
|
||||
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
|
||||
writeln!(tmpfile, "# This is a comment").unwrap();
|
||||
writeln!(tmpfile).unwrap();
|
||||
writeln!(tmpfile, "tracker.example.com").unwrap();
|
||||
writeln!(tmpfile, "ads.network.com").unwrap();
|
||||
writeln!(tmpfile, "# Another comment").unwrap();
|
||||
writeln!(tmpfile, "malware.site").unwrap();
|
||||
tmpfile.flush().unwrap();
|
||||
|
||||
let matcher = BlocklistMatcher::from_file(tmpfile.path().to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(matcher.is_blocked("tracker.example.com"));
|
||||
assert!(matcher.is_blocked("ads.network.com"));
|
||||
assert!(matcher.is_blocked("malware.site"));
|
||||
assert!(matcher.is_blocked("sub.malware.site"));
|
||||
assert!(!matcher.is_blocked("safe.com"));
|
||||
// Comments and empty lines should be skipped: 3 domains loaded
|
||||
assert_eq!(matcher.domains.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_comments_skipped() {
|
||||
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
|
||||
writeln!(tmpfile, "# Title: HaGeZi's Light DNS Blocklist").unwrap();
|
||||
writeln!(tmpfile, "# Description: test").unwrap();
|
||||
writeln!(tmpfile, "# Version: 2026.0330.0928.01").unwrap();
|
||||
writeln!(tmpfile).unwrap();
|
||||
writeln!(tmpfile, "domain1.com").unwrap();
|
||||
writeln!(tmpfile, "domain2.com").unwrap();
|
||||
tmpfile.flush().unwrap();
|
||||
|
||||
let matcher = BlocklistMatcher::from_file(tmpfile.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(matcher.domains.len(), 2);
|
||||
assert!(matcher.is_blocked("domain1.com"));
|
||||
assert!(matcher.is_blocked("domain2.com"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct ProxyConfig {
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bypass_rules: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub blocklist_file: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
@@ -27,6 +29,7 @@ impl ProxyConfig {
|
||||
pid: None,
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +42,11 @@ impl ProxyConfig {
|
||||
self.bypass_rules = bypass_rules;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_blocklist_file(mut self, blocklist_file: Option<String>) -> Self {
|
||||
self.blocklist_file = blocklist_file;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_storage_dir() -> PathBuf {
|
||||
|
||||
@@ -945,6 +945,42 @@ pub fn get_system_language() -> String {
|
||||
.unwrap_or_else(|| "en".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct SystemInfo {
|
||||
pub app_version: String,
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub portable: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_system_info() -> SystemInfo {
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Linux"
|
||||
} else {
|
||||
"Unknown"
|
||||
};
|
||||
|
||||
let arch = if cfg!(target_arch = "x86_64") {
|
||||
"x86_64"
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
"aarch64"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
SystemInfo {
|
||||
app_version: crate::app_auto_updater::AppAutoUpdater::get_current_version(),
|
||||
os: os.to_string(),
|
||||
arch: arch.to_string(),
|
||||
portable: crate::app_dirs::is_portable(),
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
|
||||
|
||||
@@ -793,6 +793,7 @@ impl SyncEngine {
|
||||
let mut sanitized = profile.clone();
|
||||
sanitized.process_id = None;
|
||||
sanitized.last_launch = None;
|
||||
sanitized.last_sync = None; // Avoid triggering sync loop on timestamp change
|
||||
|
||||
let json = serde_json::to_string_pretty(&sanitized)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::path::Path;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use super::types::{SyncError, SyncResult};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
/// Default exclude patterns for volatile browser profile files.
|
||||
/// Patterns use `**/` prefix to match at any directory depth, since the sync
|
||||
@@ -209,6 +210,39 @@ fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
|
||||
Ok(Some(hasher.finalize().to_hex().to_string()))
|
||||
}
|
||||
|
||||
/// Compute blake3 hash of metadata.json after sanitizing volatile fields.
|
||||
/// This prevents infinite sync loops where updating last_sync triggers a new sync.
|
||||
fn hash_sanitized_metadata(path: &Path) -> Result<Option<String>, SyncError> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => {
|
||||
return Err(SyncError::IoError(format!(
|
||||
"Failed to read metadata at {}: {e}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mut profile: BrowserProfile = serde_json::from_str(&content).map_err(|e| {
|
||||
SyncError::SerializationError(format!("Failed to parse metadata for hashing: {e}"))
|
||||
})?;
|
||||
|
||||
// Sanitize volatile fields that should not trigger a re-sync
|
||||
profile.last_sync = None;
|
||||
profile.process_id = None;
|
||||
profile.last_launch = None;
|
||||
|
||||
let sanitized_json = serde_json::to_string(&profile).map_err(|e| {
|
||||
SyncError::SerializationError(format!("Failed to serialize sanitized metadata: {e}"))
|
||||
})?;
|
||||
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(sanitized_json.as_bytes());
|
||||
|
||||
Ok(Some(hasher.finalize().to_hex().to_string()))
|
||||
}
|
||||
|
||||
/// Get mtime as unix timestamp
|
||||
/// Returns None if the file doesn't exist (was deleted)
|
||||
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
|
||||
@@ -324,7 +358,19 @@ pub fn generate_manifest(
|
||||
*max_mtime = (*max_mtime).max(mtime);
|
||||
|
||||
// Check cache for existing hash
|
||||
let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
|
||||
let hash = if relative_path == "metadata.json" {
|
||||
// Special case: sanitize metadata.json before hashing to prevent sync loops
|
||||
match hash_sanitized_metadata(&path)? {
|
||||
Some(computed_hash) => computed_hash,
|
||||
None => {
|
||||
log::debug!(
|
||||
"File disappeared during manifest generation, skipping: {}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
|
||||
cached_hash.to_string()
|
||||
} else {
|
||||
match hash_file(&path)? {
|
||||
@@ -592,7 +638,12 @@ mod tests {
|
||||
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
|
||||
|
||||
// metadata.json at root
|
||||
fs::write(profile_dir.join("metadata.json"), "keep").unwrap();
|
||||
let profile = BrowserProfile::default();
|
||||
fs::write(
|
||||
profile_dir.join("metadata.json"),
|
||||
serde_json::to_string(&profile).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cache = HashCache::default();
|
||||
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
|
||||
@@ -800,4 +851,85 @@ mod tests {
|
||||
assert!(diff.files_to_delete_remote.is_empty());
|
||||
assert!(diff.files_to_delete_local.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_manifest_sanitizes_metadata() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let profile_dir = temp_dir.path().join("profile");
|
||||
fs::create_dir_all(&profile_dir).unwrap();
|
||||
|
||||
let profile_id = uuid::Uuid::new_v4();
|
||||
let metadata_path = profile_dir.join("metadata.json");
|
||||
|
||||
let profile = BrowserProfile {
|
||||
id: profile_id,
|
||||
name: "test-profile".to_string(),
|
||||
last_sync: Some(100),
|
||||
process_id: Some(1234),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
fs::write(&metadata_path, serde_json::to_string(&profile).unwrap()).unwrap();
|
||||
|
||||
let mut cache = HashCache::default();
|
||||
let manifest1 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
|
||||
let hash1 = manifest1
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == "metadata.json")
|
||||
.unwrap()
|
||||
.hash
|
||||
.clone();
|
||||
|
||||
// Update volatile fields
|
||||
let profile2 = BrowserProfile {
|
||||
id: profile_id,
|
||||
name: "test-profile".to_string(),
|
||||
last_sync: Some(200),
|
||||
process_id: Some(5678),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
fs::write(&metadata_path, serde_json::to_string(&profile2).unwrap()).unwrap();
|
||||
|
||||
let manifest2 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
|
||||
let hash2 = manifest2
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == "metadata.json")
|
||||
.unwrap()
|
||||
.hash
|
||||
.clone();
|
||||
|
||||
// Hash should be identical because volatile fields are sanitized
|
||||
assert_eq!(
|
||||
hash1, hash2,
|
||||
"Metadata hash should be stable across last_sync/process_id updates"
|
||||
);
|
||||
|
||||
// Change a non-volatile field
|
||||
let profile3 = BrowserProfile {
|
||||
id: profile_id,
|
||||
name: "changed-name".to_string(),
|
||||
last_sync: Some(200),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
fs::write(&metadata_path, serde_json::to_string(&profile3).unwrap()).unwrap();
|
||||
|
||||
let manifest3 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
|
||||
let hash3 = manifest3
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == "metadata.json")
|
||||
.unwrap()
|
||||
.hash
|
||||
.clone();
|
||||
|
||||
// Hash should be different because name changed
|
||||
assert_ne!(
|
||||
hash1, hash3,
|
||||
"Metadata hash should change when non-volatile fields change"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +136,10 @@ impl WayfernManager {
|
||||
port: u16,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("http://127.0.0.1:{port}/json/version");
|
||||
let max_attempts = 50;
|
||||
let delay = Duration::from_millis(100);
|
||||
// On first launch, macOS Gatekeeper verifies the binary which can take 30+ seconds.
|
||||
// Use a generous timeout (60s) to handle this.
|
||||
let max_attempts = 120;
|
||||
let delay = Duration::from_millis(500);
|
||||
|
||||
for attempt in 0..max_attempts {
|
||||
match self.http_client.get(&url).send().await {
|
||||
|
||||
+2
-19
@@ -1,14 +1,7 @@
|
||||
"use client";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "@/styles/globals.css";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
import { useEffect } from "react";
|
||||
import { I18nProvider } from "@/components/i18n-provider";
|
||||
import { CustomThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { WindowDragArea } from "@/components/window-drag-area";
|
||||
import { setupLogging } from "@/lib/logger";
|
||||
import { ClientProviders } from "@/components/client-providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -25,22 +18,12 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
useEffect(() => {
|
||||
void setupLogging();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden bg-background`}
|
||||
>
|
||||
<I18nProvider>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<Toaster />
|
||||
</CustomThemeProvider>
|
||||
</I18nProvider>
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+41
-21
@@ -280,7 +280,7 @@ export default function Home() {
|
||||
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleUrlOpen = useCallback(
|
||||
async (url: string) => {
|
||||
(url: string) => {
|
||||
// Prevent duplicate processing of the same URL
|
||||
if (processingUrls.has(url)) {
|
||||
console.log("URL already being processed:", url);
|
||||
@@ -324,7 +324,7 @@ export default function Home() {
|
||||
const currentUrl = await getCurrent();
|
||||
if (currentUrl && currentUrl.length > 0) {
|
||||
console.log("Startup URL detected:", currentUrl[0]);
|
||||
void handleUrlOpen(currentUrl[0]);
|
||||
handleUrlOpen(currentUrl[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check current URL:", error);
|
||||
@@ -372,7 +372,7 @@ export default function Home() {
|
||||
}
|
||||
}, [proxiesError]);
|
||||
|
||||
const checkAllPermissions = useCallback(async () => {
|
||||
const checkAllPermissions = useCallback(() => {
|
||||
try {
|
||||
// Wait for permissions to be initialized before checking
|
||||
if (!isInitialized) {
|
||||
@@ -413,13 +413,13 @@ export default function Home() {
|
||||
// Listen for URL open events from the deep link handler (when app is already running)
|
||||
await listen<string>("url-open-request", (event) => {
|
||||
console.log("Received URL open request:", event.payload);
|
||||
void handleUrlOpen(event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
|
||||
// Listen for show profile selector events
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
void handleUrlOpen(event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
|
||||
// Listen for show create profile dialog events
|
||||
@@ -437,7 +437,7 @@ export default function Home() {
|
||||
// Listen for custom logo click events
|
||||
const handleLogoUrlEvent = (event: CustomEvent) => {
|
||||
console.log("Received logo URL event:", event.detail);
|
||||
void handleUrlOpen(event.detail);
|
||||
handleUrlOpen(event.detail);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
@@ -515,6 +515,7 @@ export default function Home() {
|
||||
groupId?: string;
|
||||
extensionGroupId?: string;
|
||||
ephemeral?: boolean;
|
||||
dnsBlocklist?: string;
|
||||
}) => {
|
||||
try {
|
||||
const profile = await invoke<BrowserProfile>(
|
||||
@@ -529,9 +530,10 @@ export default function Home() {
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
wayfernConfig: profileData.wayfernConfig,
|
||||
groupId:
|
||||
profileData.groupId ||
|
||||
profileData.groupId ??
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
ephemeral: profileData.ephemeral,
|
||||
dnsBlocklist: profileData.dnsBlocklist,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -764,13 +766,13 @@ export default function Home() {
|
||||
setCookieManagementDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||
const handleGroupAssignmentComplete = useCallback(() => {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
setSelectedProfilesForGroup([]);
|
||||
}, []);
|
||||
|
||||
const handleProxyAssignmentComplete = useCallback(async () => {
|
||||
const handleProxyAssignmentComplete = useCallback(() => {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setProxyAssignmentDialogOpen(false);
|
||||
setSelectedProfilesForProxy([]);
|
||||
@@ -810,7 +812,7 @@ export default function Home() {
|
||||
let unlistenStatus: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
const profilesWithTransfer = new Set<string>();
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
unlistenStatus = await listen<{
|
||||
profile_id: string;
|
||||
@@ -898,7 +900,7 @@ export default function Home() {
|
||||
};
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
setupListeners().then((cleanupFn) => {
|
||||
void setupListeners().then((cleanupFn) => {
|
||||
cleanup = cleanupFn;
|
||||
});
|
||||
|
||||
@@ -995,7 +997,7 @@ export default function Home() {
|
||||
// Check permissions when they are initialized
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
void checkAllPermissions();
|
||||
checkAllPermissions();
|
||||
}
|
||||
}, [isInitialized, checkAllPermissions]);
|
||||
|
||||
@@ -1093,7 +1095,9 @@ export default function Home() {
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
syncUnlocked={syncUnlocked}
|
||||
getProfileSyncInfo={getProfileSyncInfo}
|
||||
onLaunchWithSync={(profile) => setSyncLeaderProfile(profile)}
|
||||
onLaunchWithSync={(profile) => {
|
||||
setSyncLeaderProfile(profile);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1167,7 +1171,9 @@ export default function Home() {
|
||||
|
||||
<CloneProfileDialog
|
||||
isOpen={!!cloneProfile}
|
||||
onClose={() => setCloneProfile(null)}
|
||||
onClose={() => {
|
||||
setCloneProfile(null);
|
||||
}}
|
||||
profile={cloneProfile}
|
||||
/>
|
||||
|
||||
@@ -1197,7 +1203,9 @@ export default function Home() {
|
||||
|
||||
<ExtensionManagementDialog
|
||||
isOpen={extensionManagementDialogOpen}
|
||||
onClose={() => setExtensionManagementDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setExtensionManagementDialogOpen(false);
|
||||
}}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
|
||||
@@ -1242,7 +1250,9 @@ export default function Home() {
|
||||
selectedProfiles={selectedProfilesForCookies}
|
||||
profiles={profiles}
|
||||
runningProfiles={runningProfiles}
|
||||
onCopyComplete={() => setSelectedProfilesForCookies([])}
|
||||
onCopyComplete={() => {
|
||||
setSelectedProfilesForCookies([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CookieManagementDialog
|
||||
@@ -1256,7 +1266,9 @@ export default function Home() {
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => setShowBulkDeleteConfirmation(false)}
|
||||
onClose={() => {
|
||||
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.`}
|
||||
@@ -1279,7 +1291,9 @@ export default function Home() {
|
||||
|
||||
<SyncAllDialog
|
||||
isOpen={syncAllDialogOpen}
|
||||
onClose={() => setSyncAllDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setSyncAllDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ProfileSyncDialog
|
||||
@@ -1289,7 +1303,9 @@ export default function Home() {
|
||||
setCurrentProfileForSync(null);
|
||||
}}
|
||||
profile={currentProfileForSync}
|
||||
onSyncConfigOpen={() => setSyncConfigDialogOpen(true)}
|
||||
onSyncConfigOpen={() => {
|
||||
setSyncConfigDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Wayfern Terms and Conditions Dialog - shown if terms not accepted */}
|
||||
@@ -1313,7 +1329,9 @@ export default function Home() {
|
||||
{/* Launch on Login Dialog - shown on every startup until enabled or declined */}
|
||||
<LaunchOnLoginDialog
|
||||
isOpen={launchOnLoginDialogOpen}
|
||||
onClose={() => setLaunchOnLoginDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setLaunchOnLoginDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<WindowResizeWarningDialog
|
||||
@@ -1328,7 +1346,9 @@ export default function Home() {
|
||||
|
||||
<SyncFollowerDialog
|
||||
isOpen={syncLeaderProfile !== null}
|
||||
onClose={() => setSyncLeaderProfile(null)}
|
||||
onClose={() => {
|
||||
setSyncLeaderProfile(null);
|
||||
}}
|
||||
leaderProfile={syncLeaderProfile}
|
||||
allProfiles={profiles}
|
||||
runningProfiles={runningProfiles}
|
||||
|
||||
@@ -46,12 +46,6 @@ export function BandwidthMiniChart({
|
||||
return result;
|
||||
}, [data]);
|
||||
|
||||
// Find max value for scaling
|
||||
const _maxBandwidth = React.useMemo(() => {
|
||||
const max = Math.max(...chartData.map((d) => d.bandwidth), 1);
|
||||
return max;
|
||||
}, [chartData]);
|
||||
|
||||
// Use external bandwidth if provided, otherwise calculate from last data point
|
||||
const currentBandwidth =
|
||||
externalBandwidth ?? chartData[chartData.length - 1]?.bandwidth ?? 0;
|
||||
@@ -74,7 +68,12 @@ export function BandwidthMiniChart({
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 h-3 pointer-events-none">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { I18nProvider } from "@/components/i18n-provider";
|
||||
import { CustomThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { WindowDragArea } from "@/components/window-drag-area";
|
||||
import { setupLogging } from "@/lib/logger";
|
||||
|
||||
export function ClientProviders({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
void setupLogging();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<Toaster />
|
||||
</CustomThemeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,12 @@ export function CloneProfileDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
|
||||
@@ -80,7 +85,9 @@ export function CloneProfileDialog({
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleClone();
|
||||
}}
|
||||
|
||||
@@ -44,9 +44,15 @@ export function CommercialTrialModal({
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Commercial Trial Expired</DialogTitle>
|
||||
|
||||
@@ -50,12 +50,13 @@ interface CookieCopyDialogProps {
|
||||
onCopyComplete?: () => void;
|
||||
}
|
||||
|
||||
type SelectionState = {
|
||||
[domain: string]: {
|
||||
type SelectionState = Record<
|
||||
string,
|
||||
{
|
||||
allSelected: boolean;
|
||||
cookies: Set<string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
export function CookieCopyDialog({
|
||||
isOpen,
|
||||
@@ -109,7 +110,7 @@ export function CookieCopyDialog({
|
||||
const domainSelection = selection[domain];
|
||||
if (domainSelection.allSelected) {
|
||||
const domainData = cookieData?.domains.find((d) => d.domain === domain);
|
||||
count += domainData?.cookie_count || 0;
|
||||
count += domainData?.cookie_count ?? 0;
|
||||
} else {
|
||||
count += domainSelection.cookies.size;
|
||||
}
|
||||
@@ -148,7 +149,7 @@ export function CookieCopyDialog({
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
setSelection((prev) => {
|
||||
const current = prev[domain];
|
||||
const allSelected = current?.allSelected || false;
|
||||
const allSelected = current.allSelected;
|
||||
|
||||
if (allSelected) {
|
||||
const newSelection = { ...prev };
|
||||
@@ -171,7 +172,7 @@ export function CookieCopyDialog({
|
||||
const toggleCookie = useCallback(
|
||||
(domain: string, cookieName: string, totalCookies: number) => {
|
||||
setSelection((prev) => {
|
||||
const current = prev[domain] || {
|
||||
const current = prev[domain] ?? {
|
||||
allSelected: false,
|
||||
cookies: new Set<string>(),
|
||||
};
|
||||
@@ -412,7 +413,9 @@ export function CookieCopyDialog({
|
||||
<Input
|
||||
placeholder="Search domains or cookies..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
@@ -501,8 +504,8 @@ function DomainRow({
|
||||
onToggleExpand,
|
||||
}: DomainRowProps) {
|
||||
const domainSelection = selection[domain.domain];
|
||||
const isAllSelected = domainSelection?.allSelected || false;
|
||||
const selectedCount = domainSelection?.cookies.size || 0;
|
||||
const isAllSelected = domainSelection.allSelected;
|
||||
const selectedCount = domainSelection.cookies.size;
|
||||
const isPartial =
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
@@ -511,13 +514,17 @@ function DomainRow({
|
||||
<div className="flex items-center gap-2 p-2 hover:bg-accent/50 rounded">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
|
||||
onCheckedChange={() => {
|
||||
onToggleDomain(domain.domain, domain.cookies);
|
||||
}}
|
||||
className={isPartial ? "opacity-70" : ""}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 text-left bg-transparent border-none cursor-pointer"
|
||||
onClick={() => onToggleExpand(domain.domain)}
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LuChevronDown className="w-4 h-4" />
|
||||
@@ -533,8 +540,7 @@ function DomainRow({
|
||||
{isExpanded && (
|
||||
<div className="ml-8 pl-2 border-l space-y-1">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) || false;
|
||||
const isSelected = domainSelection.cookies.has(cookie.name);
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
@@ -542,13 +548,13 @@ function DomainRow({
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() =>
|
||||
onCheckedChange={() => {
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -45,12 +45,13 @@ interface CookieManagementDialogProps {
|
||||
initialTab?: "import" | "export";
|
||||
}
|
||||
|
||||
type SelectionState = {
|
||||
[domain: string]: {
|
||||
type SelectionState = Record<
|
||||
string,
|
||||
{
|
||||
allSelected: boolean;
|
||||
cookies: Set<string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
const countCookies = (content: string): number => {
|
||||
const trimmed = content.trim();
|
||||
@@ -150,7 +151,7 @@ export function CookieManagementDialog({
|
||||
const domainData = exportCookieData?.domains.find(
|
||||
(d) => d.domain === domain,
|
||||
);
|
||||
count += domainData?.cookie_count || 0;
|
||||
count += domainData?.cookie_count ?? 0;
|
||||
} else {
|
||||
count += ds.cookies.size;
|
||||
}
|
||||
@@ -309,7 +310,7 @@ export function CookieManagementDialog({
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
setExportSelection((prev) => {
|
||||
const current = prev[domain];
|
||||
if (current?.allSelected) {
|
||||
if (current.allSelected) {
|
||||
const next = { ...prev };
|
||||
delete next[domain];
|
||||
return next;
|
||||
@@ -329,7 +330,7 @@ export function CookieManagementDialog({
|
||||
const toggleCookie = useCallback(
|
||||
(domain: string, cookieName: string, totalCookies: number) => {
|
||||
setExportSelection((prev) => {
|
||||
const current = prev[domain] || {
|
||||
const current = prev[domain] ?? {
|
||||
allSelected: false,
|
||||
cookies: new Set<string>(),
|
||||
};
|
||||
@@ -485,7 +486,9 @@ export function CookieManagementDialog({
|
||||
<Label>Format</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => setFormat(v as "netscape" | "json")}
|
||||
onValueChange={(v) => {
|
||||
setFormat(v as "netscape" | "json");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -589,8 +592,8 @@ function ExportDomainRow({
|
||||
onToggleExpand,
|
||||
}: ExportDomainRowProps) {
|
||||
const domainSelection = selection[domain.domain];
|
||||
const isAllSelected = domainSelection?.allSelected || false;
|
||||
const selectedCount = domainSelection?.cookies.size || 0;
|
||||
const isAllSelected = domainSelection.allSelected;
|
||||
const selectedCount = domainSelection.cookies.size;
|
||||
const isPartial =
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
@@ -599,13 +602,17 @@ function ExportDomainRow({
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-accent/50 rounded">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
|
||||
onCheckedChange={() => {
|
||||
onToggleDomain(domain.domain, domain.cookies);
|
||||
}}
|
||||
className={isPartial ? "opacity-70" : ""}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 text-left text-sm bg-transparent border-none cursor-pointer"
|
||||
onClick={() => onToggleExpand(domain.domain)}
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LuChevronDown className="w-3.5 h-3.5" />
|
||||
@@ -621,8 +628,7 @@ function ExportDomainRow({
|
||||
{isExpanded && (
|
||||
<div className="ml-7 pl-2 border-l space-y-0.5">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) || false;
|
||||
const isSelected = domainSelection.cookies.has(cookie.name);
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
@@ -630,13 +636,13 @@ function ExportDomainRow({
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() =>
|
||||
onCheckedChange={() => {
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,9 @@ export function CreateGroupDialog({
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setGroupName(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && groupName.trim()) {
|
||||
void handleCreate();
|
||||
|
||||
@@ -84,6 +84,7 @@ interface CreateProfileDialogProps {
|
||||
groupId?: string;
|
||||
extensionGroupId?: string;
|
||||
ephemeral?: boolean;
|
||||
dnsBlocklist?: string;
|
||||
}) => Promise<void>;
|
||||
selectedGroupId?: string;
|
||||
crossOsUnlocked?: boolean;
|
||||
@@ -124,6 +125,7 @@ export function CreateProfileDialog({
|
||||
useState<BrowserTypeString | null>(null);
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
|
||||
@@ -172,11 +174,13 @@ export function CreateProfileDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
invoke<{ id: string; name: string; extension_ids: string[] }[]>(
|
||||
void invoke<{ id: string; name: string; extension_ids: string[] }[]>(
|
||||
"list_extension_groups",
|
||||
)
|
||||
.then(setExtensionGroups)
|
||||
.catch(() => setExtensionGroups([]));
|
||||
.catch(() => {
|
||||
setExtensionGroups([]);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
|
||||
@@ -393,6 +397,7 @@ export function CreateProfileDialog({
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
});
|
||||
} else {
|
||||
// Default to Camoufox
|
||||
@@ -418,6 +423,7 @@ export function CreateProfileDialog({
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -441,6 +447,7 @@ export function CreateProfileDialog({
|
||||
releaseType: bestVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -553,7 +560,9 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-3 pt-8">
|
||||
{/* Wayfern (Chromium) - First */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("wayfern")}
|
||||
onClick={() => {
|
||||
handleBrowserSelect("wayfern");
|
||||
}}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
@@ -577,7 +586,9 @@ export function CreateProfileDialog({
|
||||
|
||||
{/* Camoufox (Firefox) - Second */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("camoufox")}
|
||||
onClick={() => {
|
||||
handleBrowserSelect("camoufox");
|
||||
}}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
@@ -620,9 +631,9 @@ export function CreateProfileDialog({
|
||||
return (
|
||||
<Button
|
||||
key={browser.value}
|
||||
onClick={() =>
|
||||
handleBrowserSelect(browser.value)
|
||||
}
|
||||
onClick={() => {
|
||||
handleBrowserSelect(browser.value);
|
||||
}}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
@@ -657,14 +668,16 @@ export function CreateProfileDialog({
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setProfileName(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!isCreateDisabled &&
|
||||
!isCreating
|
||||
) {
|
||||
handleCreate();
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
@@ -677,9 +690,9 @@ export function CreateProfileDialog({
|
||||
<Checkbox
|
||||
id="ephemeral"
|
||||
checked={ephemeral}
|
||||
onCheckedChange={(checked) =>
|
||||
setEphemeral(checked === true)
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
setEphemeral(checked === true);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="ephemeral" className="font-medium">
|
||||
{t("profiles.ephemeral")}
|
||||
@@ -746,7 +759,9 @@ export function CreateProfileDialog({
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload("wayfern")}
|
||||
onClick={() => {
|
||||
void handleDownload("wayfern");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"wayfern",
|
||||
)}
|
||||
@@ -848,7 +863,9 @@ export function CreateProfileDialog({
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload("camoufox")}
|
||||
onClick={() => {
|
||||
void handleDownload("camoufox");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
@@ -955,9 +972,9 @@ export function CreateProfileDialog({
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() =>
|
||||
handleDownload(selectedBrowser)
|
||||
}
|
||||
onClick={() => {
|
||||
void handleDownload(selectedBrowser);
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
@@ -1014,7 +1031,9 @@ export function CreateProfileDialog({
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowProxyForm(true)}
|
||||
onClick={() => {
|
||||
setShowProxyForm(true);
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
@@ -1148,17 +1167,54 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DNS Blocklist */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("dnsBlocklist.title")}</Label>
|
||||
<Select
|
||||
value={dnsBlocklist || "none"}
|
||||
onValueChange={(val) => {
|
||||
setDnsBlocklist(val === "none" ? "" : val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("dnsBlocklist.none")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("dnsBlocklist.none")}
|
||||
</SelectItem>
|
||||
<SelectItem value="light">
|
||||
{t("dnsBlocklist.light")}
|
||||
</SelectItem>
|
||||
<SelectItem value="normal">
|
||||
{t("dnsBlocklist.normal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pro">
|
||||
{t("dnsBlocklist.pro")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pro_plus">
|
||||
{t("dnsBlocklist.proPlus")}
|
||||
</SelectItem>
|
||||
<SelectItem value="ultimate">
|
||||
{t("dnsBlocklist.ultimate")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Extension Group */}
|
||||
{extensionGroups.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.extensionGroup")}</Label>
|
||||
<Select
|
||||
value={selectedExtensionGroupId || "none"}
|
||||
onValueChange={(val) =>
|
||||
value={selectedExtensionGroupId ?? "none"}
|
||||
onValueChange={(val) => {
|
||||
setSelectedExtensionGroupId(
|
||||
val === "none" ? undefined : val,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
@@ -1190,14 +1246,16 @@ export function CreateProfileDialog({
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setProfileName(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!isCreateDisabled &&
|
||||
!isCreating
|
||||
) {
|
||||
handleCreate();
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
@@ -1251,9 +1309,9 @@ export function CreateProfileDialog({
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() =>
|
||||
handleDownload(selectedBrowser)
|
||||
}
|
||||
onClick={() => {
|
||||
void handleDownload(selectedBrowser);
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
@@ -1305,7 +1363,9 @@ export function CreateProfileDialog({
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowProxyForm(true)}
|
||||
onClick={() => {
|
||||
setShowProxyForm(true);
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
@@ -1470,7 +1530,9 @@ export function CreateProfileDialog({
|
||||
</DialogContent>
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={() => setShowProxyForm(false)}
|
||||
onClose={() => {
|
||||
setShowProxyForm(false);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -363,15 +363,17 @@ export function UnifiedToast(props: ToastProps) {
|
||||
</>
|
||||
)}
|
||||
{action &&
|
||||
"onClick" in (action as any) &&
|
||||
"label" in (action as any) && (
|
||||
"onClick" in (action as { onClick?: () => void; label?: string }) &&
|
||||
"label" in (action as { onClick?: () => void; label?: string }) && (
|
||||
<div className="mt-2 w-full">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={(action as any).onClick}
|
||||
onClick={
|
||||
(action as { onClick: () => void; label: string }).onClick
|
||||
}
|
||||
>
|
||||
{(action as any).label}
|
||||
{(action as { onClick: () => void; label: string }).label}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -40,11 +40,13 @@ function DataTableActionBar<TData>({
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [table]);
|
||||
|
||||
const portalContainer =
|
||||
portalContainerProp ?? (mounted ? globalThis.document?.body : null);
|
||||
portalContainerProp ?? (mounted ? globalThis.document.body : null);
|
||||
|
||||
if (!portalContainer) return null;
|
||||
|
||||
|
||||
@@ -148,9 +148,9 @@ export function DeleteGroupDialog({
|
||||
<Label>What should happen to these profiles?</Label>
|
||||
<RadioGroup
|
||||
value={deleteAction}
|
||||
onValueChange={(value) =>
|
||||
setDeleteAction(value as "move" | "delete")
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
setDeleteAction(value as "move" | "delete");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="move" id="move" />
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface BlocklistCacheStatus {
|
||||
level: string;
|
||||
display_name: string;
|
||||
entry_count: number;
|
||||
file_size_bytes: number;
|
||||
last_updated: number | null;
|
||||
is_fresh: boolean;
|
||||
is_cached: boolean;
|
||||
}
|
||||
|
||||
interface DnsBlocklistDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DnsBlocklistDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: DnsBlocklistDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [statuses, setStatuses] = useState<BlocklistCacheStatus[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const loadStatuses = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<BlocklistCacheStatus[]>(
|
||||
"get_dns_blocklist_cache_status",
|
||||
);
|
||||
setStatuses(result);
|
||||
} catch (e) {
|
||||
console.error("Failed to load blocklist status:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadStatuses();
|
||||
}
|
||||
}, [isOpen, loadStatuses]);
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await invoke("refresh_dns_blocklists");
|
||||
await loadStatuses();
|
||||
} catch (e) {
|
||||
console.error("Failed to refresh blocklists:", e);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number | null) => {
|
||||
if (!timestamp) return t("dnsBlocklist.notCached");
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
className="flex items-center justify-between rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{status.display_name}
|
||||
</span>
|
||||
{status.is_cached ? (
|
||||
status.is_fresh ? (
|
||||
<Badge variant="default" className="text-[10px] px-1.5">
|
||||
{t("dnsBlocklist.fresh")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5">
|
||||
{t("dnsBlocklist.stale")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 text-muted-foreground"
|
||||
>
|
||||
{t("dnsBlocklist.notCached")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{status.is_cached && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status.entry_count.toLocaleString()}{" "}
|
||||
{t("dnsBlocklist.domains")} ·{" "}
|
||||
{formatSize(status.file_size_bytes)} ·{" "}
|
||||
{formatDate(status.last_updated)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<LuRefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("dnsBlocklist.refreshAll")}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,9 @@ export function EditGroupDialog({
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setGroupName(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && groupName.trim()) {
|
||||
void handleUpdate();
|
||||
|
||||
@@ -137,7 +137,7 @@ export function ExtensionGroupAssignmentDialog({
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId || "none"}
|
||||
value={selectedGroupId ?? "none"}
|
||||
onValueChange={(value) => {
|
||||
setSelectedGroupId(value === "none" ? null : value);
|
||||
}}
|
||||
|
||||
@@ -197,9 +197,7 @@ export function ExtensionManagementDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadData().then(() => {
|
||||
// Icons will be loaded after extensions are set
|
||||
});
|
||||
void loadData();
|
||||
}
|
||||
}, [isOpen, loadData]);
|
||||
|
||||
@@ -562,7 +560,9 @@ export function ExtensionManagementDialog({
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setActiveTab("extensions")}
|
||||
onClick={() => {
|
||||
setActiveTab("extensions");
|
||||
}}
|
||||
disabled={limitedMode}
|
||||
>
|
||||
{t("extensions.extensionsTab")}
|
||||
@@ -574,7 +574,9 @@ export function ExtensionManagementDialog({
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setActiveTab("groups")}
|
||||
onClick={() => {
|
||||
setActiveTab("groups");
|
||||
}}
|
||||
disabled={limitedMode}
|
||||
>
|
||||
{t("extensions.groupsTab")}
|
||||
@@ -627,13 +629,15 @@ export function ExtensionManagementDialog({
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={extensionName}
|
||||
onChange={(e) => setExtensionName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setExtensionName(e.target.value);
|
||||
}}
|
||||
placeholder={t("extensions.namePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
onClick={() => void handleUpload()}
|
||||
disabled={isUploading || !extensionName.trim()}
|
||||
>
|
||||
{isUploading
|
||||
@@ -705,7 +709,7 @@ export function ExtensionManagementDialog({
|
||||
<Checkbox
|
||||
checked={ext.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleExtSync(ext)
|
||||
void handleToggleExtSync(ext)
|
||||
}
|
||||
disabled={isTogglingExtSync[ext.id]}
|
||||
/>
|
||||
@@ -745,7 +749,9 @@ export function ExtensionManagementDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setExtensionToDelete(ext)}
|
||||
onClick={() => {
|
||||
setExtensionToDelete(ext);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -769,7 +775,9 @@ export function ExtensionManagementDialog({
|
||||
<Label>{t("extensions.groupsTab")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setShowCreateGroup(true)}
|
||||
onClick={() => {
|
||||
setShowCreateGroup(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={limitedMode}
|
||||
>
|
||||
@@ -783,7 +791,9 @@ export function ExtensionManagementDialog({
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setNewGroupName(e.target.value);
|
||||
}}
|
||||
placeholder={t("extensions.groupNamePlaceholder")}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
@@ -792,7 +802,7 @@ export function ExtensionManagementDialog({
|
||||
/>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateGroup}
|
||||
onClick={() => void handleCreateGroup()}
|
||||
disabled={!newGroupName.trim()}
|
||||
>
|
||||
{t("common.buttons.create")}
|
||||
@@ -902,7 +912,7 @@ export function ExtensionManagementDialog({
|
||||
<Checkbox
|
||||
checked={group.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleGroupSync(group)
|
||||
void handleToggleGroupSync(group)
|
||||
}
|
||||
disabled={isTogglingGroupSync[group.id]}
|
||||
/>
|
||||
@@ -943,7 +953,9 @@ export function ExtensionManagementDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setGroupToDelete(group)}
|
||||
onClick={() => {
|
||||
setGroupToDelete(group);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -996,7 +1008,9 @@ export function ExtensionManagementDialog({
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
<Input
|
||||
value={editGroupName}
|
||||
onChange={(e) => setEditGroupName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setEditGroupName(e.target.value);
|
||||
}}
|
||||
placeholder={t("extensions.groupNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
@@ -1007,9 +1021,9 @@ export function ExtensionManagementDialog({
|
||||
<Label>{t("extensions.addToGroup")}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(extId) =>
|
||||
setEditGroupExtensionIds((prev) => [...prev, extId])
|
||||
}
|
||||
onValueChange={(extId) => {
|
||||
setEditGroupExtensionIds((prev) => [...prev, extId]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("extensions.addToGroup")} />
|
||||
@@ -1055,11 +1069,11 @@ export function ExtensionManagementDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
setEditGroupExtensionIds((prev) =>
|
||||
prev.filter((id) => id !== extId),
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -1083,7 +1097,7 @@ export function ExtensionManagementDialog({
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton
|
||||
onClick={handleSaveGroupEdits}
|
||||
onClick={() => void handleSaveGroupEdits()}
|
||||
disabled={!editGroupName.trim()}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
@@ -1117,7 +1131,9 @@ export function ExtensionManagementDialog({
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
<Input
|
||||
value={editExtensionName}
|
||||
onChange={(e) => setEditExtensionName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setEditExtensionName(e.target.value);
|
||||
}}
|
||||
placeholder={t("extensions.namePlaceholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleUpdateExtension();
|
||||
@@ -1239,7 +1255,7 @@ export function ExtensionManagementDialog({
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton
|
||||
onClick={handleUpdateExtension}
|
||||
onClick={() => void handleUpdateExtension()}
|
||||
disabled={!editExtensionName.trim()}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
@@ -1251,7 +1267,9 @@ export function ExtensionManagementDialog({
|
||||
{/* Delete extension confirmation */}
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={extensionToDelete !== null}
|
||||
onClose={() => setExtensionToDelete(null)}
|
||||
onClose={() => {
|
||||
setExtensionToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteExtension}
|
||||
title={t("extensions.deleteConfirmTitle")}
|
||||
description={t("extensions.deleteConfirmDescription", {
|
||||
@@ -1263,7 +1281,9 @@ export function ExtensionManagementDialog({
|
||||
{/* Delete group confirmation */}
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={groupToDelete !== null}
|
||||
onClose={() => setGroupToDelete(null)}
|
||||
onClose={() => {
|
||||
setGroupToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteGroup}
|
||||
title={t("extensions.deleteGroupConfirmTitle")}
|
||||
description={t("extensions.deleteGroupConfirmDescription", {
|
||||
|
||||
@@ -144,7 +144,9 @@ export function GroupAssignmentDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Create Group
|
||||
</RippleButton>
|
||||
@@ -155,7 +157,7 @@ export function GroupAssignmentDialog({
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId || "default"}
|
||||
value={selectedGroupId ?? "default"}
|
||||
onValueChange={(value) => {
|
||||
setSelectedGroupId(value === "default" ? null : value);
|
||||
}}
|
||||
@@ -201,7 +203,9 @@ export function GroupAssignmentDialog({
|
||||
</DialogContent>
|
||||
<CreateGroupDialog
|
||||
isOpen={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setCreateDialogOpen(false);
|
||||
}}
|
||||
onGroupCreated={(group) => {
|
||||
setGroups((prev) => [...prev, group]);
|
||||
setSelectedGroupId(group.id);
|
||||
|
||||
@@ -246,7 +246,9 @@ export function GroupManagementDialog({
|
||||
<Label>Groups</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
@@ -350,7 +352,9 @@ export function GroupManagementDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
onClick={() => {
|
||||
handleEditGroup(group);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -364,7 +368,9 @@ export function GroupManagementDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
onClick={() => {
|
||||
handleDeleteGroup(group);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -395,20 +401,26 @@ export function GroupManagementDialog({
|
||||
|
||||
<CreateGroupDialog
|
||||
isOpen={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setCreateDialogOpen(false);
|
||||
}}
|
||||
onGroupCreated={handleGroupCreated}
|
||||
/>
|
||||
|
||||
<EditGroupDialog
|
||||
isOpen={editDialogOpen}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setEditDialogOpen(false);
|
||||
}}
|
||||
group={selectedGroup}
|
||||
onGroupUpdated={handleGroupUpdated}
|
||||
/>
|
||||
|
||||
<DeleteGroupDialog
|
||||
isOpen={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
}}
|
||||
group={selectedGroup}
|
||||
onGroupDeleted={handleGroupDeleted}
|
||||
/>
|
||||
|
||||
@@ -166,7 +166,7 @@ function useLogoEasterEgg() {
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
onSettingsDialogOpen: (open: boolean) => void;
|
||||
onProxyManagementDialogOpen: (open: boolean) => void;
|
||||
onGroupManagementDialogOpen: (open: boolean) => void;
|
||||
@@ -177,7 +177,7 @@ type Props = {
|
||||
onExtensionManagementDialogOpen: (open: boolean) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const HomeHeader = ({
|
||||
onSettingsDialogOpen,
|
||||
@@ -211,9 +211,15 @@ const HomeHeader = ({
|
||||
type="button"
|
||||
className="p-1 cursor-pointer select-none"
|
||||
onClick={handleClick}
|
||||
onPointerDown={() => setIsPressed(true)}
|
||||
onPointerUp={() => setIsPressed(false)}
|
||||
onPointerLeave={() => setIsPressed(false)}
|
||||
onPointerDown={() => {
|
||||
setIsPressed(true);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
setIsPressed(false);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setIsPressed(false);
|
||||
}}
|
||||
>
|
||||
<Logo
|
||||
key={wobbleKey}
|
||||
@@ -238,14 +244,18 @@ const HomeHeader = ({
|
||||
type="text"
|
||||
placeholder={t("header.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
onChange={(e) => {
|
||||
onSearchQueryChange(e.target.value);
|
||||
}}
|
||||
className="pr-8 pl-10 w-48"
|
||||
/>
|
||||
<LuSearch className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-muted-foreground" />
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchQueryChange("")}
|
||||
onClick={() => {
|
||||
onSearchQueryChange("");
|
||||
}}
|
||||
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
aria-label={t("header.clearSearch")}
|
||||
>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function IntegrationsDialog({
|
||||
});
|
||||
const [apiServerPort, setApiServerPort] = useState<number | null>(null);
|
||||
const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);
|
||||
const [_mcpRunning, setMcpRunning] = useState(false);
|
||||
const [, setMcpRunning] = useState(false);
|
||||
const [showApiToken, setShowApiToken] = useState(false);
|
||||
const [showMcpToken, setShowMcpToken] = useState(false);
|
||||
const [isApiStarting, setIsApiStarting] = useState(false);
|
||||
@@ -119,12 +119,12 @@ export function IntegrationsDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSettings();
|
||||
loadApiServerStatus();
|
||||
loadMcpConfig();
|
||||
loadMcpServerStatus();
|
||||
loadClaudeDesktopStatus();
|
||||
loadClaudeCodeStatus();
|
||||
void loadSettings();
|
||||
void loadApiServerStatus();
|
||||
void loadMcpConfig();
|
||||
void loadMcpServerStatus();
|
||||
void loadClaudeDesktopStatus();
|
||||
void loadClaudeCodeStatus();
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
@@ -177,7 +177,7 @@ export function IntegrationsDialog({
|
||||
settings: { ...settings, mcp_enabled: true, mcp_port: port },
|
||||
});
|
||||
setSettings(next);
|
||||
loadMcpConfig();
|
||||
void loadMcpConfig();
|
||||
showSuccessToast(`MCP server started on port ${port}`);
|
||||
} else {
|
||||
await invoke("stop_mcp_server");
|
||||
@@ -198,11 +198,13 @@ export function IntegrationsDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const _obfuscateToken = (token: string) =>
|
||||
"•".repeat(Math.min(token.length, 32));
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>Integrations</DialogTitle>
|
||||
@@ -221,7 +223,7 @@ export function IntegrationsDialog({
|
||||
id="api-enabled"
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={handleApiToggle}
|
||||
onCheckedChange={(checked) => void handleApiToggle(!!checked)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
@@ -269,7 +271,9 @@ export function IntegrationsDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowApiToken(!showApiToken)}
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
}}
|
||||
>
|
||||
{showApiToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -297,7 +301,7 @@ export function IntegrationsDialog({
|
||||
id="mcp-enabled"
|
||||
checked={settings.mcp_enabled && mcpConfig !== null}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={handleMcpToggle}
|
||||
onCheckedChange={(checked) => void handleMcpToggle(!!checked)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
@@ -336,7 +340,9 @@ export function IntegrationsDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowMcpToken(!showMcpToken)}
|
||||
onClick={() => {
|
||||
setShowMcpToken(!showMcpToken);
|
||||
}}
|
||||
>
|
||||
{showMcpToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
|
||||
@@ -62,9 +62,15 @@ export function LaunchOnLoginDialog({
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent
|
||||
className="sm:max-w-sm"
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Launch on Login?</DialogTitle>
|
||||
|
||||
@@ -62,13 +62,17 @@ export function LocationProxyDialog({
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setIsLoadingCountries(true);
|
||||
invoke<LocationItem[]>("cloud_get_countries")
|
||||
.then((data) => setCountries(data))
|
||||
void invoke<LocationItem[]>("cloud_get_countries")
|
||||
.then((data) => {
|
||||
setCountries(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch countries:", err);
|
||||
toast.error("Failed to load countries");
|
||||
})
|
||||
.finally(() => setIsLoadingCountries(false));
|
||||
.finally(() => {
|
||||
setIsLoadingCountries(false);
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch regions when country changes
|
||||
@@ -83,10 +87,18 @@ export function LocationProxyDialog({
|
||||
setSelectedIsp("");
|
||||
setCities([]);
|
||||
setIsps([]);
|
||||
invoke<LocationItem[]>("cloud_get_regions", { country: selectedCountry })
|
||||
.then((data) => setRegions(data))
|
||||
.catch((err) => console.error("Failed to fetch regions:", err))
|
||||
.finally(() => setIsLoadingRegions(false));
|
||||
void invoke<LocationItem[]>("cloud_get_regions", {
|
||||
country: selectedCountry,
|
||||
})
|
||||
.then((data) => {
|
||||
setRegions(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch regions:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingRegions(false);
|
||||
});
|
||||
}, [selectedCountry]);
|
||||
|
||||
// Fetch cities when country or region changes (cities can be loaded without region)
|
||||
@@ -103,10 +115,16 @@ export function LocationProxyDialog({
|
||||
if (selectedRegion) {
|
||||
args.region = selectedRegion;
|
||||
}
|
||||
invoke<LocationItem[]>("cloud_get_cities", args)
|
||||
.then((data) => setCities(data))
|
||||
.catch((err) => console.error("Failed to fetch cities:", err))
|
||||
.finally(() => setIsLoadingCities(false));
|
||||
void invoke<LocationItem[]>("cloud_get_cities", args)
|
||||
.then((data) => {
|
||||
setCities(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch cities:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCities(false);
|
||||
});
|
||||
}, [selectedCountry, selectedRegion]);
|
||||
|
||||
// Fetch ISPs when country/region/city changes
|
||||
@@ -122,10 +140,16 @@ export function LocationProxyDialog({
|
||||
};
|
||||
if (selectedRegion) args.region = selectedRegion;
|
||||
if (selectedCity) args.city = selectedCity;
|
||||
invoke<LocationItem[]>("cloud_get_isps", args)
|
||||
.then((data) => setIsps(data))
|
||||
.catch((err) => console.error("Failed to fetch ISPs:", err))
|
||||
.finally(() => setIsLoadingIsps(false));
|
||||
void invoke<LocationItem[]>("cloud_get_isps", args)
|
||||
.then((data) => {
|
||||
setIsps(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch ISPs:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingIsps(false);
|
||||
});
|
||||
}, [selectedCountry, selectedRegion, selectedCity]);
|
||||
|
||||
// Auto-generate name from selections
|
||||
@@ -302,7 +326,9 @@ export function LocationProxyDialog({
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={proxyName}
|
||||
onChange={(e) => setProxyName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setProxyName(e.target.value);
|
||||
}}
|
||||
placeholder="Proxy name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -19,9 +19,7 @@ export interface Option {
|
||||
/** Group the options by providing key. */
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
interface GroupOption {
|
||||
[key: string]: Option[];
|
||||
}
|
||||
type GroupOption = Record<string, Option[]>;
|
||||
|
||||
interface MultipleSelectorProps {
|
||||
value?: Option[];
|
||||
@@ -77,12 +75,13 @@ export interface MultipleSelectorRef {
|
||||
input: HTMLInputElement;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useDebounce<T>(value: T, delay?: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay ?? 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
@@ -104,11 +103,11 @@ function transToGroupOption(options: Option[], groupBy?: string) {
|
||||
|
||||
const groupOption: GroupOption = {};
|
||||
options.forEach((option) => {
|
||||
const key = (option[groupBy] as string) || "";
|
||||
const key = (option[groupBy] as string) ?? "";
|
||||
if (!groupOption[key]) {
|
||||
groupOption[key] = [option];
|
||||
} else {
|
||||
groupOption[key]?.push(option);
|
||||
groupOption[key].push(option);
|
||||
}
|
||||
});
|
||||
return groupOption;
|
||||
@@ -197,12 +196,12 @@ const MultipleSelector = React.forwardRef<
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const [selected, setSelected] = React.useState<Option[]>(value || []);
|
||||
const [selected, setSelected] = React.useState<Option[]>(value ?? []);
|
||||
const [options, setOptions] = React.useState<GroupOption>(
|
||||
transToGroupOption(arrayDefaultOptions, groupBy),
|
||||
);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
|
||||
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
@@ -231,7 +230,7 @@ const MultipleSelector = React.forwardRef<
|
||||
if (input.value === "" && selected.length > 0) {
|
||||
const lastSelectOption = selected[selected.length - 1];
|
||||
// If last item is fixed, we should not remove it.
|
||||
if (!lastSelectOption?.fixed) {
|
||||
if (!lastSelectOption.fixed) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: false positive
|
||||
handleUnselect(selected.at(-1)!);
|
||||
}
|
||||
@@ -257,7 +256,7 @@ const MultipleSelector = React.forwardRef<
|
||||
if (!arrayOptions || onSearch) {
|
||||
return;
|
||||
}
|
||||
const newOption = transToGroupOption(arrayOptions || [], groupBy);
|
||||
const newOption = transToGroupOption(arrayOptions, groupBy);
|
||||
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
|
||||
setOptions(newOption);
|
||||
}
|
||||
@@ -267,7 +266,7 @@ const MultipleSelector = React.forwardRef<
|
||||
const doSearch = async () => {
|
||||
setIsLoading(true);
|
||||
const res = await onSearch?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
setOptions(transToGroupOption(res ?? [], groupBy));
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
@@ -284,7 +283,6 @@ const MultipleSelector = React.forwardRef<
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]);
|
||||
|
||||
const CreatableItem = () => {
|
||||
@@ -414,14 +412,14 @@ const MultipleSelector = React.forwardRef<
|
||||
badgeClassName,
|
||||
)}
|
||||
data-fixed={option.fixed}
|
||||
data-disabled={disabled || undefined}
|
||||
data-disabled={disabled ?? undefined}
|
||||
>
|
||||
{option.label ?? option.value}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
(disabled || option.fixed) && "hidden",
|
||||
(disabled ?? option.fixed) && "hidden",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -432,7 +430,9 @@ const MultipleSelector = React.forwardRef<
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => handleUnselect(option)}
|
||||
onClick={() => {
|
||||
handleUnselect(option);
|
||||
}}
|
||||
>
|
||||
<LuX className="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
@@ -490,7 +490,7 @@ const MultipleSelector = React.forwardRef<
|
||||
onFocus={(event) => {
|
||||
setOpen(true);
|
||||
if (triggerSearchOnFocus && onSearch) {
|
||||
onSearch(debouncedSearchTerm);
|
||||
void onSearch(debouncedSearchTerm);
|
||||
}
|
||||
inputProps?.onFocus?.(event);
|
||||
}}
|
||||
|
||||
@@ -156,7 +156,9 @@ export function PermissionDialog({
|
||||
<LoadingButton
|
||||
isLoading={isRequesting}
|
||||
onClick={() => {
|
||||
handleRequestPermission().catch(console.error);
|
||||
handleRequestPermission().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
className="min-w-24"
|
||||
>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import {
|
||||
ProfileBypassRulesDialog,
|
||||
ProfileDnsBlocklistDialog,
|
||||
ProfileInfoDialog,
|
||||
} from "@/components/profile-info-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -102,7 +103,7 @@ import { RippleButton } from "./ui/ripple";
|
||||
|
||||
// Stable table meta type to pass volatile state/handlers into TanStack Table without
|
||||
// causing column definitions to be recreated on every render.
|
||||
type TableMeta = {
|
||||
interface TableMeta {
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
selectedProfiles: string[];
|
||||
selectableCount: number;
|
||||
@@ -216,14 +217,14 @@ type TableMeta = {
|
||||
}
|
||||
| undefined;
|
||||
onLaunchWithSync: (profile: BrowserProfile) => void;
|
||||
};
|
||||
}
|
||||
|
||||
type SyncStatusDot = {
|
||||
interface SyncStatusDot {
|
||||
color: string;
|
||||
tooltip: string;
|
||||
animate: boolean;
|
||||
encrypted: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function getProfileSyncStatusDot(
|
||||
profile: BrowserProfile,
|
||||
@@ -436,7 +437,9 @@ const TagsCell = React.memo<{
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
}, [openTagsEditorFor, profile.id, setOpenTagsEditorFor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -444,7 +447,7 @@ const TagsCell = React.memo<{
|
||||
// Focus the inner input of MultipleSelector on open
|
||||
const inputEl = editorRef.current.querySelector("input");
|
||||
if (inputEl) {
|
||||
(inputEl as HTMLInputElement).focus();
|
||||
inputEl.focus();
|
||||
}
|
||||
}
|
||||
}, [openTagsEditorFor, profile.id]);
|
||||
@@ -537,8 +540,12 @@ const TagsCell = React.memo<{
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Escape") setOpenTagsEditorFor(null);
|
||||
},
|
||||
onFocus: () => setIsFocused(true),
|
||||
onBlur: () => setIsFocused(false),
|
||||
onFocus: () => {
|
||||
setIsFocused(true);
|
||||
},
|
||||
onBlur: () => {
|
||||
setIsFocused(false);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -569,8 +576,12 @@ const NonHoverableTooltip = React.memo<{
|
||||
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
onMouseEnter={() => setIsOpen(true)}
|
||||
onMouseLeave={() => setIsOpen(false)}
|
||||
onMouseEnter={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
@@ -578,8 +589,12 @@ const NonHoverableTooltip = React.memo<{
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
arrowOffset={horizontalOffset}
|
||||
onPointerEnter={(e) => e.preventDefault()}
|
||||
onPointerLeave={() => setIsOpen(false)}
|
||||
onPointerEnter={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="pointer-events-none"
|
||||
style={
|
||||
horizontalOffset !== 0
|
||||
@@ -623,7 +638,7 @@ const NoteCell = React.memo<{
|
||||
|
||||
const onNoteChange = React.useCallback(
|
||||
async (newNote: string | null) => {
|
||||
const trimmedNote = newNote?.trim() || null;
|
||||
const trimmedNote = newNote?.trim() ?? null;
|
||||
setNoteOverrides((prev) => ({ ...prev, [profile.id]: trimmedNote }));
|
||||
try {
|
||||
await invoke<BrowserProfile>("update_profile_note", {
|
||||
@@ -639,12 +654,12 @@ const NoteCell = React.memo<{
|
||||
|
||||
const editorRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const [noteValue, setNoteValue] = React.useState(effectiveNote || "");
|
||||
const [noteValue, setNoteValue] = React.useState(effectiveNote ?? "");
|
||||
|
||||
// Update local state when effective note changes (from outside)
|
||||
React.useEffect(() => {
|
||||
if (openNoteEditorFor !== profile.id) {
|
||||
setNoteValue(effectiveNote || "");
|
||||
setNoteValue(effectiveNote ?? "");
|
||||
}
|
||||
}, [effectiveNote, openNoteEditorFor, profile.id]);
|
||||
|
||||
@@ -678,13 +693,15 @@ const NoteCell = React.memo<{
|
||||
target &&
|
||||
!editorRef.current.contains(target)
|
||||
) {
|
||||
const currentValue = textareaRef.current?.value || "";
|
||||
const currentValue = textareaRef.current?.value ?? "";
|
||||
void onNoteChange(currentValue);
|
||||
setOpenNoteEditorFor(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
}, [openNoteEditorFor, profile.id, setOpenNoteEditorFor, onNoteChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -696,7 +713,7 @@ const NoteCell = React.memo<{
|
||||
}
|
||||
}, [openNoteEditorFor, profile.id]);
|
||||
|
||||
const displayNote = effectiveNote || "";
|
||||
const displayNote = effectiveNote ?? "";
|
||||
const trimmedNote =
|
||||
displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote;
|
||||
const showTooltip = displayNote.length > 12 || displayNote.length > 0;
|
||||
@@ -716,7 +733,7 @@ const NoteCell = React.memo<{
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
setNoteValue(effectiveNote || "");
|
||||
setNoteValue(effectiveNote ?? "");
|
||||
setOpenNoteEditorFor(profile.id);
|
||||
}
|
||||
}}
|
||||
@@ -734,7 +751,7 @@ const NoteCell = React.memo<{
|
||||
{showTooltip && (
|
||||
<TooltipContent className="max-w-[320px]">
|
||||
<p className="whitespace-pre-wrap wrap-break-word">
|
||||
{effectiveNote || "No Note"}
|
||||
{effectiveNote ?? "No Note"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
@@ -760,7 +777,7 @@ const NoteCell = React.memo<{
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setNoteValue(effectiveNote || "");
|
||||
setNoteValue(effectiveNote ?? "");
|
||||
setOpenNoteEditorFor(null);
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
void onNoteChange(noteValue);
|
||||
@@ -918,6 +935,8 @@ export function ProfilesDataTable({
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [bypassRulesProfile, setBypassRulesProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -1100,14 +1119,13 @@ export function ProfilesDataTable({
|
||||
isUpdating,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
crossOsUnlocked,
|
||||
);
|
||||
|
||||
// Listen for sync status events
|
||||
React.useEffect(() => {
|
||||
if (!browserState.isClient) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
unlisten = await listen<{
|
||||
profile_id: string;
|
||||
@@ -1168,8 +1186,12 @@ export function ProfilesDataTable({
|
||||
};
|
||||
|
||||
void fetchTrafficSnapshots();
|
||||
const interval = setInterval(fetchTrafficSnapshots, 1000);
|
||||
return () => clearInterval(interval);
|
||||
const interval = setInterval(() => {
|
||||
void fetchTrafficSnapshots();
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [browserState.isClient, runningCount, runningProfileIds]);
|
||||
|
||||
// Clean up snapshots for profiles that are no longer running
|
||||
@@ -1196,7 +1218,7 @@ export function ProfilesDataTable({
|
||||
React.useEffect(() => {
|
||||
if (!browserState.isClient) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
unlisten = await listen<{ id: string; is_running: boolean }>(
|
||||
"profile-running-changed",
|
||||
@@ -1231,7 +1253,7 @@ export function ProfilesDataTable({
|
||||
React.useEffect(() => {
|
||||
if (!browserState.isClient) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
unlisten = await listen("stored-proxies-changed", () => {
|
||||
// Also refresh tags on profile updates
|
||||
@@ -1521,7 +1543,11 @@ export function ProfilesDataTable({
|
||||
|
||||
// Overflow actions
|
||||
onAssignProfilesToGroup,
|
||||
onCloneProfile,
|
||||
onCloneProfile: onCloneProfile
|
||||
? (profile: BrowserProfile) => {
|
||||
void onCloneProfile(profile);
|
||||
}
|
||||
: undefined,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onOpenCookieManagement,
|
||||
@@ -1553,7 +1579,11 @@ export function ProfilesDataTable({
|
||||
|
||||
// Synchronizer
|
||||
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
|
||||
onLaunchWithSync: onLaunchWithSync ?? (() => {}),
|
||||
onLaunchWithSync:
|
||||
onLaunchWithSync ??
|
||||
(() => {
|
||||
/* empty */
|
||||
}),
|
||||
}),
|
||||
[
|
||||
t,
|
||||
@@ -1625,7 +1655,9 @@ export function ProfilesDataTable({
|
||||
meta.selectedProfiles.length === meta.selectableCount &&
|
||||
meta.selectableCount !== 0
|
||||
}
|
||||
onCheckedChange={(value) => meta.handleToggleAll(!!value)}
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleToggleAll(!!value);
|
||||
}}
|
||||
aria-label="Select all"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
@@ -1669,7 +1701,9 @@ export function ProfilesDataTable({
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
||||
onClick={() => meta.handleIconClick(profile.id)}
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label="Select profile"
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
@@ -1705,9 +1739,9 @@ export function ProfilesDataTable({
|
||||
<span className="flex justify-center items-center w-4 h-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) =>
|
||||
meta.handleCheckboxChange(profile.id, !!value)
|
||||
}
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleCheckboxChange(profile.id, !!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
@@ -1753,9 +1787,9 @@ export function ProfilesDataTable({
|
||||
<span className="flex justify-center items-center w-4 h-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) =>
|
||||
meta.handleCheckboxChange(profile.id, !!value)
|
||||
}
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleCheckboxChange(profile.id, !!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
@@ -1774,7 +1808,9 @@ export function ProfilesDataTable({
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
||||
onClick={() => meta.handleIconClick(profile.id)}
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label="Select profile"
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
@@ -1848,12 +1884,12 @@ export function ProfilesDataTable({
|
||||
const syncInfo = meta.getProfileSyncInfo(profile.id);
|
||||
const isLeader = syncInfo?.isLeader === true;
|
||||
const isFollower = syncInfo?.isLeader === false;
|
||||
const isDesynced = isFollower && syncInfo?.failedAtUrl != null;
|
||||
const isDesynced = isFollower && syncInfo.failedAtUrl != null;
|
||||
const stopTooltip = isLeader
|
||||
? meta.t("profiles.synchronizer.stopLeader")
|
||||
: isFollower
|
||||
? meta.t("profiles.synchronizer.stopFollower", {
|
||||
leaderName: syncInfo?.session.leader_profile_name ?? "",
|
||||
leaderName: syncInfo.session.leader_profile_name ?? "",
|
||||
})
|
||||
: tooltipContent;
|
||||
|
||||
@@ -1920,7 +1956,7 @@ export function ProfilesDataTable({
|
||||
onClick={() =>
|
||||
isRunning
|
||||
? void handleStop()
|
||||
: handleProfileLaunch(profile)
|
||||
: void handleProfileLaunch(profile)
|
||||
}
|
||||
>
|
||||
{isLaunching || isStopping ? (
|
||||
@@ -1951,9 +1987,9 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
Name
|
||||
@@ -2102,10 +2138,10 @@ export function ProfilesDataTable({
|
||||
<TagsCell
|
||||
profile={profile}
|
||||
isDisabled={isDisabled}
|
||||
tagsOverrides={meta.tagsOverrides || {}}
|
||||
allTags={meta.allTags || []}
|
||||
tagsOverrides={meta.tagsOverrides ?? {}}
|
||||
allTags={meta.allTags ?? []}
|
||||
setAllTags={meta.setAllTags}
|
||||
openTagsEditorFor={meta.openTagsEditorFor || null}
|
||||
openTagsEditorFor={meta.openTagsEditorFor ?? null}
|
||||
setOpenTagsEditorFor={meta.setOpenTagsEditorFor}
|
||||
setTagsOverrides={meta.setTagsOverrides}
|
||||
/>
|
||||
@@ -2131,8 +2167,8 @@ export function ProfilesDataTable({
|
||||
<NoteCell
|
||||
profile={profile}
|
||||
isDisabled={isDisabled}
|
||||
noteOverrides={meta.noteOverrides || {}}
|
||||
openNoteEditorFor={meta.openNoteEditorFor || null}
|
||||
noteOverrides={meta.noteOverrides ?? {}}
|
||||
openNoteEditorFor={meta.openNoteEditorFor ?? null}
|
||||
setOpenNoteEditorFor={meta.setOpenNoteEditorFor}
|
||||
setNoteOverrides={meta.setNoteOverrides}
|
||||
/>
|
||||
@@ -2196,12 +2232,12 @@ export function ProfilesDataTable({
|
||||
? [...snapshot.recent_bandwidth]
|
||||
: [];
|
||||
const currentBandwidth =
|
||||
(snapshot?.current_bytes_sent || 0) +
|
||||
(snapshot?.current_bytes_received || 0);
|
||||
(snapshot?.current_bytes_sent ?? 0) +
|
||||
(snapshot?.current_bytes_received ?? 0);
|
||||
|
||||
return (
|
||||
<BandwidthMiniChart
|
||||
key={`${profile.id}-${snapshot?.last_update || 0}-${bandwidthData.length}`}
|
||||
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
|
||||
data={bandwidthData}
|
||||
currentBandwidth={currentBandwidth}
|
||||
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
|
||||
@@ -2213,9 +2249,9 @@ export function ProfilesDataTable({
|
||||
<div className="flex gap-2 items-center">
|
||||
<Popover
|
||||
open={isSelectorOpen}
|
||||
onOpenChange={(open) =>
|
||||
meta.setOpenProxySelectorFor(open ? profile.id : null)
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
meta.setOpenProxySelectorFor(open ? profile.id : null);
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2468,7 +2504,9 @@ export function ProfilesDataTable({
|
||||
variant="ghost"
|
||||
className="p-0 w-8 h-8"
|
||||
disabled={!meta.isClient}
|
||||
onClick={() => setProfileForInfoDialog(profile)}
|
||||
onClick={() => {
|
||||
setProfileForInfoDialog(profile);
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Profile info</span>
|
||||
<LuInfo className="w-4 h-4" />
|
||||
@@ -2598,7 +2636,9 @@ export function ProfilesDataTable({
|
||||
</ScrollArea>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={profileToDelete !== null}
|
||||
onClose={() => setProfileToDelete(null)}
|
||||
onClose={() => {
|
||||
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.`}
|
||||
@@ -2618,7 +2658,9 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<ProfileInfoDialog
|
||||
isOpen={profileForInfoDialog !== null}
|
||||
onClose={() => setProfileForInfoDialog(null)}
|
||||
onClose={() => {
|
||||
setProfileForInfoDialog(null);
|
||||
}}
|
||||
profile={infoProfile}
|
||||
storedProxies={storedProxies}
|
||||
vpnConfigs={vpnConfigs}
|
||||
@@ -2632,7 +2674,12 @@ export function ProfilesDataTable({
|
||||
onCopyCookiesToProfile={onCopyCookiesToProfile}
|
||||
onOpenCookieManagement={onOpenCookieManagement}
|
||||
onAssignExtensionGroup={onAssignExtensionGroup}
|
||||
onOpenBypassRules={(profile) => setBypassRulesProfile(profile)}
|
||||
onOpenBypassRules={(profile) => {
|
||||
setBypassRulesProfile(profile);
|
||||
}}
|
||||
onOpenDnsBlocklist={(profile) => {
|
||||
setDnsBlocklistProfile(profile);
|
||||
}}
|
||||
onCloneProfile={onCloneProfile}
|
||||
onLaunchWithSync={onLaunchWithSync}
|
||||
onDeleteProfile={(profile) => {
|
||||
@@ -2700,17 +2747,29 @@ export function ProfilesDataTable({
|
||||
{trafficDialogProfile && (
|
||||
<TrafficDetailsDialog
|
||||
isOpen={trafficDialogProfile !== null}
|
||||
onClose={() => setTrafficDialogProfile(null)}
|
||||
onClose={() => {
|
||||
setTrafficDialogProfile(null);
|
||||
}}
|
||||
profileId={trafficDialogProfile.id}
|
||||
profileName={trafficDialogProfile.name}
|
||||
/>
|
||||
)}
|
||||
<ProfileBypassRulesDialog
|
||||
isOpen={bypassRulesProfile !== null}
|
||||
onClose={() => setBypassRulesProfile(null)}
|
||||
onClose={() => {
|
||||
setBypassRulesProfile(null);
|
||||
}}
|
||||
profileId={bypassRulesProfile?.id ?? null}
|
||||
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
|
||||
/>
|
||||
<ProfileDnsBlocklistDialog
|
||||
isOpen={dnsBlocklistProfile !== null}
|
||||
onClose={() => {
|
||||
setDnsBlocklistProfile(null);
|
||||
}}
|
||||
profileId={dnsBlocklistProfile?.id ?? null}
|
||||
currentLevel={dnsBlocklistProfile?.dns_blocklist ?? null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
LuPuzzle,
|
||||
LuRefreshCw,
|
||||
LuSettings,
|
||||
LuShield,
|
||||
LuShieldCheck,
|
||||
LuTrash2,
|
||||
LuUsers,
|
||||
@@ -64,6 +65,7 @@ interface ProfileInfoDialogProps {
|
||||
onOpenCookieManagement?: (profile: BrowserProfile) => void;
|
||||
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
||||
onOpenBypassRules?: (profile: BrowserProfile) => void;
|
||||
onOpenDnsBlocklist?: (profile: BrowserProfile) => void;
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onDeleteProfile?: (profile: BrowserProfile) => void;
|
||||
onLaunchWithSync?: (profile: BrowserProfile) => void;
|
||||
@@ -110,6 +112,7 @@ export function ProfileInfoDialog({
|
||||
onOpenCookieManagement,
|
||||
onAssignExtensionGroup,
|
||||
onOpenBypassRules,
|
||||
onOpenDnsBlocklist,
|
||||
onCloneProfile,
|
||||
onDeleteProfile,
|
||||
onLaunchWithSync,
|
||||
@@ -131,7 +134,7 @@ export function ProfileInfoDialog({
|
||||
setGroupName(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const groups = await invoke<ProfileGroup[]>("get_groups");
|
||||
const group = groups.find((g) => g.id === profile.group_id);
|
||||
@@ -147,7 +150,7 @@ export function ProfileInfoDialog({
|
||||
setExtensionGroupName(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const group = await invoke<{ name: string } | null>(
|
||||
"get_extension_group_for_profile",
|
||||
@@ -195,7 +198,9 @@ export function ProfileInfoDialog({
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -213,7 +218,7 @@ export function ProfileInfoDialog({
|
||||
const hasNote = !!profile.note;
|
||||
const showCrossOs = isCrossOsProfile(profile);
|
||||
|
||||
type ActionItem = {
|
||||
interface ActionItem {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
@@ -222,34 +227,41 @@ export function ProfileInfoDialog({
|
||||
proBadge?: boolean;
|
||||
runningBadge?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const actions: ActionItem[] = [
|
||||
{
|
||||
icon: <LuGlobe className="w-4 h-4" />,
|
||||
label: t("profiles.actions.viewNetwork"),
|
||||
onClick: () => handleAction(() => onOpenTrafficDialog?.(profile.id)),
|
||||
onClick: () => {
|
||||
handleAction(() => onOpenTrafficDialog?.(profile.id));
|
||||
},
|
||||
disabled: isCrossOs,
|
||||
},
|
||||
{
|
||||
icon: <LuRefreshCw className="w-4 h-4" />,
|
||||
label: t("profiles.actions.syncSettings"),
|
||||
onClick: () => handleAction(() => onOpenProfileSyncDialog?.(profile)),
|
||||
onClick: () => {
|
||||
handleAction(() => onOpenProfileSyncDialog?.(profile));
|
||||
},
|
||||
disabled: isCrossOs,
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
icon: <LuGroup className="w-4 h-4" />,
|
||||
label: t("profiles.actions.assignToGroup"),
|
||||
onClick: () =>
|
||||
handleAction(() => onAssignProfilesToGroup?.([profile.id])),
|
||||
onClick: () => {
|
||||
handleAction(() => onAssignProfilesToGroup?.([profile.id]));
|
||||
},
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
},
|
||||
{
|
||||
icon: <LuFingerprint className="w-4 h-4" />,
|
||||
label: t("profiles.actions.changeFingerprint"),
|
||||
onClick: () => handleAction(() => onConfigureCamoufox?.(profile)),
|
||||
onClick: () => {
|
||||
handleAction(() => onConfigureCamoufox?.(profile));
|
||||
},
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
|
||||
@@ -257,7 +269,9 @@ export function ProfileInfoDialog({
|
||||
{
|
||||
icon: <LuUsers className="w-4 h-4" />,
|
||||
label: t("profiles.synchronizer.launchWithSync"),
|
||||
onClick: () => handleAction(() => onLaunchWithSync?.(profile)),
|
||||
onClick: () => {
|
||||
handleAction(() => onLaunchWithSync?.(profile));
|
||||
},
|
||||
disabled: isDisabled || isRunning || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
|
||||
@@ -265,7 +279,9 @@ export function ProfileInfoDialog({
|
||||
{
|
||||
icon: <LuCopy className="w-4 h-4" />,
|
||||
label: t("profiles.actions.copyCookiesToProfile"),
|
||||
onClick: () => handleAction(() => onCopyCookiesToProfile?.(profile)),
|
||||
onClick: () => {
|
||||
handleAction(() => onCopyCookiesToProfile?.(profile));
|
||||
},
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden:
|
||||
@@ -276,7 +292,9 @@ export function ProfileInfoDialog({
|
||||
{
|
||||
icon: <LuCookie className="w-4 h-4" />,
|
||||
label: t("profileInfo.actions.manageCookies"),
|
||||
onClick: () => handleAction(() => onOpenCookieManagement?.(profile)),
|
||||
onClick: () => {
|
||||
handleAction(() => onOpenCookieManagement?.(profile));
|
||||
},
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden:
|
||||
@@ -287,7 +305,9 @@ export function ProfileInfoDialog({
|
||||
{
|
||||
icon: <LuSettings className="w-4 h-4" />,
|
||||
label: t("profiles.actions.clone"),
|
||||
onClick: () => handleAction(() => onCloneProfile?.(profile)),
|
||||
onClick: () => {
|
||||
handleAction(() => onCloneProfile?.(profile));
|
||||
},
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden: profile.ephemeral === true,
|
||||
@@ -295,21 +315,33 @@ export function ProfileInfoDialog({
|
||||
{
|
||||
icon: <LuPuzzle className="w-4 h-4" />,
|
||||
label: t("profileInfo.actions.assignExtensionGroup"),
|
||||
onClick: () => handleAction(() => onAssignExtensionGroup?.([profile.id])),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning && crossOsUnlocked,
|
||||
onClick: () => {
|
||||
handleAction(() => onAssignExtensionGroup?.([profile.id]));
|
||||
},
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
icon: <LuShieldCheck className="w-4 h-4" />,
|
||||
label: t("profileInfo.network.bypassRulesTitle"),
|
||||
onClick: () => handleAction(() => onOpenBypassRules?.(profile)),
|
||||
onClick: () => {
|
||||
handleAction(() => onOpenBypassRules?.(profile));
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <LuShield className="w-4 h-4" />,
|
||||
label: t("dnsBlocklist.title"),
|
||||
onClick: () => {
|
||||
handleAction(() => onOpenDnsBlocklist?.(profile));
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <LuTrash2 className="w-4 h-4" />,
|
||||
label: t("profiles.actions.delete"),
|
||||
onClick: () => handleAction(() => onDeleteProfile?.(profile)),
|
||||
onClick: () => {
|
||||
handleAction(() => onDeleteProfile?.(profile));
|
||||
},
|
||||
disabled: isDeleteDisabled,
|
||||
destructive: true,
|
||||
},
|
||||
@@ -318,7 +350,12 @@ export function ProfileInfoDialog({
|
||||
const visibleActions = actions.filter((a) => !a.hidden);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
|
||||
@@ -427,6 +464,16 @@ export function ProfileInfoDialog({
|
||||
: t("profileInfo.values.never")
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t("dnsBlocklist.title")}
|
||||
value={
|
||||
profile.dns_blocklist
|
||||
? t(
|
||||
`dnsBlocklist.${profile.dns_blocklist === "pro_plus" ? "proPlus" : profile.dns_blocklist}`,
|
||||
)
|
||||
: t("dnsBlocklist.none")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sync */}
|
||||
@@ -443,7 +490,7 @@ export function ProfileInfoDialog({
|
||||
>
|
||||
{syncMode === "Disabled"
|
||||
? t("sync.mode.disabled")
|
||||
: syncStatus?.status === "syncing"
|
||||
: syncStatus.status === "syncing"
|
||||
? t("common.status.syncing")
|
||||
: t("common.status.synced")}
|
||||
</Badge>
|
||||
@@ -535,6 +582,99 @@ export function ProfileInfoDialog({
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileDnsBlocklistDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileId: string | null;
|
||||
currentLevel: string | null;
|
||||
}
|
||||
|
||||
export function ProfileDnsBlocklistDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileId,
|
||||
currentLevel,
|
||||
}: ProfileDnsBlocklistDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [level, setLevel] = React.useState(currentLevel ?? "");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLevel(currentLevel ?? "");
|
||||
}
|
||||
}, [isOpen, currentLevel]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!profileId) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("update_profile_dns_blocklist", {
|
||||
profileId,
|
||||
dnsBlocklist: level || null,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update DNS blocklist:", err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const options = [
|
||||
{ value: "", label: t("dnsBlocklist.none") },
|
||||
{ value: "light", label: t("dnsBlocklist.light") },
|
||||
{ value: "normal", label: t("dnsBlocklist.normal") },
|
||||
{ value: "pro", label: t("dnsBlocklist.pro") },
|
||||
{ value: "pro_plus", label: t("dnsBlocklist.proPlus") },
|
||||
{ value: "ultimate", label: t("dnsBlocklist.ultimate") },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("dnsBlocklist.settingsDescription")}{" "}
|
||||
<a
|
||||
href="https://github.com/hagezi/dns-blocklists"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("common.buttons.moreInfo")}
|
||||
</a>
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setLevel(option.value)}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
level === option.value
|
||||
? "bg-primary/10 text-primary border border-primary/30"
|
||||
: "hover:bg-accent border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving || level === (currentLevel ?? "")}
|
||||
className="w-full"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileBypassRulesDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -585,7 +725,12 @@ export function ProfileBypassRulesDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("profileInfo.network.bypassRulesTitle")}</DialogTitle>
|
||||
@@ -598,7 +743,9 @@ export function ProfileBypassRulesDialog({
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newRule}
|
||||
onChange={(e) => setNewRule(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setNewRule(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddRule();
|
||||
}}
|
||||
@@ -628,7 +775,9 @@ export function ProfileBypassRulesDialog({
|
||||
<span className="font-mono text-xs truncate">{rule}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRule(rule)}
|
||||
onClick={() => {
|
||||
handleRemoveRule(rule);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<LuX className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ProfileSelectorDialog({
|
||||
const { profiles, runningProfiles: hookRunningProfiles } = useProfileEvents();
|
||||
|
||||
// Use external runningProfiles if provided, otherwise use hook's runningProfiles
|
||||
const runningProfiles = externalRunningProfiles || hookRunningProfiles;
|
||||
const runningProfiles = externalRunningProfiles ?? hookRunningProfiles;
|
||||
|
||||
const { storedProxies } = useProxyEvents();
|
||||
|
||||
@@ -60,9 +60,7 @@ export function ProfileSelectorDialog({
|
||||
const [launchingProfiles, setLaunchingProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [stoppingProfiles, _setStoppingProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [stoppingProfiles] = useState<Set<string>>(new Set());
|
||||
|
||||
// Use shared browser state hook
|
||||
const browserState = useBrowserState(
|
||||
|
||||
@@ -53,7 +53,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
await navigator.clipboard.writeText(exportContent);
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
toast.error("Failed to copy to clipboard");
|
||||
@@ -99,7 +101,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
<Label>Export Format</Label>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => setFormat(value as "json" | "txt")}
|
||||
onValueChange={(value) => {
|
||||
setFormat(value as "json" | "txt");
|
||||
}}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -105,8 +105,8 @@ export function ProxyFormDialog({
|
||||
proxy_type: editingProxy.proxy_settings.proxy_type,
|
||||
host: editingProxy.proxy_settings.host,
|
||||
port: editingProxy.proxy_settings.port,
|
||||
username: editingProxy.proxy_settings.username || "",
|
||||
password: editingProxy.proxy_settings.password || "",
|
||||
username: editingProxy.proxy_settings.username ?? "",
|
||||
password: editingProxy.proxy_settings.password ?? "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -250,7 +250,12 @@ export function ProxyFormDialog({
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{!editingProxy && (
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as ProxyMode)}>
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => {
|
||||
setMode(v as ProxyMode);
|
||||
}}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="regular" className="flex-1">
|
||||
{t("proxies.tabs.regular")}
|
||||
@@ -275,9 +280,9 @@ export function ProxyFormDialog({
|
||||
<Input
|
||||
id="proxy-name"
|
||||
value={regularForm.name}
|
||||
onChange={(e) =>
|
||||
setRegularForm({ ...regularForm, name: e.target.value })
|
||||
}
|
||||
onChange={(e) => {
|
||||
setRegularForm({ ...regularForm, name: e.target.value });
|
||||
}}
|
||||
placeholder="e.g. Office Proxy, Home VPN, etc."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -287,9 +292,9 @@ export function ProxyFormDialog({
|
||||
<Label>{t("proxies.form.type")}</Label>
|
||||
<Select
|
||||
value={regularForm.proxy_type}
|
||||
onValueChange={(value) =>
|
||||
setRegularForm({ ...regularForm, proxy_type: value })
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
setRegularForm({ ...regularForm, proxy_type: value });
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -311,9 +316,9 @@ export function ProxyFormDialog({
|
||||
<Input
|
||||
id="proxy-host"
|
||||
value={regularForm.host}
|
||||
onChange={(e) =>
|
||||
setRegularForm({ ...regularForm, host: e.target.value })
|
||||
}
|
||||
onChange={(e) => {
|
||||
setRegularForm({ ...regularForm, host: e.target.value });
|
||||
}}
|
||||
placeholder={t("proxies.form.hostPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -325,12 +330,12 @@ export function ProxyFormDialog({
|
||||
id="proxy-port"
|
||||
type="number"
|
||||
value={regularForm.port}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
setRegularForm({
|
||||
...regularForm,
|
||||
port: parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
placeholder={t("proxies.form.portPlaceholder")}
|
||||
min="1"
|
||||
max="65535"
|
||||
@@ -348,12 +353,12 @@ export function ProxyFormDialog({
|
||||
<Input
|
||||
id="proxy-username"
|
||||
value={regularForm.username}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
setRegularForm({
|
||||
...regularForm,
|
||||
username: e.target.value,
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
placeholder={t("proxies.form.usernamePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -368,12 +373,12 @@ export function ProxyFormDialog({
|
||||
id="proxy-password"
|
||||
type="password"
|
||||
value={regularForm.password}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
setRegularForm({
|
||||
...regularForm,
|
||||
password: e.target.value,
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
placeholder={t("proxies.form.passwordPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -387,9 +392,9 @@ export function ProxyFormDialog({
|
||||
<Input
|
||||
id="dynamic-name"
|
||||
value={dynamicForm.name}
|
||||
onChange={(e) =>
|
||||
setDynamicForm({ ...dynamicForm, name: e.target.value })
|
||||
}
|
||||
onChange={(e) => {
|
||||
setDynamicForm({ ...dynamicForm, name: e.target.value });
|
||||
}}
|
||||
placeholder="e.g. My Tunnel"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -400,9 +405,9 @@ export function ProxyFormDialog({
|
||||
<Input
|
||||
id="dynamic-url"
|
||||
value={dynamicForm.url}
|
||||
onChange={(e) =>
|
||||
setDynamicForm({ ...dynamicForm, url: e.target.value })
|
||||
}
|
||||
onChange={(e) => {
|
||||
setDynamicForm({ ...dynamicForm, url: e.target.value });
|
||||
}}
|
||||
placeholder={t("proxies.dynamic.urlPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -412,9 +417,9 @@ export function ProxyFormDialog({
|
||||
<Label>{t("proxies.dynamic.format")}</Label>
|
||||
<Select
|
||||
value={dynamicForm.format}
|
||||
onValueChange={(value) =>
|
||||
setDynamicForm({ ...dynamicForm, format: value })
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
setDynamicForm({ ...dynamicForm, format: value });
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
}, []);
|
||||
|
||||
const processContent = useCallback(
|
||||
async (content: string, isJson: boolean, _filename: string = "") => {
|
||||
async (content: string, isJson: boolean, _filename = "") => {
|
||||
try {
|
||||
if (isJson) {
|
||||
setIsImporting(true);
|
||||
@@ -180,7 +180,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
useEffect(() => {
|
||||
if (!isOpen || step !== "dropzone") return;
|
||||
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const text = e.clipboardData?.getData("text");
|
||||
if (text) {
|
||||
// Try to detect if it's JSON
|
||||
@@ -189,7 +189,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
|
||||
// Use "pasted.txt" as filename to trigger content-based detection
|
||||
await processContent(text, isJson, "pasted.txt");
|
||||
void processContent(text, isJson, "pasted.txt");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,7 +339,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
id="name-prefix"
|
||||
placeholder="Imported"
|
||||
value={namePrefix}
|
||||
onChange={(e) => setNamePrefix(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setNamePrefix(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Proxies will be named "{namePrefix || "Imported"} Proxy
|
||||
@@ -408,9 +410,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
type="radio"
|
||||
name={`format-${i}`}
|
||||
checked={proxy.selectedFormat === format}
|
||||
onChange={() =>
|
||||
handleAmbiguousFormatSelect(i, format)
|
||||
}
|
||||
onChange={() => {
|
||||
handleAmbiguousFormatSelect(i, format);
|
||||
}}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-xs">{format}</span>
|
||||
|
||||
@@ -389,7 +389,9 @@ export function ProxyManagementDialog({
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
onClick={() => {
|
||||
setShowImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
@@ -398,7 +400,9 @@ export function ProxyManagementDialog({
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
onClick={() => {
|
||||
setShowExportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
@@ -487,7 +491,7 @@ export function ProxyManagementDialog({
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(proxy)
|
||||
void handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
@@ -542,9 +546,9 @@ export function ProxyManagementDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleEditProxy(proxy)
|
||||
}
|
||||
onClick={() => {
|
||||
handleEditProxy(proxy);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -559,9 +563,9 @@ export function ProxyManagementDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteProxy(proxy)
|
||||
}
|
||||
onClick={() => {
|
||||
handleDeleteProxy(proxy);
|
||||
}}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
}
|
||||
@@ -604,7 +608,9 @@ export function ProxyManagementDialog({
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowVpnImportDialog(true)}
|
||||
onClick={() => {
|
||||
setShowVpnImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
@@ -690,7 +696,7 @@ export function ProxyManagementDialog({
|
||||
<Checkbox
|
||||
checked={vpn.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleVpnSync(vpn)
|
||||
void handleToggleVpnSync(vpn)
|
||||
}
|
||||
disabled={
|
||||
isTogglingVpnSync[vpn.id] ||
|
||||
@@ -728,7 +734,9 @@ export function ProxyManagementDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditVpn(vpn)}
|
||||
onClick={() => {
|
||||
handleEditVpn(vpn);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -743,9 +751,9 @@ export function ProxyManagementDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteVpn(vpn)
|
||||
}
|
||||
onClick={() => {
|
||||
handleDeleteVpn(vpn);
|
||||
}}
|
||||
disabled={
|
||||
(vpnUsage[vpn.id] ?? 0) > 0
|
||||
}
|
||||
@@ -796,7 +804,9 @@ export function ProxyManagementDialog({
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={proxyToDelete !== null}
|
||||
onClose={() => setProxyToDelete(null)}
|
||||
onClose={() => {
|
||||
setProxyToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Proxy"
|
||||
description={`This action cannot be undone. This will permanently delete the proxy "${proxyToDelete?.name ?? ""}".`}
|
||||
@@ -805,11 +815,15 @@ export function ProxyManagementDialog({
|
||||
/>
|
||||
<ProxyImportDialog
|
||||
isOpen={showImportDialog}
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
onClose={() => {
|
||||
setShowImportDialog(false);
|
||||
}}
|
||||
/>
|
||||
<ProxyExportDialog
|
||||
isOpen={showExportDialog}
|
||||
onClose={() => setShowExportDialog(false)}
|
||||
onClose={() => {
|
||||
setShowExportDialog(false);
|
||||
}}
|
||||
/>
|
||||
<VpnFormDialog
|
||||
isOpen={showVpnForm}
|
||||
@@ -818,7 +832,9 @@ export function ProxyManagementDialog({
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={vpnToDelete !== null}
|
||||
onClose={() => setVpnToDelete(null)}
|
||||
onClose={() => {
|
||||
setVpnToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDeleteVpn}
|
||||
title="Delete VPN"
|
||||
description={`This action cannot be undone. This will permanently delete the VPN "${vpnToDelete?.name ?? ""}".`}
|
||||
@@ -827,7 +843,9 @@ export function ProxyManagementDialog({
|
||||
/>
|
||||
<VpnImportDialog
|
||||
isOpen={showVpnImportDialog}
|
||||
onClose={() => setShowVpnImportDialog(false)}
|
||||
onClose={() => {
|
||||
setShowVpnImportDialog(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+592
-511
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,7 @@ function ObjectEditor({
|
||||
const [jsonString, setJsonString] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setJsonString(JSON.stringify(value || {}, null, 2));
|
||||
setJsonString(JSON.stringify(value ?? {}, null, 2));
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
@@ -108,7 +108,9 @@ function ObjectEditor({
|
||||
<Label>{title}</Label>
|
||||
<Textarea
|
||||
value={jsonString}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleChange(e.target.value);
|
||||
}}
|
||||
placeholder={`Enter ${title} as JSON`}
|
||||
className="font-mono text-sm"
|
||||
rows={6}
|
||||
@@ -142,7 +144,7 @@ export function SharedCamoufoxConfigForm({
|
||||
|
||||
const handleGenerateFingerprint = async () => {
|
||||
if (!profileVersion) return;
|
||||
const browser = profileBrowser || browserType || "camoufox";
|
||||
const browser = profileBrowser ?? browserType ?? "camoufox";
|
||||
setIsGeneratingFingerprint(true);
|
||||
try {
|
||||
const configJson = JSON.stringify(config);
|
||||
@@ -267,7 +269,9 @@ export function SharedCamoufoxConfigForm({
|
||||
</div>
|
||||
<Select
|
||||
value={selectedOS}
|
||||
onValueChange={(value: CamoufoxOS) => onConfigChange("os", value)}
|
||||
onValueChange={(value: CamoufoxOS) => {
|
||||
onConfigChange("os", value);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -301,10 +305,10 @@ export function SharedCamoufoxConfigForm({
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="randomize-fingerprint"
|
||||
checked={config.randomize_fingerprint_on_launch || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange("randomize_fingerprint_on_launch", checked)
|
||||
}
|
||||
checked={config.randomize_fingerprint_on_launch ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
onConfigChange("randomize_fingerprint_on_launch", checked);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Label htmlFor="randomize-fingerprint" className="font-medium">
|
||||
@@ -365,10 +369,10 @@ export function SharedCamoufoxConfigForm({
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="block-images"
|
||||
checked={config.block_images || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange("block_images", checked)
|
||||
}
|
||||
checked={config.block_images ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
onConfigChange("block_images", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="block-images">
|
||||
{t("fingerprint.blockImages")}
|
||||
@@ -377,10 +381,10 @@ export function SharedCamoufoxConfigForm({
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="block-webrtc"
|
||||
checked={config.block_webrtc || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange("block_webrtc", checked)
|
||||
}
|
||||
checked={config.block_webrtc ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
onConfigChange("block_webrtc", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="block-webrtc">
|
||||
{t("fingerprint.blockWebRTC")}
|
||||
@@ -389,10 +393,10 @@ export function SharedCamoufoxConfigForm({
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="block-webgl"
|
||||
checked={config.block_webgl || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange("block_webgl", checked)
|
||||
}
|
||||
checked={config.block_webgl ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
onConfigChange("block_webgl", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="block-webgl">
|
||||
{t("fingerprint.blockWebGL")}
|
||||
@@ -410,13 +414,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Label htmlFor="user-agent">{t("fingerprint.userAgent")}</Label>
|
||||
<Input
|
||||
id="user-agent"
|
||||
value={fingerprintConfig["navigator.userAgent"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["navigator.userAgent"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"navigator.userAgent",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="Mozilla/5.0..."
|
||||
/>
|
||||
</div>
|
||||
@@ -424,13 +428,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Label htmlFor="platform">{t("fingerprint.platform")}</Label>
|
||||
<Input
|
||||
id="platform"
|
||||
value={fingerprintConfig["navigator.platform"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["navigator.platform"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"navigator.platform",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., MacIntel, Win32"
|
||||
/>
|
||||
</div>
|
||||
@@ -440,13 +444,13 @@ export function SharedCamoufoxConfigForm({
|
||||
</Label>
|
||||
<Input
|
||||
id="app-version"
|
||||
value={fingerprintConfig["navigator.appVersion"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["navigator.appVersion"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"navigator.appVersion",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 5.0 (Macintosh)"
|
||||
/>
|
||||
</div>
|
||||
@@ -454,13 +458,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Label htmlFor="oscpu">{t("fingerprint.osCpu")}</Label>
|
||||
<Input
|
||||
id="oscpu"
|
||||
value={fingerprintConfig["navigator.oscpu"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["navigator.oscpu"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"navigator.oscpu",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Intel Mac OS X 10.15"
|
||||
/>
|
||||
</div>
|
||||
@@ -472,14 +476,14 @@ export function SharedCamoufoxConfigForm({
|
||||
id="hardware-concurrency"
|
||||
type="number"
|
||||
value={
|
||||
fingerprintConfig["navigator.hardwareConcurrency"] || ""
|
||||
fingerprintConfig["navigator.hardwareConcurrency"] ?? ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"navigator.hardwareConcurrency",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 8"
|
||||
/>
|
||||
</div>
|
||||
@@ -490,13 +494,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="max-touch-points"
|
||||
type="number"
|
||||
value={fingerprintConfig["navigator.maxTouchPoints"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["navigator.maxTouchPoints"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"navigator.maxTouchPoints",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 0"
|
||||
/>
|
||||
</div>
|
||||
@@ -505,13 +509,13 @@ export function SharedCamoufoxConfigForm({
|
||||
{t("fingerprint.doNotTrack")}
|
||||
</Label>
|
||||
<Select
|
||||
value={fingerprintConfig["navigator.doNotTrack"] || ""}
|
||||
onValueChange={(value) =>
|
||||
value={fingerprintConfig["navigator.doNotTrack"] ?? ""}
|
||||
onValueChange={(value) => {
|
||||
updateFingerprintConfig(
|
||||
"navigator.doNotTrack",
|
||||
value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
@@ -535,13 +539,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Label htmlFor="language">{t("fingerprint.language")}</Label>
|
||||
<Input
|
||||
id="language"
|
||||
value={fingerprintConfig["navigator.language"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["navigator.language"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"navigator.language",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., en-US"
|
||||
/>
|
||||
</div>
|
||||
@@ -559,13 +563,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="screen-width"
|
||||
type="number"
|
||||
value={fingerprintConfig["screen.width"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["screen.width"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"screen.width",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 1920"
|
||||
/>
|
||||
</div>
|
||||
@@ -576,13 +580,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="screen-height"
|
||||
type="number"
|
||||
value={fingerprintConfig["screen.height"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["screen.height"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"screen.height",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 1080"
|
||||
/>
|
||||
</div>
|
||||
@@ -593,13 +597,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="avail-width"
|
||||
type="number"
|
||||
value={fingerprintConfig["screen.availWidth"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["screen.availWidth"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"screen.availWidth",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 1920"
|
||||
/>
|
||||
</div>
|
||||
@@ -610,13 +614,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="avail-height"
|
||||
type="number"
|
||||
value={fingerprintConfig["screen.availHeight"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["screen.availHeight"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"screen.availHeight",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 1055"
|
||||
/>
|
||||
</div>
|
||||
@@ -627,13 +631,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="color-depth"
|
||||
type="number"
|
||||
value={fingerprintConfig["screen.colorDepth"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["screen.colorDepth"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"screen.colorDepth",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 30"
|
||||
/>
|
||||
</div>
|
||||
@@ -644,13 +648,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="pixel-depth"
|
||||
type="number"
|
||||
value={fingerprintConfig["screen.pixelDepth"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["screen.pixelDepth"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"screen.pixelDepth",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 30"
|
||||
/>
|
||||
</div>
|
||||
@@ -668,13 +672,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="outer-width"
|
||||
type="number"
|
||||
value={fingerprintConfig["window.outerWidth"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["window.outerWidth"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"window.outerWidth",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 1512"
|
||||
/>
|
||||
</div>
|
||||
@@ -685,13 +689,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="outer-height"
|
||||
type="number"
|
||||
value={fingerprintConfig["window.outerHeight"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["window.outerHeight"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"window.outerHeight",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 886"
|
||||
/>
|
||||
</div>
|
||||
@@ -702,13 +706,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="inner-width"
|
||||
type="number"
|
||||
value={fingerprintConfig["window.innerWidth"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["window.innerWidth"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"window.innerWidth",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 1512"
|
||||
/>
|
||||
</div>
|
||||
@@ -719,13 +723,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="inner-height"
|
||||
type="number"
|
||||
value={fingerprintConfig["window.innerHeight"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["window.innerHeight"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"window.innerHeight",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 886"
|
||||
/>
|
||||
</div>
|
||||
@@ -734,13 +738,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="screen-x"
|
||||
type="number"
|
||||
value={fingerprintConfig["window.screenX"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["window.screenX"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"window.screenX",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 0"
|
||||
/>
|
||||
</div>
|
||||
@@ -749,13 +753,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="screen-y"
|
||||
type="number"
|
||||
value={fingerprintConfig["window.screenY"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["window.screenY"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"window.screenY",
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 0"
|
||||
/>
|
||||
</div>
|
||||
@@ -772,13 +776,13 @@ export function SharedCamoufoxConfigForm({
|
||||
id="latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={fingerprintConfig["geolocation:latitude"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["geolocation:latitude"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"geolocation:latitude",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 41.0019"
|
||||
/>
|
||||
</div>
|
||||
@@ -788,13 +792,13 @@ export function SharedCamoufoxConfigForm({
|
||||
id="longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={fingerprintConfig["geolocation:longitude"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["geolocation:longitude"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"geolocation:longitude",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 28.9645"
|
||||
/>
|
||||
</div>
|
||||
@@ -803,13 +807,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="timezone"
|
||||
type="text"
|
||||
value={fingerprintConfig.timezone || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig.timezone ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"timezone",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., America/New_York"
|
||||
/>
|
||||
</div>
|
||||
@@ -826,13 +830,13 @@ export function SharedCamoufoxConfigForm({
|
||||
</Label>
|
||||
<Input
|
||||
id="locale-language"
|
||||
value={fingerprintConfig["locale:language"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["locale:language"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"locale:language",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., tr"
|
||||
/>
|
||||
</div>
|
||||
@@ -840,13 +844,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Label htmlFor="locale-region">{t("fingerprint.region")}</Label>
|
||||
<Input
|
||||
id="locale-region"
|
||||
value={fingerprintConfig["locale:region"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["locale:region"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"locale:region",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., TR"
|
||||
/>
|
||||
</div>
|
||||
@@ -854,13 +858,13 @@ export function SharedCamoufoxConfigForm({
|
||||
<Label htmlFor="locale-script">{t("fingerprint.script")}</Label>
|
||||
<Input
|
||||
id="locale-script"
|
||||
value={fingerprintConfig["locale:script"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["locale:script"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"locale:script",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Latn"
|
||||
/>
|
||||
</div>
|
||||
@@ -877,13 +881,13 @@ export function SharedCamoufoxConfigForm({
|
||||
</Label>
|
||||
<Input
|
||||
id="webgl-vendor"
|
||||
value={fingerprintConfig["webGl:vendor"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["webGl:vendor"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"webGl:vendor",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Mesa"
|
||||
/>
|
||||
</div>
|
||||
@@ -893,13 +897,13 @@ export function SharedCamoufoxConfigForm({
|
||||
</Label>
|
||||
<Input
|
||||
id="webgl-renderer"
|
||||
value={fingerprintConfig["webGl:renderer"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["webGl:renderer"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"webGl:renderer",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., llvmpipe, or similar"
|
||||
/>
|
||||
</div>
|
||||
@@ -913,11 +917,11 @@ export function SharedCamoufoxConfigForm({
|
||||
(fingerprintConfig["webGl:parameters"] as Record<
|
||||
string,
|
||||
unknown
|
||||
>) || {}
|
||||
}
|
||||
onChange={(value) =>
|
||||
updateFingerprintConfig("webGl:parameters", value)
|
||||
>) ?? {}
|
||||
}
|
||||
onChange={(value) => {
|
||||
updateFingerprintConfig("webGl:parameters", value);
|
||||
}}
|
||||
title={t("fingerprint.webglParameters")}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
@@ -930,11 +934,11 @@ export function SharedCamoufoxConfigForm({
|
||||
(fingerprintConfig["webGl2:parameters"] as Record<
|
||||
string,
|
||||
unknown
|
||||
>) || {}
|
||||
}
|
||||
onChange={(value) =>
|
||||
updateFingerprintConfig("webGl2:parameters", value)
|
||||
>) ?? {}
|
||||
}
|
||||
onChange={(value) => {
|
||||
updateFingerprintConfig("webGl2:parameters", value);
|
||||
}}
|
||||
title={t("fingerprint.webgl2Parameters")}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
@@ -947,11 +951,11 @@ export function SharedCamoufoxConfigForm({
|
||||
(fingerprintConfig["webGl:shaderPrecisionFormats"] as Record<
|
||||
string,
|
||||
unknown
|
||||
>) || {}
|
||||
}
|
||||
onChange={(value) =>
|
||||
updateFingerprintConfig("webGl:shaderPrecisionFormats", value)
|
||||
>) ?? {}
|
||||
}
|
||||
onChange={(value) => {
|
||||
updateFingerprintConfig("webGl:shaderPrecisionFormats", value);
|
||||
}}
|
||||
title={t("fingerprint.webglShaderPrecisionFormats")}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
@@ -964,11 +968,11 @@ export function SharedCamoufoxConfigForm({
|
||||
(fingerprintConfig["webGl2:shaderPrecisionFormats"] as Record<
|
||||
string,
|
||||
unknown
|
||||
>) || {}
|
||||
}
|
||||
onChange={(value) =>
|
||||
updateFingerprintConfig("webGl2:shaderPrecisionFormats", value)
|
||||
>) ?? {}
|
||||
}
|
||||
onChange={(value) => {
|
||||
updateFingerprintConfig("webGl2:shaderPrecisionFormats", value);
|
||||
}}
|
||||
title={t("fingerprint.webgl2ShaderPrecisionFormats")}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
@@ -1000,12 +1004,12 @@ export function SharedCamoufoxConfigForm({
|
||||
value: font,
|
||||
}));
|
||||
})()}
|
||||
onChange={(selected: Option[]) =>
|
||||
onChange={(selected: Option[]) => {
|
||||
updateFingerprintConfig(
|
||||
"fonts",
|
||||
selected.map((s: Option) => s.value),
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="Add fonts..."
|
||||
creatable
|
||||
/>
|
||||
@@ -1019,10 +1023,10 @@ export function SharedCamoufoxConfigForm({
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="battery-charging"
|
||||
checked={fingerprintConfig["battery:charging"] || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFingerprintConfig("battery:charging", checked)
|
||||
}
|
||||
checked={fingerprintConfig["battery:charging"] ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
updateFingerprintConfig("battery:charging", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="battery-charging">
|
||||
{t("fingerprint.charging")}
|
||||
@@ -1037,13 +1041,13 @@ export function SharedCamoufoxConfigForm({
|
||||
id="charging-time"
|
||||
type="number"
|
||||
step="any"
|
||||
value={fingerprintConfig["battery:chargingTime"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["battery:chargingTime"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"battery:chargingTime",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 0"
|
||||
/>
|
||||
</div>
|
||||
@@ -1055,13 +1059,13 @@ export function SharedCamoufoxConfigForm({
|
||||
id="discharging-time"
|
||||
type="number"
|
||||
step="any"
|
||||
value={fingerprintConfig["battery:dischargingTime"] || ""}
|
||||
onChange={(e) =>
|
||||
value={fingerprintConfig["battery:dischargingTime"] ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"battery:dischargingTime",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 0"
|
||||
/>
|
||||
</div>
|
||||
@@ -1132,9 +1136,9 @@ export function SharedCamoufoxConfigForm({
|
||||
<Label>{t("fingerprint.osLabel")}</Label>
|
||||
<Select
|
||||
value={selectedOS}
|
||||
onValueChange={(value: CamoufoxOS) =>
|
||||
onConfigChange("os", value)
|
||||
}
|
||||
onValueChange={(value: CamoufoxOS) => {
|
||||
onConfigChange("os", value);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -1170,10 +1174,10 @@ export function SharedCamoufoxConfigForm({
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="randomize-fingerprint-auto"
|
||||
checked={config.randomize_fingerprint_on_launch || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange("randomize_fingerprint_on_launch", checked)
|
||||
}
|
||||
checked={config.randomize_fingerprint_on_launch ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
onConfigChange("randomize_fingerprint_on_launch", checked);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Label
|
||||
@@ -1222,15 +1226,15 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="screen-max-width"
|
||||
type="number"
|
||||
value={config.screen_max_width || ""}
|
||||
onChange={(e) =>
|
||||
value={config.screen_max_width ?? ""}
|
||||
onChange={(e) => {
|
||||
onConfigChange(
|
||||
"screen_max_width",
|
||||
e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 1920"
|
||||
/>
|
||||
</div>
|
||||
@@ -1241,15 +1245,15 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="screen-max-height"
|
||||
type="number"
|
||||
value={config.screen_max_height || ""}
|
||||
onChange={(e) =>
|
||||
value={config.screen_max_height ?? ""}
|
||||
onChange={(e) => {
|
||||
onConfigChange(
|
||||
"screen_max_height",
|
||||
e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 1080"
|
||||
/>
|
||||
</div>
|
||||
@@ -1260,15 +1264,15 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="screen-min-width"
|
||||
type="number"
|
||||
value={config.screen_min_width || ""}
|
||||
onChange={(e) =>
|
||||
value={config.screen_min_width ?? ""}
|
||||
onChange={(e) => {
|
||||
onConfigChange(
|
||||
"screen_min_width",
|
||||
e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 800"
|
||||
/>
|
||||
</div>
|
||||
@@ -1279,15 +1283,15 @@ export function SharedCamoufoxConfigForm({
|
||||
<Input
|
||||
id="screen-min-height"
|
||||
type="number"
|
||||
value={config.screen_min_height || ""}
|
||||
onChange={(e) =>
|
||||
value={config.screen_min_height ?? ""}
|
||||
onChange={(e) => {
|
||||
onConfigChange(
|
||||
"screen_min_height",
|
||||
e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,9 +67,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("cloud");
|
||||
const [_liveProxyUsage, setLiveProxyUsage] = useState<ProxyUsage | null>(
|
||||
null,
|
||||
);
|
||||
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
"unknown" | "testing" | "connected" | "error"
|
||||
@@ -91,8 +89,8 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
setServerUrl(settings.sync_server_url || "");
|
||||
setToken(settings.sync_token || "");
|
||||
setServerUrl(settings.sync_server_url ?? "");
|
||||
setToken(settings.sync_token ?? "");
|
||||
if (settings.sync_server_url && settings.sync_token) {
|
||||
void testConnection(settings.sync_server_url);
|
||||
}
|
||||
@@ -110,9 +108,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setCodeSent(false);
|
||||
setOtpCode("");
|
||||
setEmail("");
|
||||
invoke<ProxyUsage | null>("cloud_get_proxy_usage")
|
||||
void invoke<ProxyUsage | null>("cloud_get_proxy_usage")
|
||||
.then(setLiveProxyUsage)
|
||||
.catch(() => setLiveProxyUsage(null));
|
||||
.catch(() => {
|
||||
setLiveProxyUsage(null);
|
||||
});
|
||||
}
|
||||
}, [isOpen, loadSettings]);
|
||||
|
||||
@@ -342,7 +342,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleCloudLogout}
|
||||
onClick={() => void handleCloudLogout()}
|
||||
>
|
||||
{t("sync.cloud.logout")}
|
||||
</Button>
|
||||
@@ -388,7 +388,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
type="email"
|
||||
placeholder={t("sync.cloud.emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !codeSent) {
|
||||
void handleSendCode();
|
||||
@@ -396,7 +398,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
}}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={handleSendCode}
|
||||
onClick={() => void handleSendCode()}
|
||||
isLoading={isSendingCode}
|
||||
disabled={!email || codeSent}
|
||||
variant="outline"
|
||||
@@ -415,7 +417,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
id="cloud-otp"
|
||||
placeholder={t("sync.cloud.codePlaceholder")}
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setOtpCode(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleVerifyOtp();
|
||||
@@ -423,7 +427,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
}}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={handleVerifyOtp}
|
||||
onClick={() => void handleVerifyOtp()}
|
||||
isLoading={isVerifying}
|
||||
disabled={!otpCode}
|
||||
className="w-full"
|
||||
@@ -453,7 +457,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
id="sync-server-url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -465,14 +471,18 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
}}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
onClick={() => {
|
||||
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"}
|
||||
>
|
||||
@@ -515,7 +525,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
{hasConfig && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
onClick={() => void handleDisconnect()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Disconnect
|
||||
@@ -523,13 +533,13 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
onClick={() => void handleTestConnection()}
|
||||
disabled={isTesting || !serverUrl}
|
||||
>
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
isLoading={isSaving}
|
||||
disabled={!serverUrl || !token}
|
||||
>
|
||||
|
||||
@@ -156,21 +156,23 @@ export function SyncFollowerDialog({
|
||||
<div
|
||||
key={profile.id}
|
||||
className="flex items-center gap-3 p-2 rounded-md hover:bg-accent cursor-pointer"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
handleToggle(
|
||||
profile.id,
|
||||
!selectedIds.has(profile.id),
|
||||
)
|
||||
}
|
||||
onKeyDown={() => {}}
|
||||
);
|
||||
}}
|
||||
onKeyDown={() => {
|
||||
/* empty */
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(profile.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(profile.id, checked === true)
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
handleToggle(profile.id, checked === true);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm truncate flex-1">
|
||||
{profile.name}
|
||||
@@ -203,7 +205,9 @@ export function SyncFollowerDialog({
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { applyThemeColors, clearThemeColors } from "@/lib/themes";
|
||||
|
||||
interface AppSettings {
|
||||
@@ -10,43 +16,62 @@ interface AppSettings {
|
||||
custom_theme?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: "system",
|
||||
setTheme: () => {},
|
||||
});
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
interface CustomThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function resolveSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function applyClassToHtml(theme: string) {
|
||||
const resolved = theme === "system" ? resolveSystemTheme() : theme;
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(resolved);
|
||||
}
|
||||
|
||||
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [defaultTheme, setDefaultTheme] = useState<string>("system");
|
||||
const [_mounted, setMounted] = useState(false);
|
||||
const [theme, setThemeState] = useState("system");
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const setTheme = useCallback((newTheme: string) => {
|
||||
setThemeState(newTheme);
|
||||
if (newTheme === "custom") {
|
||||
applyClassToHtml("dark");
|
||||
} else {
|
||||
applyClassToHtml(newTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load initial theme from Tauri settings
|
||||
useEffect(() => {
|
||||
const loadTheme = async () => {
|
||||
try {
|
||||
// Lazy import to avoid pulling Tauri API on SSR
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
const themeValue = settings?.theme ?? "system";
|
||||
|
||||
console.log("[theme-provider] Loaded settings:", {
|
||||
theme: themeValue,
|
||||
hasCustomTheme: !!settings?.custom_theme,
|
||||
customThemeKeys: settings?.custom_theme
|
||||
? Object.keys(settings.custom_theme).length
|
||||
: 0,
|
||||
});
|
||||
|
||||
if (
|
||||
themeValue === "light" ||
|
||||
themeValue === "dark" ||
|
||||
themeValue === "system"
|
||||
) {
|
||||
setDefaultTheme(themeValue);
|
||||
} else if (themeValue === "custom") {
|
||||
setDefaultTheme("dark");
|
||||
if (themeValue === "custom") {
|
||||
setThemeState("custom");
|
||||
applyClassToHtml("dark");
|
||||
if (
|
||||
settings.custom_theme &&
|
||||
Object.keys(settings.custom_theme).length > 0
|
||||
@@ -57,16 +82,22 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
console.warn("Failed to apply custom theme variables:", error);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
themeValue === "light" ||
|
||||
themeValue === "dark" ||
|
||||
themeValue === "system"
|
||||
) {
|
||||
setThemeState(themeValue);
|
||||
applyClassToHtml(themeValue);
|
||||
} else {
|
||||
setDefaultTheme("system");
|
||||
applyClassToHtml("system");
|
||||
}
|
||||
} catch (error) {
|
||||
// Failed to load settings; fall back to system (handled by next-themes)
|
||||
console.warn(
|
||||
"Failed to load theme settings; defaulting to system:",
|
||||
error,
|
||||
);
|
||||
setDefaultTheme("system");
|
||||
applyClassToHtml("system");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -75,42 +106,44 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
void loadTheme();
|
||||
}, []);
|
||||
|
||||
// Additional effect to ensure custom theme is applied after mount
|
||||
// Re-apply custom theme after mount
|
||||
useEffect(() => {
|
||||
if (!isLoading && _mounted) {
|
||||
if (!isLoading && theme === "custom") {
|
||||
const reapplyCustomTheme = async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
|
||||
if (settings?.theme === "custom" && settings.custom_theme) {
|
||||
applyThemeColors(settings.custom_theme);
|
||||
} else {
|
||||
clearThemeColors();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to reapply custom theme:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply after a short delay to ensure CSS has loaded
|
||||
setTimeout(reapplyCustomTheme, 100);
|
||||
setTimeout(() => {
|
||||
void reapplyCustomTheme();
|
||||
}, 100);
|
||||
} else if (!isLoading) {
|
||||
clearThemeColors();
|
||||
}
|
||||
}, [isLoading, _mounted]);
|
||||
}, [isLoading, theme]);
|
||||
|
||||
// Listen for system theme changes when in "system" mode
|
||||
useEffect(() => {
|
||||
if (theme !== "system") return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => applyClassToHtml("system");
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [theme]);
|
||||
|
||||
const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
|
||||
|
||||
if (isLoading) {
|
||||
// Keep UI simple during initial settings load to avoid flicker
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme={defaultTheme}
|
||||
enableSystem={true}
|
||||
disableTransitionOnChange={false}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +173,9 @@ export function TrafficDetailsDialog({
|
||||
};
|
||||
|
||||
void fetchStats();
|
||||
const interval = setInterval(fetchStats, 2000);
|
||||
const interval = setInterval(() => {
|
||||
void fetchStats();
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
@@ -244,7 +246,12 @@ export function TrafficDetailsDialog({
|
||||
}, [stats]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -265,7 +272,9 @@ export function TrafficDetailsDialog({
|
||||
<h3 className="text-sm font-medium">Bandwidth Over Time</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => setTimePeriod(v as TimePeriod)}
|
||||
onValueChange={(v) => {
|
||||
setTimePeriod(v as TimePeriod);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] h-8">
|
||||
<SelectValue placeholder="Time period" />
|
||||
@@ -286,7 +295,12 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
|
||||
<div className="h-[200px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
|
||||
@@ -400,7 +414,7 @@ export function TrafficDetailsDialog({
|
||||
Sent ({timePeriod === "all" ? "total" : timePeriod})
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-1">
|
||||
{formatBytes(stats?.period_bytes_sent || 0)}
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
@@ -408,7 +422,7 @@ export function TrafficDetailsDialog({
|
||||
Received ({timePeriod === "all" ? "total" : timePeriod})
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-2">
|
||||
{formatBytes(stats?.period_bytes_received || 0)}
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
@@ -416,7 +430,7 @@ export function TrafficDetailsDialog({
|
||||
Requests ({timePeriod === "all" ? "total" : timePeriod})
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests || 0).toLocaleString()}
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -426,13 +440,13 @@ export function TrafficDetailsDialog({
|
||||
<div>
|
||||
<span className="font-medium">All-time traffic:</span>{" "}
|
||||
{formatBytes(
|
||||
(stats?.total_bytes_sent || 0) +
|
||||
(stats?.total_bytes_received || 0),
|
||||
(stats?.total_bytes_sent ?? 0) +
|
||||
(stats?.total_bytes_received ?? 0),
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">All-time requests:</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() || 0}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ const ChartContainer = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
<RechartsPrimitive.ResponsiveContainer minWidth={1} minHeight={1}>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -214,7 +214,9 @@ export const ColorPickerSelection = memo(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => setIsDragging(false);
|
||||
const handlePointerUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
@@ -268,7 +270,9 @@ export const ColorPickerHue = ({
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
max={360}
|
||||
onValueChange={([hue]) => setHue(hue)}
|
||||
onValueChange={([hue]) => {
|
||||
setHue(hue);
|
||||
}}
|
||||
step={1}
|
||||
value={[hue]}
|
||||
{...props}
|
||||
@@ -293,7 +297,9 @@ export const ColorPickerAlpha = ({
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
max={100}
|
||||
onValueChange={([alpha]) => setAlpha(alpha)}
|
||||
onValueChange={([alpha]) => {
|
||||
setAlpha(alpha);
|
||||
}}
|
||||
step={1}
|
||||
value={[alpha]}
|
||||
{...props}
|
||||
@@ -357,7 +363,7 @@ export type ColorPickerOutputProps = ComponentProps<typeof SelectTrigger>;
|
||||
const formats = ["hex", "rgb", "css", "hsl"];
|
||||
|
||||
export const ColorPickerOutput = ({
|
||||
className,
|
||||
className: _className,
|
||||
...props
|
||||
}: ColorPickerOutputProps) => {
|
||||
const { mode, setMode } = useColorPicker();
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CopyToClipboard({
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={`relative ${className || ""}`}
|
||||
className={`relative ${className ?? ""}`}
|
||||
onClick={copyToClipboard}
|
||||
aria-label={copied ? "Copied" : "Copy to clipboard"}
|
||||
>
|
||||
|
||||
@@ -7,12 +7,12 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type HighlightMode = "children" | "parent";
|
||||
|
||||
type Bounds = {
|
||||
interface Bounds {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_BOUNDS_OFFSET: Bounds = {
|
||||
top: 0,
|
||||
@@ -21,7 +21,7 @@ const DEFAULT_BOUNDS_OFFSET: Bounds = {
|
||||
height: 0,
|
||||
};
|
||||
|
||||
type HighlightContextType<T extends string> = {
|
||||
interface HighlightContextType<T extends string> {
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
mode: HighlightMode;
|
||||
activeValue: T | null;
|
||||
@@ -40,11 +40,10 @@ type HighlightContextType<T extends string> = {
|
||||
enabled?: boolean;
|
||||
exitDelay?: number;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const HighlightContext = React.createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
HighlightContextType<any> | undefined
|
||||
HighlightContextType<string> | undefined
|
||||
>(undefined);
|
||||
|
||||
function useHighlight<T extends string>(): HighlightContextType<T> {
|
||||
@@ -55,7 +54,7 @@ function useHighlight<T extends string>(): HighlightContextType<T> {
|
||||
return context as unknown as HighlightContextType<T>;
|
||||
}
|
||||
|
||||
type BaseHighlightProps<T extends React.ElementType = "div"> = {
|
||||
interface BaseHighlightProps<T extends React.ElementType = "div"> {
|
||||
as?: T;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
mode?: HighlightMode;
|
||||
@@ -70,13 +69,13 @@ type BaseHighlightProps<T extends React.ElementType = "div"> = {
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
exitDelay?: number;
|
||||
};
|
||||
}
|
||||
|
||||
type ParentModeHighlightProps = {
|
||||
interface ParentModeHighlightProps {
|
||||
boundsOffset?: Partial<Bounds>;
|
||||
containerClassName?: string;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type ControlledParentModeHighlightProps<T extends React.ElementType = "div"> =
|
||||
BaseHighlightProps<T> &
|
||||
@@ -142,7 +141,7 @@ function Highlight<T extends React.ElementType = "div">({
|
||||
const localRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
||||
|
||||
const propsBoundsOffset = (props as ParentModeHighlightProps)?.boundsOffset;
|
||||
const propsBoundsOffset = (props as ParentModeHighlightProps).boundsOffset;
|
||||
const boundsOffset = propsBoundsOffset ?? DEFAULT_BOUNDS_OFFSET;
|
||||
const boundsOffsetTop = boundsOffset.top ?? 0;
|
||||
const boundsOffsetLeft = boundsOffset.left ?? 0;
|
||||
@@ -249,7 +248,9 @@ function Highlight<T extends React.ElementType = "div">({
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [mode, activeValue]);
|
||||
|
||||
const render = (children: React.ReactNode) => {
|
||||
@@ -259,7 +260,7 @@ function Highlight<T extends React.ElementType = "div">({
|
||||
ref={localRef}
|
||||
data-slot="motion-highlight-container"
|
||||
style={{ position: "relative", zIndex: 1 }}
|
||||
className={(props as ParentModeHighlightProps)?.containerClassName}
|
||||
className={(props as ParentModeHighlightProps).containerClassName}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{boundsState && (
|
||||
@@ -320,7 +321,7 @@ function Highlight<T extends React.ElementType = "div">({
|
||||
activeClassName: activeClassNameState,
|
||||
setActiveClassName: setActiveClassNameState,
|
||||
forceUpdateBounds: (props as ParentModeHighlightProps)
|
||||
?.forceUpdateBounds,
|
||||
.forceUpdateBounds,
|
||||
}}
|
||||
>
|
||||
{enabled
|
||||
@@ -328,7 +329,7 @@ function Highlight<T extends React.ElementType = "div">({
|
||||
? render(children)
|
||||
: render(
|
||||
React.Children.map(children, (child, index) => (
|
||||
<HighlightItem key={index} className={props?.itemsClassName}>
|
||||
<HighlightItem key={index} className={props.itemsClassName}>
|
||||
{child}
|
||||
</HighlightItem>
|
||||
)),
|
||||
@@ -418,7 +419,7 @@ function HighlightItem<T extends React.ElementType>({
|
||||
const Component = as ?? "div";
|
||||
const element = children as React.ReactElement<ExtendedChildProps>;
|
||||
const childValue =
|
||||
id ?? value ?? element.props?.["data-value"] ?? element.props?.id ?? itemId;
|
||||
id ?? value ?? element.props["data-value"] ?? element.props.id ?? itemId;
|
||||
const isActive = activeValue === childValue;
|
||||
const isDisabled = disabled === undefined ? contextDisabled : disabled;
|
||||
const itemTransition = transition ?? contextTransition;
|
||||
@@ -466,7 +467,10 @@ function HighlightItem<T extends React.ElementType>({
|
||||
setActiveClassName(activeClassName ?? "");
|
||||
} else if (!activeValue) clearBounds();
|
||||
|
||||
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId);
|
||||
if (shouldUpdateBounds)
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [
|
||||
mode,
|
||||
isActive,
|
||||
|
||||
@@ -50,11 +50,11 @@ const rippleVariants = cva("absolute rounded-full size-5 pointer-events-none", {
|
||||
},
|
||||
});
|
||||
|
||||
type Ripple = {
|
||||
interface Ripple {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
type RippleButtonProps = HTMLMotionProps<"button"> & {
|
||||
children: React.ReactNode;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user