mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-27 17:09:59 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82a2efa7f2 | |||
| 9fe973039d | |||
| 2cdbdaa1ab | |||
| d31b22f57d | |||
| 45e57662de | |||
| 7931a241e7 | |||
| 224c35388f | |||
| 2bf45357ab | |||
| dd0ccda5fd | |||
| c422217b0f | |||
| 55b0016d31 | |||
| fede1d93a8 | |||
| 17ee38d316 | |||
| 826cb187c7 | |||
| 0deea7eb0c | |||
| 3f1f11001e | |||
| a0205aafa9 | |||
| 7d03968123 | |||
| 05791ace1f | |||
| 80757829c2 | |||
| 90ef4f3069 | |||
| 378430d7c0 | |||
| fc860ccc35 | |||
| 806aee3e0e | |||
| c6568a126d | |||
| 168eac0065 | |||
| 9c33d4f7b1 | |||
| 30f8e3eab2 | |||
| 02e1f158bd |
@@ -36,7 +36,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 #v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@54443bfb7e090ec3130dc972e689a3e5cc55a7f9 #v1.3.3
|
||||
uses: anomalyco/opencode/github@6314f09c14fdd6a3ab8bedc4f7b7182647551d12 #v1.3.13
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -40,6 +40,22 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Configure aws-cli for R2
|
||||
# aws-cli v2.23+ sends integrity checksums by default; Cloudflare R2
|
||||
# rejects those headers with `Unauthorized` on ListObjectsV2.
|
||||
# Also normalise the endpoint URL (must start with https://).
|
||||
# Both values propagate to later steps via $GITHUB_ENV.
|
||||
env:
|
||||
RAW_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
run: |
|
||||
endpoint="$RAW_ENDPOINT"
|
||||
if [[ "$endpoint" != https://* && "$endpoint" != http://* ]]; then
|
||||
endpoint="https://$endpoint"
|
||||
fi
|
||||
echo "R2_ENDPOINT=$endpoint" >> "$GITHUB_ENV"
|
||||
echo "AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
|
||||
echo "AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -67,7 +83,6 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
DEB_DIR="/tmp/repo/deb"
|
||||
@@ -131,7 +146,6 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
RPM_DIR="/tmp/repo/rpm"
|
||||
@@ -164,7 +178,6 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
echo "Uploading DEB repository..."
|
||||
@@ -182,7 +195,6 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
|
||||
@@ -139,6 +139,10 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
# NEXT_PUBLIC_* vars are inlined at build time and must be forwarded
|
||||
# from secrets explicitly — they are NOT inherited from the job env.
|
||||
env:
|
||||
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
|
||||
run: pnpm exec next build
|
||||
|
||||
- name: Verify frontend dist exists
|
||||
@@ -216,6 +220,12 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
# tauri-action invokes `pnpm tauri build`, which runs
|
||||
# `beforeBuildCommand` from tauri.conf.json. That rebuilds the
|
||||
# frontend in its own subprocess, so the env var MUST be forwarded
|
||||
# here or the inner `next build` inlines an empty string and
|
||||
# overwrites the dist the explicit "Build frontend" step produced.
|
||||
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
|
||||
with:
|
||||
projectPath: ./src-tauri
|
||||
tagName: ${{ github.ref_name }}
|
||||
@@ -535,7 +545,7 @@ jobs:
|
||||
|
||||
update-flake:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
needs: [release, changelog]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -138,6 +138,10 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
# NEXT_PUBLIC_* vars are inlined at build time and must be forwarded
|
||||
# from secrets explicitly — they are NOT inherited from the job env.
|
||||
env:
|
||||
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
|
||||
run: pnpm exec next build
|
||||
|
||||
- name: Verify frontend dist exists
|
||||
@@ -226,6 +230,9 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
# tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand
|
||||
# which rebuilds dist/ in a subprocess. The env var must be here too.
|
||||
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
|
||||
with:
|
||||
projectPath: ./src-tauri
|
||||
tagName: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d #v1.44.0
|
||||
uses: crate-ci/typos@02ea592e44b3a53c302f697cddca7641cd051c3d #v1.45.0
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
# Wait for MinIO to be ready
|
||||
for i in {1..30}; do
|
||||
if curl -sf http://localhost:8987/minio/health/live; then
|
||||
if curl -sf http://127.0.0.1:8987/minio/health/live; then
|
||||
echo "MinIO is ready"
|
||||
break
|
||||
fi
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
working-directory: donut-sync
|
||||
env:
|
||||
SYNC_TOKEN: test-sync-token
|
||||
S3_ENDPOINT: http://localhost:8987
|
||||
S3_ENDPOINT: http://127.0.0.1:8987
|
||||
S3_ACCESS_KEY_ID: minioadmin
|
||||
S3_SECRET_ACCESS_KEY: minioadmin
|
||||
S3_BUCKET: donut-sync-test
|
||||
|
||||
+105
@@ -1,5 +1,110 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.20.2 (2026-04-08)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: aws integrity checks
|
||||
- chore: inject NEXT_PUBLIC_TURNSTILE everywhere
|
||||
- chore: update flake.nix for v0.20.1 [skip ci] (#272)
|
||||
|
||||
|
||||
## v0.20.1 (2026-04-08)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: normalize r2 endpoint
|
||||
- chore: pull turnstile public key in frontend at build time
|
||||
- chore: update flake.nix for v0.20.0 [skip ci] (#270)
|
||||
|
||||
|
||||
## v0.20.0 (2026-04-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- cookie copying for wayfern
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
- dynamic proxy
|
||||
|
||||
### Documentation
|
||||
|
||||
- update CHANGELOG.md and README.md for v0.19.0 [skip ci] (#261)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: update flake.nix for v0.19.0 [skip ci] (#262)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(rust)(deps): bump the rust-dependencies group
|
||||
- deps(deps): bump the frontend-dependencies group with 19 updates
|
||||
|
||||
|
||||
## v0.19.0 (2026-04-04)
|
||||
|
||||
### Features
|
||||
|
||||
- captcha on email input
|
||||
- dns block lists
|
||||
- portable build
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- follow latest MCP spec
|
||||
- wayfern initial connection on macos doesn't timeout
|
||||
|
||||
### Refactoring
|
||||
|
||||
- linux auto updates
|
||||
- more robust vpn handling
|
||||
- don't allow portable build to be set as the default browser
|
||||
- show app version in settings
|
||||
|
||||
### Documentation
|
||||
|
||||
- remove codacy badge
|
||||
- agents
|
||||
- contrib-readme-action has updated readme
|
||||
- update CHANGELOG.md and README.md for v0.18.1 [skip ci]
|
||||
- cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- test: simplify
|
||||
- chore: preserve cargo
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- chore: update dependencies
|
||||
- chore: repo publish workflow
|
||||
- chore: copy and backlink
|
||||
- test: serialize
|
||||
- chore: copy correct file
|
||||
- chore: linting
|
||||
- chore: do not provide possible cause
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 8 updates
|
||||
- chore: commit doc changes directly and pretty discord notifications
|
||||
- chore: update flake.nix for v0.18.1 [skip ci]
|
||||
- chore: fix linting and formatting
|
||||
|
||||
### Other
|
||||
|
||||
- deps(deps): bump the frontend-dependencies group with 35 updates
|
||||
- deps(rust)(deps): bump the rust-dependencies group
|
||||
|
||||
## v0.18.1 (2026-03-24)
|
||||
|
||||
### Refactoring
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -61,15 +61,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_x64-setup.exe)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut-0.18.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut-0.18.1-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut-0.20.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut-0.20.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
|
||||
@@ -18,4 +18,3 @@ services:
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1022.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1022.0",
|
||||
"@nestjs/common": "^11.1.17",
|
||||
"@aws-sdk/client-s3": "^3.1024.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1024.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.17",
|
||||
"@nestjs/platform-express": "^11.1.17",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/platform-express": "^11.1.18",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
@@ -31,11 +31,11 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/schematics": "^11.0.10",
|
||||
"@nestjs/testing": "^11.1.17",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.3.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -44,7 +44,7 @@
|
||||
"ts-loader": "^9.5.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -2,18 +2,29 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { App } from "supertest/types";
|
||||
import { AppModule } from "./../src/app.module.js";
|
||||
import { AppController } from "./../src/app.controller.js";
|
||||
import { AppService } from "./../src/app.service.js";
|
||||
import { SyncService } from "./../src/sync/sync.service.js";
|
||||
|
||||
describe("AppController (e2e)", () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: SyncService,
|
||||
useValue: {
|
||||
checkS3Connectivity: async () => true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
await app.listen(0);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"maxWorkers": 1,
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+\\.(t|j)s$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": "<rootDir>/tsconfig.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
@@ -6,6 +8,11 @@ import { App } from "supertest/types";
|
||||
import { AppController } from "./../src/app.controller.js";
|
||||
import { AppService } from "./../src/app.service.js";
|
||||
import { SyncModule } from "./../src/sync/sync.module.js";
|
||||
import {
|
||||
configureTestEnv,
|
||||
TEST_SYNC_TOKEN,
|
||||
waitForTestS3,
|
||||
} from "./test-env.js";
|
||||
|
||||
interface PresignResponse {
|
||||
url: string;
|
||||
@@ -29,26 +36,12 @@ interface StatResponse {
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
interface SSEError {
|
||||
code?: string;
|
||||
timeout?: boolean;
|
||||
response?: { status: number };
|
||||
}
|
||||
|
||||
const TEST_TOKEN = "test-sync-token";
|
||||
|
||||
describe("SyncController (e2e)", () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.SYNC_TOKEN = TEST_TOKEN;
|
||||
process.env.S3_ENDPOINT =
|
||||
process.env.S3_ENDPOINT || "http://localhost:8987";
|
||||
process.env.S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || "minioadmin";
|
||||
process.env.S3_SECRET_ACCESS_KEY =
|
||||
process.env.S3_SECRET_ACCESS_KEY || "minioadmin";
|
||||
process.env.S3_BUCKET = "donut-sync-test";
|
||||
process.env.S3_FORCE_PATH_STYLE = "true";
|
||||
configureTestEnv();
|
||||
await waitForTestS3();
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
@@ -62,7 +55,7 @@ describe("SyncController (e2e)", () => {
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
await app.listen(0);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -88,7 +81,7 @@ describe("SyncController (e2e)", () => {
|
||||
it("should accept requests with valid token", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: "nonexistent-key" })
|
||||
.expect(200)
|
||||
.expect({ exists: false });
|
||||
@@ -99,7 +92,7 @@ describe("SyncController (e2e)", () => {
|
||||
it("should return exists: false for non-existent key", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: "does-not-exist" })
|
||||
.expect(200)
|
||||
.expect({ exists: false });
|
||||
@@ -110,7 +103,7 @@ describe("SyncController (e2e)", () => {
|
||||
it("should return a presigned upload URL", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-upload")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: "test/upload-key.txt", contentType: "text/plain" })
|
||||
.expect(200);
|
||||
|
||||
@@ -125,7 +118,7 @@ describe("SyncController (e2e)", () => {
|
||||
it("should return a presigned download URL", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-download")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: "test/download-key.txt" })
|
||||
.expect(200);
|
||||
|
||||
@@ -140,7 +133,7 @@ describe("SyncController (e2e)", () => {
|
||||
it("should list objects with prefix", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/list")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ prefix: "profiles/" })
|
||||
.expect(200);
|
||||
|
||||
@@ -155,7 +148,7 @@ describe("SyncController (e2e)", () => {
|
||||
it("should delete object and create tombstone", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/delete")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({
|
||||
key: "test/to-delete.txt",
|
||||
tombstoneKey: "tombstones/test/to-delete.json",
|
||||
@@ -176,7 +169,7 @@ describe("SyncController (e2e)", () => {
|
||||
it("should complete full upload/download cycle with presigned URLs", async () => {
|
||||
const uploadResponse = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-upload")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: testKey, contentType: "text/plain" })
|
||||
.expect(200);
|
||||
|
||||
@@ -192,7 +185,7 @@ describe("SyncController (e2e)", () => {
|
||||
|
||||
const statResponse = await request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
|
||||
@@ -202,7 +195,7 @@ describe("SyncController (e2e)", () => {
|
||||
|
||||
const downloadResponse = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-download")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
|
||||
@@ -215,13 +208,13 @@ describe("SyncController (e2e)", () => {
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/v1/objects/delete")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
|
||||
const finalStatResponse = await request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
|
||||
@@ -238,20 +231,28 @@ describe("SyncController (e2e)", () => {
|
||||
});
|
||||
|
||||
it("should return SSE stream with valid token", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v1/objects/subscribe")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Accept", "text/event-stream")
|
||||
.buffer(true)
|
||||
.timeout(3000)
|
||||
.catch((err: SSEError) => {
|
||||
if (err.code === "ECONNABORTED" || err.timeout) {
|
||||
return err.response ?? { status: 200 };
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
const address = (
|
||||
app.getHttpServer() as Server
|
||||
).address() as AddressInfo | null;
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected app to be listening on a TCP port");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${address.port}/v1/objects/subscribe`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Authorization: `Bearer ${TEST_SYNC_TOKEN}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"text/event-stream",
|
||||
);
|
||||
await response.body?.cancel();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ListBucketsCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
export const TEST_SYNC_TOKEN = "test-sync-token";
|
||||
export const TEST_S3_ENDPOINT = "http://127.0.0.1:8987";
|
||||
|
||||
export function configureTestEnv() {
|
||||
process.env.SYNC_TOKEN ||= TEST_SYNC_TOKEN;
|
||||
process.env.S3_ENDPOINT ||= TEST_S3_ENDPOINT;
|
||||
process.env.S3_ACCESS_KEY_ID ||= "minioadmin";
|
||||
process.env.S3_SECRET_ACCESS_KEY ||= "minioadmin";
|
||||
process.env.S3_BUCKET ||= "donut-sync-test";
|
||||
process.env.S3_FORCE_PATH_STYLE ||= "true";
|
||||
}
|
||||
|
||||
export async function waitForTestS3(timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const s3Client = new S3Client({
|
||||
endpoint: TEST_S3_ENDPOINT,
|
||||
region: "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: "minioadmin",
|
||||
secretAccessKey: "minioadmin",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await s3Client.send(new ListBucketsCommand({}));
|
||||
return;
|
||||
} catch {}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for S3 at ${TEST_S3_ENDPOINT}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".."
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
||||
@@ -94,17 +94,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.18.1";
|
||||
releaseVersion = "0.20.2";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.AppImage";
|
||||
hash = "sha256-+twOKfcM5qdV3+415/PecdQUgTTe+9xwL7/qu4kCxQI=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_amd64.AppImage";
|
||||
hash = "sha256-YkdQgsDMJYVyr8590cDJnpPboHcm5X77ycfvMamUvWc=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.AppImage";
|
||||
hash = "sha256-/Fj2euuxKzP6DxcV7sqShsNr6sy7Ck1iERtYcMt2hZQ=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_aarch64.AppImage";
|
||||
hash = "sha256-F3YtLU+jZw9UUE/2CcKnNxB8WPjYIdnkS5JYwoSP4qw=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./dist/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -74,11 +74,11 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.9",
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tauri-apps/cli": "~2.10.1",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
|
||||
Generated
+237
-226
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -81,7 +81,7 @@ echo -e "${YELLOW}Waiting for MinIO to be healthy...${NC}"
|
||||
MAX_RETRIES=30
|
||||
RETRY_COUNT=0
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
if curl -sf http://localhost:8987/minio/health/live > /dev/null 2>&1; then
|
||||
if curl -sf http://127.0.0.1:8987/minio/health/live > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}MinIO is ready!${NC}"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -27,6 +27,10 @@ done
|
||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
# aws-cli v2.23+ sends integrity checksums by default; R2 rejects them
|
||||
# with `Unauthorized` on ListObjectsV2. Disable.
|
||||
export AWS_REQUEST_CHECKSUM_CALCULATION="WHEN_REQUIRED"
|
||||
export AWS_RESPONSE_CHECKSUM_VALIDATION="WHEN_REQUIRED"
|
||||
|
||||
# Ensure endpoint URL has https:// prefix
|
||||
R2_ENDPOINT="$R2_ENDPOINT_URL"
|
||||
|
||||
Generated
+37
-15
@@ -920,9 +920,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.58"
|
||||
version = "1.2.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
|
||||
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -1705,7 +1705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.19.0"
|
||||
version = "0.20.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -1758,6 +1758,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"smoltcp",
|
||||
"sys-locale",
|
||||
"sysinfo",
|
||||
@@ -1780,7 +1781,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tray-icon",
|
||||
"tray-icon 0.22.0",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"utoipa",
|
||||
@@ -3837,9 +3838,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a"
|
||||
checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
@@ -5877,9 +5878,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
@@ -6691,7 +6692,7 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tray-icon",
|
||||
"tray-icon 0.21.3",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
@@ -7218,9 +7219,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
version = "1.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -7235,9 +7236,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -7530,6 +7531,27 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e1484378c343c5a9b291188fa58917c7184967683f8cfe4a05461986970553"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -8844,9 +8866,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.19.0"
|
||||
version = "0.20.2"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -85,6 +85,7 @@ aes = "0.8"
|
||||
cbc = "0.1"
|
||||
pbkdf2 = "0.12"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
hyper = { version = "1.8", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
@@ -109,7 +110,7 @@ boringtun = "0.7"
|
||||
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
||||
|
||||
# Daemon dependencies (tray icon)
|
||||
tray-icon = "0.21"
|
||||
tray-icon = "0.22"
|
||||
muda = "0.17"
|
||||
tao = "0.35"
|
||||
image = "0.25"
|
||||
|
||||
+30
-42
@@ -31,6 +31,7 @@ pub struct ApiProfile {
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub proxy_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub process_id: Option<u32>,
|
||||
pub last_launch: Option<u64>,
|
||||
pub release_type: String,
|
||||
@@ -59,6 +60,7 @@ pub struct CreateProfileRequest {
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub proxy_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
@@ -74,6 +76,7 @@ pub struct UpdateProfileRequest {
|
||||
pub browser: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
@@ -111,17 +114,13 @@ struct ApiProxyResponse {
|
||||
name: String,
|
||||
#[schema(value_type = Object)]
|
||||
proxy_settings: ProxySettings,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct CreateProxyRequest {
|
||||
name: String,
|
||||
#[schema(value_type = Object)]
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
proxy_settings: ProxySettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
@@ -129,8 +128,6 @@ struct UpdateProxyRequest {
|
||||
name: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
@@ -486,6 +483,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
browser: profile.browser.clone(),
|
||||
version: profile.version.clone(),
|
||||
proxy_id: profile.proxy_id.clone(),
|
||||
launch_hook: profile.launch_hook.clone(),
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
@@ -541,6 +539,7 @@ async fn get_profile(
|
||||
browser: profile.browser.clone(),
|
||||
version: profile.version.clone(),
|
||||
proxy_id: profile.proxy_id.clone(),
|
||||
launch_hook: profile.launch_hook.clone(),
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
@@ -612,6 +611,7 @@ async fn create_profile(
|
||||
request.group_id.clone(),
|
||||
false,
|
||||
None,
|
||||
request.launch_hook.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -641,6 +641,7 @@ async fn create_profile(
|
||||
browser: profile.browser,
|
||||
version: profile.version,
|
||||
proxy_id: profile.proxy_id,
|
||||
launch_hook: profile.launch_hook,
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type,
|
||||
@@ -714,6 +715,21 @@ async fn update_profile(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(launch_hook) = request.launch_hook {
|
||||
let normalized = if launch_hook.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(launch_hook)
|
||||
};
|
||||
|
||||
if profile_manager
|
||||
.update_profile_launch_hook(&state.app_handle, &id, normalized)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(camoufox_config) = request.camoufox_config {
|
||||
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
|
||||
match config {
|
||||
@@ -1035,8 +1051,6 @@ async fn get_proxies(
|
||||
.map(|p| ApiProxyResponse {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
dynamic_proxy_url: p.dynamic_proxy_url,
|
||||
dynamic_proxy_format: p.dynamic_proxy_format,
|
||||
proxy_settings: p.proxy_settings,
|
||||
})
|
||||
.collect(),
|
||||
@@ -1070,8 +1084,6 @@ async fn get_proxy(
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
dynamic_proxy_url: proxy.dynamic_proxy_url,
|
||||
dynamic_proxy_format: proxy.dynamic_proxy_format,
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
@@ -1097,27 +1109,16 @@ async fn create_proxy(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
let result = if let (Some(url), Some(format)) =
|
||||
(&request.dynamic_proxy_url, &request.dynamic_proxy_format)
|
||||
{
|
||||
PROXY_MANAGER.create_dynamic_proxy(
|
||||
&state.app_handle,
|
||||
request.name.clone(),
|
||||
url.clone(),
|
||||
format.clone(),
|
||||
)
|
||||
} else if let Some(settings) = request.proxy_settings {
|
||||
PROXY_MANAGER.create_stored_proxy(&state.app_handle, request.name.clone(), settings)
|
||||
} else {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
let result = PROXY_MANAGER.create_stored_proxy(
|
||||
&state.app_handle,
|
||||
request.name.clone(),
|
||||
request.proxy_settings,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(proxy) => Ok(Json(ApiProxyResponse {
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
dynamic_proxy_url: proxy.dynamic_proxy_url,
|
||||
dynamic_proxy_format: proxy.dynamic_proxy_format,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
@@ -1148,26 +1149,13 @@ async fn update_proxy(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some();
|
||||
|
||||
let result = if is_dynamic {
|
||||
PROXY_MANAGER.update_dynamic_proxy(
|
||||
&state.app_handle,
|
||||
&id,
|
||||
request.name,
|
||||
request.dynamic_proxy_url,
|
||||
request.dynamic_proxy_format,
|
||||
)
|
||||
} else {
|
||||
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings)
|
||||
};
|
||||
let result =
|
||||
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings);
|
||||
|
||||
match result {
|
||||
Ok(proxy) => Ok(Json(ApiProxyResponse {
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
dynamic_proxy_url: proxy.dynamic_proxy_url,
|
||||
dynamic_proxy_format: proxy.dynamic_proxy_format,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
|
||||
@@ -683,6 +683,7 @@ mod tests {
|
||||
process_id: None,
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
|
||||
@@ -1199,6 +1199,7 @@ mod tests {
|
||||
version: "1.0.0".to_string(),
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use sysinfo::System;
|
||||
pub struct BrowserRunner {
|
||||
pub profile_manager: &'static ProfileManager,
|
||||
@@ -60,8 +60,6 @@ impl BrowserRunner {
|
||||
|
||||
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
||||
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
|
||||
/// Resolve proxy settings for a profile, returning an error for dynamic proxy failures.
|
||||
/// Returns Ok(None) when no proxy is configured, Ok(Some) on success, Err on dynamic fetch failure.
|
||||
async fn resolve_proxy_with_refresh(
|
||||
&self,
|
||||
proxy_id: Option<&String>,
|
||||
@@ -72,13 +70,6 @@ impl BrowserRunner {
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// Handle dynamic proxies: fetch from URL at launch time
|
||||
if PROXY_MANAGER.is_dynamic_proxy(proxy_id) {
|
||||
log::info!("Fetching dynamic proxy settings for proxy {proxy_id}");
|
||||
let settings = PROXY_MANAGER.resolve_dynamic_proxy(proxy_id).await?;
|
||||
return Ok(Some(settings));
|
||||
}
|
||||
|
||||
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
|
||||
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
@@ -92,6 +83,38 @@ impl BrowserRunner {
|
||||
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
|
||||
}
|
||||
|
||||
async fn resolve_launch_hook_proxy(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
let Some(url) = profile.launch_hook.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Calling launch hook for profile {} (ID: {})",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
|
||||
PROXY_MANAGER
|
||||
.fetch_proxy_from_url(url, Duration::from_millis(500))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_launch_proxy(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
if let Some(proxy_settings) = self.resolve_launch_hook_proxy(profile).await? {
|
||||
return Ok(Some(proxy_settings));
|
||||
}
|
||||
|
||||
self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get the executable path for a browser profile
|
||||
/// This is a common helper to eliminate code duplication across the codebase
|
||||
pub fn get_browser_executable_path(
|
||||
@@ -147,9 +170,8 @@ impl BrowserRunner {
|
||||
});
|
||||
|
||||
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.resolve_launch_proxy(profile)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
|
||||
@@ -408,9 +430,8 @@ impl BrowserRunner {
|
||||
});
|
||||
|
||||
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.resolve_launch_proxy(profile)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
|
||||
@@ -763,10 +784,8 @@ impl BrowserRunner {
|
||||
headless: bool,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Always start a local proxy for API launches
|
||||
// Determine upstream proxy if configured; otherwise use DIRECT
|
||||
// Refresh cloud proxy credentials before resolving
|
||||
let upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.resolve_launch_proxy(profile)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
|
||||
@@ -2273,10 +2292,7 @@ pub async fn launch_browser_profile(
|
||||
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
|
||||
// Refresh cloud proxy credentials and inject profile-specific sid
|
||||
let mut upstream_proxy = BrowserRunner::instance()
|
||||
.resolve_proxy_with_refresh(
|
||||
profile_for_launch.proxy_id.as_ref(),
|
||||
Some(&profile_for_launch.id.to_string()),
|
||||
)
|
||||
.resolve_launch_proxy(&profile_for_launch)
|
||||
.await?;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
|
||||
+782
-112
File diff suppressed because it is too large
Load Diff
@@ -260,6 +260,7 @@ mod tests {
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
|
||||
+10
-53
@@ -67,8 +67,9 @@ use browser_runner::{
|
||||
use profile::manager::{
|
||||
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
|
||||
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_dns_blocklist,
|
||||
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
|
||||
update_profile_tags, update_profile_vpn, update_wayfern_config,
|
||||
update_profile_launch_hook, update_profile_note, update_profile_proxy,
|
||||
update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
|
||||
update_wayfern_config,
|
||||
};
|
||||
|
||||
use browser_version_manager::{
|
||||
@@ -212,19 +213,13 @@ async fn create_stored_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
name: String,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_dynamic_proxy(&app_handle, name, url.clone(), format.clone())
|
||||
.map_err(|e| format!("Failed to create dynamic proxy: {e}"))
|
||||
} else if let Some(settings) = proxy_settings {
|
||||
if let Some(settings) = proxy_settings {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(&app_handle, name, settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
} else {
|
||||
Err("Either proxy_settings or dynamic proxy URL and format are required".to_string())
|
||||
Err("proxy_settings is required".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,26 +234,10 @@ async fn update_stored_proxy(
|
||||
proxy_id: String,
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
// Check if this is a dynamic proxy update
|
||||
let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id);
|
||||
if is_dynamic || dynamic_proxy_url.is_some() {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_dynamic_proxy(
|
||||
&app_handle,
|
||||
&proxy_id,
|
||||
name,
|
||||
dynamic_proxy_url,
|
||||
dynamic_proxy_format,
|
||||
)
|
||||
.map_err(|e| format!("Failed to update dynamic proxy: {e}"))
|
||||
} else {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
}
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -273,13 +252,8 @@ async fn check_proxy_validity(
|
||||
proxy_id: String,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
||||
// For dynamic proxies, fetch settings first
|
||||
let settings = if let Some(s) = proxy_settings {
|
||||
s
|
||||
} else if crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id) {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.resolve_dynamic_proxy(&proxy_id)
|
||||
.await?
|
||||
} else {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.get_proxy_settings_by_id(&proxy_id)
|
||||
@@ -290,24 +264,6 @@ async fn check_proxy_validity(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_dynamic_proxy(
|
||||
url: String,
|
||||
format: String,
|
||||
) -> Result<crate::browser::ProxySettings, String> {
|
||||
let settings = crate::proxy_manager::PROXY_MANAGER
|
||||
.fetch_dynamic_proxy(&url, &format)
|
||||
.await?;
|
||||
|
||||
// Validate the proxy actually works by connecting through it
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.check_proxy_validity("_dynamic_test", &settings)
|
||||
.await
|
||||
.map_err(|e| format!("Proxy resolved but connection failed: {e}"))?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::ProxyCheckResult> {
|
||||
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
|
||||
@@ -1189,6 +1145,7 @@ async fn generate_sample_fingerprint(
|
||||
process_id: None,
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
@@ -1889,6 +1846,7 @@ pub fn run() {
|
||||
update_profile_vpn,
|
||||
update_profile_tags,
|
||||
update_profile_note,
|
||||
update_profile_launch_hook,
|
||||
update_profile_proxy_bypass_rules,
|
||||
update_profile_dns_blocklist,
|
||||
check_browser_status,
|
||||
@@ -1929,7 +1887,6 @@ pub fn run() {
|
||||
update_stored_proxy,
|
||||
delete_stored_proxy,
|
||||
check_proxy_validity,
|
||||
fetch_dynamic_proxy,
|
||||
get_cached_proxy_check,
|
||||
export_proxies,
|
||||
import_proxies_json,
|
||||
|
||||
+87
-109
@@ -508,6 +508,10 @@ impl McpServer {
|
||||
"type": "string",
|
||||
"description": "Optional proxy UUID to assign"
|
||||
},
|
||||
"launch_hook": {
|
||||
"type": "string",
|
||||
"description": "Optional HTTP(S) URL to call before launch for transient proxy overrides"
|
||||
},
|
||||
"group_id": {
|
||||
"type": "string",
|
||||
"description": "Optional group UUID to assign"
|
||||
@@ -539,6 +543,10 @@ impl McpServer {
|
||||
"type": "string",
|
||||
"description": "Proxy UUID to assign (empty string to remove)"
|
||||
},
|
||||
"launch_hook": {
|
||||
"type": "string",
|
||||
"description": "Launch hook URL to assign (empty string to remove)"
|
||||
},
|
||||
"group_id": {
|
||||
"type": "string",
|
||||
"description": "Group UUID to assign (empty string to remove)"
|
||||
@@ -713,7 +721,7 @@ impl McpServer {
|
||||
},
|
||||
McpTool {
|
||||
name: "create_proxy".to_string(),
|
||||
description: "Create a new proxy configuration. For regular proxies, provide proxy_type/host/port. For dynamic proxies, provide dynamic_proxy_url and dynamic_proxy_format instead.".to_string(),
|
||||
description: "Create a new proxy configuration.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -741,18 +749,9 @@ impl McpServer {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Optional password for authentication (for regular proxies)"
|
||||
},
|
||||
"dynamic_proxy_url": {
|
||||
"type": "string",
|
||||
"description": "URL to fetch proxy settings from (for dynamic proxies)"
|
||||
},
|
||||
"dynamic_proxy_format": {
|
||||
"type": "string",
|
||||
"enum": ["json", "text"],
|
||||
"description": "Format of the dynamic proxy response: 'json' for JSON object or 'text' for text like host:port:user:pass (for dynamic proxies)"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
"required": ["name", "proxy_type", "host", "port"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
@@ -789,15 +788,6 @@ impl McpServer {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Optional password for authentication (for regular proxies)"
|
||||
},
|
||||
"dynamic_proxy_url": {
|
||||
"type": "string",
|
||||
"description": "URL to fetch proxy settings from (for dynamic proxies)"
|
||||
},
|
||||
"dynamic_proxy_format": {
|
||||
"type": "string",
|
||||
"enum": ["json", "text"],
|
||||
"description": "Format of the dynamic proxy response (for dynamic proxies)"
|
||||
}
|
||||
},
|
||||
"required": ["proxy_id"]
|
||||
@@ -1809,6 +1799,10 @@ impl McpServer {
|
||||
.get("proxy_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let launch_hook = arguments
|
||||
.get("launch_hook")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let group_id = arguments
|
||||
.get("group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -1838,8 +1832,19 @@ impl McpServer {
|
||||
|
||||
let mut profile = ProfileManager::instance()
|
||||
.create_profile_with_group(
|
||||
app_handle, name, browser, version, "stable", proxy_id, None, None, None, group_id, false,
|
||||
app_handle,
|
||||
name,
|
||||
browser,
|
||||
version,
|
||||
"stable",
|
||||
proxy_id,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
group_id,
|
||||
false,
|
||||
None,
|
||||
launch_hook,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
@@ -1907,6 +1912,19 @@ impl McpServer {
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(launch_hook) = arguments.get("launch_hook").and_then(|v| v.as_str()) {
|
||||
let normalized = if launch_hook.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(launch_hook.to_string())
|
||||
};
|
||||
pm.update_profile_launch_hook(app_handle, profile_id, normalized)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update launch hook: {e}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(group_id) = arguments.get("group_id").and_then(|v| v.as_str()) {
|
||||
let gid = if group_id.is_empty() {
|
||||
None
|
||||
@@ -2361,74 +2379,54 @@ impl McpServer {
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?;
|
||||
|
||||
// Check if this is a dynamic proxy creation
|
||||
let dynamic_url = arguments.get("dynamic_proxy_url").and_then(|v| v.as_str());
|
||||
let dynamic_format = arguments
|
||||
.get("dynamic_proxy_format")
|
||||
.and_then(|v| v.as_str());
|
||||
let proxy_type = arguments
|
||||
.get("proxy_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing proxy_type".to_string(),
|
||||
})?;
|
||||
|
||||
let proxy = if let (Some(url), Some(format)) = (dynamic_url, dynamic_format) {
|
||||
PROXY_MANAGER
|
||||
.create_dynamic_proxy(
|
||||
app_handle,
|
||||
name.to_string(),
|
||||
url.to_string(),
|
||||
format.to_string(),
|
||||
)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to create dynamic proxy: {e}"),
|
||||
})?
|
||||
} else {
|
||||
let proxy_type = arguments
|
||||
.get("proxy_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing proxy_type (required for regular proxies)".to_string(),
|
||||
})?;
|
||||
let host = arguments
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing host".to_string(),
|
||||
})?;
|
||||
|
||||
let host = arguments
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing host (required for regular proxies)".to_string(),
|
||||
})?;
|
||||
let port = arguments
|
||||
.get("port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing port".to_string(),
|
||||
})? as u16;
|
||||
|
||||
let port = arguments
|
||||
.get("port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing port (required for regular proxies)".to_string(),
|
||||
})? as u16;
|
||||
let username = arguments
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let password = arguments
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let username = arguments
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let password = arguments
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let proxy_settings = ProxySettings {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
|
||||
PROXY_MANAGER
|
||||
.create_stored_proxy(app_handle, name.to_string(), proxy_settings)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to create proxy: {e}"),
|
||||
})?
|
||||
let proxy_settings = ProxySettings {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
|
||||
let proxy = PROXY_MANAGER
|
||||
.create_stored_proxy(app_handle, name.to_string(), proxy_settings)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to create proxy: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -2517,32 +2515,12 @@ impl McpServer {
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?;
|
||||
|
||||
// Check for dynamic proxy fields
|
||||
let dynamic_url = arguments
|
||||
.get("dynamic_proxy_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let dynamic_format = arguments
|
||||
.get("dynamic_proxy_format")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(proxy_id) || dynamic_url.is_some();
|
||||
|
||||
let proxy = if is_dynamic {
|
||||
PROXY_MANAGER
|
||||
.update_dynamic_proxy(app_handle, proxy_id, name, dynamic_url, dynamic_format)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update dynamic proxy: {e}"),
|
||||
})?
|
||||
} else {
|
||||
PROXY_MANAGER
|
||||
.update_stored_proxy(app_handle, proxy_id, name, proxy_settings)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update proxy: {e}"),
|
||||
})?
|
||||
};
|
||||
let proxy = PROXY_MANAGER
|
||||
.update_stored_proxy(app_handle, proxy_id, name, proxy_settings)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update proxy: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
|
||||
@@ -89,96 +89,17 @@ pub mod macos {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use AppleScript
|
||||
let escaped_url = url
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("'", "\\'");
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
try
|
||||
tell application "System Events"
|
||||
-- Find the exact process by PID
|
||||
set targetProcess to (first application process whose unix id is {pid})
|
||||
|
||||
-- Verify the process exists
|
||||
if not (exists targetProcess) then
|
||||
error "No process found with PID {pid}"
|
||||
end if
|
||||
|
||||
-- Get the process name for verification
|
||||
set processName to name of targetProcess
|
||||
|
||||
-- Bring the process to the front first
|
||||
set frontmost of targetProcess to true
|
||||
delay 1.0
|
||||
|
||||
-- Check if the process has any visible windows
|
||||
set windowList to windows of targetProcess
|
||||
set hasVisibleWindow to false
|
||||
repeat with w in windowList
|
||||
if visible of w is true then
|
||||
set hasVisibleWindow to true
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
|
||||
if not hasVisibleWindow then
|
||||
-- No visible windows, create a new one
|
||||
tell targetProcess
|
||||
keystroke "n" using command down
|
||||
delay 2.0
|
||||
end tell
|
||||
end if
|
||||
|
||||
-- Ensure the process is frontmost again
|
||||
set frontmost of targetProcess to true
|
||||
delay 0.5
|
||||
|
||||
-- Focus on the address bar and open URL
|
||||
tell targetProcess
|
||||
-- Open a new tab
|
||||
keystroke "t" using command down
|
||||
delay 1.5
|
||||
|
||||
-- Focus address bar (Cmd+L)
|
||||
keystroke "l" using command down
|
||||
delay 0.5
|
||||
|
||||
-- Type the URL
|
||||
keystroke "{escaped_url}"
|
||||
delay 0.5
|
||||
|
||||
-- Press Enter to navigate
|
||||
keystroke return
|
||||
end tell
|
||||
|
||||
return "Successfully opened URL in " & processName & " (PID: {pid})"
|
||||
end tell
|
||||
on error errMsg number errNum
|
||||
return "AppleScript failed: " & errMsg & " (Error " & errNum & ")"
|
||||
end try
|
||||
"#
|
||||
);
|
||||
|
||||
log::info!("Executing AppleScript fallback for Firefox-based browser (PID: {pid})...");
|
||||
let output = Command::new("osascript").args(["-e", &script]).output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
log::info!("AppleScript failed: {error_msg}");
|
||||
return Err(
|
||||
format!(
|
||||
"Both Firefox remote command and AppleScript failed. AppleScript error: {error_msg}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
log::info!("AppleScript succeeded");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// The Firefox `-new-tab` remote command failed. We intentionally do NOT
|
||||
// fall back to an AppleScript `System Events` keystroke path: that would
|
||||
// send Apple Events to another application and trigger the macOS TCC
|
||||
// "<Donut> wants control of <Browser>" / "prevented from modifying other
|
||||
// apps" prompts. Donut must never touch other apps on the user's Mac.
|
||||
Err(
|
||||
format!(
|
||||
"Firefox remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn kill_browser_process_impl(
|
||||
@@ -378,93 +299,18 @@ end try
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to AppleScript
|
||||
let escaped_url = url
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("'", "\\'");
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
try
|
||||
tell application "System Events"
|
||||
-- Find the exact process by PID
|
||||
set targetProcess to (first application process whose unix id is {pid})
|
||||
|
||||
-- Verify the process exists
|
||||
if not (exists targetProcess) then
|
||||
error "No process found with PID {pid}"
|
||||
end if
|
||||
|
||||
-- Get the process name for verification
|
||||
set processName to name of targetProcess
|
||||
|
||||
-- Bring the process to the front first
|
||||
set frontmost of targetProcess to true
|
||||
delay 1.0
|
||||
|
||||
-- Check if the process has any visible windows
|
||||
set windowList to windows of targetProcess
|
||||
set hasVisibleWindow to false
|
||||
repeat with w in windowList
|
||||
if visible of w is true then
|
||||
set hasVisibleWindow to true
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
|
||||
if not hasVisibleWindow then
|
||||
-- No visible windows, create a new one
|
||||
tell targetProcess
|
||||
keystroke "n" using command down
|
||||
delay 2.0
|
||||
end tell
|
||||
end if
|
||||
|
||||
-- Ensure the process is frontmost again
|
||||
set frontmost of targetProcess to true
|
||||
delay 0.5
|
||||
|
||||
-- Focus on the address bar and open URL
|
||||
tell targetProcess
|
||||
-- Open a new tab
|
||||
keystroke "t" using command down
|
||||
delay 1.5
|
||||
|
||||
-- Focus address bar (Cmd+L)
|
||||
keystroke "l" using command down
|
||||
delay 0.5
|
||||
|
||||
-- Type the URL
|
||||
keystroke "{escaped_url}"
|
||||
delay 0.5
|
||||
|
||||
-- Press Enter to navigate
|
||||
keystroke return
|
||||
end tell
|
||||
|
||||
return "Successfully opened URL in " & processName & " (PID: {pid})"
|
||||
end tell
|
||||
on error errMsg number errNum
|
||||
return "AppleScript failed: " & errMsg & " (Error " & errNum & ")"
|
||||
end try
|
||||
"#
|
||||
);
|
||||
|
||||
log::info!("Executing AppleScript for Chromium-based browser (PID: {pid})...");
|
||||
let output = Command::new("osascript").args(["-e", &script]).output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
log::info!("AppleScript failed: {error_msg}");
|
||||
return Err(
|
||||
format!("Failed to open URL in existing Chromium-based browser: {error_msg}").into(),
|
||||
);
|
||||
} else {
|
||||
log::info!("AppleScript succeeded");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// The Chromium `--user-data-dir=<path> <url>` remote command failed.
|
||||
// We intentionally do NOT fall back to an AppleScript `System Events`
|
||||
// keystroke path: that would send Apple Events to another application
|
||||
// and trigger the macOS TCC "<Donut> wants control of <Browser>" /
|
||||
// "prevented from modifying other apps" prompts. Donut must never touch
|
||||
// other apps on the user's Mac.
|
||||
Err(
|
||||
format!(
|
||||
"Chromium remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::wayfern_manager::WayfernConfig;
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
|
||||
use url::Url;
|
||||
|
||||
pub struct ProfileManager {
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
@@ -36,6 +37,25 @@ impl ProfileManager {
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
fn normalize_launch_hook(
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let Some(raw) = launch_hook else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let parsed = Url::parse(trimmed).map_err(|e| format!("Invalid launch hook URL: {e}"))?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => Ok(Some(parsed.to_string())),
|
||||
_ => Err("Launch hook URL must use http or https".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_profile_with_group(
|
||||
&self,
|
||||
@@ -51,11 +71,14 @@ impl ProfileManager {
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
dns_blocklist: Option<String>,
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
}
|
||||
|
||||
let launch_hook = Self::normalize_launch_hook(launch_hook)?;
|
||||
|
||||
// Sync cloud proxy credentials if the profile uses a cloud or cloud-derived proxy
|
||||
if let Some(ref pid) = proxy_id {
|
||||
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
|
||||
@@ -142,6 +165,7 @@ impl ProfileManager {
|
||||
version: version.to_string(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
launch_hook: launch_hook.clone(),
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: release_type.to_string(),
|
||||
@@ -242,6 +266,7 @@ impl ProfileManager {
|
||||
version: version.to_string(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
launch_hook: launch_hook.clone(),
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: release_type.to_string(),
|
||||
@@ -296,6 +321,7 @@ impl ProfileManager {
|
||||
version: version.to_string(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: vpn_id.clone(),
|
||||
launch_hook,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: release_type.to_string(),
|
||||
@@ -739,6 +765,35 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn update_profile_launch_hook(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
|
||||
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
if let Err(e) = events::emit("profile-updated", &profile) {
|
||||
log::warn!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn update_profile_proxy_bypass_rules(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
@@ -913,6 +968,7 @@ impl ProfileManager {
|
||||
version: source.version,
|
||||
proxy_id: source.proxy_id,
|
||||
vpn_id: source.vpn_id,
|
||||
launch_hook: source.launch_hook,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: source.release_type,
|
||||
@@ -1970,6 +2026,36 @@ mod tests {
|
||||
"PAC URL should percent-encode spaces: {pac_line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_launch_hook_accepts_http_and_https() {
|
||||
let http =
|
||||
ProfileManager::normalize_launch_hook(Some(" http://localhost:3000/hook ".to_string()))
|
||||
.unwrap();
|
||||
let https = ProfileManager::normalize_launch_hook(Some(
|
||||
"https://example.com/hooks/profile-launch".to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(http.as_deref(), Some("http://localhost:3000/hook"));
|
||||
assert_eq!(
|
||||
https.as_deref(),
|
||||
Some("https://example.com/hooks/profile-launch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_launch_hook_clears_empty_values() {
|
||||
let result = ProfileManager::normalize_launch_hook(Some(" ".to_string())).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_launch_hook_rejects_invalid_scheme() {
|
||||
let err = ProfileManager::normalize_launch_hook(Some("ftp://example.com/hook".to_string()))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("http or https"));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1987,6 +2073,7 @@ pub async fn create_browser_profile_with_group(
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
dns_blocklist: Option<String>,
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
@@ -2003,6 +2090,7 @@ pub async fn create_browser_profile_with_group(
|
||||
group_id,
|
||||
ephemeral,
|
||||
dns_blocklist,
|
||||
launch_hook,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
||||
@@ -2066,6 +2154,18 @@ pub fn update_profile_note(
|
||||
.map_err(|e| format!("Failed to update profile note: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_launch_hook(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_launch_hook(&app_handle, &profile_id, launch_hook)
|
||||
.map_err(|e| format!("Failed to update profile launch hook: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_proxy_bypass_rules(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -2128,6 +2228,7 @@ pub async fn create_browser_profile_new(
|
||||
group_id: Option<String>,
|
||||
ephemeral: Option<bool>,
|
||||
dns_blocklist: Option<String>,
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let fingerprint_os = camoufox_config
|
||||
.as_ref()
|
||||
@@ -2156,6 +2257,7 @@ pub async fn create_browser_profile_new(
|
||||
group_id,
|
||||
ephemeral.unwrap_or(false),
|
||||
dns_blocklist,
|
||||
launch_hook,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct BrowserProfile {
|
||||
#[serde(default)]
|
||||
pub vpn_id: Option<String>, // Reference to stored VPN config
|
||||
#[serde(default)]
|
||||
pub launch_hook: Option<String>,
|
||||
#[serde(default)]
|
||||
pub process_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub last_launch: Option<u64>,
|
||||
|
||||
@@ -565,6 +565,7 @@ impl ProfileImporter {
|
||||
version: version.clone(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
@@ -644,6 +645,7 @@ impl ProfileImporter {
|
||||
version: version.clone(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
@@ -694,6 +696,7 @@ impl ProfileImporter {
|
||||
version,
|
||||
proxy_id,
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
|
||||
+110
-215
@@ -145,10 +145,6 @@ impl StoredProxy {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dynamic(&self) -> bool {
|
||||
self.dynamic_proxy_url.is_some()
|
||||
}
|
||||
|
||||
/// Migrate legacy geo_state to geo_region
|
||||
pub fn migrate_geo_fields(&mut self) {
|
||||
if self.geo_region.is_none() && self.geo_state.is_some() {
|
||||
@@ -1066,20 +1062,13 @@ impl ProxyManager {
|
||||
self.load_proxy_check_cache(proxy_id)
|
||||
}
|
||||
|
||||
// Check if a stored proxy is dynamic
|
||||
pub fn is_dynamic_proxy(&self, proxy_id: &str) -> bool {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.get(proxy_id).is_some_and(|p| p.is_dynamic())
|
||||
}
|
||||
|
||||
// Fetch proxy settings from a dynamic proxy URL
|
||||
pub async fn fetch_dynamic_proxy(
|
||||
pub async fn fetch_proxy_from_url(
|
||||
&self,
|
||||
url: &str,
|
||||
format: &str,
|
||||
) -> Result<ProxySettings, String> {
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
@@ -1087,33 +1076,39 @@ impl ProxyManager {
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch dynamic proxy: {e}"))?;
|
||||
.map_err(|e| format!("Failed to fetch launch hook: {e}"))?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Dynamic proxy URL returned status {}",
|
||||
response.status()
|
||||
));
|
||||
return Err(format!("Launch hook returned status {}", response.status()));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read dynamic proxy response: {e}"))?;
|
||||
.map_err(|e| format!("Failed to read launch hook response: {e}"))?;
|
||||
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return Err("Dynamic proxy URL returned empty response".to_string());
|
||||
return Err("Launch hook returned empty response".to_string());
|
||||
}
|
||||
|
||||
match format {
|
||||
"json" => Self::parse_dynamic_proxy_json(body),
|
||||
"text" => Self::parse_dynamic_proxy_text(body),
|
||||
_ => Err(format!("Unsupported dynamic proxy format: {format}")),
|
||||
if let Ok(settings) = Self::parse_dynamic_proxy_json(body) {
|
||||
return Ok(Some(settings));
|
||||
}
|
||||
|
||||
match Self::parse_dynamic_proxy_text(body) {
|
||||
Ok(settings) => Ok(Some(settings)),
|
||||
Err(text_error) => Err(format!(
|
||||
"Failed to parse launch hook response: {text_error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON format: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
|
||||
// Parse JSON proxy payload: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
|
||||
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
|
||||
@@ -1179,7 +1174,7 @@ impl ProxyManager {
|
||||
})
|
||||
}
|
||||
|
||||
// Parse text format using the same logic as proxy import
|
||||
// Parse plain text proxy payload using the same logic as proxy import
|
||||
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
|
||||
let line = body
|
||||
.lines()
|
||||
@@ -1210,136 +1205,6 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve dynamic proxy: fetch from URL and return settings
|
||||
pub async fn resolve_dynamic_proxy(&self, proxy_id: &str) -> Result<ProxySettings, String> {
|
||||
let (url, format) = {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
let proxy = stored_proxies
|
||||
.get(proxy_id)
|
||||
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
|
||||
|
||||
match (&proxy.dynamic_proxy_url, &proxy.dynamic_proxy_format) {
|
||||
(Some(url), Some(format)) => (url.clone(), format.clone()),
|
||||
_ => return Err("Proxy is not a dynamic proxy".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
self.fetch_dynamic_proxy(&url, &format).await
|
||||
}
|
||||
|
||||
// Create a dynamic stored proxy
|
||||
pub fn create_dynamic_proxy(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
name: String,
|
||||
url: String,
|
||||
format: String,
|
||||
) -> Result<StoredProxy, String> {
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if stored_proxies.values().any(|p| p.name == name) {
|
||||
return Err(format!("Proxy with name '{name}' already exists"));
|
||||
}
|
||||
}
|
||||
|
||||
let placeholder_settings = ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "dynamic".to_string(),
|
||||
port: 0,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
let mut stored_proxy = StoredProxy::new(name, placeholder_settings);
|
||||
stored_proxy.dynamic_proxy_url = Some(url);
|
||||
stored_proxy.dynamic_proxy_format = Some(format);
|
||||
|
||||
{
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
|
||||
}
|
||||
|
||||
if let Err(e) = self.save_proxy(&stored_proxy) {
|
||||
log::warn!("Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit_empty("proxies-changed") {
|
||||
log::error!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
if stored_proxy.sync_enabled {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let id = stored_proxy.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_proxy_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stored_proxy)
|
||||
}
|
||||
|
||||
// Update a dynamic proxy's URL and format
|
||||
pub fn update_dynamic_proxy(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
proxy_id: &str,
|
||||
name: Option<String>,
|
||||
url: Option<String>,
|
||||
format: Option<String>,
|
||||
) -> Result<StoredProxy, String> {
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if !stored_proxies.contains_key(proxy_id) {
|
||||
return Err(format!("Proxy with ID '{proxy_id}' not found"));
|
||||
}
|
||||
if let Some(ref new_name) = name {
|
||||
if stored_proxies
|
||||
.values()
|
||||
.any(|p| p.id != proxy_id && p.name == *new_name)
|
||||
{
|
||||
return Err(format!("Proxy with name '{new_name}' already exists"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let updated_proxy = {
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap();
|
||||
|
||||
if let Some(new_name) = name {
|
||||
stored_proxy.update_name(new_name);
|
||||
}
|
||||
if let Some(new_url) = url {
|
||||
stored_proxy.dynamic_proxy_url = Some(new_url);
|
||||
}
|
||||
if let Some(new_format) = format {
|
||||
stored_proxy.dynamic_proxy_format = Some(new_format);
|
||||
}
|
||||
|
||||
stored_proxy.clone()
|
||||
};
|
||||
|
||||
if let Err(e) = self.save_proxy(&updated_proxy) {
|
||||
log::warn!("Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit_empty("proxies-changed") {
|
||||
log::error!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
if updated_proxy.sync_enabled {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let id = updated_proxy.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_proxy_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated_proxy)
|
||||
}
|
||||
|
||||
// Export all proxies as JSON
|
||||
pub fn export_proxies_json(&self) -> Result<String, String> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -2239,6 +2104,8 @@ mod tests {
|
||||
use hyper::Response;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
// Helper function to build donut-proxy binary for testing
|
||||
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
@@ -3668,74 +3535,102 @@ mod tests {
|
||||
assert!(err.contains("Empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stored_proxy_is_dynamic() {
|
||||
let mut proxy = StoredProxy::new(
|
||||
"test".to_string(),
|
||||
ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "h.com".to_string(),
|
||||
port: 80,
|
||||
username: None,
|
||||
password: None,
|
||||
},
|
||||
);
|
||||
assert!(!proxy.is_dynamic());
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_parses_json_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"host":"proxy.example.com","port":3128,"type":"socks5","username":"user","password":"pass"}"#,
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string());
|
||||
assert!(proxy.is_dynamic());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_dynamic_proxy_via_manager() {
|
||||
let pm = ProxyManager::new();
|
||||
let result = pm
|
||||
.fetch_proxy_from_url(
|
||||
&format!("{}/hook", server.uri()),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let mut proxy = StoredProxy::new(
|
||||
"DynTest".to_string(),
|
||||
ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "dynamic".to_string(),
|
||||
port: 0,
|
||||
username: None,
|
||||
password: None,
|
||||
},
|
||||
);
|
||||
proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string());
|
||||
proxy.dynamic_proxy_format = Some("json".to_string());
|
||||
|
||||
let id = proxy.id.clone();
|
||||
pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy);
|
||||
|
||||
assert!(pm.is_dynamic_proxy(&id));
|
||||
assert!(!pm.is_dynamic_proxy("nonexistent"));
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 3128);
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
assert_eq!(result.username.as_deref(), Some("user"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_dynamic_proxy_not_dynamic() {
|
||||
async fn test_fetch_proxy_from_url_parses_text_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("socks5://user:pass@1.2.3.4:1080"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let result = pm
|
||||
.fetch_proxy_from_url(
|
||||
&format!("{}/hook", server.uri()),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let proxy = StoredProxy::new(
|
||||
"Regular".to_string(),
|
||||
ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "1.2.3.4".to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
},
|
||||
);
|
||||
let id = proxy.id.clone();
|
||||
pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy);
|
||||
|
||||
let err = pm.resolve_dynamic_proxy(&id).await.unwrap_err();
|
||||
assert!(err.contains("not a dynamic proxy"));
|
||||
assert_eq!(result.host, "1.2.3.4");
|
||||
assert_eq!(result.port, 1080);
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
assert_eq!(result.username.as_deref(), Some("user"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_dynamic_proxy_not_found() {
|
||||
let pm = ProxyManager::new();
|
||||
async fn test_fetch_proxy_from_url_returns_none_for_no_content() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = pm.resolve_dynamic_proxy("nonexistent").await.unwrap_err();
|
||||
assert!(err.contains("not found"));
|
||||
let pm = ProxyManager::new();
|
||||
let result = pm
|
||||
.fetch_proxy_from_url(
|
||||
&format!("{}/hook", server.uri()),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_respects_timeout() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_millis(200))
|
||||
.set_body_string(r#"{"host":"1.2.3.4","port":8080}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let err = pm
|
||||
.fetch_proxy_from_url(&format!("{}/hook", server.uri()), Duration::from_millis(50))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Failed to fetch launch hook"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +303,11 @@ impl SynchronizerManager {
|
||||
}
|
||||
|
||||
/// Bring the leader browser window to front.
|
||||
///
|
||||
/// On macOS this is a no-op on purpose: the only way to raise another
|
||||
/// app's window from Rust is via `osascript` / Apple Events, which
|
||||
/// triggers the TCC "prevented from modifying other apps" prompt. Donut
|
||||
/// must never touch other apps on the user's Mac.
|
||||
async fn focus_leader_window(leader: &BrowserProfile) {
|
||||
let profile = match Self::get_profile(&leader.id.to_string()) {
|
||||
Ok(p) => p,
|
||||
@@ -312,18 +317,6 @@ impl SynchronizerManager {
|
||||
return;
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = tokio::process::Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(format!(
|
||||
"tell application \"System Events\" to set frontmost of (first process whose unix id is {}) to true",
|
||||
pid
|
||||
))
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = tokio::process::Command::new("xdotool")
|
||||
@@ -338,7 +331,7 @@ impl SynchronizerManager {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = pid;
|
||||
}
|
||||
|
||||
@@ -511,11 +511,11 @@ impl WayfernManager {
|
||||
let name: String = row.get(0).unwrap_or_default();
|
||||
let host: String = row.get(1).unwrap_or_default();
|
||||
let encrypted: Vec<u8> = row.get(2).unwrap_or_default();
|
||||
let decrypted =
|
||||
crate::cookie_manager::chrome_decrypt::decrypt(
|
||||
&encrypted,
|
||||
&encryption_key,
|
||||
);
|
||||
let decrypted = crate::cookie_manager::chrome_decrypt::decrypt(
|
||||
&encrypted,
|
||||
&host,
|
||||
&encryption_key,
|
||||
);
|
||||
match decrypted {
|
||||
Some(val) => log::info!(
|
||||
"Pre-launch: Cookie decryption SUCCEEDED for '{}' (host: {}, decrypted {} bytes)",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.2",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -516,6 +516,7 @@ export default function Home() {
|
||||
extensionGroupId?: string;
|
||||
ephemeral?: boolean;
|
||||
dnsBlocklist?: string;
|
||||
launchHook?: string;
|
||||
}) => {
|
||||
try {
|
||||
const profile = await invoke<BrowserProfile>(
|
||||
@@ -534,6 +535,7 @@ export default function Home() {
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
ephemeral: profileData.ephemeral,
|
||||
dnsBlocklist: profileData.dnsBlocklist,
|
||||
launchHook: profileData.launchHook,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -77,11 +77,16 @@ export function CookieCopyDialog({
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Never offer a selected profile as a source — you can't copy a profile's
|
||||
// cookies onto itself, and including it here would leave the user in a
|
||||
// dead-end state (source picked = target list empty = copy button disabled).
|
||||
const eligibleSourceProfiles = useMemo(() => {
|
||||
return profiles.filter(
|
||||
(p) => p.browser === "wayfern" || p.browser === "camoufox",
|
||||
(p) =>
|
||||
!selectedProfiles.includes(p.id) &&
|
||||
(p.browser === "wayfern" || p.browser === "camoufox"),
|
||||
);
|
||||
}, [profiles]);
|
||||
}, [profiles, selectedProfiles]);
|
||||
|
||||
const targetProfiles = useMemo(() => {
|
||||
return profiles.filter(
|
||||
@@ -148,22 +153,21 @@ export function CookieCopyDialog({
|
||||
const toggleDomain = useCallback(
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
setSelection((prev) => {
|
||||
const current = prev[domain];
|
||||
const allSelected = current.allSelected;
|
||||
|
||||
if (allSelected) {
|
||||
// `prev[domain]` is `undefined` for any domain not yet interacted with
|
||||
// and after the user fully deselects it (toggleCookie deletes the
|
||||
// entry on empty). Treat missing as "not selected".
|
||||
if (prev[domain]?.allSelected) {
|
||||
const newSelection = { ...prev };
|
||||
delete newSelection[domain];
|
||||
return newSelection;
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
[domain]: {
|
||||
allSelected: true,
|
||||
cookies: new Set(cookies.map((c) => c.name)),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[domain]: {
|
||||
allSelected: true,
|
||||
cookies: new Set(cookies.map((c) => c.name)),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -503,9 +507,13 @@ function DomainRow({
|
||||
onToggleCookie,
|
||||
onToggleExpand,
|
||||
}: DomainRowProps) {
|
||||
// `selection[domain.domain]` is `undefined` for domains the user hasn't
|
||||
// touched yet (initial state after loading cookies is `{}`) and for any
|
||||
// domain the user fully deselected (toggleCookie deletes the entry on
|
||||
// empty). Default to "no cookies selected" instead of crashing.
|
||||
const domainSelection = selection[domain.domain];
|
||||
const isAllSelected = domainSelection.allSelected;
|
||||
const selectedCount = domainSelection.cookies.size;
|
||||
const isAllSelected = domainSelection?.allSelected ?? false;
|
||||
const selectedCount = domainSelection?.cookies.size ?? 0;
|
||||
const isPartial =
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
@@ -540,7 +548,8 @@ function DomainRow({
|
||||
{isExpanded && (
|
||||
<div className="ml-8 pl-2 border-l space-y-1">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected = domainSelection.cookies.has(cookie.name);
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
|
||||
@@ -309,8 +309,11 @@ export function CookieManagementDialog({
|
||||
const toggleDomain = useCallback(
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
setExportSelection((prev) => {
|
||||
const current = prev[domain];
|
||||
if (current.allSelected) {
|
||||
// `prev[domain]` is `undefined` when the domain was previously fully
|
||||
// deselected (entries are deleted on empty — see toggleCookie). Treat
|
||||
// missing as "not selected" so re-enabling falls through to the add
|
||||
// branch instead of crashing on `.allSelected`.
|
||||
if (prev[domain]?.allSelected) {
|
||||
const next = { ...prev };
|
||||
delete next[domain];
|
||||
return next;
|
||||
@@ -592,8 +595,8 @@ function ExportDomainRow({
|
||||
onToggleExpand,
|
||||
}: ExportDomainRowProps) {
|
||||
const domainSelection = selection[domain.domain];
|
||||
const isAllSelected = domainSelection.allSelected;
|
||||
const selectedCount = domainSelection.cookies.size;
|
||||
const isAllSelected = domainSelection?.allSelected ?? false;
|
||||
const selectedCount = domainSelection?.cookies.size ?? 0;
|
||||
const isPartial =
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
@@ -628,7 +631,8 @@ function ExportDomainRow({
|
||||
{isExpanded && (
|
||||
<div className="ml-7 pl-2 border-l space-y-0.5">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected = domainSelection.cookies.has(cookie.name);
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
|
||||
@@ -85,6 +85,7 @@ interface CreateProfileDialogProps {
|
||||
extensionGroupId?: string;
|
||||
ephemeral?: boolean;
|
||||
dnsBlocklist?: string;
|
||||
launchHook?: string;
|
||||
}) => Promise<void>;
|
||||
selectedGroupId?: string;
|
||||
crossOsUnlocked?: boolean;
|
||||
@@ -126,6 +127,7 @@ export function CreateProfileDialog({
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
|
||||
const [launchHook, setLaunchHook] = useState("");
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
|
||||
@@ -150,6 +152,7 @@ export function CreateProfileDialog({
|
||||
setSelectedBrowser(null);
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
@@ -158,6 +161,7 @@ export function CreateProfileDialog({
|
||||
setSelectedBrowser(null);
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
};
|
||||
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
@@ -398,6 +402,7 @@ export function CreateProfileDialog({
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
// Default to Camoufox
|
||||
@@ -424,6 +429,7 @@ export function CreateProfileDialog({
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -448,6 +454,7 @@ export function CreateProfileDialog({
|
||||
proxyId: selectedProxyId,
|
||||
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -469,6 +476,7 @@ export function CreateProfileDialog({
|
||||
setActiveTab("anti-detect");
|
||||
setSelectedBrowser(null);
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
setReleaseTypes({});
|
||||
setIsLoadingReleaseTypes(false);
|
||||
setReleaseTypesError(null);
|
||||
@@ -1167,6 +1175,23 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="launch-hook-url">
|
||||
{t("createProfile.launchHook.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="launch-hook-url"
|
||||
value={launchHook}
|
||||
onChange={(e) => {
|
||||
setLaunchHook(e.target.value);
|
||||
}}
|
||||
placeholder={t(
|
||||
"createProfile.launchHook.placeholder",
|
||||
)}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DNS Blocklist */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("dnsBlocklist.title")}</Label>
|
||||
@@ -1498,6 +1523,23 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="launch-hook-url-regular">
|
||||
{t("createProfile.launchHook.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="launch-hook-url-regular"
|
||||
value={launchHook}
|
||||
onChange={(e) => {
|
||||
setLaunchHook(e.target.value);
|
||||
}}
|
||||
placeholder={t(
|
||||
"createProfile.launchHook.placeholder",
|
||||
)}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ProfileBypassRulesDialog,
|
||||
ProfileDnsBlocklistDialog,
|
||||
ProfileInfoDialog,
|
||||
ProfileLaunchHookDialog,
|
||||
} from "@/components/profile-info-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -937,6 +938,8 @@ export function ProfilesDataTable({
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [launchHookProfile, setLaunchHookProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -2680,6 +2683,9 @@ export function ProfilesDataTable({
|
||||
onOpenDnsBlocklist={(profile) => {
|
||||
setDnsBlocklistProfile(profile);
|
||||
}}
|
||||
onOpenLaunchHook={(profile) => {
|
||||
setLaunchHookProfile(profile);
|
||||
}}
|
||||
onCloneProfile={onCloneProfile}
|
||||
onLaunchWithSync={onLaunchWithSync}
|
||||
onDeleteProfile={(profile) => {
|
||||
@@ -2770,6 +2776,14 @@ export function ProfilesDataTable({
|
||||
profileId={dnsBlocklistProfile?.id ?? null}
|
||||
currentLevel={dnsBlocklistProfile?.dns_blocklist ?? null}
|
||||
/>
|
||||
<ProfileLaunchHookDialog
|
||||
isOpen={launchHookProfile !== null}
|
||||
onClose={() => {
|
||||
setLaunchHookProfile(null);
|
||||
}}
|
||||
profileId={launchHookProfile?.id ?? null}
|
||||
currentLaunchHook={launchHookProfile?.launch_hook ?? null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
LuLink,
|
||||
LuPlus,
|
||||
LuPuzzle,
|
||||
LuRefreshCw,
|
||||
@@ -66,6 +67,7 @@ interface ProfileInfoDialogProps {
|
||||
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
||||
onOpenBypassRules?: (profile: BrowserProfile) => void;
|
||||
onOpenDnsBlocklist?: (profile: BrowserProfile) => void;
|
||||
onOpenLaunchHook?: (profile: BrowserProfile) => void;
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onDeleteProfile?: (profile: BrowserProfile) => void;
|
||||
onLaunchWithSync?: (profile: BrowserProfile) => void;
|
||||
@@ -113,6 +115,7 @@ export function ProfileInfoDialog({
|
||||
onAssignExtensionGroup,
|
||||
onOpenBypassRules,
|
||||
onOpenDnsBlocklist,
|
||||
onOpenLaunchHook,
|
||||
onCloneProfile,
|
||||
onDeleteProfile,
|
||||
onLaunchWithSync,
|
||||
@@ -218,6 +221,13 @@ export function ProfileInfoDialog({
|
||||
const hasNote = !!profile.note;
|
||||
const showCrossOs = isCrossOsProfile(profile);
|
||||
|
||||
// Items in the settings tab `actions` list MUST only open another dialog
|
||||
// (or trigger a navigation/action that closes this one). Do NOT put inline
|
||||
// settings UI — inputs, toggles, save buttons — directly in this dialog's
|
||||
// settings tab. Each setting belongs in its own focused dialog (see
|
||||
// `ProfileLaunchHookDialog`, `ProfileBypassRulesDialog`,
|
||||
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
|
||||
// a navigation hub.
|
||||
interface ActionItem {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
@@ -336,6 +346,14 @@ export function ProfileInfoDialog({
|
||||
handleAction(() => onOpenDnsBlocklist?.(profile));
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <LuLink className="w-4 h-4" />,
|
||||
label: t("profiles.actions.launchHook"),
|
||||
onClick: () => {
|
||||
handleAction(() => onOpenLaunchHook?.(profile));
|
||||
},
|
||||
hidden: !onOpenLaunchHook,
|
||||
},
|
||||
{
|
||||
icon: <LuTrash2 className="w-4 h-4" />,
|
||||
label: t("profiles.actions.delete"),
|
||||
@@ -474,6 +492,10 @@ export function ProfileInfoDialog({
|
||||
: t("dnsBlocklist.none")
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.launchHook")}
|
||||
value={profile.launch_hook || t("profileInfo.values.none")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sync */}
|
||||
@@ -546,33 +568,37 @@ export function ProfileInfoDialog({
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="overflow-y-auto max-h-[calc(80vh-12rem)]">
|
||||
<div className="flex flex-col py-1">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
|
||||
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
|
||||
action.destructive &&
|
||||
"text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{action.label}
|
||||
{action.runningBadge && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-primary/15 text-primary uppercase">
|
||||
{t("common.status.running")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-3 py-1">
|
||||
<div className="flex flex-col">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
|
||||
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
|
||||
action.destructive &&
|
||||
"text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
{action.proBadge && !action.runningBadge && <ProBadge />}
|
||||
</span>
|
||||
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{action.label}
|
||||
{action.runningBadge && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-primary/15 text-primary uppercase">
|
||||
{t("common.status.running")}
|
||||
</span>
|
||||
)}
|
||||
{action.proBadge && !action.runningBadge && (
|
||||
<ProBadge />
|
||||
)}
|
||||
</span>
|
||||
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -582,6 +608,80 @@ export function ProfileInfoDialog({
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileLaunchHookDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileId: string | null;
|
||||
currentLaunchHook: string | null;
|
||||
}
|
||||
|
||||
export function ProfileLaunchHookDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileId,
|
||||
currentLaunchHook,
|
||||
}: ProfileLaunchHookDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = React.useState(currentLaunchHook ?? "");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(currentLaunchHook ?? "");
|
||||
}
|
||||
}, [isOpen, currentLaunchHook]);
|
||||
|
||||
const trimmed = value.trim();
|
||||
const saved = currentLaunchHook ?? "";
|
||||
const isDirty = trimmed !== saved;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!profileId) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("update_profile_launch_hook", {
|
||||
profileId,
|
||||
launchHook: trimmed || null,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update launch hook:", err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.launchHook.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.launchHook.description")}
|
||||
</p>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
}}
|
||||
placeholder={t("profileInfo.launchHook.placeholder")}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving || !isDirty}
|
||||
className="w-full"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileDnsBlocklistDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -50,9 +50,7 @@ export function ProxyCheckButton({
|
||||
try {
|
||||
const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
|
||||
proxyId: proxy.id,
|
||||
proxySettings: proxy.dynamic_proxy_url
|
||||
? undefined
|
||||
: proxy.proxy_settings,
|
||||
proxySettings: proxy.proxy_settings,
|
||||
});
|
||||
setLocalResult(result);
|
||||
onCheckComplete?.(result);
|
||||
|
||||
@@ -21,11 +21,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { ProxySettings, StoredProxy } from "@/types";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface RegularFormData {
|
||||
interface ProxyFormData {
|
||||
name: string;
|
||||
proxy_type: string;
|
||||
host: string;
|
||||
@@ -34,20 +33,21 @@ interface RegularFormData {
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface DynamicFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
type ProxyMode = "regular" | "dynamic";
|
||||
|
||||
interface ProxyFormDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editingProxy?: StoredProxy | null;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: ProxyFormData = {
|
||||
name: "",
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
port: 8080,
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
export function ProxyFormDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -55,158 +55,66 @@ export function ProxyFormDialog({
|
||||
}: ProxyFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [mode, setMode] = useState<ProxyMode>("regular");
|
||||
const [regularForm, setRegularForm] = useState<RegularFormData>({
|
||||
name: "",
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
port: 8080,
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [dynamicForm, setDynamicForm] = useState<DynamicFormData>({
|
||||
name: "",
|
||||
url: "",
|
||||
format: "json",
|
||||
});
|
||||
const [form, setForm] = useState<ProxyFormData>(DEFAULT_FORM);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setRegularForm({
|
||||
name: "",
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
port: 8080,
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
setDynamicForm({
|
||||
name: "",
|
||||
url: "",
|
||||
format: "json",
|
||||
});
|
||||
setMode("regular");
|
||||
setForm(DEFAULT_FORM);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingProxy) {
|
||||
if (editingProxy.dynamic_proxy_url) {
|
||||
setMode("dynamic");
|
||||
setDynamicForm({
|
||||
name: editingProxy.name,
|
||||
url: editingProxy.dynamic_proxy_url,
|
||||
format: editingProxy.dynamic_proxy_format || "json",
|
||||
});
|
||||
} else {
|
||||
setMode("regular");
|
||||
setRegularForm({
|
||||
name: editingProxy.name,
|
||||
proxy_type: editingProxy.proxy_settings.proxy_type,
|
||||
host: editingProxy.proxy_settings.host,
|
||||
port: editingProxy.proxy_settings.port,
|
||||
username: editingProxy.proxy_settings.username ?? "",
|
||||
password: editingProxy.proxy_settings.password ?? "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingProxy, resetForm]);
|
||||
|
||||
const handleTestDynamic = useCallback(async () => {
|
||||
if (!dynamicForm.url.trim()) {
|
||||
toast.error(t("proxies.dynamic.urlRequired"));
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const settings = await invoke<ProxySettings>("fetch_dynamic_proxy", {
|
||||
url: dynamicForm.url.trim(),
|
||||
format: dynamicForm.format,
|
||||
});
|
||||
toast.success(
|
||||
t("proxies.dynamic.testSuccess", {
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t("proxies.dynamic.testFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
|
||||
if (!editingProxy) {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
}, [dynamicForm, t]);
|
||||
|
||||
setForm({
|
||||
name: editingProxy.name,
|
||||
proxy_type: editingProxy.proxy_settings.proxy_type,
|
||||
host: editingProxy.proxy_settings.host,
|
||||
port: editingProxy.proxy_settings.port,
|
||||
username: editingProxy.proxy_settings.username ?? "",
|
||||
password: editingProxy.proxy_settings.password ?? "",
|
||||
});
|
||||
}, [editingProxy, isOpen, resetForm]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (mode === "regular") {
|
||||
if (!regularForm.name.trim()) {
|
||||
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
|
||||
return;
|
||||
}
|
||||
if (!regularForm.host.trim() || !regularForm.port) {
|
||||
toast.error(
|
||||
t("proxies.form.hostPortRequired", "Host and port are required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!dynamicForm.name.trim()) {
|
||||
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
|
||||
return;
|
||||
}
|
||||
if (!dynamicForm.url.trim()) {
|
||||
toast.error(t("proxies.dynamic.urlRequired"));
|
||||
return;
|
||||
}
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.host.trim() || !form.port) {
|
||||
toast.error(
|
||||
t("proxies.form.hostPortRequired", "Host and port are required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
proxySettings: {
|
||||
proxy_type: form.proxy_type,
|
||||
host: form.host.trim(),
|
||||
port: form.port,
|
||||
username: form.username.trim() || undefined,
|
||||
password: form.password.trim() || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (editingProxy) {
|
||||
if (mode === "dynamic") {
|
||||
await invoke("update_stored_proxy", {
|
||||
proxyId: editingProxy.id,
|
||||
name: dynamicForm.name.trim(),
|
||||
dynamicProxyUrl: dynamicForm.url.trim(),
|
||||
dynamicProxyFormat: dynamicForm.format,
|
||||
});
|
||||
} else {
|
||||
await invoke("update_stored_proxy", {
|
||||
proxyId: editingProxy.id,
|
||||
name: regularForm.name.trim(),
|
||||
proxySettings: {
|
||||
proxy_type: regularForm.proxy_type,
|
||||
host: regularForm.host.trim(),
|
||||
port: regularForm.port,
|
||||
username: regularForm.username.trim() || undefined,
|
||||
password: regularForm.password.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await invoke("update_stored_proxy", {
|
||||
proxyId: editingProxy.id,
|
||||
...payload,
|
||||
});
|
||||
toast.success(t("toasts.success.proxyUpdated"));
|
||||
} else {
|
||||
if (mode === "dynamic") {
|
||||
await invoke("create_stored_proxy", {
|
||||
name: dynamicForm.name.trim(),
|
||||
dynamicProxyUrl: dynamicForm.url.trim(),
|
||||
dynamicProxyFormat: dynamicForm.format,
|
||||
});
|
||||
} else {
|
||||
await invoke("create_stored_proxy", {
|
||||
name: regularForm.name.trim(),
|
||||
proxySettings: {
|
||||
proxy_type: regularForm.proxy_type,
|
||||
host: regularForm.host.trim(),
|
||||
port: regularForm.port,
|
||||
username: regularForm.username.trim() || undefined,
|
||||
password: regularForm.password.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await invoke("create_stored_proxy", payload);
|
||||
toast.success(t("toasts.success.proxyCreated"));
|
||||
}
|
||||
|
||||
@@ -219,7 +127,7 @@ export function ProxyFormDialog({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [mode, regularForm, dynamicForm, editingProxy, onClose, t]);
|
||||
}, [editingProxy, form, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
@@ -227,17 +135,8 @@ export function ProxyFormDialog({
|
||||
}
|
||||
}, [isSubmitting, onClose]);
|
||||
|
||||
const isRegularValid =
|
||||
regularForm.name.trim() &&
|
||||
regularForm.host.trim() &&
|
||||
regularForm.port > 0 &&
|
||||
regularForm.port <= 65535;
|
||||
|
||||
const isDynamicValid = dynamicForm.name.trim() && dynamicForm.url.trim();
|
||||
|
||||
const isFormValid = mode === "regular" ? isRegularValid : isDynamicValid;
|
||||
|
||||
const isEditingDynamic = editingProxy?.dynamic_proxy_url != null;
|
||||
const isFormValid =
|
||||
form.name.trim() && form.host.trim() && form.port > 0 && form.port <= 65535;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -249,210 +148,109 @@ export function ProxyFormDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{!editingProxy && (
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => {
|
||||
setMode(v as ProxyMode);
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
|
||||
<Input
|
||||
id="proxy-name"
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
}}
|
||||
placeholder={t("proxies.form.namePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("proxies.form.type")}</Label>
|
||||
<Select
|
||||
value={form.proxy_type}
|
||||
onValueChange={(value) => {
|
||||
setForm({ ...form, proxy_type: value });
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="regular" className="flex-1">
|
||||
{t("proxies.tabs.regular")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dynamic" className="flex-1">
|
||||
{t("proxies.tabs.dynamic")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select proxy type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5"].map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{editingProxy && isEditingDynamic && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxies.dynamic.description")}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
|
||||
<Input
|
||||
id="proxy-host"
|
||||
value={form.host}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, host: e.target.value });
|
||||
}}
|
||||
placeholder={t("proxies.form.hostPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "regular" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
|
||||
<Input
|
||||
id="proxy-name"
|
||||
value={regularForm.name}
|
||||
onChange={(e) => {
|
||||
setRegularForm({ ...regularForm, name: e.target.value });
|
||||
}}
|
||||
placeholder="e.g. Office Proxy, Home VPN, etc."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
|
||||
<Input
|
||||
id="proxy-port"
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => {
|
||||
setForm({
|
||||
...form,
|
||||
port: Number.parseInt(e.target.value, 10) || 0,
|
||||
});
|
||||
}}
|
||||
placeholder={t("proxies.form.portPlaceholder")}
|
||||
min="1"
|
||||
max="65535"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("proxies.form.type")}</Label>
|
||||
<Select
|
||||
value={regularForm.proxy_type}
|
||||
onValueChange={(value) => {
|
||||
setRegularForm({ ...regularForm, proxy_type: value });
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select proxy type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5"].map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-username">
|
||||
{t("proxies.form.username")} (
|
||||
{t("proxies.form.usernamePlaceholder")})
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-username"
|
||||
value={form.username}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, username: e.target.value });
|
||||
}}
|
||||
placeholder={t("proxies.form.usernamePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
|
||||
<Input
|
||||
id="proxy-host"
|
||||
value={regularForm.host}
|
||||
onChange={(e) => {
|
||||
setRegularForm({ ...regularForm, host: e.target.value });
|
||||
}}
|
||||
placeholder={t("proxies.form.hostPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
|
||||
<Input
|
||||
id="proxy-port"
|
||||
type="number"
|
||||
value={regularForm.port}
|
||||
onChange={(e) => {
|
||||
setRegularForm({
|
||||
...regularForm,
|
||||
port: parseInt(e.target.value, 10) || 0,
|
||||
});
|
||||
}}
|
||||
placeholder={t("proxies.form.portPlaceholder")}
|
||||
min="1"
|
||||
max="65535"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-username">
|
||||
{t("proxies.form.username")} (
|
||||
{t("proxies.form.usernamePlaceholder")})
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-username"
|
||||
value={regularForm.username}
|
||||
onChange={(e) => {
|
||||
setRegularForm({
|
||||
...regularForm,
|
||||
username: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder={t("proxies.form.usernamePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-password">
|
||||
{t("proxies.form.password")} (
|
||||
{t("proxies.form.passwordPlaceholder")})
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-password"
|
||||
type="password"
|
||||
value={regularForm.password}
|
||||
onChange={(e) => {
|
||||
setRegularForm({
|
||||
...regularForm,
|
||||
password: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder={t("proxies.form.passwordPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dynamic-name">{t("proxies.form.name")}</Label>
|
||||
<Input
|
||||
id="dynamic-name"
|
||||
value={dynamicForm.name}
|
||||
onChange={(e) => {
|
||||
setDynamicForm({ ...dynamicForm, name: e.target.value });
|
||||
}}
|
||||
placeholder="e.g. My Tunnel"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dynamic-url">{t("proxies.dynamic.url")}</Label>
|
||||
<Input
|
||||
id="dynamic-url"
|
||||
value={dynamicForm.url}
|
||||
onChange={(e) => {
|
||||
setDynamicForm({ ...dynamicForm, url: e.target.value });
|
||||
}}
|
||||
placeholder={t("proxies.dynamic.urlPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("proxies.dynamic.format")}</Label>
|
||||
<Select
|
||||
value={dynamicForm.format}
|
||||
onValueChange={(value) => {
|
||||
setDynamicForm({ ...dynamicForm, format: value });
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">
|
||||
{t("proxies.dynamic.formatJson")}
|
||||
</SelectItem>
|
||||
<SelectItem value="text">
|
||||
{t("proxies.dynamic.formatText")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dynamicForm.format === "json"
|
||||
? t("proxies.dynamic.formatJsonHint")
|
||||
: t("proxies.dynamic.formatTextHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestDynamic}
|
||||
disabled={isSubmitting || isTesting || !dynamicForm.url.trim()}
|
||||
>
|
||||
{isTesting
|
||||
? t("proxies.dynamic.testing")
|
||||
: t("proxies.dynamic.testUrl")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-password">
|
||||
{t("proxies.form.password")} (
|
||||
{t("proxies.form.passwordPlaceholder")})
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, password: e.target.value });
|
||||
}}
|
||||
placeholder={t("proxies.form.passwordPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -461,7 +259,7 @@ export function ProxyFormDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isSubmitting}
|
||||
|
||||
@@ -469,14 +469,6 @@ export function ProxyManagementDialog({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{proxy.name}
|
||||
{proxy.dynamic_proxy_url && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0"
|
||||
>
|
||||
Dynamic
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
"syncSettings": "Sync Settings",
|
||||
"assignToGroup": "Assign to Group",
|
||||
"changeFingerprint": "Change Fingerprint",
|
||||
"copyCookiesToProfile": "Copy Cookies to Profile"
|
||||
"copyCookiesToProfile": "Copy Cookies to Profile",
|
||||
"launchHook": "Launch Hook URL"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Launch with Synchronizer",
|
||||
@@ -229,6 +230,10 @@
|
||||
"noProxy": "No proxy / VPN",
|
||||
"noProxiesAvailable": "No proxies or VPNs available. Add one to route this profile's traffic."
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "Launch Hook URL",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Fetching available versions...",
|
||||
"fetchError": "Failed to fetch browser versions. Please check your internet connection and try again.",
|
||||
@@ -759,6 +764,7 @@
|
||||
"browser": "Browser",
|
||||
"releaseType": "Release Type",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"launchHook": "Launch Hook",
|
||||
"group": "Group",
|
||||
"tags": "Tags",
|
||||
"note": "Note",
|
||||
@@ -783,6 +789,12 @@
|
||||
"noRules": "No bypass rules configured.",
|
||||
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "Launch Hook URL",
|
||||
"label": "Launch Hook URL",
|
||||
"description": "Donut Browser will POST to this URL whenever the profile is launched.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Manage Cookies",
|
||||
"assignExtensionGroup": "Assign Extension Group"
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
"syncSettings": "Configuración de Sincronización",
|
||||
"assignToGroup": "Asignar a Grupo",
|
||||
"changeFingerprint": "Cambiar Huella Digital",
|
||||
"copyCookiesToProfile": "Copiar Cookies al Perfil"
|
||||
"copyCookiesToProfile": "Copiar Cookies al Perfil",
|
||||
"launchHook": "URL del hook de inicio"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Lanzar con Sincronizador",
|
||||
@@ -229,6 +230,10 @@
|
||||
"noProxy": "Sin proxy / VPN",
|
||||
"noProxiesAvailable": "No hay proxies o VPNs disponibles. Agrega uno para enrutar el tráfico de este perfil."
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "URL del hook de inicio",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Obteniendo versiones disponibles...",
|
||||
"fetchError": "Error al obtener versiones del navegador. Por favor verifica tu conexión a internet e intenta de nuevo.",
|
||||
@@ -759,6 +764,7 @@
|
||||
"browser": "Navegador",
|
||||
"releaseType": "Tipo de Versión",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"launchHook": "Hook de inicio",
|
||||
"group": "Grupo",
|
||||
"tags": "Etiquetas",
|
||||
"note": "Nota",
|
||||
@@ -783,6 +789,12 @@
|
||||
"noRules": "No hay reglas de omisión configuradas.",
|
||||
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "URL del hook de inicio",
|
||||
"label": "URL del hook de inicio",
|
||||
"description": "Donut Browser enviará una solicitud POST a esta URL cada vez que se inicie el perfil.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Administrar Cookies",
|
||||
"assignExtensionGroup": "Asignar Grupo de Extensiones"
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
"syncSettings": "Paramètres de Synchronisation",
|
||||
"assignToGroup": "Assigner au Groupe",
|
||||
"changeFingerprint": "Changer l'Empreinte",
|
||||
"copyCookiesToProfile": "Copier les Cookies vers le Profil"
|
||||
"copyCookiesToProfile": "Copier les Cookies vers le Profil",
|
||||
"launchHook": "URL du hook de lancement"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Lancer avec le synchroniseur",
|
||||
@@ -229,6 +230,10 @@
|
||||
"noProxy": "Pas de proxy / VPN",
|
||||
"noProxiesAvailable": "Aucun proxy ou VPN disponible. Ajoutez-en un pour router le trafic de ce profil."
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "URL du hook de lancement",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Récupération des versions disponibles...",
|
||||
"fetchError": "Échec de la récupération des versions du navigateur. Veuillez vérifier votre connexion Internet et réessayer.",
|
||||
@@ -759,6 +764,7 @@
|
||||
"browser": "Navigateur",
|
||||
"releaseType": "Type de Version",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"launchHook": "Hook de lancement",
|
||||
"group": "Groupe",
|
||||
"tags": "Tags",
|
||||
"note": "Note",
|
||||
@@ -783,6 +789,12 @@
|
||||
"noRules": "Aucune règle de contournement configurée.",
|
||||
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "URL du hook de lancement",
|
||||
"label": "URL du hook de lancement",
|
||||
"description": "Donut Browser enverra une requête POST à cette URL chaque fois que le profil est lancé.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gérer les Cookies",
|
||||
"assignExtensionGroup": "Assigner un Groupe d'Extensions"
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
"syncSettings": "同期設定",
|
||||
"assignToGroup": "グループに割り当て",
|
||||
"changeFingerprint": "フィンガープリントを変更",
|
||||
"copyCookiesToProfile": "Cookieをプロファイルにコピー"
|
||||
"copyCookiesToProfile": "Cookieをプロファイルにコピー",
|
||||
"launchHook": "起動フックURL"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "シンクロナイザーで起動",
|
||||
@@ -229,6 +230,10 @@
|
||||
"noProxy": "プロキシ / VPNなし",
|
||||
"noProxiesAvailable": "利用可能なプロキシまたはVPNがありません。このプロファイルのトラフィックをルーティングするために追加してください。"
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "起動フックURL",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"version": {
|
||||
"fetching": "利用可能なバージョンを取得中...",
|
||||
"fetchError": "ブラウザバージョンの取得に失敗しました。インターネット接続を確認して再試行してください。",
|
||||
@@ -759,6 +764,7 @@
|
||||
"browser": "ブラウザ",
|
||||
"releaseType": "リリースタイプ",
|
||||
"proxyVpn": "プロキシ / VPN",
|
||||
"launchHook": "起動フック",
|
||||
"group": "グループ",
|
||||
"tags": "タグ",
|
||||
"note": "メモ",
|
||||
@@ -783,6 +789,12 @@
|
||||
"noRules": "バイパスルールは設定されていません。",
|
||||
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "起動フックURL",
|
||||
"label": "起動フックURL",
|
||||
"description": "プロファイルが起動されるたびに、Donut BrowserはこのURLにPOSTリクエストを送信します。",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Cookieを管理",
|
||||
"assignExtensionGroup": "拡張機能グループを割り当て"
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
"syncSettings": "Configurações de Sincronização",
|
||||
"assignToGroup": "Atribuir ao Grupo",
|
||||
"changeFingerprint": "Alterar Impressão Digital",
|
||||
"copyCookiesToProfile": "Copiar Cookies para o Perfil"
|
||||
"copyCookiesToProfile": "Copiar Cookies para o Perfil",
|
||||
"launchHook": "URL do hook de inicialização"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Iniciar com Sincronizador",
|
||||
@@ -229,6 +230,10 @@
|
||||
"noProxy": "Sem proxy / VPN",
|
||||
"noProxiesAvailable": "Nenhum proxy ou VPN disponível. Adicione um para rotear o tráfego deste perfil."
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "URL do hook de inicialização",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Buscando versões disponíveis...",
|
||||
"fetchError": "Falha ao buscar versões do navegador. Por favor, verifique sua conexão com a internet e tente novamente.",
|
||||
@@ -759,6 +764,7 @@
|
||||
"browser": "Navegador",
|
||||
"releaseType": "Tipo de Versão",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"launchHook": "Hook de inicialização",
|
||||
"group": "Grupo",
|
||||
"tags": "Tags",
|
||||
"note": "Nota",
|
||||
@@ -783,6 +789,12 @@
|
||||
"noRules": "Nenhuma regra de bypass configurada.",
|
||||
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "URL do hook de inicialização",
|
||||
"label": "URL do hook de inicialização",
|
||||
"description": "O Donut Browser enviará uma requisição POST para esta URL sempre que o perfil for iniciado.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gerenciar Cookies",
|
||||
"assignExtensionGroup": "Atribuir Grupo de Extensões"
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
"syncSettings": "Настройки синхронизации",
|
||||
"assignToGroup": "Назначить группе",
|
||||
"changeFingerprint": "Изменить отпечаток",
|
||||
"copyCookiesToProfile": "Копировать Cookie в профиль"
|
||||
"copyCookiesToProfile": "Копировать Cookie в профиль",
|
||||
"launchHook": "URL хука запуска"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Запустить с синхронизатором",
|
||||
@@ -229,6 +230,10 @@
|
||||
"noProxy": "Без прокси / VPN",
|
||||
"noProxiesAvailable": "Нет доступных прокси или VPN. Добавьте один для маршрутизации трафика этого профиля."
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "URL хука запуска",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Получение доступных версий...",
|
||||
"fetchError": "Не удалось получить версии браузера. Проверьте интернет-соединение и попробуйте снова.",
|
||||
@@ -759,6 +764,7 @@
|
||||
"browser": "Браузер",
|
||||
"releaseType": "Тип релиза",
|
||||
"proxyVpn": "Прокси / VPN",
|
||||
"launchHook": "Хук запуска",
|
||||
"group": "Группа",
|
||||
"tags": "Теги",
|
||||
"note": "Заметка",
|
||||
@@ -783,6 +789,12 @@
|
||||
"noRules": "Правила обхода не настроены.",
|
||||
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "URL хука запуска",
|
||||
"label": "URL хука запуска",
|
||||
"description": "Donut Browser будет отправлять POST-запрос на этот URL при каждом запуске профиля.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Управление Cookie",
|
||||
"assignExtensionGroup": "Назначить группу расширений"
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
"syncSettings": "同步设置",
|
||||
"assignToGroup": "分配到组",
|
||||
"changeFingerprint": "更改指纹",
|
||||
"copyCookiesToProfile": "复制 Cookies 到配置文件"
|
||||
"copyCookiesToProfile": "复制 Cookies 到配置文件",
|
||||
"launchHook": "启动钩子 URL"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "使用同步器启动",
|
||||
@@ -229,6 +230,10 @@
|
||||
"noProxy": "无代理 / VPN",
|
||||
"noProxiesAvailable": "没有可用的代理或VPN。添加一个来路由此配置文件的流量。"
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "启动钩子 URL",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"version": {
|
||||
"fetching": "正在获取可用版本...",
|
||||
"fetchError": "获取浏览器版本失败。请检查您的网络连接并重试。",
|
||||
@@ -759,6 +764,7 @@
|
||||
"browser": "浏览器",
|
||||
"releaseType": "发布类型",
|
||||
"proxyVpn": "代理 / VPN",
|
||||
"launchHook": "启动钩子",
|
||||
"group": "分组",
|
||||
"tags": "标签",
|
||||
"note": "备注",
|
||||
@@ -783,6 +789,12 @@
|
||||
"noRules": "未配置绕过规则。",
|
||||
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "启动钩子 URL",
|
||||
"label": "启动钩子 URL",
|
||||
"description": "每次启动配置文件时,Donut Browser 都会向此 URL 发送 POST 请求。",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "管理 Cookie",
|
||||
"assignExtensionGroup": "分配扩展程序组"
|
||||
|
||||
+1
-2
@@ -18,6 +18,7 @@ export interface BrowserProfile {
|
||||
version: string;
|
||||
proxy_id?: string; // Reference to stored proxy
|
||||
vpn_id?: string; // Reference to stored VPN config
|
||||
launch_hook?: string;
|
||||
process_id?: number;
|
||||
last_launch?: number;
|
||||
release_type: string; // "stable" or "nightly"
|
||||
@@ -135,8 +136,6 @@ export interface StoredProxy {
|
||||
geo_region?: string;
|
||||
geo_city?: string;
|
||||
geo_isp?: string;
|
||||
dynamic_proxy_url?: string;
|
||||
dynamic_proxy_format?: string;
|
||||
}
|
||||
|
||||
export interface LocationItem {
|
||||
|
||||
Reference in New Issue
Block a user