mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-24 07:29:56 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 931d02fefd | |||
| 7b39c5dea9 | |||
| 8588a44fb5 | |||
| fe3ae13928 | |||
| 94cccc3702 | |||
| 9edc154397 | |||
| f29b161cf4 | |||
| 4007dedcf0 | |||
| 50d2834634 | |||
| f8791a9ec5 | |||
| 4598b22af1 | |||
| 4ac4c6e8a9 | |||
| 5a82b18fb8 | |||
| 5fada3f929 | |||
| 828a604c9d | |||
| 02328e59a2 | |||
| 577ab79fd0 | |||
| 8c221d02fe |
@@ -31,10 +31,10 @@ jobs:
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||
env:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Gather context
|
||||
env:
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -479,7 +479,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -617,10 +617,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@abda3515f444c4d28a98953d153c5a3e1892d3d4 #v1.17.4
|
||||
uses: anomalyco/opencode/github@11e47f91496005aab4d7c5a2d0a7da5d2651b4ac #v1.17.8
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -34,10 +34,10 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -105,10 +105,10 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -148,12 +148,12 @@ jobs:
|
||||
- name: Verify frontend dist exists
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -d "dist" ]; then
|
||||
echo "Error: dist directory not found after build"
|
||||
ls -la
|
||||
if [ ! -f "dist/index.html" ]; then
|
||||
echo "Error: dist/index.html not found after build (static export incomplete)"
|
||||
ls -la dist 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
echo "Frontend dist directory verified at $(pwd)/dist"
|
||||
echo "Frontend dist verified at $(pwd)/dist (index.html present)"
|
||||
echo "Checking from src-tauri perspective:"
|
||||
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
|
||||
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -454,7 +454,7 @@ jobs:
|
||||
needs: [release, changelog]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -552,7 +552,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
||||
@@ -104,10 +104,10 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
|
||||
@@ -21,6 +21,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
|
||||
|
||||
@@ -32,10 +32,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.27.0 (2026-06-17)
|
||||
|
||||
### Features
|
||||
|
||||
- amek window resizable
|
||||
|
||||
### Refactoring
|
||||
|
||||
- better tray icon
|
||||
- simplify socks connection
|
||||
- switch local proxy from http to socks
|
||||
|
||||
### Documentation
|
||||
|
||||
- readme
|
||||
- readme
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- ci(deps): bump anomalyco/opencode in the github-actions group (#437)
|
||||
- chore: update flake.nix for v0.26.0 [skip ci] (#428)
|
||||
|
||||
|
||||
## v0.26.0 (2026-06-08)
|
||||
|
||||
### Features
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Contributing to Donut Browser
|
||||
|
||||
Contributions are welcome! To start working on an issue, leave a comment indicating you're taking it on.
|
||||
Contributions are welcome! Please do not create PRs for the sake of being added to the contributors list. Reviewing PRs takes time, so please create PRs only if you believe that your change will improve Donut for yourself and others. If you are thinking of making a significant change, please get in touch with the maintainer first.
|
||||
|
||||
## Before Starting
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -178,6 +178,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<br />
|
||||
<sub><b>Thiago Mafra</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/liasica">
|
||||
<img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/>
|
||||
<br />
|
||||
<sub><b>liasica</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
|
||||
+10
-10
@@ -18,30 +18,30 @@
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@aws-sdk/client-s3": "^3.1073.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1073.0",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/platform-express": "^11.1.27",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/cli": "^11.0.23",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@nestjs/testing": "^11.1.27",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-loader": "^9.5.7",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-loader": "^9.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.26.0";
|
||||
releaseVersion = "0.27.0";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage";
|
||||
hash = "sha256-uwt8T+BeGf5NTFOj3D1gc8I9wkF02X2bJRpU3Yn5E2E=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage";
|
||||
hash = "sha256-b9jY+SPw+5UvvTKgXmvxLJjIbrLW6kHTVeZywJA6DFE=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage";
|
||||
hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage";
|
||||
hash = "sha256-UyK3p88kx3JkJmQ9Jv1hQGmfLbG1YZDuF2pZ1h529sQ=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+31
-31
@@ -32,22 +32,22 @@
|
||||
"precargo": "pnpm copy-proxy-binary"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-portal": "^1.1.10",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-checkbox": "^1.3.5",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-label": "^2.1.10",
|
||||
"@radix-ui/react-popover": "^1.1.17",
|
||||
"@radix-ui/react-portal": "^1.1.12",
|
||||
"@radix-ui/react-progress": "^1.1.10",
|
||||
"@radix-ui/react-radio-group": "^1.4.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.12",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "~2.11.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"@tauri-apps/api": "~2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
@@ -61,17 +61,17 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"i18next": "^26.1.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "^16.2.6",
|
||||
"framer-motion": "^12.40.0",
|
||||
"i18next": "^26.3.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"motion": "^12.40.0",
|
||||
"next": "^16.2.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"onborda": "^1.2.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -79,17 +79,17 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tauri-apps/cli": "~2.11.1",
|
||||
"@biomejs/biome": "2.5.0",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@tauri-apps/cli": "~2.11.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"lint-staged": "^17.0.8",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3"
|
||||
|
||||
Generated
+2577
-2927
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,10 @@ overrides:
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
multer@>=2.0.0 <2.2.0: '>=2.2.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
js-yaml@>=4.0.0 <4.2.0: '>=4.2.0 <5'
|
||||
'@babel/core@<7.29.6': '>=7.29.6 <8'
|
||||
|
||||
allowBuilds:
|
||||
'@nestjs/core': true
|
||||
|
||||
@@ -113,8 +113,11 @@ for arch in amd64 arm64; do
|
||||
BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}"
|
||||
|
||||
# dpkg-scanpackages needs to run from the repo root
|
||||
# and needs paths relative to that root
|
||||
(cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \
|
||||
# and needs paths relative to that root.
|
||||
# -m / --multiversion keeps every version present in the pool in the index
|
||||
# (without it only the newest is listed, making older releases uninstallable
|
||||
# via apt — createrepo_c already keeps all versions for the RPM repo).
|
||||
(cd "$DEB_DIR" && dpkg-scanpackages -m --arch "$arch" pool/main) \
|
||||
> "$BINARY_DIR/Packages"
|
||||
|
||||
gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz"
|
||||
|
||||
@@ -44,7 +44,17 @@ if (!cmd) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, { stdio: "inherit", shell: false });
|
||||
// On Windows, npm-installed bins (e.g. `tauri`) are `.cmd` shims that cannot be
|
||||
// launched with `shell: false` — Node refuses to exec a batch file directly and
|
||||
// the spawn fails with ENOENT/EINVAL. Run through the shell on Windows (cmd.exe
|
||||
// resolves `tauri.cmd`); macOS/Linux keep `shell: false`, where the bin is a
|
||||
// directly-executable script. Under the Windows shell, quote args containing
|
||||
// whitespace so paths with spaces aren't split into multiple arguments.
|
||||
const isWindows = process.platform === "win32";
|
||||
const spawnArgs = isWindows
|
||||
? args.map((a) => (/\s/.test(a) ? `"${a}"` : a))
|
||||
: args;
|
||||
const child = spawn(cmd, spawnArgs, { stdio: "inherit", shell: isWindows });
|
||||
child.on("error", (err) => {
|
||||
console.error(`Failed to spawn ${cmd}:`, err.message);
|
||||
process.exit(1);
|
||||
|
||||
Generated
+278
-410
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
axum = { version = "0.8.9", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
rand = "0.10.1"
|
||||
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.2"
|
||||
@@ -145,7 +145,7 @@ hyper = { version = "1.10", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tower-http = { version = "0.7", features = ["fs", "trace"] }
|
||||
futures-util = "0.3"
|
||||
serial_test = "3"
|
||||
|
||||
|
||||
@@ -244,6 +244,52 @@ struct ImportCookiesResponse {
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct BatchRunRequest {
|
||||
/// Profile IDs to launch.
|
||||
profile_ids: Vec<String>,
|
||||
/// Optional URL to open in every launched profile.
|
||||
url: Option<String>,
|
||||
/// Launch headless. Defaults to false.
|
||||
headless: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchRunResult {
|
||||
profile_id: String,
|
||||
/// Whether this profile launched successfully.
|
||||
ok: bool,
|
||||
/// Remote debugging port if launched, otherwise null.
|
||||
remote_debugging_port: Option<u16>,
|
||||
/// Failure reason if not launched, otherwise null.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchRunResponse {
|
||||
results: Vec<BatchRunResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct BatchStopRequest {
|
||||
/// Profile IDs to stop.
|
||||
profile_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchStopResult {
|
||||
profile_id: String,
|
||||
/// Whether this profile was stopped successfully.
|
||||
ok: bool,
|
||||
/// Failure reason if not stopped, otherwise null.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchStopResponse {
|
||||
results: Vec<BatchStopResult>,
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
@@ -255,6 +301,8 @@ struct ImportCookiesResponse {
|
||||
run_profile,
|
||||
open_url_in_profile,
|
||||
kill_profile,
|
||||
batch_run_profiles,
|
||||
batch_stop_profiles,
|
||||
import_profile_cookies,
|
||||
get_groups,
|
||||
get_group,
|
||||
@@ -297,6 +345,12 @@ struct ImportCookiesResponse {
|
||||
DownloadBrowserResponse,
|
||||
RunProfileResponse,
|
||||
RunProfileRequest,
|
||||
BatchRunRequest,
|
||||
BatchRunResult,
|
||||
BatchRunResponse,
|
||||
BatchStopRequest,
|
||||
BatchStopResult,
|
||||
BatchStopResponse,
|
||||
OpenUrlRequest,
|
||||
ImportCookiesRequest,
|
||||
ImportCookiesResponse,
|
||||
@@ -396,6 +450,8 @@ impl ApiServer {
|
||||
.routes(routes!(run_profile))
|
||||
.routes(routes!(open_url_in_profile))
|
||||
.routes(routes!(kill_profile))
|
||||
.routes(routes!(batch_run_profiles))
|
||||
.routes(routes!(batch_stop_profiles))
|
||||
.routes(routes!(import_profile_cookies))
|
||||
.routes(routes!(get_groups, create_group))
|
||||
.routes(routes!(get_group, update_group, delete_group))
|
||||
@@ -1951,6 +2007,170 @@ async fn kill_profile(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// API Handler - Batch run profiles (paid: browser automation). Mirrors the
|
||||
// single `/run` gate; never breaks the batch on a single profile's failure —
|
||||
// each profile gets its own result entry.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/batch/run",
|
||||
request_body = BatchRunRequest,
|
||||
responses(
|
||||
(status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "profiles"
|
||||
)]
|
||||
async fn batch_run_profiles(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<BatchRunRequest>,
|
||||
) -> Result<Json<BatchRunResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let headless = request.headless.unwrap_or(false);
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut results = Vec::with_capacity(request.profile_ids.len());
|
||||
for profile_id in &request.profile_ids {
|
||||
let fail = |error: &str| BatchRunResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: false,
|
||||
remote_debugging_port: None,
|
||||
error: Some(error.to_string()),
|
||||
};
|
||||
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
results.push(fail("profile not found"));
|
||||
continue;
|
||||
};
|
||||
if profile.is_cross_os() {
|
||||
results.push(fail("cross-OS profiles cannot be launched"));
|
||||
continue;
|
||||
}
|
||||
if crate::team_lock::acquire_team_lock_if_needed(profile)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
results.push(fail("profile is locked by another team member"));
|
||||
continue;
|
||||
}
|
||||
|
||||
let port = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
|
||||
Ok(listener) => match listener.local_addr() {
|
||||
Ok(addr) => addr.port(),
|
||||
Err(_) => {
|
||||
results.push(fail("failed to allocate debugging port"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
results.push(fail("failed to allocate debugging port"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match crate::browser_runner::launch_browser_profile_impl(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
request.url.clone(),
|
||||
Some(port),
|
||||
headless,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => results.push(BatchRunResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: true,
|
||||
remote_debugging_port: Some(port),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => results.push(fail(&format!("launch failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(BatchRunResponse { results }))
|
||||
}
|
||||
|
||||
// API Handler - Batch stop profiles (paid: browser automation).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/batch/stop",
|
||||
request_body = BatchStopRequest,
|
||||
responses(
|
||||
(status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "profiles"
|
||||
)]
|
||||
async fn batch_stop_profiles(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<BatchStopRequest>,
|
||||
) -> Result<Json<BatchStopResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
let mut results = Vec::with_capacity(request.profile_ids.len());
|
||||
for profile_id in &request.profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
results.push(BatchStopResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: false,
|
||||
error: Some("profile not found".to_string()),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
match browser_runner
|
||||
.kill_browser_process(state.app_handle.clone(), profile)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
results.push(BatchStopResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: true,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => results.push(BatchStopResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: false,
|
||||
error: Some(format!("stop failed: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(BatchStopResponse { results }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/{id}/cookies/import",
|
||||
|
||||
@@ -1492,7 +1492,7 @@ impl AppAutoUpdater {
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/bin/sh
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
@@ -1521,7 +1521,7 @@ rm "{}"
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
@@ -1668,7 +1668,7 @@ rm "{}"
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/bin/sh
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
@@ -1697,7 +1697,7 @@ rm "{}"
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
|
||||
@@ -409,6 +409,10 @@ impl BrowserRunner {
|
||||
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
||||
}
|
||||
|
||||
// Persist the real browser PID so the detached proxy worker self-reaps
|
||||
// when this browser dies, even after the GUI exits/restarts.
|
||||
PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id);
|
||||
|
||||
// Save the updated profile (includes new fingerprint if randomize is enabled)
|
||||
log::info!(
|
||||
"Saving profile {} with camoufox_config fingerprint length: {}",
|
||||
@@ -696,6 +700,10 @@ impl BrowserRunner {
|
||||
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
||||
}
|
||||
|
||||
// Persist the real browser PID so the detached proxy worker self-reaps
|
||||
// when this browser dies, even after the GUI exits/restarts.
|
||||
PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id);
|
||||
|
||||
// Save the updated profile
|
||||
log::info!(
|
||||
"Saving profile {} with wayfern_config fingerprint length: {}",
|
||||
|
||||
@@ -85,7 +85,11 @@ impl GroupManager {
|
||||
|
||||
// Check if group with this name already exists
|
||||
if groups_data.groups.iter().any(|g| g.name == name) {
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_ALREADY_EXISTS" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
@@ -131,14 +135,18 @@ impl GroupManager {
|
||||
.iter()
|
||||
.any(|g| g.name == name && g.id != id)
|
||||
{
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_ALREADY_EXISTS" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let group = groups_data
|
||||
.groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == id)
|
||||
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
||||
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?;
|
||||
|
||||
group.name = name;
|
||||
group.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
@@ -204,7 +212,11 @@ impl GroupManager {
|
||||
let initial_len = groups_data.groups.len();
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_NOT_FOUND" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(())
|
||||
@@ -229,7 +241,11 @@ impl GroupManager {
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_NOT_FOUND" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
@@ -334,7 +350,7 @@ pub async fn create_profile_group(
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.create_group(&app_handle, name)
|
||||
.map_err(|e| format!("Failed to create group: {e}"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -346,7 +362,7 @@ pub async fn update_profile_group(
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.update_group(&app_handle, group_id, name)
|
||||
.map_err(|e| format!("Failed to update group: {e}"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -357,7 +373,7 @@ pub async fn delete_profile_group(
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.delete_group(&app_handle, group_id)
|
||||
.map_err(|e| format!("Failed to delete group: {e}"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -564,6 +564,44 @@ impl McpServer {
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "batch_run_profiles".to_string(),
|
||||
description: "Launch multiple browser profiles at once with an optional URL. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "UUIDs of the profiles to launch"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Optional URL to open in every launched profile"
|
||||
},
|
||||
"headless": {
|
||||
"type": "boolean",
|
||||
"description": "Run the browsers in headless mode"
|
||||
}
|
||||
},
|
||||
"required": ["profile_ids"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "batch_stop_profiles".to_string(),
|
||||
description: "Stop multiple running browser profiles at once. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "UUIDs of the profiles to stop"
|
||||
}
|
||||
},
|
||||
"required": ["profile_ids"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "create_profile".to_string(),
|
||||
description: "Create a new browser profile".to_string(),
|
||||
@@ -1676,6 +1714,22 @@ impl McpServer {
|
||||
.await?;
|
||||
self.handle_kill_profile(arguments).await
|
||||
}
|
||||
"batch_run_profiles" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_batch_run_profiles(arguments).await
|
||||
}
|
||||
"batch_stop_profiles" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_batch_stop_profiles(arguments).await
|
||||
}
|
||||
"create_profile" => self.handle_create_profile(arguments).await,
|
||||
"update_profile" => self.handle_update_profile(arguments).await,
|
||||
"delete_profile" => self.handle_delete_profile(arguments).await,
|
||||
@@ -2062,6 +2116,169 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_batch_run_profiles(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
Self::require_capability(
|
||||
"Batch launching profiles",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_ids: Vec<String> = arguments
|
||||
.get("profile_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_ids array".to_string(),
|
||||
})?;
|
||||
|
||||
let url = arguments.get("url").and_then(|v| v.as_str());
|
||||
let headless = arguments
|
||||
.get("headless")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list profiles: {e}"),
|
||||
})?;
|
||||
|
||||
// Clone the app handle and release the lock before the launch loop so we
|
||||
// never hold the inner mutex across the per-profile awaits.
|
||||
let app_handle = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut launched = 0usize;
|
||||
let mut lines: Vec<String> = Vec::with_capacity(profile_ids.len());
|
||||
for profile_id in &profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
lines.push(format!("{profile_id}: not found"));
|
||||
continue;
|
||||
};
|
||||
if profile.browser != "wayfern" && profile.browser != "camoufox" {
|
||||
lines.push(format!(
|
||||
"{profile_id}: unsupported browser (MCP supports Wayfern/Camoufox)"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = crate::team_lock::acquire_team_lock_if_needed(profile).await {
|
||||
lines.push(format!("{profile_id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
match crate::browser_runner::launch_browser_profile_impl(
|
||||
app_handle.clone(),
|
||||
profile.clone(),
|
||||
url.map(|s| s.to_string()),
|
||||
None,
|
||||
headless,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
launched += 1;
|
||||
lines.push(format!("{}: launched", profile.name));
|
||||
}
|
||||
Err(e) => lines.push(format!("{}: launch failed: {e}", profile.name)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Launched {}/{} profile(s):\n{}", launched, profile_ids.len(), lines.join("\n"))
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_batch_stop_profiles(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
Self::require_capability(
|
||||
"Batch stopping profiles",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_ids: Vec<String> = arguments
|
||||
.get("profile_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_ids array".to_string(),
|
||||
})?;
|
||||
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list profiles: {e}"),
|
||||
})?;
|
||||
|
||||
let app_handle = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut stopped = 0usize;
|
||||
let mut lines: Vec<String> = Vec::with_capacity(profile_ids.len());
|
||||
for profile_id in &profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
lines.push(format!("{profile_id}: not found"));
|
||||
continue;
|
||||
};
|
||||
match crate::browser_runner::BrowserRunner::instance()
|
||||
.kill_browser_process(app_handle.clone(), profile)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
stopped += 1;
|
||||
lines.push(format!("{}: stopped", profile.name));
|
||||
}
|
||||
Err(e) => lines.push(format!("{}: stop failed: {e}", profile.name)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Stopped {}/{} profile(s):\n{}", stopped, profile_ids.len(), lines.join("\n"))
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_create_profile(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
|
||||
@@ -634,19 +634,25 @@ pub mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Linux-specific environment variables for better compatibility
|
||||
cmd.env(
|
||||
"DISPLAY",
|
||||
std::env::var("DISPLAY").unwrap_or(":0".to_string()),
|
||||
);
|
||||
// Propagate DISPLAY only when this session actually has an X11 display.
|
||||
// Forcing DISPLAY=:0 breaks Wayland-only sessions (there is no X server on
|
||||
// :0, so any X11 client launched with it set will fail to connect). When
|
||||
// DISPLAY is set the child already inherits it from our environment, so
|
||||
// setting it explicitly here is purely defensive; when it's unset we leave
|
||||
// it unset and let the browser use Wayland.
|
||||
if let Ok(display) = std::env::var("DISPLAY") {
|
||||
cmd.env("DISPLAY", display);
|
||||
}
|
||||
|
||||
// Set MOZ_ENABLE_WAYLAND for better Wayland support
|
||||
if std::env::var("WAYLAND_DISPLAY").is_ok() {
|
||||
cmd.env("MOZ_ENABLE_WAYLAND", "1");
|
||||
}
|
||||
|
||||
// Disable GPU acceleration if running in headless environments
|
||||
if std::env::var("DISPLAY").is_err() || std::env::var("WAYLAND_DISPLAY").is_err() {
|
||||
// Warn only when running truly headless — i.e. NEITHER X11 nor Wayland is
|
||||
// available. Using OR here would fire on every normal Wayland-only session
|
||||
// (DISPLAY unset) or X11-only session (WAYLAND_DISPLAY unset).
|
||||
if std::env::var("DISPLAY").is_err() && std::env::var("WAYLAND_DISPLAY").is_err() {
|
||||
log::info!("No display detected, browser may fail to start");
|
||||
}
|
||||
|
||||
|
||||
@@ -1860,6 +1860,38 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the real browser PID onto the worker's on-disk config so the
|
||||
/// detached worker can self-terminate when that browser dies, independent of
|
||||
/// the GUI being alive. Resolved via the profile→proxy_id map rather than the
|
||||
/// PID-keyed `active_proxies` map: the latter uses a placeholder key 0 during
|
||||
/// launch that collides across concurrent launches, which could tag a live
|
||||
/// worker with the wrong (dead) PID and make it self-exit. Safe on the reuse
|
||||
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
|
||||
/// of 0 (launch failed to report a PID) is ignored so the worker never
|
||||
/// self-exits against a bogus PID.
|
||||
pub fn set_browser_pid_for_profile(&self, profile_id: &str, browser_pid: u32) {
|
||||
if browser_pid == 0 {
|
||||
return;
|
||||
}
|
||||
let proxy_id = {
|
||||
let map = self.profile_active_proxy_ids.lock().unwrap();
|
||||
match map.get(profile_id) {
|
||||
Some(id) => id.clone(),
|
||||
None => return, // No local worker for this profile — nothing to tag.
|
||||
}
|
||||
};
|
||||
if let Some(mut cfg) = crate::proxy_storage::get_proxy_config(&proxy_id) {
|
||||
cfg.browser_pid = Some(browser_pid);
|
||||
if crate::proxy_storage::update_proxy_config(&cfg) {
|
||||
log::info!(
|
||||
"Recorded browser PID {browser_pid} on proxy config {proxy_id} for self-reaping"
|
||||
);
|
||||
} else {
|
||||
log::warn!("Failed to persist browser_pid {browser_pid} to proxy config {proxy_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up proxies for dead browser processes
|
||||
// Only clean up orphaned config files where the proxy process itself is dead
|
||||
pub async fn cleanup_dead_proxies(
|
||||
@@ -2894,6 +2926,7 @@ mod tests {
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
let dead_config = ProxyConfig {
|
||||
id: dead_id.clone(),
|
||||
@@ -2906,6 +2939,7 @@ mod tests {
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
|
||||
save_proxy_config(&live_config).unwrap();
|
||||
@@ -2946,6 +2980,7 @@ mod tests {
|
||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
|
||||
// Save
|
||||
@@ -3265,6 +3300,7 @@ mod tests {
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
save_proxy_config(&config).unwrap();
|
||||
|
||||
|
||||
+444
-50
@@ -7,13 +7,13 @@ 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::collections::{HashMap, HashSet};
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
use tokio::net::TcpStream;
|
||||
@@ -326,19 +326,15 @@ async fn handle_connect(
|
||||
let port = upstream.port().unwrap_or(1080);
|
||||
let socks_addr = format!("{}:{}", host, port);
|
||||
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let (username, password) = upstream_userpass(&upstream);
|
||||
let auth = (!username.is_empty()).then_some((username.as_str(), password.as_str()));
|
||||
|
||||
match connect_via_socks(
|
||||
&socks_addr,
|
||||
target_host,
|
||||
target_port,
|
||||
scheme == "socks5",
|
||||
if !username.is_empty() {
|
||||
Some((username, password))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
auth,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -378,7 +374,12 @@ async fn connect_via_http_proxy(
|
||||
) -> Result<TcpStream, Box<dyn std::error::Error>> {
|
||||
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||
let proxy_port = upstream.port().unwrap_or(8080);
|
||||
let mut stream = TcpStream::connect((proxy_host, proxy_port)).await?;
|
||||
let mut stream = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
TcpStream::connect((proxy_host, proxy_port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("upstream proxy connect to {proxy_host}:{proxy_port} timed out"))??;
|
||||
|
||||
// Add proxy authentication if provided
|
||||
let mut connect_req = format!(
|
||||
@@ -386,10 +387,9 @@ async fn connect_via_http_proxy(
|
||||
target_host, target_port, target_host, target_port
|
||||
);
|
||||
|
||||
if !upstream.username().is_empty() {
|
||||
let (username, password) = upstream_userpass(upstream);
|
||||
if !username.is_empty() {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
|
||||
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
|
||||
}
|
||||
@@ -399,7 +399,9 @@ async fn connect_via_http_proxy(
|
||||
stream.write_all(connect_req.as_bytes()).await?;
|
||||
|
||||
let mut buffer = [0u8; 4096];
|
||||
let n = stream.read(&mut buffer).await?;
|
||||
let n = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, stream.read(&mut buffer))
|
||||
.await
|
||||
.map_err(|_| "upstream proxy CONNECT response timed out")??;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") {
|
||||
@@ -409,6 +411,96 @@ async fn connect_via_http_proxy(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract percent-decoded (username, password) from the upstream URL.
|
||||
///
|
||||
/// `url::Url::username()` / `Url::password()` return percent-encoded ASCII
|
||||
/// strings per the WHATWG spec. `build_proxy_url` on the producer side
|
||||
/// already percent-encodes the credentials with `urlencoding::encode`, so
|
||||
/// we must decode here — otherwise the upstream SOCKS5 / HTTP CONNECT
|
||||
/// receives `%40` instead of `@`, breaking RFC1929 user/password
|
||||
/// authentication or HTTP Basic-Auth
|
||||
fn upstream_userpass(upstream: &Url) -> (String, String) {
|
||||
let username = urlencoding::decode(upstream.username())
|
||||
.map(|cow| cow.into_owned())
|
||||
.unwrap_or_default();
|
||||
let password = urlencoding::decode(upstream.password().unwrap_or(""))
|
||||
.map(|cow| cow.into_owned())
|
||||
.unwrap_or_default();
|
||||
(username, password)
|
||||
}
|
||||
|
||||
/// Transparent AsyncRead/AsyncWrite wrapper that logs every read/write
|
||||
/// byte of the SOCKS5 handshake. Used only during the handshake — the
|
||||
/// inner stream is taken back via `into_inner` once the handshake
|
||||
/// completes, so the tunnel phase pays no overhead
|
||||
struct SocksHandshakeLogger<S> {
|
||||
inner: S,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl<S> SocksHandshakeLogger<S> {
|
||||
fn new(inner: S, label: String) -> Self {
|
||||
Self { inner, label }
|
||||
}
|
||||
|
||||
fn into_inner(self) -> S {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for SocksHandshakeLogger<S> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let before = buf.filled().len();
|
||||
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
if let Poll::Ready(Ok(())) = &result {
|
||||
let after = buf.filled().len();
|
||||
if after > before {
|
||||
let bytes = &buf.filled()[before..after];
|
||||
log::trace!(
|
||||
"[socks-handshake:{}] <- {} byte(s): {:02x?}",
|
||||
self.label,
|
||||
bytes.len(),
|
||||
bytes
|
||||
);
|
||||
} else {
|
||||
log::trace!("[socks-handshake:{}] <- EOF (peer closed)", self.label);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite + Unpin> AsyncWrite for SocksHandshakeLogger<S> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
|
||||
if let Poll::Ready(Ok(n)) = &result {
|
||||
log::trace!(
|
||||
"[socks-handshake:{}] -> {} byte(s): {:02x?}",
|
||||
self.label,
|
||||
n,
|
||||
&buf[..*n]
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_via_socks(
|
||||
socks_addr: &str,
|
||||
target_host: &str,
|
||||
@@ -416,7 +508,9 @@ async fn connect_via_socks(
|
||||
is_socks5: bool,
|
||||
auth: Option<(&str, &str)>,
|
||||
) -> Result<TcpStream, Box<dyn std::error::Error>> {
|
||||
let mut stream = TcpStream::connect(socks_addr).await?;
|
||||
let stream = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, TcpStream::connect(socks_addr))
|
||||
.await
|
||||
.map_err(|_| format!("SOCKS upstream connect to {socks_addr} timed out"))??;
|
||||
|
||||
if is_socks5 {
|
||||
// SOCKS5 connection using async_socks5
|
||||
@@ -433,9 +527,52 @@ async fn connect_via_socks(
|
||||
password: pass.to_string(),
|
||||
});
|
||||
|
||||
connect(&mut stream, target, auth_info).await?;
|
||||
Ok(stream)
|
||||
let has_auth = auth_info.is_some();
|
||||
log::trace!(
|
||||
"[socks-handshake] dialing {} (target={}:{}, has_auth={})",
|
||||
socks_addr,
|
||||
target_host,
|
||||
target_port,
|
||||
has_auth
|
||||
);
|
||||
|
||||
// Disable Nagle so the kernel doesn't further delay/coalesce the
|
||||
// syscalls issued when BufStream flushes
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
// BufStream wrapping is required: async_socks5 calls write_u8 for every
|
||||
// single-byte SOCKS5 / RFC1929 field, and on a raw TcpStream each call
|
||||
// becomes its own TCP segment. Some upstream SOCKS5 implementations
|
||||
// treat such a "fragmented auth submission" as a misbehaving client
|
||||
// and silently FIN instead of returning an RFC1929 status. BufStream
|
||||
// coalesces those small writes into one syscall on flush — this is
|
||||
// the usage pattern shown in the async_socks5 README
|
||||
let label = format!("{socks_addr}->{target_host}:{target_port}");
|
||||
let logged = SocksHandshakeLogger::new(stream, label);
|
||||
let mut buffered = tokio::io::BufStream::new(logged);
|
||||
let handshake = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
connect(&mut buffered, target, auth_info),
|
||||
)
|
||||
.await;
|
||||
// Unwrap the layered stream: BufStream → SocksHandshakeLogger → TcpStream
|
||||
let stream = buffered.into_inner().into_inner();
|
||||
match handshake {
|
||||
Ok(Ok(_)) => {
|
||||
log::trace!("[socks-handshake] handshake completed ok");
|
||||
Ok(stream)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::trace!("[socks-handshake] handshake failed: {:?}", e);
|
||||
Err(e.into())
|
||||
}
|
||||
Err(_) => {
|
||||
log::trace!("[socks-handshake] handshake timed out");
|
||||
Err("SOCKS5 upstream handshake timed out".into())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut stream = stream;
|
||||
// SOCKS4 - simplified implementation
|
||||
let ip: std::net::IpAddr = target_host.parse()?;
|
||||
|
||||
@@ -1140,7 +1277,16 @@ pub async fn handle_proxy_connection(
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("CONNECT tunnel ended with error: {e}");
|
||||
let msg = e.to_string();
|
||||
if let Some(suppressed) = log_throttle(&msg) {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"CONNECT tunnel ended with error: {msg} ({suppressed} more suppressed in last 30s)"
|
||||
);
|
||||
} else {
|
||||
log::warn!("CONNECT tunnel ended with error: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1359,6 +1505,48 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
}
|
||||
});
|
||||
|
||||
// Self-reaping supervisor. The worker is a detached process that outlives the
|
||||
// GUI, so it cannot rely on the GUI's in-memory death-monitor (which is lost
|
||||
// when the GUI restarts). Once the GUI records the browser PID this worker
|
||||
// serves, poll it and exit when that browser is gone — never while it is
|
||||
// alive, and never before a PID is recorded (covers the launch window and
|
||||
// pre-upgrade configs lacking the field). A 2-miss debounce avoids exiting on
|
||||
// a transient sysinfo false-negative under load / sleep-wake.
|
||||
{
|
||||
let watch_id = config.id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(15));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut consecutive_misses: u32 = 0;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::proxy_storage::get_proxy_config(&watch_id) {
|
||||
Some(cfg) => match cfg.browser_pid {
|
||||
Some(bpid) if bpid != 0 => {
|
||||
if crate::proxy_storage::is_process_running(bpid) {
|
||||
consecutive_misses = 0;
|
||||
} else {
|
||||
consecutive_misses += 1;
|
||||
if consecutive_misses >= 2 {
|
||||
log::info!("Browser PID {bpid} for config {watch_id} is gone; worker exiting");
|
||||
crate::proxy_storage::delete_proxy_config(&watch_id);
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No browser PID recorded yet (launch window / old config): keep running.
|
||||
_ => consecutive_misses = 0,
|
||||
},
|
||||
// Our own config was removed (e.g. GUI stopped us): nothing to serve.
|
||||
None => {
|
||||
log::info!("Proxy config {watch_id} was removed; worker exiting");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
|
||||
let blocklist_matcher = if let Some(ref path) = config.blocklist_file {
|
||||
match BlocklistMatcher::from_file(path) {
|
||||
@@ -1372,20 +1560,37 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
BlocklistMatcher::new()
|
||||
};
|
||||
|
||||
// Bound concurrent connection handlers. A client retry-storm (e.g. a browser
|
||||
// hammering CONNECT requests while DNS is failing) must not spawn unbounded
|
||||
// tasks,
|
||||
// each of which parks a Tokio blocking thread inside getaddrinfo — that is
|
||||
// what exhausted the resolver pool and pegged the CPU on long-lived workers.
|
||||
// A real browser never approaches this ceiling; waiting for a permit
|
||||
// backpressures a storm instead of amplifying it.
|
||||
let conn_semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CONNECTIONS));
|
||||
|
||||
// Keep the runtime alive with an infinite loop
|
||||
// This ensures the process doesn't exit even if there are no active connections
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _peer_addr)) => {
|
||||
// The semaphore is never closed, so acquire cannot fail.
|
||||
let permit = conn_semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("connection semaphore is never closed");
|
||||
let upstream = upstream_url.clone();
|
||||
let matcher = bypass_matcher.clone();
|
||||
let blocker = blocklist_matcher.clone();
|
||||
if serve_socks5 {
|
||||
tokio::task::spawn(async move {
|
||||
let _permit = permit;
|
||||
crate::socks5_local::handle_socks5_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
} else {
|
||||
tokio::task::spawn(async move {
|
||||
let _permit = permit;
|
||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
}
|
||||
@@ -1451,7 +1656,7 @@ async fn handle_connect_from_buffer(
|
||||
tracker.record_request(&domain, 0, 0);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
log::debug!(
|
||||
"CONNECT {}:{} (upstream={})",
|
||||
target_host,
|
||||
target_port,
|
||||
@@ -1481,6 +1686,145 @@ async fn handle_connect_from_buffer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upper bound on concurrent connection handlers per worker. A real browser
|
||||
/// never holds anywhere near this many simultaneous tunnels; the cap stops a
|
||||
/// client retry-storm from spawning unbounded tasks (each of which parks a
|
||||
/// Tokio blocking thread inside getaddrinfo).
|
||||
const MAX_CONCURRENT_CONNECTIONS: usize = 512;
|
||||
|
||||
/// Connect timeout for the direct (no-upstream) dial path. Bounds a wedged
|
||||
/// `getaddrinfo` so a broken resolver can't park a blocking thread for the
|
||||
/// full OS timeout.
|
||||
const DIRECT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Overall timeout for dialing an UPSTREAM proxy (TCP connect + CONNECT/SOCKS/SS
|
||||
/// handshake). Without it, an upstream that accepts TCP but stalls before
|
||||
/// replying hangs the worker task forever and holds a connection slot; under
|
||||
/// load (e.g. two profiles sharing one proxy) the slots exhaust and the browser
|
||||
/// sees `ERR_PROXY_CONNECTION_FAILED` until the profile is restarted (issue
|
||||
/// #439). A bounded dial fails fast and releases the slot.
|
||||
const UPSTREAM_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Per-host failure state (last failure instant, consecutive failure count) for
|
||||
/// the direct dial path. Process-global — each worker is its own process.
|
||||
fn direct_dial_failures() -> &'static Mutex<HashMap<String, (std::time::Instant, u32)>> {
|
||||
static M: OnceLock<Mutex<HashMap<String, (std::time::Instant, u32)>>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// If `host` is inside its failure backoff window, return the remaining time so
|
||||
/// the caller can short-circuit without a fresh getaddrinfo/connect. Never
|
||||
/// mutates state, so the window always expires and the path self-heals once
|
||||
/// DNS recovers.
|
||||
fn direct_backoff_remaining(host: &str) -> Option<std::time::Duration> {
|
||||
let map = direct_dial_failures();
|
||||
let guard = map.lock().unwrap();
|
||||
let (last, fails) = guard.get(host).copied()?;
|
||||
// Exponential window capped at 30s: 2, 4, 8, 16, 30, 30, ...
|
||||
let window = std::time::Duration::from_secs((1u64 << fails.min(5)).min(30));
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed < window {
|
||||
Some(window - elapsed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a direct-dial failure for `host`, growing its backoff window.
|
||||
fn direct_backoff_record(host: &str) {
|
||||
let map = direct_dial_failures();
|
||||
let mut guard = map.lock().unwrap();
|
||||
// Bound memory against a page that emits many distinct failing hosts.
|
||||
if guard.len() > 2048 {
|
||||
guard.retain(|_, (last, _)| last.elapsed() < std::time::Duration::from_secs(60));
|
||||
}
|
||||
let entry = guard
|
||||
.entry(host.to_string())
|
||||
.or_insert_with(|| (std::time::Instant::now(), 0));
|
||||
entry.0 = std::time::Instant::now();
|
||||
entry.1 = entry.1.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Clear `host`'s failure state after a successful dial.
|
||||
fn direct_backoff_clear(host: &str) {
|
||||
direct_dial_failures().lock().unwrap().remove(host);
|
||||
}
|
||||
|
||||
/// Dial a target directly (no upstream) with a connect timeout and per-host
|
||||
/// failure backoff. This is the server-side counterpart to the browser's
|
||||
/// instant client-side retry: when a host's DNS/connect is failing (e.g. the
|
||||
/// macOS resolver wedges after sleep/wake), repeated CONNECT requests
|
||||
/// short-circuit
|
||||
/// here instead of each spawning a fresh blocking getaddrinfo — which is what
|
||||
/// let a retry-storm exhaust the blocking thread pool and peg the CPU.
|
||||
async fn dial_direct(host: &str, port: u16) -> Result<TcpStream, Box<dyn std::error::Error>> {
|
||||
if let Some(remaining) = direct_backoff_remaining(host) {
|
||||
return Err(
|
||||
format!(
|
||||
"skipping direct dial to {host}: backing off ~{}s after repeated connect failures",
|
||||
remaining.as_secs().max(1)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
match tokio::time::timeout(DIRECT_CONNECT_TIMEOUT, TcpStream::connect((host, port))).await {
|
||||
Ok(Ok(stream)) => {
|
||||
let _ = stream.set_nodelay(true);
|
||||
direct_backoff_clear(host);
|
||||
Ok(stream)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
direct_backoff_record(host);
|
||||
Err(e.into())
|
||||
}
|
||||
Err(_) => {
|
||||
direct_backoff_record(host);
|
||||
Err(
|
||||
format!(
|
||||
"direct connect to {host}:{port} timed out after {}s",
|
||||
DIRECT_CONNECT_TIMEOUT.as_secs()
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate-limit a repetitive log line keyed by `key`: returns `Some(suppressed)`
|
||||
/// when the caller should emit (first time or after a 30s window, with the
|
||||
/// count dropped since the last emit), or `None` to skip. Stops a connect/DNS
|
||||
/// storm from writing the same WARN millions of times (the line that grew
|
||||
/// worker logs to 100MB).
|
||||
pub(crate) fn log_throttle(key: &str) -> Option<u64> {
|
||||
fn throttle_map() -> &'static Mutex<HashMap<String, (std::time::Instant, u64)>> {
|
||||
static M: OnceLock<Mutex<HashMap<String, (std::time::Instant, u64)>>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
let map = throttle_map();
|
||||
let mut guard = map.lock().unwrap();
|
||||
if guard.len() > 2048 {
|
||||
guard.retain(|_, (last, _)| last.elapsed() < std::time::Duration::from_secs(60));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
match guard.get_mut(key) {
|
||||
Some((last, suppressed)) => {
|
||||
if now.duration_since(*last) >= std::time::Duration::from_secs(30) {
|
||||
let dropped = *suppressed;
|
||||
*last = now;
|
||||
*suppressed = 0;
|
||||
Some(dropped)
|
||||
} else {
|
||||
*suppressed += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
guard.insert(key.to_string(), (now, 0));
|
||||
Some(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a stream to `target_host:target_port`, either directly or through
|
||||
/// the configured upstream proxy. Shared by the HTTP CONNECT path and the
|
||||
/// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT,
|
||||
@@ -1498,21 +1842,8 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
let _ = stream.set_nodelay(true);
|
||||
};
|
||||
let target_stream: BoxedAsyncStream = match upstream_url {
|
||||
None => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
Some("DIRECT") => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
_ if should_bypass => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
None | Some("DIRECT") => Box::new(dial_direct(target_host, target_port).await?),
|
||||
_ if should_bypass => Box::new(dial_direct(target_host, target_port).await?),
|
||||
Some(upstream_url_str) => {
|
||||
let upstream = Url::parse(upstream_url_str)?;
|
||||
let scheme = upstream.scheme();
|
||||
@@ -1521,7 +1852,14 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
"http" | "https" => {
|
||||
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||
let proxy_port = upstream.port().unwrap_or(8080);
|
||||
let mut proxy_stream = TcpStream::connect((proxy_host, proxy_port)).await?;
|
||||
let mut proxy_stream = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
TcpStream::connect((proxy_host, proxy_port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
format!("upstream proxy connect to {proxy_host}:{proxy_port} timed out")
|
||||
})??;
|
||||
configure_tcp(&proxy_stream);
|
||||
|
||||
let mut connect_req = format!(
|
||||
@@ -1529,10 +1867,9 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
target_host, target_port, target_host, target_port
|
||||
);
|
||||
|
||||
if !upstream.username().is_empty() {
|
||||
let (username, password) = upstream_userpass(&upstream);
|
||||
if !username.is_empty() {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
|
||||
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
|
||||
}
|
||||
@@ -1542,7 +1879,9 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
proxy_stream.write_all(connect_req.as_bytes()).await?;
|
||||
|
||||
let mut buffer = [0u8; 4096];
|
||||
let n = proxy_stream.read(&mut buffer).await?;
|
||||
let n = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, proxy_stream.read(&mut buffer))
|
||||
.await
|
||||
.map_err(|_| "upstream proxy CONNECT response timed out")??;
|
||||
let response_full = String::from_utf8_lossy(&buffer[..n]).to_string();
|
||||
let status_line = response_full.lines().next().unwrap_or("").to_string();
|
||||
|
||||
@@ -1590,19 +1929,15 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
let socks_port = upstream.port().unwrap_or(1080);
|
||||
let socks_addr = format!("{}:{}", socks_host, socks_port);
|
||||
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let (username, password) = upstream_userpass(&upstream);
|
||||
let auth = (!username.is_empty()).then_some((username.as_str(), password.as_str()));
|
||||
|
||||
let stream = connect_via_socks(
|
||||
&socks_addr,
|
||||
target_host,
|
||||
target_port,
|
||||
scheme == "socks5",
|
||||
if !username.is_empty() {
|
||||
Some((username, password))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
auth,
|
||||
)
|
||||
.await?;
|
||||
Box::new(stream)
|
||||
@@ -1645,12 +1980,16 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
let target_addr =
|
||||
shadowsocks::relay::Address::DomainNameAddress(target_host.to_string(), target_port);
|
||||
|
||||
let stream = shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect(
|
||||
context,
|
||||
&svr_cfg,
|
||||
target_addr,
|
||||
let stream = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect(
|
||||
context,
|
||||
&svr_cfg,
|
||||
target_addr,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Shadowsocks connection timed out".to_string())?
|
||||
.map_err(|e| format!("Shadowsocks connection failed: {e}"))?;
|
||||
|
||||
Box::new(stream)
|
||||
@@ -1743,6 +2082,61 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// Build an upstream URL with `urlencoding::encode`-d user/pass,
|
||||
/// mirroring what `proxy_manager::build_proxy_url` actually emits
|
||||
fn parse_encoded_upstream(scheme: &str, user: &str, pass: &str) -> Url {
|
||||
let s = format!(
|
||||
"{}://{}:{}@127.0.0.1:1080",
|
||||
scheme,
|
||||
urlencoding::encode(user),
|
||||
urlencoding::encode(pass),
|
||||
);
|
||||
Url::parse(&s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_handles_plain_ascii() {
|
||||
let u = parse_encoded_upstream("socks5", "alice", "secret123");
|
||||
assert_eq!(upstream_userpass(&u), ("alice".into(), "secret123".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_decodes_special_chars() {
|
||||
// These characters all get percent-encoded by build_proxy_url before
|
||||
// landing in the URL, and must be decoded back to the original literal
|
||||
// before being handed off to the upstream
|
||||
let cases = [
|
||||
("alice", "p@ssw0rd"),
|
||||
("alice", "p:assw0rd"),
|
||||
("alice", "p ass word"),
|
||||
("alice", "abc/d+e=f"),
|
||||
("alice", "100%off!"),
|
||||
("alice", "测试密码"),
|
||||
("u@name", "v@lue"),
|
||||
];
|
||||
for (user, pass) in cases {
|
||||
let u = parse_encoded_upstream("socks5", user, pass);
|
||||
assert_eq!(
|
||||
upstream_userpass(&u),
|
||||
(user.to_string(), pass.to_string()),
|
||||
"decode failed: user={user:?} pass={pass:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_empty_when_no_credentials() {
|
||||
let u = Url::parse("socks5://127.0.0.1:1080").unwrap();
|
||||
assert_eq!(upstream_userpass(&u), (String::new(), String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_handles_username_only() {
|
||||
let s = format!("socks5://{}@127.0.0.1:1080", urlencoding::encode("u@name"));
|
||||
let u = Url::parse(&s).unwrap();
|
||||
assert_eq!(upstream_userpass(&u), ("u@name".into(), String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_exact_match() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
|
||||
@@ -22,6 +22,13 @@ pub struct ProxyConfig {
|
||||
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
|
||||
#[serde(default)]
|
||||
pub local_protocol: Option<String>,
|
||||
/// PID of the browser process this worker serves, recorded by the GUI after
|
||||
/// launch. The detached worker watches this and self-terminates when the
|
||||
/// browser dies, so it dies with its browser even if the GUI has exited or
|
||||
/// restarted. `None` until launch completes (the worker keeps running while
|
||||
/// it is `None`).
|
||||
#[serde(default)]
|
||||
pub browser_pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
@@ -37,6 +44,7 @@ impl ProxyConfig {
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ async fn handle_connect(
|
||||
tracker.record_request(&host, 0, 0);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
log::debug!(
|
||||
"SOCKS5 CONNECT {}:{} (upstream={})",
|
||||
host,
|
||||
port,
|
||||
@@ -252,16 +252,29 @@ async fn handle_connect(
|
||||
|
||||
// Resolve to the target stream, logging and dropping the (non-Send) dial
|
||||
// error inside the match arm so it is never held across the await below.
|
||||
let target =
|
||||
match connect_to_target_via_upstream(&host, port, upstream_url.as_deref(), &bypass_matcher)
|
||||
.await
|
||||
{
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}");
|
||||
None
|
||||
let target = match connect_to_target_via_upstream(
|
||||
&host,
|
||||
port,
|
||||
upstream_url.as_deref(),
|
||||
&bypass_matcher,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
let key = format!("socks5-connect:{host}:{port}");
|
||||
if let Some(suppressed) = crate::proxy_server::log_throttle(&key) {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"SOCKS5 CONNECT to {host}:{port} failed: {e} ({suppressed} more suppressed in last 30s)"
|
||||
);
|
||||
} else {
|
||||
log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let Some(target) = target else {
|
||||
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
|
||||
@@ -29,24 +29,35 @@ pub enum SyncWorkItem {
|
||||
Tombstone(String, String),
|
||||
}
|
||||
|
||||
/// Where a subscription's sync token comes from, so reconnects can re-fetch a
|
||||
/// fresh one (tokens are short-lived, ~15 min).
|
||||
#[derive(Clone, Copy)]
|
||||
enum TokenSource {
|
||||
Cloud,
|
||||
SelfHosted,
|
||||
}
|
||||
|
||||
pub struct SyncSubscription {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
token: String,
|
||||
source: TokenSource,
|
||||
running: Arc<AtomicBool>,
|
||||
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
|
||||
}
|
||||
|
||||
impl SyncSubscription {
|
||||
pub fn new(
|
||||
fn new(
|
||||
base_url: String,
|
||||
token: String,
|
||||
source: TokenSource,
|
||||
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token,
|
||||
source,
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
work_tx,
|
||||
}
|
||||
@@ -66,7 +77,7 @@ impl SyncSubscription {
|
||||
let Some(token) = token else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(Some(Self::new(url, token, work_tx)));
|
||||
return Ok(Some(Self::new(url, token, TokenSource::Cloud, work_tx)));
|
||||
}
|
||||
|
||||
// Fall back to self-hosted settings
|
||||
@@ -88,7 +99,12 @@ impl SyncSubscription {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(Self::new(server_url, token, work_tx)))
|
||||
Ok(Some(Self::new(
|
||||
server_url,
|
||||
token,
|
||||
TokenSource::SelfHosted,
|
||||
work_tx,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
@@ -106,9 +122,10 @@ impl SyncSubscription {
|
||||
|
||||
let running = self.running.clone();
|
||||
let base_url = self.base_url.clone();
|
||||
let token = self.token.clone();
|
||||
let source = self.source;
|
||||
let work_tx = self.work_tx.clone();
|
||||
let client = self.client.clone();
|
||||
let mut token = self.token.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while running.load(Ordering::SeqCst) {
|
||||
@@ -126,6 +143,20 @@ impl SyncSubscription {
|
||||
|
||||
if running.load(Ordering::SeqCst) {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
// Refresh the sync token before reconnecting. The token may have
|
||||
// expired while the stream was open (tokens last ~15 min); reusing
|
||||
// the construction-time token otherwise produces an endless 401
|
||||
// reconnect loop until the app is restarted (issue #440).
|
||||
match Self::fetch_sync_token(source, &app_handle).await {
|
||||
Ok(Some(fresh)) => token = fresh,
|
||||
Ok(None) => {
|
||||
log::info!("Sync token no longer available; stopping subscription");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to refresh sync token: {e}; retrying with the current token");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +164,24 @@ impl SyncSubscription {
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch a current sync token from the same source the subscription was
|
||||
/// created from, so reconnects never reuse a stale (expired) token.
|
||||
async fn fetch_sync_token(
|
||||
source: TokenSource,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<Option<String>, String> {
|
||||
match source {
|
||||
TokenSource::Cloud => crate::cloud_auth::CLOUD_AUTH
|
||||
.get_or_refresh_sync_token()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh cloud sync token: {e}")),
|
||||
TokenSource::SelfHosted => SettingsManager::instance()
|
||||
.get_sync_token(app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh self-hosted sync token: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_and_listen(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
|
||||
+194
-35
@@ -228,6 +228,10 @@ export default function Home() {
|
||||
// Cloud auth for cross-OS unlock
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
||||
// Bulk run/stop is a paid (browser automation) feature, matching the
|
||||
// /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop
|
||||
// actions disabled with a Pro badge.
|
||||
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
|
||||
|
||||
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||
useState(false);
|
||||
@@ -708,49 +712,67 @@ export default function Home() {
|
||||
);
|
||||
|
||||
const listenForUrlEvents = useCallback(async () => {
|
||||
// Collect every listener we register so that — whether setup completes or
|
||||
// throws partway through — we tear down exactly what was registered.
|
||||
// Previously the Tauri unlisten handles were discarded (so re-runs stacked
|
||||
// duplicate handlers and a single URL was handled N times), and a failing
|
||||
// listen() call would leak the listeners that had already succeeded.
|
||||
const unlisteners: Array<() => void> = [];
|
||||
let handleLogoUrlEvent: ((event: CustomEvent) => void) | undefined;
|
||||
const teardown = () => {
|
||||
for (const unlisten of unlisteners) unlisten();
|
||||
if (handleLogoUrlEvent) {
|
||||
window.removeEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 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);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("url-open-request", (event) => {
|
||||
console.log("Received URL open request:", 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);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen for show create profile dialog events
|
||||
await listen<string>("show-create-profile-dialog", (event) => {
|
||||
console.log(
|
||||
"Received show create profile dialog request:",
|
||||
event.payload,
|
||||
);
|
||||
showErrorToast(t("errors.noProfilesForUrl"));
|
||||
setCreateProfileDialogOpen(true);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("show-create-profile-dialog", (event) => {
|
||||
console.log(
|
||||
"Received show create profile dialog request:",
|
||||
event.payload,
|
||||
);
|
||||
showErrorToast(t("errors.noProfilesForUrl"));
|
||||
setCreateProfileDialogOpen(true);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen for custom logo click events
|
||||
const handleLogoUrlEvent = (event: CustomEvent) => {
|
||||
handleLogoUrlEvent = (event: CustomEvent) => {
|
||||
console.log("Received logo URL event:", event.detail);
|
||||
handleUrlOpen(event.detail);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
};
|
||||
return teardown;
|
||||
} catch (error) {
|
||||
console.error("Failed to setup URL listener:", error);
|
||||
// Tear down whatever did register before the failure so nothing leaks.
|
||||
teardown();
|
||||
}
|
||||
}, [handleUrlOpen, t]);
|
||||
|
||||
@@ -1128,6 +1150,75 @@ export default function Home() {
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, [selectedProfiles, profiles, t]);
|
||||
|
||||
const [pendingBulkAction, setPendingBulkAction] = useState<{
|
||||
action: "run" | "stop";
|
||||
profiles: BrowserProfile[];
|
||||
} | null>(null);
|
||||
const [isBulkActing, setIsBulkActing] = useState(false);
|
||||
|
||||
const executeBulkRun = useCallback(
|
||||
async (targets: BrowserProfile[]) => {
|
||||
setIsBulkActing(true);
|
||||
try {
|
||||
await Promise.allSettled(targets.map((p) => launchProfile(p)));
|
||||
setSelectedProfiles([]);
|
||||
} finally {
|
||||
setIsBulkActing(false);
|
||||
setPendingBulkAction(null);
|
||||
}
|
||||
},
|
||||
[launchProfile],
|
||||
);
|
||||
|
||||
const executeBulkStop = useCallback(
|
||||
async (targets: BrowserProfile[]) => {
|
||||
setIsBulkActing(true);
|
||||
try {
|
||||
await Promise.allSettled(targets.map((p) => handleKillProfile(p)));
|
||||
setSelectedProfiles([]);
|
||||
} finally {
|
||||
setIsBulkActing(false);
|
||||
setPendingBulkAction(null);
|
||||
}
|
||||
},
|
||||
[handleKillProfile],
|
||||
);
|
||||
|
||||
// Bulk run/stop only touch eligible profiles (run: not already running;
|
||||
// stop: currently running). An empty result shows a toast instead of a silent
|
||||
// no-op (guard), and 10+ targets require confirmation before launching/stopping.
|
||||
const handleBulkRun = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
const targets = profiles.filter(
|
||||
(p) => selectedProfiles.includes(p.id) && !runningProfiles.has(p.id),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
showErrorToast(t("profiles.bulkRun.noneToRun"));
|
||||
return;
|
||||
}
|
||||
if (targets.length >= 10) {
|
||||
setPendingBulkAction({ action: "run", profiles: targets });
|
||||
return;
|
||||
}
|
||||
void executeBulkRun(targets);
|
||||
}, [selectedProfiles, profiles, runningProfiles, executeBulkRun, t]);
|
||||
|
||||
const handleBulkStop = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
const targets = profiles.filter(
|
||||
(p) => selectedProfiles.includes(p.id) && runningProfiles.has(p.id),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
showErrorToast(t("profiles.bulkStop.noneToStop"));
|
||||
return;
|
||||
}
|
||||
if (targets.length >= 10) {
|
||||
setPendingBulkAction({ action: "stop", profiles: targets });
|
||||
return;
|
||||
}
|
||||
void executeBulkStop(targets);
|
||||
}, [selectedProfiles, profiles, runningProfiles, executeBulkStop, t]);
|
||||
|
||||
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
|
||||
setSelectedProfilesForCookies([profile.id]);
|
||||
setCookieCopyDialogOpen(true);
|
||||
@@ -1184,6 +1275,7 @@ export default function Home() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlistenStatus: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
const profilesWithTransfer = new Set<string>();
|
||||
@@ -1260,25 +1352,35 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
});
|
||||
// If the effect was torn down while we were awaiting the listeners,
|
||||
// unlisten immediately — the cleanup below already ran and would have
|
||||
// missed these handles. (Tauri unlisten is safe to call more than once.)
|
||||
if (disposed) {
|
||||
unlistenStatus?.();
|
||||
unlistenProgress?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync events:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (unlistenStatus) unlistenStatus();
|
||||
if (unlistenProgress) unlistenProgress();
|
||||
};
|
||||
}, [profiles, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for URL open events and get cleanup function
|
||||
const setupListeners = async () => {
|
||||
const cleanup = await listenForUrlEvents();
|
||||
return cleanup;
|
||||
};
|
||||
|
||||
// Listen for URL open events. Guard against the effect tearing down (or
|
||||
// re-running) before the async listener setup resolves: if that happens,
|
||||
// run the cleanup as soon as it's available so the listeners never leak.
|
||||
let cleanup: (() => void) | undefined;
|
||||
void setupListeners().then((cleanupFn) => {
|
||||
let disposed = false;
|
||||
void listenForUrlEvents().then((cleanupFn) => {
|
||||
if (disposed) {
|
||||
cleanupFn?.();
|
||||
return;
|
||||
}
|
||||
cleanup = cleanupFn;
|
||||
});
|
||||
|
||||
@@ -1306,10 +1408,9 @@ export default function Home() {
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearInterval(updateInterval);
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
cleanup?.();
|
||||
};
|
||||
}, [
|
||||
checkForUpdates,
|
||||
@@ -1323,6 +1424,7 @@ export default function Home() {
|
||||
// E2E encryption listeners — surface password-required prompts and rollover
|
||||
// progress so the user isn't left guessing whether sealing finished.
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlistenRequired: (() => void) | undefined;
|
||||
let unlistenStarted: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
@@ -1399,9 +1501,20 @@ export default function Home() {
|
||||
duration: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
// If the effect was torn down mid-setup, the cleanup below already ran
|
||||
// before these handles existed — unlisten them now so nothing leaks.
|
||||
if (disposed) {
|
||||
unlistenRequired?.();
|
||||
unlistenStarted?.();
|
||||
unlistenProgress?.();
|
||||
unlistenCompleted?.();
|
||||
unlistenWayfernBlocked?.();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlistenRequired?.();
|
||||
unlistenStarted?.();
|
||||
unlistenProgress?.();
|
||||
@@ -1569,6 +1682,9 @@ export default function Home() {
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onBulkProxyAssignment={handleBulkProxyAssignment}
|
||||
onBulkCopyCookies={handleBulkCopyCookies}
|
||||
onBulkRun={handleBulkRun}
|
||||
onBulkStop={handleBulkStop}
|
||||
bulkActionsUnlocked={automationUnlocked}
|
||||
onBulkExtensionGroupAssignment={
|
||||
handleBulkExtensionGroupAssignment
|
||||
}
|
||||
@@ -1868,6 +1984,49 @@ export default function Home() {
|
||||
profile={currentProfileForCookieManagement}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={pendingBulkAction !== null}
|
||||
onClose={() => {
|
||||
setPendingBulkAction(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (!pendingBulkAction) return;
|
||||
if (pendingBulkAction.action === "run") {
|
||||
void executeBulkRun(pendingBulkAction.profiles);
|
||||
} else {
|
||||
void executeBulkStop(pendingBulkAction.profiles);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
pendingBulkAction?.action === "stop"
|
||||
? t("profiles.bulkStop.confirmTitle", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
: t("profiles.bulkRun.confirmTitle", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
}
|
||||
description={
|
||||
pendingBulkAction?.action === "stop"
|
||||
? t("profiles.bulkStop.confirmDescription", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
: t("profiles.bulkRun.confirmDescription", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
}
|
||||
confirmButtonText={
|
||||
pendingBulkAction?.action === "stop"
|
||||
? t("profiles.bulkStop.confirmButton", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
: t("profiles.bulkRun.confirmButton", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
}
|
||||
confirmButtonVariant="default"
|
||||
isLoading={isBulkActing}
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -50,8 +51,7 @@ export function CreateGroupDialog({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("groups.createFailed");
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -19,6 +19,12 @@ interface DeleteConfirmationDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmButtonText?: string;
|
||||
confirmButtonVariant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost";
|
||||
isLoading?: boolean;
|
||||
profileIds?: string[];
|
||||
profiles?: { id: string; name: string }[];
|
||||
@@ -31,6 +37,7 @@ export function DeleteConfirmationDialog({
|
||||
title,
|
||||
description,
|
||||
confirmButtonText,
|
||||
confirmButtonVariant = "destructive",
|
||||
isLoading = false,
|
||||
profileIds,
|
||||
profiles = [],
|
||||
@@ -79,7 +86,7 @@ export function DeleteConfirmationDialog({
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
variant={confirmButtonVariant}
|
||||
onClick={() => void handleConfirm()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { BrowserProfile, ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -97,8 +98,7 @@ export function DeleteGroupDialog({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("groups.deleteFailed");
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -61,8 +62,7 @@ export function EditGroupDialog({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("groups.updateFailed");
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -495,9 +495,15 @@ export function GroupManagementDialog({
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((groupId) => invoke("delete_profile_group", { groupId })),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected");
|
||||
if (failed.length > 0) {
|
||||
showErrorToast(t("groups.deleteFailed"));
|
||||
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("groups.deleteFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("groups.deleteSuccess"));
|
||||
}
|
||||
@@ -507,9 +513,7 @@ export function GroupManagementDialog({
|
||||
onGroupManagementComplete();
|
||||
} catch (err) {
|
||||
console.error("Bulk group delete failed:", err);
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("groups.deleteFailed"),
|
||||
);
|
||||
showErrorToast(translateBackendError(t, err));
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
|
||||
@@ -51,11 +51,18 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -1134,6 +1141,9 @@ interface ProfilesDataTableProps {
|
||||
onBulkGroupAssignment?: () => void;
|
||||
onBulkProxyAssignment?: () => void;
|
||||
onBulkCopyCookies?: () => void;
|
||||
onBulkRun?: () => void;
|
||||
onBulkStop?: () => void;
|
||||
bulkActionsUnlocked?: boolean;
|
||||
onBulkExtensionGroupAssignment?: () => void;
|
||||
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
@@ -1179,6 +1189,9 @@ export function ProfilesDataTable({
|
||||
onBulkGroupAssignment,
|
||||
onBulkProxyAssignment,
|
||||
onBulkCopyCookies,
|
||||
onBulkRun,
|
||||
onBulkStop,
|
||||
bulkActionsUnlocked = false,
|
||||
onBulkExtensionGroupAssignment,
|
||||
onAssignExtensionGroup,
|
||||
onOpenProfileSyncDialog,
|
||||
@@ -1237,14 +1250,16 @@ export function ProfilesDataTable({
|
||||
(id) => newSelection[id],
|
||||
);
|
||||
|
||||
// Only update external state if selection actually changed
|
||||
const prevIds = Object.keys(prevSelection).filter(
|
||||
(id) => prevSelection[id],
|
||||
// Only update external state if selection actually changed.
|
||||
// A Set gives O(1) membership; Array.includes() inside .every() would
|
||||
// be O(n*m) over large selections.
|
||||
const prevIdSet = new Set(
|
||||
Object.keys(prevSelection).filter((id) => prevSelection[id]),
|
||||
);
|
||||
|
||||
if (
|
||||
selectedIds.length !== prevIds.length ||
|
||||
!selectedIds.every((id) => prevIds.includes(id))
|
||||
selectedIds.length !== prevIdSet.size ||
|
||||
!selectedIds.every((id) => prevIdSet.has(id))
|
||||
) {
|
||||
onSelectedProfilesChange(selectedIds);
|
||||
}
|
||||
@@ -1546,10 +1561,13 @@ export function ProfilesDataTable({
|
||||
"get_all_traffic_snapshots",
|
||||
);
|
||||
const newSnapshots: Record<string, TrafficSnapshot> = {};
|
||||
// O(1) membership; runningProfileIds.includes() in this loop would be
|
||||
// O(snapshots * runningProfiles).
|
||||
const runningSet = new Set(runningProfileIds);
|
||||
for (const snapshot of allSnapshots) {
|
||||
if (snapshot.profile_id) {
|
||||
// Only keep snapshots for profiles that are currently running
|
||||
if (runningProfileIds.includes(snapshot.profile_id)) {
|
||||
if (runningSet.has(snapshot.profile_id)) {
|
||||
const existing = newSnapshots[snapshot.profile_id];
|
||||
if (!existing || snapshot.last_update > existing.last_update) {
|
||||
newSnapshots[snapshot.profile_id] = snapshot;
|
||||
@@ -1578,9 +1596,10 @@ export function ProfilesDataTable({
|
||||
|
||||
setTrafficSnapshots((prev) => {
|
||||
const cleaned: Record<string, TrafficSnapshot> = {};
|
||||
const runningSet = new Set(runningProfileIds);
|
||||
for (const [profileId, snapshot] of Object.entries(prev)) {
|
||||
// Only keep snapshots for profiles that are currently running
|
||||
if (runningProfileIds.includes(profileId)) {
|
||||
if (runningSet.has(profileId)) {
|
||||
cleaned[profileId] = snapshot;
|
||||
}
|
||||
}
|
||||
@@ -2374,28 +2393,89 @@ export function ProfilesDataTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hidden, sort-only column so profiles can be sorted by creation date
|
||||
// without showing a Created column in the table (issue #454). Kept
|
||||
// hidden via columnVisibility; sorting still works on hidden columns.
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.created_at ?? 0,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
sortingFn: "basic",
|
||||
header: () => null,
|
||||
cell: () => null,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
// The only column without a fixed width: table-fixed hands it all
|
||||
// remaining space as the window grows or shrinks.
|
||||
meta: { flexWidth: true },
|
||||
header: ({ column, table }) => {
|
||||
// The Name header doubles as the sort control: clicking opens a menu to
|
||||
// sort by name (A–Z / Z–A) or by creation date (newest / oldest), so
|
||||
// creation-date sorting needs no visible column.
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const sort = table.getState().sorting[0];
|
||||
const isActive = (id: string, desc: boolean) =>
|
||||
sort?.id === id && !!sort.desc === desc;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
{meta.t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<LuChevronUp className="ml-2 size-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
<LuChevronDown className="ml-2 size-4" />
|
||||
) : null}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
{meta.t("common.labels.name")}
|
||||
{isActive("name", false) ? (
|
||||
<LuChevronUp className="ml-2 size-4" />
|
||||
) : isActive("name", true) ? (
|
||||
<LuChevronDown className="ml-2 size-4" />
|
||||
) : (
|
||||
<LuChevronDown className="ml-2 size-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "name", desc: false }])
|
||||
}
|
||||
>
|
||||
{isActive("name", false) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.nameAsc")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => table.setSorting([{ id: "name", desc: true }])}
|
||||
>
|
||||
{isActive("name", true) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.nameDesc")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "created_at", desc: true }])
|
||||
}
|
||||
>
|
||||
{isActive("created_at", true) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.newest")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "created_at", desc: false }])
|
||||
}
|
||||
>
|
||||
{isActive("created_at", false) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.oldest")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
@@ -2927,7 +3007,7 @@ export function ProfilesDataTable({
|
||||
// expendable first); their data stays reachable via the profile info
|
||||
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
React.useState<VisibilityState>({ created_at: false });
|
||||
|
||||
// Content columns grow proportionally with the container but never drop
|
||||
// below the compact-layout floor; the name column takes the remainder.
|
||||
@@ -2987,6 +3067,8 @@ export function ProfilesDataTable({
|
||||
setContainerWidth(Math.round(w / 8) * 8);
|
||||
setColumnVisibility((prev) => {
|
||||
const next: VisibilityState = {
|
||||
// Always hidden — sort-only column (issue #454).
|
||||
created_at: false,
|
||||
dns: w >= 768,
|
||||
ext: w >= 672,
|
||||
note: w >= 576,
|
||||
@@ -3223,6 +3305,44 @@ export function ProfilesDataTable({
|
||||
})()}
|
||||
<DataTableActionBar table={table}>
|
||||
<DataTableActionBarSelection table={table} />
|
||||
{onBulkRun && (
|
||||
<span className="relative inline-flex">
|
||||
<DataTableActionBarAction
|
||||
tooltip={
|
||||
bulkActionsUnlocked
|
||||
? t("profiles.actionBar.runSelected")
|
||||
: t("profiles.actionBar.proRequired")
|
||||
}
|
||||
onClick={bulkActionsUnlocked ? onBulkRun : undefined}
|
||||
disabled={!bulkActionsUnlocked}
|
||||
size="icon"
|
||||
>
|
||||
<LuPlay className="fill-current" />
|
||||
</DataTableActionBarAction>
|
||||
{!bulkActionsUnlocked && (
|
||||
<ProBadge className="absolute -top-2 -right-2 pointer-events-none" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{onBulkStop && (
|
||||
<span className="relative inline-flex">
|
||||
<DataTableActionBarAction
|
||||
tooltip={
|
||||
bulkActionsUnlocked
|
||||
? t("profiles.actionBar.stopSelected")
|
||||
: t("profiles.actionBar.proRequired")
|
||||
}
|
||||
onClick={bulkActionsUnlocked ? onBulkStop : undefined}
|
||||
disabled={!bulkActionsUnlocked}
|
||||
size="icon"
|
||||
>
|
||||
<LuSquare className="fill-current" />
|
||||
</DataTableActionBarAction>
|
||||
{!bulkActionsUnlocked && (
|
||||
<ProBadge className="absolute -top-2 -right-2 pointer-events-none" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{onBulkGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={t("profiles.actionBar.assignToGroup")}
|
||||
|
||||
@@ -878,6 +878,17 @@ function ProfileInfoLayout({
|
||||
{t("profileInfo.sections.activity")}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.created")}
|
||||
value={
|
||||
profile.created_at
|
||||
? new Date(profile.created_at * 1000).toLocaleString(
|
||||
undefined,
|
||||
{ dateStyle: "medium", timeStyle: "short" },
|
||||
)
|
||||
: t("profileInfo.values.unknown")
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.lastLaunched")}
|
||||
value={
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -127,9 +128,11 @@ export function ProxyFormDialog({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t("proxies.form.saveFailed", { error: errorMessage }));
|
||||
toast.error(
|
||||
t("proxies.form.saveFailed", {
|
||||
error: translateBackendError(t, error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
aria-label={t(labelKey)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||
"relative grid place-items-center size-7 rounded-md cursor-pointer transition-colors duration-100 shrink-0",
|
||||
active
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
@@ -385,7 +385,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
aria-label={t("rail.more.label")}
|
||||
aria-expanded={moreOpen}
|
||||
className={cn(
|
||||
"grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||
"grid place-items-center size-7 rounded-md cursor-pointer transition-colors duration-100 shrink-0",
|
||||
moreOpen
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
@@ -407,7 +407,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
aria-label={t("rail.settings")}
|
||||
aria-current={currentPage === "settings" ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||
"relative grid place-items-center size-7 rounded-md cursor-pointer transition-colors duration-100 shrink-0",
|
||||
currentPage === "settings"
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
@@ -444,7 +444,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
setMoreOpen(false);
|
||||
onNavigate(page);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-accent transition-colors duration-100 text-left"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md cursor-pointer hover:bg-accent transition-colors duration-100 text-left"
|
||||
>
|
||||
<span className="grid place-items-center size-5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
<Icon className="size-3" />
|
||||
|
||||
@@ -153,7 +153,7 @@ function CommandItem({
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square size-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"cursor-pointer aspect-square size-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -305,11 +305,32 @@
|
||||
"assignToGroup": "Assign to Group",
|
||||
"assignProxy": "Assign Proxy",
|
||||
"assignExtensionGroup": "Assign Extension Group",
|
||||
"copyCookies": "Copy Cookies"
|
||||
"copyCookies": "Copy Cookies",
|
||||
"runSelected": "Run selected",
|
||||
"stopSelected": "Stop selected",
|
||||
"proRequired": "Pro plan required for bulk run/stop"
|
||||
},
|
||||
"passwordProtectedBadge": "Password Protected",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
},
|
||||
"bulkRun": {
|
||||
"confirmTitle": "Run {{count}} profiles?",
|
||||
"confirmDescription": "Launching {{count}} profiles at once can use a lot of system resources. Continue?",
|
||||
"confirmButton": "Run {{count}}",
|
||||
"noneToRun": "No selected profiles to run."
|
||||
},
|
||||
"bulkStop": {
|
||||
"confirmTitle": "Stop {{count}} profiles?",
|
||||
"confirmDescription": "Stop {{count}} running profiles?",
|
||||
"confirmButton": "Stop {{count}}",
|
||||
"noneToStop": "No selected profiles to stop."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Name (A–Z)",
|
||||
"nameDesc": "Name (Z–A)",
|
||||
"newest": "Newest first",
|
||||
"oldest": "Oldest first"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1134,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies stored",
|
||||
"localDataTransfer": "Local data transfer"
|
||||
"localDataTransfer": "Local data transfer",
|
||||
"created": "Created"
|
||||
},
|
||||
"values": {
|
||||
"none": "None",
|
||||
@@ -1143,7 +1165,8 @@
|
||||
"yes": "Yes",
|
||||
"activeNow": "Active now",
|
||||
"direct": "Direct",
|
||||
"loading": "Loading…"
|
||||
"loading": "Loading…",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Proxy Bypass Rules",
|
||||
@@ -1821,6 +1844,7 @@
|
||||
"passwordTooShort": "Password must be at least {{min}} characters",
|
||||
"proxyNotFound": "Proxy not found",
|
||||
"groupNotFound": "Group not found",
|
||||
"groupAlreadyExists": "A group with this name already exists",
|
||||
"vpnNotFound": "VPN not found",
|
||||
"extensionNotFound": "Extension not found",
|
||||
"extensionGroupNotFound": "Extension group not found",
|
||||
|
||||
@@ -305,11 +305,32 @@
|
||||
"assignToGroup": "Asignar a grupo",
|
||||
"assignProxy": "Asignar proxy",
|
||||
"assignExtensionGroup": "Asignar grupo de extensiones",
|
||||
"copyCookies": "Copiar cookies"
|
||||
"copyCookies": "Copiar cookies",
|
||||
"runSelected": "Ejecutar seleccionados",
|
||||
"stopSelected": "Detener seleccionados",
|
||||
"proRequired": "Se requiere el plan Pro para ejecución/parada masiva"
|
||||
},
|
||||
"passwordProtectedBadge": "Protegido por Contraseña",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
},
|
||||
"bulkRun": {
|
||||
"confirmTitle": "¿Ejecutar {{count}} perfiles?",
|
||||
"confirmDescription": "Iniciar {{count}} perfiles a la vez puede consumir muchos recursos del sistema. ¿Continuar?",
|
||||
"confirmButton": "Ejecutar {{count}}",
|
||||
"noneToRun": "No hay perfiles seleccionados para ejecutar."
|
||||
},
|
||||
"bulkStop": {
|
||||
"confirmTitle": "¿Detener {{count}} perfiles?",
|
||||
"confirmDescription": "¿Detener {{count}} perfiles en ejecución?",
|
||||
"confirmButton": "Detener {{count}}",
|
||||
"noneToStop": "No hay perfiles seleccionados para detener."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Nombre (A–Z)",
|
||||
"nameDesc": "Nombre (Z–A)",
|
||||
"newest": "Más recientes primero",
|
||||
"oldest": "Más antiguos primero"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1134,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies guardadas",
|
||||
"localDataTransfer": "Transferencia de datos local"
|
||||
"localDataTransfer": "Transferencia de datos local",
|
||||
"created": "Creado"
|
||||
},
|
||||
"values": {
|
||||
"none": "Ninguno",
|
||||
@@ -1143,7 +1165,8 @@
|
||||
"yes": "Sí",
|
||||
"activeNow": "Activo ahora",
|
||||
"direct": "Directa",
|
||||
"loading": "Cargando…"
|
||||
"loading": "Cargando…",
|
||||
"unknown": "Desconocido"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Reglas de Omisión de Proxy",
|
||||
@@ -1821,6 +1844,7 @@
|
||||
"passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres",
|
||||
"proxyNotFound": "Proxy no encontrado",
|
||||
"groupNotFound": "Grupo no encontrado",
|
||||
"groupAlreadyExists": "Ya existe un grupo con este nombre",
|
||||
"vpnNotFound": "VPN no encontrada",
|
||||
"extensionNotFound": "Extensión no encontrada",
|
||||
"extensionGroupNotFound": "Grupo de extensiones no encontrado",
|
||||
|
||||
@@ -305,11 +305,32 @@
|
||||
"assignToGroup": "Assigner à un groupe",
|
||||
"assignProxy": "Assigner un proxy",
|
||||
"assignExtensionGroup": "Assigner un groupe d’extensions",
|
||||
"copyCookies": "Copier les cookies"
|
||||
"copyCookies": "Copier les cookies",
|
||||
"runSelected": "Lancer la sélection",
|
||||
"stopSelected": "Arrêter la sélection",
|
||||
"proRequired": "Plan Pro requis pour le lancement/arrêt groupé"
|
||||
},
|
||||
"passwordProtectedBadge": "Protégé par mot de passe",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
},
|
||||
"bulkRun": {
|
||||
"confirmTitle": "Lancer {{count}} profils ?",
|
||||
"confirmDescription": "Lancer {{count}} profils à la fois peut consommer beaucoup de ressources système. Continuer ?",
|
||||
"confirmButton": "Lancer {{count}}",
|
||||
"noneToRun": "Aucun profil sélectionné à lancer."
|
||||
},
|
||||
"bulkStop": {
|
||||
"confirmTitle": "Arrêter {{count}} profils ?",
|
||||
"confirmDescription": "Arrêter {{count}} profils en cours d’exécution ?",
|
||||
"confirmButton": "Arrêter {{count}}",
|
||||
"noneToStop": "Aucun profil sélectionné à arrêter."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Nom (A–Z)",
|
||||
"nameDesc": "Nom (Z–A)",
|
||||
"newest": "Plus récents d’abord",
|
||||
"oldest": "Plus anciens d’abord"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1134,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies stockés",
|
||||
"localDataTransfer": "Transfert de données local"
|
||||
"localDataTransfer": "Transfert de données local",
|
||||
"created": "Créé le"
|
||||
},
|
||||
"values": {
|
||||
"none": "Aucun",
|
||||
@@ -1143,7 +1165,8 @@
|
||||
"yes": "Oui",
|
||||
"activeNow": "Actif maintenant",
|
||||
"direct": "Direct",
|
||||
"loading": "Chargement…"
|
||||
"loading": "Chargement…",
|
||||
"unknown": "Inconnu"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Règles de Contournement du Proxy",
|
||||
@@ -1821,6 +1844,7 @@
|
||||
"passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
|
||||
"proxyNotFound": "Proxy introuvable",
|
||||
"groupNotFound": "Groupe introuvable",
|
||||
"groupAlreadyExists": "Un groupe portant ce nom existe déjà",
|
||||
"vpnNotFound": "VPN introuvable",
|
||||
"extensionNotFound": "Extension introuvable",
|
||||
"extensionGroupNotFound": "Groupe d'extensions introuvable",
|
||||
|
||||
@@ -305,11 +305,32 @@
|
||||
"assignToGroup": "グループに割り当て",
|
||||
"assignProxy": "プロキシを割り当て",
|
||||
"assignExtensionGroup": "拡張機能グループを割り当て",
|
||||
"copyCookies": "Cookieをコピー"
|
||||
"copyCookies": "Cookieをコピー",
|
||||
"runSelected": "選択を実行",
|
||||
"stopSelected": "選択を停止",
|
||||
"proRequired": "一括実行・停止には Pro プランが必要です"
|
||||
},
|
||||
"passwordProtectedBadge": "パスワード保護",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
},
|
||||
"bulkRun": {
|
||||
"confirmTitle": "{{count}} 個のプロファイルを実行しますか?",
|
||||
"confirmDescription": "{{count}} 個のプロファイルを一度に起動すると、システムリソースを大量に消費する可能性があります。続行しますか?",
|
||||
"confirmButton": "{{count}} 個を実行",
|
||||
"noneToRun": "実行する選択中のプロファイルがありません。"
|
||||
},
|
||||
"bulkStop": {
|
||||
"confirmTitle": "{{count}} 個のプロファイルを停止しますか?",
|
||||
"confirmDescription": "実行中の {{count}} 個のプロファイルを停止しますか?",
|
||||
"confirmButton": "{{count}} 個を停止",
|
||||
"noneToStop": "停止する選択中のプロファイルがありません。"
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "名前 (A→Z)",
|
||||
"nameDesc": "名前 (Z→A)",
|
||||
"newest": "新しい順",
|
||||
"oldest": "古い順"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1134,7 +1155,8 @@
|
||||
"proxy": "プロキシ",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "保存された Cookie",
|
||||
"localDataTransfer": "ローカルデータ転送量"
|
||||
"localDataTransfer": "ローカルデータ転送量",
|
||||
"created": "作成日"
|
||||
},
|
||||
"values": {
|
||||
"none": "なし",
|
||||
@@ -1143,7 +1165,8 @@
|
||||
"yes": "はい",
|
||||
"activeNow": "現在アクティブ",
|
||||
"direct": "直接",
|
||||
"loading": "読み込み中…"
|
||||
"loading": "読み込み中…",
|
||||
"unknown": "不明"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "プロキシバイパスルール",
|
||||
@@ -1821,6 +1844,7 @@
|
||||
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
|
||||
"proxyNotFound": "プロキシが見つかりません",
|
||||
"groupNotFound": "グループが見つかりません",
|
||||
"groupAlreadyExists": "この名前のグループは既に存在します",
|
||||
"vpnNotFound": "VPNが見つかりません",
|
||||
"extensionNotFound": "拡張機能が見つかりません",
|
||||
"extensionGroupNotFound": "拡張機能グループが見つかりません",
|
||||
|
||||
@@ -305,11 +305,32 @@
|
||||
"assignToGroup": "그룹에 할당",
|
||||
"assignProxy": "프록시 할당",
|
||||
"assignExtensionGroup": "확장 프로그램 그룹 할당",
|
||||
"copyCookies": "쿠키 복사"
|
||||
"copyCookies": "쿠키 복사",
|
||||
"runSelected": "선택 실행",
|
||||
"stopSelected": "선택 중지",
|
||||
"proRequired": "대량 실행/중지하려면 Pro 플랜이 필요합니다"
|
||||
},
|
||||
"passwordProtectedBadge": "비밀번호 보호됨",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
},
|
||||
"bulkRun": {
|
||||
"confirmTitle": "{{count}}개의 프로필을 실행할까요?",
|
||||
"confirmDescription": "{{count}}개의 프로필을 한 번에 실행하면 시스템 리소스를 많이 사용할 수 있습니다. 계속할까요?",
|
||||
"confirmButton": "{{count}}개 실행",
|
||||
"noneToRun": "실행할 선택된 프로필이 없습니다."
|
||||
},
|
||||
"bulkStop": {
|
||||
"confirmTitle": "{{count}}개의 프로필을 중지할까요?",
|
||||
"confirmDescription": "실행 중인 {{count}}개의 프로필을 중지할까요?",
|
||||
"confirmButton": "{{count}}개 중지",
|
||||
"noneToStop": "중지할 선택된 프로필이 없습니다."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "이름 (A→Z)",
|
||||
"nameDesc": "이름 (Z→A)",
|
||||
"newest": "최신순",
|
||||
"oldest": "오래된순"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1134,7 +1155,8 @@
|
||||
"proxy": "프록시",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "저장된 쿠키",
|
||||
"localDataTransfer": "로컬 데이터 전송"
|
||||
"localDataTransfer": "로컬 데이터 전송",
|
||||
"created": "생성일"
|
||||
},
|
||||
"values": {
|
||||
"none": "없음",
|
||||
@@ -1143,7 +1165,8 @@
|
||||
"yes": "예",
|
||||
"activeNow": "지금 활성",
|
||||
"direct": "직접",
|
||||
"loading": "불러오는 중…"
|
||||
"loading": "불러오는 중…",
|
||||
"unknown": "알 수 없음"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "프록시 우회 규칙",
|
||||
@@ -1821,6 +1844,7 @@
|
||||
"passwordTooShort": "비밀번호는 {{min}}자 이상이어야 합니다",
|
||||
"proxyNotFound": "프록시를 찾을 수 없습니다",
|
||||
"groupNotFound": "그룹을 찾을 수 없습니다",
|
||||
"groupAlreadyExists": "이 이름의 그룹이 이미 존재합니다",
|
||||
"vpnNotFound": "VPN을 찾을 수 없습니다",
|
||||
"extensionNotFound": "확장 프로그램을 찾을 수 없습니다",
|
||||
"extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다",
|
||||
|
||||
@@ -305,11 +305,32 @@
|
||||
"assignToGroup": "Atribuir a grupo",
|
||||
"assignProxy": "Atribuir proxy",
|
||||
"assignExtensionGroup": "Atribuir grupo de extensões",
|
||||
"copyCookies": "Copiar cookies"
|
||||
"copyCookies": "Copiar cookies",
|
||||
"runSelected": "Executar selecionados",
|
||||
"stopSelected": "Parar selecionados",
|
||||
"proRequired": "Plano Pro necessário para execução/parada em massa"
|
||||
},
|
||||
"passwordProtectedBadge": "Protegido por Senha",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
},
|
||||
"bulkRun": {
|
||||
"confirmTitle": "Executar {{count}} perfis?",
|
||||
"confirmDescription": "Iniciar {{count}} perfis de uma vez pode consumir muitos recursos do sistema. Continuar?",
|
||||
"confirmButton": "Executar {{count}}",
|
||||
"noneToRun": "Nenhum perfil selecionado para executar."
|
||||
},
|
||||
"bulkStop": {
|
||||
"confirmTitle": "Parar {{count}} perfis?",
|
||||
"confirmDescription": "Parar {{count}} perfis em execução?",
|
||||
"confirmButton": "Parar {{count}}",
|
||||
"noneToStop": "Nenhum perfil selecionado para parar."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Nome (A–Z)",
|
||||
"nameDesc": "Nome (Z–A)",
|
||||
"newest": "Mais recentes primeiro",
|
||||
"oldest": "Mais antigos primeiro"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1134,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies armazenados",
|
||||
"localDataTransfer": "Transferência de dados local"
|
||||
"localDataTransfer": "Transferência de dados local",
|
||||
"created": "Criado em"
|
||||
},
|
||||
"values": {
|
||||
"none": "Nenhum",
|
||||
@@ -1143,7 +1165,8 @@
|
||||
"yes": "Sim",
|
||||
"activeNow": "Ativo agora",
|
||||
"direct": "Direto",
|
||||
"loading": "Carregando…"
|
||||
"loading": "Carregando…",
|
||||
"unknown": "Desconhecido"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Regras de Bypass de Proxy",
|
||||
@@ -1821,6 +1844,7 @@
|
||||
"passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres",
|
||||
"proxyNotFound": "Proxy não encontrado",
|
||||
"groupNotFound": "Grupo não encontrado",
|
||||
"groupAlreadyExists": "Já existe um grupo com este nome",
|
||||
"vpnNotFound": "VPN não encontrada",
|
||||
"extensionNotFound": "Extensão não encontrada",
|
||||
"extensionGroupNotFound": "Grupo de extensões não encontrado",
|
||||
|
||||
@@ -305,11 +305,32 @@
|
||||
"assignToGroup": "Назначить группе",
|
||||
"assignProxy": "Назначить прокси",
|
||||
"assignExtensionGroup": "Назначить группу расширений",
|
||||
"copyCookies": "Копировать cookies"
|
||||
"copyCookies": "Копировать cookies",
|
||||
"runSelected": "Запустить выбранные",
|
||||
"stopSelected": "Остановить выбранные",
|
||||
"proRequired": "Для массового запуска/остановки требуется план Pro"
|
||||
},
|
||||
"passwordProtectedBadge": "Защищено паролем",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
},
|
||||
"bulkRun": {
|
||||
"confirmTitle": "Запустить {{count}} профилей?",
|
||||
"confirmDescription": "Одновременный запуск {{count}} профилей может потребовать много системных ресурсов. Продолжить?",
|
||||
"confirmButton": "Запустить {{count}}",
|
||||
"noneToRun": "Нет выбранных профилей для запуска."
|
||||
},
|
||||
"bulkStop": {
|
||||
"confirmTitle": "Остановить {{count}} профилей?",
|
||||
"confirmDescription": "Остановить {{count}} запущенных профилей?",
|
||||
"confirmButton": "Остановить {{count}}",
|
||||
"noneToStop": "Нет выбранных профилей для остановки."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Имя (А–Я)",
|
||||
"nameDesc": "Имя (Я–А)",
|
||||
"newest": "Сначала новые",
|
||||
"oldest": "Сначала старые"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1134,7 +1155,8 @@
|
||||
"proxy": "ПРОКСИ",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Хранится Cookie",
|
||||
"localDataTransfer": "Локальный трафик"
|
||||
"localDataTransfer": "Локальный трафик",
|
||||
"created": "Создан"
|
||||
},
|
||||
"values": {
|
||||
"none": "Нет",
|
||||
@@ -1143,7 +1165,8 @@
|
||||
"yes": "Да",
|
||||
"activeNow": "Сейчас активен",
|
||||
"direct": "Без прокси",
|
||||
"loading": "Загрузка…"
|
||||
"loading": "Загрузка…",
|
||||
"unknown": "Неизвестно"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Правила обхода прокси",
|
||||
@@ -1821,6 +1844,7 @@
|
||||
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
|
||||
"proxyNotFound": "Прокси не найден",
|
||||
"groupNotFound": "Группа не найдена",
|
||||
"groupAlreadyExists": "Группа с таким именем уже существует",
|
||||
"vpnNotFound": "VPN не найден",
|
||||
"extensionNotFound": "Расширение не найдено",
|
||||
"extensionGroupNotFound": "Группа расширений не найдена",
|
||||
|
||||
@@ -305,11 +305,32 @@
|
||||
"assignToGroup": "Gán vào nhóm",
|
||||
"assignProxy": "Gán Proxy",
|
||||
"assignExtensionGroup": "Gán nhóm tiện ích",
|
||||
"copyCookies": "Sao chép Cookie"
|
||||
"copyCookies": "Sao chép Cookie",
|
||||
"runSelected": "Chạy mục đã chọn",
|
||||
"stopSelected": "Dừng mục đã chọn",
|
||||
"proRequired": "Cần gói Pro để chạy/dừng hàng loạt"
|
||||
},
|
||||
"passwordProtectedBadge": "Được bảo vệ bằng mật khẩu",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
},
|
||||
"bulkRun": {
|
||||
"confirmTitle": "Chạy {{count}} hồ sơ?",
|
||||
"confirmDescription": "Khởi chạy {{count}} hồ sơ cùng lúc có thể tiêu tốn nhiều tài nguyên hệ thống. Tiếp tục?",
|
||||
"confirmButton": "Chạy {{count}}",
|
||||
"noneToRun": "Không có hồ sơ nào được chọn để chạy."
|
||||
},
|
||||
"bulkStop": {
|
||||
"confirmTitle": "Dừng {{count}} hồ sơ?",
|
||||
"confirmDescription": "Dừng {{count}} hồ sơ đang chạy?",
|
||||
"confirmButton": "Dừng {{count}}",
|
||||
"noneToStop": "Không có hồ sơ nào được chọn để dừng."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Tên (A–Z)",
|
||||
"nameDesc": "Tên (Z–A)",
|
||||
"newest": "Mới nhất trước",
|
||||
"oldest": "Cũ nhất trước"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1134,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookie đã lưu",
|
||||
"localDataTransfer": "Truyền dữ liệu cục bộ"
|
||||
"localDataTransfer": "Truyền dữ liệu cục bộ",
|
||||
"created": "Đã tạo"
|
||||
},
|
||||
"values": {
|
||||
"none": "Không có",
|
||||
@@ -1143,7 +1165,8 @@
|
||||
"yes": "Có",
|
||||
"activeNow": "Đang hoạt động",
|
||||
"direct": "Trực tiếp",
|
||||
"loading": "Đang tải…"
|
||||
"loading": "Đang tải…",
|
||||
"unknown": "Không rõ"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Quy tắc bỏ qua proxy",
|
||||
@@ -1821,6 +1844,7 @@
|
||||
"passwordTooShort": "Mật khẩu phải có ít nhất {{min}} ký tự",
|
||||
"proxyNotFound": "Không tìm thấy proxy",
|
||||
"groupNotFound": "Không tìm thấy nhóm",
|
||||
"groupAlreadyExists": "Đã tồn tại một nhóm với tên này",
|
||||
"vpnNotFound": "Không tìm thấy VPN",
|
||||
"extensionNotFound": "Không tìm thấy tiện ích",
|
||||
"extensionGroupNotFound": "Không tìm thấy nhóm tiện ích",
|
||||
|
||||
@@ -305,11 +305,32 @@
|
||||
"assignToGroup": "分配到分组",
|
||||
"assignProxy": "分配代理",
|
||||
"assignExtensionGroup": "分配扩展分组",
|
||||
"copyCookies": "复制 Cookie"
|
||||
"copyCookies": "复制 Cookie",
|
||||
"runSelected": "运行所选",
|
||||
"stopSelected": "停止所选",
|
||||
"proRequired": "批量运行/停止需要 Pro 套餐"
|
||||
},
|
||||
"passwordProtectedBadge": "密码保护",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
},
|
||||
"bulkRun": {
|
||||
"confirmTitle": "运行 {{count}} 个配置文件?",
|
||||
"confirmDescription": "一次启动 {{count}} 个配置文件可能会占用大量系统资源。是否继续?",
|
||||
"confirmButton": "运行 {{count}} 个",
|
||||
"noneToRun": "没有选中可运行的配置文件。"
|
||||
},
|
||||
"bulkStop": {
|
||||
"confirmTitle": "停止 {{count}} 个配置文件?",
|
||||
"confirmDescription": "停止 {{count}} 个正在运行的配置文件?",
|
||||
"confirmButton": "停止 {{count}} 个",
|
||||
"noneToStop": "没有选中可停止的配置文件。"
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "名称 (A–Z)",
|
||||
"nameDesc": "名称 (Z–A)",
|
||||
"newest": "最新优先",
|
||||
"oldest": "最早优先"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1134,7 +1155,8 @@
|
||||
"proxy": "代理",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "存储的 Cookie",
|
||||
"localDataTransfer": "本地数据传输"
|
||||
"localDataTransfer": "本地数据传输",
|
||||
"created": "创建时间"
|
||||
},
|
||||
"values": {
|
||||
"none": "无",
|
||||
@@ -1143,7 +1165,8 @@
|
||||
"yes": "是",
|
||||
"activeNow": "当前活动",
|
||||
"direct": "直连",
|
||||
"loading": "加载中…"
|
||||
"loading": "加载中…",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "代理绕过规则",
|
||||
@@ -1821,6 +1844,7 @@
|
||||
"passwordTooShort": "密码至少需要 {{min}} 个字符",
|
||||
"proxyNotFound": "未找到代理",
|
||||
"groupNotFound": "未找到分组",
|
||||
"groupAlreadyExists": "已存在同名分组",
|
||||
"vpnNotFound": "未找到 VPN",
|
||||
"extensionNotFound": "未找到扩展",
|
||||
"extensionGroupNotFound": "未找到扩展分组",
|
||||
|
||||
@@ -22,6 +22,7 @@ export type BackendErrorCode =
|
||||
| "SELF_HOSTED_REQUIRES_LOGOUT"
|
||||
| "PROXY_NOT_FOUND"
|
||||
| "GROUP_NOT_FOUND"
|
||||
| "GROUP_ALREADY_EXISTS"
|
||||
| "VPN_NOT_FOUND"
|
||||
| "EXTENSION_NOT_FOUND"
|
||||
| "EXTENSION_GROUP_NOT_FOUND"
|
||||
@@ -113,6 +114,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.proxyNotFound");
|
||||
case "GROUP_NOT_FOUND":
|
||||
return t("backendErrors.groupNotFound");
|
||||
case "GROUP_ALREADY_EXISTS":
|
||||
return t("backendErrors.groupAlreadyExists");
|
||||
case "VPN_NOT_FOUND":
|
||||
return t("backendErrors.vpnNotFound");
|
||||
case "EXTENSION_NOT_FOUND":
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Get flag icon CSS class for a country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
export function getFlagIconClass(countryCode: string): string {
|
||||
if (!countryCode || countryCode.length !== 2) {
|
||||
if (countryCode?.length !== 2) {
|
||||
return "";
|
||||
}
|
||||
return `fi fi-${countryCode.toLowerCase()}`;
|
||||
|
||||
@@ -155,6 +155,23 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
/* Interactive elements show a pointer cursor app-wide, so cursor behavior is
|
||||
consistent everywhere without per-element classes — and there's no lingering
|
||||
"fix the cursor" surface for drive-by PRs. Disabled controls rely on
|
||||
pointer-events:none / their own disabled cursor, and an explicit `cursor-*`
|
||||
utility still wins over this base rule (utilities out-rank the base layer),
|
||||
e.g. the invisible click-to-close backdrop and the auto-scroll buttons. */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
[role="menuitem"],
|
||||
[role="menuitemcheckbox"],
|
||||
[role="menuitemradio"],
|
||||
[role="option"],
|
||||
[role="radio"],
|
||||
[role="tab"],
|
||||
[role="switch"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll-fade utility: a vertical mask that thins the alpha of the top and
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface BrowserProfile {
|
||||
proxy_bypass_rules?: string[];
|
||||
created_by_id?: string;
|
||||
created_by_email?: string;
|
||||
/** Profile creation timestamp (epoch seconds, UTC). Undefined for legacy
|
||||
* profiles created before this field existed. */
|
||||
created_at?: number;
|
||||
dns_blocklist?: string;
|
||||
password_protected?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user