Refactor ProxyWarden routing and settings flow
This commit is contained in:
60
.github/workflows/ci.yml
vendored
Normal file
60
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
windows-baseline:
|
||||
name: Windows baseline
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend tests
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Check Rust formatting
|
||||
working-directory: src-tauri
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run Rust lints
|
||||
working-directory: src-tauri
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: src-tauri
|
||||
run: cargo test --all-targets
|
||||
|
||||
- name: Check Tauri environment
|
||||
run: npm run tauri -- info
|
||||
|
||||
- name: Plan control app installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-control-app.ps1 -PlanOnly
|
||||
|
||||
- name: Plan ProxiFyre installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||
|
||||
- name: Plan sing-box installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-singbox.ps1 -PlanOnly
|
||||
452
package-lock.json
generated
452
package-lock.json
generated
@@ -21,7 +21,8 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^7.0.0"
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1499,6 +1500,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1547,6 +1566,131 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
||||
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.7",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.41",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
|
||||
@@ -1594,6 +1738,16 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001800",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
||||
@@ -1615,6 +1769,33 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1647,6 +1828,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.385",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
|
||||
@@ -1654,6 +1845,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
@@ -1706,6 +1904,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1782,6 +2000,13 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -1801,6 +2026,16 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1837,6 +2072,23 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1978,6 +2230,13 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1988,6 +2247,54 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal/node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -2005,6 +2312,36 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2125,6 +2462,119 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.4.1",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"pathe": "^2.0.3",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.7",
|
||||
"@vitest/mocker": "3.2.7",
|
||||
"@vitest/pretty-format": "^3.2.7",
|
||||
"@vitest/runner": "3.2.7",
|
||||
"@vitest/snapshot": "3.2.7",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"std-env": "^3.9.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tinypool": "^1.1.1",
|
||||
"tinyrainbow": "^2.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||
"vite-node": "3.2.4",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/debug": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -24,6 +25,7 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.2.4",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -12,12 +12,14 @@ use crate::component_detection::{
|
||||
proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier,
|
||||
DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost,
|
||||
};
|
||||
use crate::elevated_scripts;
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
||||
};
|
||||
use crate::process::command_no_window;
|
||||
use crate::safe_fs;
|
||||
use crate::singbox_service::{
|
||||
build_singbox_setup_status, ensure_safe_singbox_install_dir,
|
||||
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
|
||||
@@ -718,54 +720,71 @@ pub fn select_singbox_server(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ping_singbox_server(
|
||||
pub async fn ping_singbox_server(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
input: PingSingBoxServerInputDto,
|
||||
) -> Result<PingServerResponse, CommandError> {
|
||||
ping_singbox_server_in_storage(&state.storage(), input)
|
||||
let storage = state.storage();
|
||||
tauri::async_runtime::spawn_blocking(move || ping_singbox_server_in_storage(&storage, input))
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ping_all_singbox_servers(
|
||||
pub async fn ping_all_singbox_servers(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
) -> Result<Vec<PingServerResponse>, CommandError> {
|
||||
ping_all_singbox_servers_in_storage(&state.storage())
|
||||
let storage = state.storage();
|
||||
tauri::async_runtime::spawn_blocking(move || ping_all_singbox_servers_in_storage(&storage))
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ping_proxy_target(
|
||||
pub async fn ping_proxy_target(
|
||||
input: PingProxyTargetInputDto,
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
ping_proxy_target_endpoint(input)
|
||||
tauri::async_runtime::spawn_blocking(move || ping_proxy_target_endpoint(input))
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn generate_singbox_config(
|
||||
pub async fn generate_singbox_config(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
) -> Result<GenerateSingBoxConfigResponse, CommandError> {
|
||||
let detected = detect_singbox_install();
|
||||
let binary_path = detected
|
||||
.as_ref()
|
||||
.map(|detected| detected.executable_path.as_path());
|
||||
generate_singbox_config_with_services(
|
||||
&state.storage(),
|
||||
&SingBoxAdapter::default(),
|
||||
&SingBoxCommandChecker,
|
||||
&SystemClock,
|
||||
binary_path,
|
||||
)
|
||||
let storage = state.storage();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let detected = detect_singbox_install();
|
||||
let binary_path = detected
|
||||
.as_ref()
|
||||
.map(|detected| detected.executable_path.as_path());
|
||||
generate_singbox_config_with_services(
|
||||
&storage,
|
||||
&SingBoxAdapter::default(),
|
||||
&SingBoxCommandChecker,
|
||||
&SystemClock,
|
||||
binary_path,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn apply_profiles(
|
||||
pub async fn apply_profiles(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||
let storage = state.storage();
|
||||
let adapter = ProxiFyreAdapter::default();
|
||||
let helper = DetectedProxyApplyHelper::system();
|
||||
let clock = SystemClock;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let adapter = ProxiFyreAdapter::default();
|
||||
let helper = DetectedProxyApplyHelper::system();
|
||||
let clock = SystemClock;
|
||||
|
||||
apply_profiles_with_services(&storage, &adapter, &helper, &clock)
|
||||
apply_profiles_with_services(&storage, &adapter, &helper, &clock)
|
||||
})
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2193,11 +2212,7 @@ fn write_elevated_singbox_service_script(
|
||||
config_source: Option<&Path>,
|
||||
config_target: Option<&Path>,
|
||||
) -> Result<PathBuf, CommandError> {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
let script_path = env::temp_dir().join(format!("proxywarden-singbox-service-{nonce}.ps1"));
|
||||
let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service");
|
||||
let script =
|
||||
elevated_singbox_service_script(action, service_name, config_source, config_target);
|
||||
|
||||
@@ -2374,10 +2389,6 @@ fn run_elevated_singbox_package_script(
|
||||
installer_args: Vec<String>,
|
||||
artifact_dir: &Path,
|
||||
) -> Result<(), CommandError> {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
fs::create_dir_all(artifact_dir).map_err(|error| {
|
||||
CommandError::new(
|
||||
action.error_code(),
|
||||
@@ -2388,18 +2399,12 @@ fn run_elevated_singbox_package_script(
|
||||
)
|
||||
})?;
|
||||
|
||||
let installer_path = artifact_dir.join(format!(
|
||||
"proxywarden-singbox-{}-{nonce}.ps1",
|
||||
action.file_label()
|
||||
));
|
||||
let runner_path = artifact_dir.join(format!(
|
||||
"proxywarden-singbox-{}-{nonce}.runner.ps1",
|
||||
action.file_label()
|
||||
));
|
||||
let result_path = artifact_dir.join(format!(
|
||||
"proxywarden-singbox-{}-{nonce}.log",
|
||||
action.file_label()
|
||||
));
|
||||
let prefix = format!("proxywarden-singbox-{}", action.file_label());
|
||||
let installer_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
|
||||
let runner_path =
|
||||
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.runner"), "ps1");
|
||||
let result_path =
|
||||
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
|
||||
|
||||
write_powershell_script(&installer_path, installer_body).map_err(|error| {
|
||||
CommandError::new(
|
||||
@@ -2735,10 +2740,7 @@ fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> ResolvedAppDt
|
||||
}
|
||||
|
||||
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(storage_error)?;
|
||||
}
|
||||
fs::write(path, contents).map_err(storage_error)
|
||||
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn open_file_or_select(path: &Path) -> Result<(), CommandError> {
|
||||
@@ -3041,11 +3043,7 @@ fn write_elevated_service_script(
|
||||
action: ServiceControlAction,
|
||||
service_names: &[String],
|
||||
) -> Result<PathBuf, CommandError> {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
let script_path = env::temp_dir().join(format!("proxywarden-proxifyre-service-{nonce}.ps1"));
|
||||
let script_path = elevated_scripts::temp_script_path("proxywarden-proxifyre-service");
|
||||
let script = elevated_service_script(action, service_names);
|
||||
|
||||
write_powershell_script(&script_path, &script).map_err(|error| {
|
||||
@@ -3405,10 +3403,6 @@ fn run_elevated_package_script(
|
||||
body: String,
|
||||
artifact_dir: &Path,
|
||||
) -> Result<(), CommandError> {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
fs::create_dir_all(artifact_dir).map_err(|error| {
|
||||
CommandError::new(
|
||||
action.error_code(),
|
||||
@@ -3418,14 +3412,10 @@ fn run_elevated_package_script(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let script_path = artifact_dir.join(format!(
|
||||
"proxywarden-proxifyre-{}-{nonce}.ps1",
|
||||
action.file_label()
|
||||
));
|
||||
let result_path = artifact_dir.join(format!(
|
||||
"proxywarden-proxifyre-{}-{nonce}.log",
|
||||
action.file_label()
|
||||
));
|
||||
let prefix = format!("proxywarden-proxifyre-{}", action.file_label());
|
||||
let script_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
|
||||
let result_path =
|
||||
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
|
||||
let script = wrap_elevated_package_script(&body, &result_path);
|
||||
|
||||
write_powershell_script(&script_path, &script).map_err(|error| {
|
||||
@@ -3845,46 +3835,17 @@ fn apply_to_detected_proxyfier(
|
||||
return staged_apply_result(request);
|
||||
};
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err(
|
||||
|error| {
|
||||
CommandError::new(
|
||||
"proxyfier_apply_failed",
|
||||
format!(
|
||||
"Не удалось создать папку конфига ProxiFyre '{}': {error}",
|
||||
parent.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if config_path.exists() {
|
||||
let backup_path = config_path.with_file_name(format!(
|
||||
"{}.bak",
|
||||
config_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("app-config.json")
|
||||
));
|
||||
fs::copy(config_path, backup_path).map_err(|error| {
|
||||
CommandError::new(
|
||||
"proxyfier_apply_failed",
|
||||
format!(
|
||||
"Не удалось создать backup текущего конфига ProxiFyre '{}': {error}",
|
||||
"Не удалось безопасно записать конфиг ProxiFyre '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
fs::write(config_path, request.config_contents).map_err(|error| {
|
||||
CommandError::new(
|
||||
"proxyfier_apply_failed",
|
||||
format!(
|
||||
"Не удалось записать конфиг ProxiFyre '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
|
||||
19
src-tauri/src/elevated_scripts.rs
Normal file
19
src-tauri/src/elevated_scripts.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn temp_script_path(prefix: &str) -> PathBuf {
|
||||
env::temp_dir().join(unique_file_name(prefix, "ps1"))
|
||||
}
|
||||
|
||||
pub fn artifact_path(artifact_dir: &Path, prefix: &str, extension: &str) -> PathBuf {
|
||||
artifact_dir.join(unique_file_name(prefix, extension))
|
||||
}
|
||||
|
||||
fn unique_file_name(prefix: &str, extension: &str) -> String {
|
||||
let extension = extension.trim_start_matches('.');
|
||||
format!(
|
||||
"{prefix}-{}.{}",
|
||||
uuid::Uuid::new_v4().hyphenated(),
|
||||
extension
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod activity;
|
||||
pub mod commands;
|
||||
pub mod component_detection;
|
||||
pub mod elevated_scripts;
|
||||
pub mod helper;
|
||||
pub mod models;
|
||||
pub mod process;
|
||||
pub mod safe_fs;
|
||||
pub mod singbox_service;
|
||||
pub mod storage;
|
||||
pub mod subscription;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use percent_encoding::percent_decode_str;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
|
||||
@@ -293,24 +294,22 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
match trimmed.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
let host = rest
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("subscription");
|
||||
format!("{scheme}://{host}/...")
|
||||
}
|
||||
None => {
|
||||
let visible = trimmed.chars().take(18).collect::<String>();
|
||||
if trimmed.chars().count() <= 18 {
|
||||
"***".to_string()
|
||||
} else {
|
||||
format!("{visible}...")
|
||||
}
|
||||
}
|
||||
}
|
||||
let Ok(parsed) = Url::parse(trimmed) else {
|
||||
return "***".to_string();
|
||||
};
|
||||
|
||||
let host = parsed.host_str().unwrap_or("subscription");
|
||||
let host = if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host.to_string()
|
||||
};
|
||||
let port = parsed
|
||||
.port()
|
||||
.map(|port| format!(":{port}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!("{}://{}{}/...", parsed.scheme(), host, port)
|
||||
}
|
||||
|
||||
pub fn decode_percent_encoded_utf8(value: &str) -> String {
|
||||
|
||||
53
src-tauri/src/safe_fs.rs
Normal file
53
src-tauri/src/safe_fs.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, "bak")
|
||||
}
|
||||
|
||||
pub fn corrupt_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(
|
||||
path,
|
||||
&format!("corrupt.{}", uuid::Uuid::new_v4().hyphenated()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn temp_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, &format!("tmp.{}", uuid::Uuid::new_v4().hyphenated()))
|
||||
}
|
||||
|
||||
pub fn write_with_backup(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let temp_path = temp_path(path);
|
||||
fs::write(&temp_path, contents)?;
|
||||
|
||||
let backup_path = backup_path(path);
|
||||
if path.exists() {
|
||||
fs::copy(path, &backup_path)?;
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
match fs::rename(&temp_path, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
if !path.exists() && backup_path.exists() {
|
||||
let _ = fs::copy(&backup_path, path);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("proxywarden-file");
|
||||
|
||||
path.with_file_name(format!("{file_name}.{suffix}"))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
||||
use crate::models::{
|
||||
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
|
||||
};
|
||||
use crate::safe_fs;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{self, ErrorKind};
|
||||
@@ -144,10 +145,9 @@ impl JsonStorage {
|
||||
T: DeserializeOwned + Default,
|
||||
{
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => Ok(T::default()),
|
||||
},
|
||||
Ok(contents) => {
|
||||
parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error))
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
@@ -167,7 +167,9 @@ impl JsonStorage {
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => Ok(serde_json::from_str(&contents).ok()),
|
||||
Ok(contents) => parse_json(path, &contents)
|
||||
.map(Some)
|
||||
.or_else(|error| recover_corrupt_json(path, error).map(Some)),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
@@ -181,40 +183,72 @@ impl Default for JsonStorage {
|
||||
}
|
||||
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, "bak")
|
||||
}
|
||||
|
||||
fn temp_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, "tmp")
|
||||
}
|
||||
|
||||
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("storage.json");
|
||||
|
||||
path.with_file_name(format!("{file_name}.{suffix}"))
|
||||
safe_fs::backup_path(path)
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
safe_fs::write_with_backup(path, contents)
|
||||
}
|
||||
|
||||
fn parse_json<T>(path: &Path, contents: &str) -> io::Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
serde_json::from_str(contents).map_err(|error| {
|
||||
io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("Invalid JSON in '{}': {error}", path.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn recover_corrupt_json<T>(path: &Path, parse_error: io::Error) -> io::Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let corrupt_path = safe_fs::corrupt_path(path);
|
||||
move_corrupt_file(path, &corrupt_path)?;
|
||||
|
||||
let backup_path = backup_path(path);
|
||||
if backup_path.exists() {
|
||||
let backup_contents = fs::read_to_string(&backup_path)?;
|
||||
match parse_json(&backup_path, &backup_contents) {
|
||||
Ok(value) => {
|
||||
fs::copy(&backup_path, path)?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(backup_error) => Err(io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Invalid JSON in '{}'; corrupt file moved to '{}'; backup '{}' could not be restored: {backup_error}; original error: {parse_error}",
|
||||
path.display(),
|
||||
corrupt_path.display(),
|
||||
backup_path.display()
|
||||
),
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Invalid JSON in '{}'; corrupt file moved to '{}'; no valid backup available: {parse_error}",
|
||||
path.display(),
|
||||
corrupt_path.display()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let temp_path = temp_path(path);
|
||||
fs::write(&temp_path, contents)?;
|
||||
|
||||
if path.exists() {
|
||||
fs::copy(path, backup_path(path))?;
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
match fs::rename(&temp_path, path) {
|
||||
fn move_corrupt_file(path: &Path, corrupt_path: &Path) -> io::Result<()> {
|
||||
match fs::rename(path, corrupt_path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
Err(error)
|
||||
Err(rename_error) => {
|
||||
fs::copy(path, corrupt_path)?;
|
||||
fs::remove_file(path)?;
|
||||
if !corrupt_path.exists() {
|
||||
return Err(rename_error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use reqwest::redirect;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
const DEFAULT_APP_NAME: &str = "ProxyWarden";
|
||||
const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionError {
|
||||
@@ -34,6 +39,11 @@ pub struct ParsedSubscription {
|
||||
pub servers: Vec<SubscriptionServer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct SubscriptionFetchPolicy {
|
||||
pub allow_unsafe_local_urls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionFetchIdentity {
|
||||
pub device_hwid: Option<String>,
|
||||
@@ -134,16 +144,35 @@ pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionEr
|
||||
pub fn fetch_subscription_with_identity(
|
||||
url: &str,
|
||||
identity: &SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
fetch_subscription_with_identity_and_policy(url, identity, SubscriptionFetchPolicy::default())
|
||||
}
|
||||
|
||||
pub fn fetch_subscription_with_identity_and_policy(
|
||||
url: &str,
|
||||
identity: &SubscriptionFetchIdentity,
|
||||
policy: SubscriptionFetchPolicy,
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
let parsed_url =
|
||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription URL must use http or https",
|
||||
));
|
||||
}
|
||||
validate_subscription_fetch_url(&parsed_url, policy)?;
|
||||
|
||||
let mut request = reqwest::blocking::Client::new().get(parsed_url);
|
||||
let redirect_policy = redirect::Policy::custom(move |attempt| {
|
||||
if validate_subscription_fetch_url(attempt.url(), policy).is_ok() {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
}
|
||||
});
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
|
||||
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
|
||||
.redirect(redirect_policy)
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
|
||||
})?;
|
||||
let mut request = client.get(parsed_url);
|
||||
|
||||
for (name, value) in identity.request_headers_without_device_hwid() {
|
||||
request = request.header(name, value);
|
||||
@@ -189,6 +218,69 @@ pub fn fetch_subscription_with_identity(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_subscription_fetch_url(
|
||||
parsed_url: &Url,
|
||||
policy: SubscriptionFetchPolicy,
|
||||
) -> Result<(), SubscriptionError> {
|
||||
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription URL must use http or https",
|
||||
));
|
||||
}
|
||||
|
||||
if !policy.allow_unsafe_local_urls && is_unsafe_subscription_host(parsed_url) {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription URL host is local, private, link-local, multicast, or metadata-only",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_unsafe_subscription_host(parsed_url: &Url) -> bool {
|
||||
let Some(host) = parsed_url.host_str() else {
|
||||
return true;
|
||||
};
|
||||
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
|
||||
|
||||
if matches!(host.as_str(), "localhost" | "metadata.google.internal")
|
||||
|| host.ends_with(".localhost")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
host.parse::<IpAddr>().is_ok_and(is_unsafe_ip)
|
||||
}
|
||||
|
||||
fn is_unsafe_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
ip.is_loopback()
|
||||
|| ip.is_private()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.octets() == [169, 254, 169, 254]
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| is_unique_local_ipv6(ip)
|
||||
|| is_unicast_link_local_ipv6(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unique_local_ipv6(ip: Ipv6Addr) -> bool {
|
||||
(ip.segments()[0] & 0xfe00) == 0xfc00
|
||||
}
|
||||
|
||||
fn is_unicast_link_local_ipv6(ip: Ipv6Addr) -> bool {
|
||||
(ip.segments()[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
|
||||
fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
|
||||
let decoded = maybe_decode_base64(body);
|
||||
let links = decoded
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "ProxyWarden",
|
||||
"width": 820,
|
||||
"width": 920,
|
||||
"height": 760,
|
||||
"minWidth": 820,
|
||||
"maxWidth": 820,
|
||||
"minWidth": 760,
|
||||
"maxWidth": 1200,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -400,11 +400,14 @@ fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||
|
||||
let applied =
|
||||
fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config");
|
||||
let backup =
|
||||
fs::read_to_string(install_dir.join("app-config.json.bak")).expect("read backup config");
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.changed);
|
||||
assert_eq!(result.action, "proxifyre.apply-detected-config");
|
||||
assert_eq!(applied, r#"{"proxies":[]}"#);
|
||||
assert_eq!(backup, "{}");
|
||||
assert!(install_dir.join("app-config.json.bak").exists());
|
||||
|
||||
cleanup(&root);
|
||||
|
||||
@@ -159,7 +159,7 @@ fn reads_percent_encoded_singbox_tags_as_utf8() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_subscription_cache_falls_back_to_none() {
|
||||
fn invalid_subscription_cache_without_backup_returns_error_and_moves_corrupt_file() {
|
||||
let root = test_root("invalid-subscription-cache");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
fs::create_dir_all(&storage.paths().state_dir).expect("create state dir");
|
||||
@@ -169,27 +169,62 @@ fn invalid_subscription_cache_falls_back_to_none() {
|
||||
)
|
||||
.expect("write invalid cache");
|
||||
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("invalid cache fallback"),
|
||||
None
|
||||
);
|
||||
let error = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect_err("invalid cache should not silently fallback");
|
||||
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(!storage.paths().singbox_subscription_cache_file.exists());
|
||||
assert!(has_corrupt_sibling(
|
||||
&storage.paths().singbox_subscription_cache_file
|
||||
));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_falls_back_to_empty_collection() {
|
||||
let root = test_root("invalid-json");
|
||||
fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() {
|
||||
let root = test_root("invalid-json-no-backup");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
fs::create_dir_all(&storage.paths().config_dir).expect("create config dir");
|
||||
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
|
||||
|
||||
let error = storage
|
||||
.read_profiles()
|
||||
.expect_err("invalid profiles should not silently fallback");
|
||||
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(!storage.paths().profiles_file.exists());
|
||||
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_recovers_from_valid_backup() {
|
||||
let root = test_root("invalid-json-valid-backup");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let backup_profiles = vec![sample_profile("backup")];
|
||||
let current_profiles = vec![sample_profile("current")];
|
||||
|
||||
storage
|
||||
.write_profiles(&backup_profiles)
|
||||
.expect("write first profiles");
|
||||
storage
|
||||
.write_profiles(¤t_profiles)
|
||||
.expect("write second profiles");
|
||||
fs::write(&storage.paths().profiles_file, "{not valid json").expect("corrupt live json");
|
||||
|
||||
let recovered = storage
|
||||
.read_profiles()
|
||||
.expect("invalid profiles should recover from valid backup");
|
||||
|
||||
assert_eq!(recovered, backup_profiles);
|
||||
assert_eq!(
|
||||
storage.read_profiles().expect("invalid profiles fallback"),
|
||||
Vec::<Profile>::new()
|
||||
storage.read_profiles().expect("restored live profiles"),
|
||||
backup_profiles
|
||||
);
|
||||
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -279,6 +314,26 @@ fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
|
||||
fs::write(path, contents).expect("write json");
|
||||
}
|
||||
|
||||
fn has_corrupt_sibling(path: &Path) -> bool {
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let prefix = format!("{file_name}.corrupt.");
|
||||
|
||||
fs::read_dir(parent)
|
||||
.expect("read sibling dir")
|
||||
.filter_map(Result::ok)
|
||||
.any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with(&prefix))
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_profile(id: &str) -> Profile {
|
||||
Profile {
|
||||
id: id.to_string(),
|
||||
|
||||
@@ -2,6 +2,7 @@ use base64::{engine::general_purpose, Engine};
|
||||
use proxywarden_lib::models::redact_subscription_url;
|
||||
use proxywarden_lib::subscription::{
|
||||
self, parse_subscription_body, parse_user_info, SubscriptionFetchIdentity,
|
||||
SubscriptionFetchPolicy,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
@@ -91,6 +92,25 @@ fn rejects_invalid_or_non_http_subscription_url_before_network() {
|
||||
assert!(unsupported.message.contains("http or https"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_local_subscription_urls_before_network() {
|
||||
for url in [
|
||||
"http://127.0.0.1:9/subscription",
|
||||
"http://localhost/subscription",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"http://192.168.0.1/subscription",
|
||||
"http://[::1]/subscription",
|
||||
] {
|
||||
let error = subscription::fetch_subscription(url)
|
||||
.expect_err("unsafe local URL should fail before request");
|
||||
assert!(
|
||||
error.message.contains("local, private"),
|
||||
"unexpected error for {url}: {}",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
|
||||
@@ -129,8 +149,14 @@ fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() {
|
||||
|
||||
let mut identity = SubscriptionFetchIdentity::with_device_hwid(Some("hwid-abc123"));
|
||||
identity.device_os_version = Some("Windows 11 Pro | 25H2 | build 26200.8655".to_string());
|
||||
let cache = subscription::fetch_subscription_with_identity(&url, &identity)
|
||||
.expect("fetch subscription through local test server");
|
||||
let cache = subscription::fetch_subscription_with_identity_and_policy(
|
||||
&url,
|
||||
&identity,
|
||||
SubscriptionFetchPolicy {
|
||||
allow_unsafe_local_urls: true,
|
||||
},
|
||||
)
|
||||
.expect("fetch subscription through local test server");
|
||||
let request = request_thread.join().expect("request thread");
|
||||
|
||||
assert_eq!(cache.servers[0].tag, "nl-1");
|
||||
@@ -151,7 +177,13 @@ fn redacts_subscription_url_for_display() {
|
||||
);
|
||||
assert_eq!(
|
||||
redact_subscription_url("vless://uuid@example.test"),
|
||||
"vless://uuid@example.test/..."
|
||||
"vless://example.test/..."
|
||||
);
|
||||
assert_eq!(
|
||||
redact_subscription_url(
|
||||
"https://user:password@sub.example.test:8443/path?token=secret#frag"
|
||||
),
|
||||
"https://sub.example.test:8443/..."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from '../api/tauriCommands';
|
||||
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
|
||||
import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
|
||||
import { parseProxy, type ParsedProxy } from './lib/parseProxy';
|
||||
import { getApplyReadiness } from './readiness';
|
||||
import { serviceControlState } from './viewModel';
|
||||
|
||||
@@ -1641,12 +1642,6 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
interface ParsedProxy {
|
||||
protocol: 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface SummaryStateInput {
|
||||
isLoading: boolean;
|
||||
isDetectingComponents: boolean;
|
||||
@@ -2337,35 +2332,6 @@ function changesApplyButtonLabel(
|
||||
return 'Применить изменения';
|
||||
}
|
||||
|
||||
function parseProxy(rawValue: string): ParsedProxy {
|
||||
const value = rawValue.trim();
|
||||
if (!value) throw new Error('Введи адрес прокси.');
|
||||
|
||||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(withProtocol);
|
||||
} catch {
|
||||
throw new Error('Формат: socks5://host:port или host:port.');
|
||||
}
|
||||
|
||||
const protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||||
if (protocol !== 'socks5') {
|
||||
throw new Error('Сейчас поддерживается только SOCKS5.');
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
|
||||
}
|
||||
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||
const port = Number(parsed.port);
|
||||
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('Укажи хост и порт прокси.');
|
||||
}
|
||||
|
||||
return { protocol: 'socks5', host, port };
|
||||
}
|
||||
|
||||
function routeProxyCheckTarget(
|
||||
routeMode: RouteMode,
|
||||
proxyInput: string,
|
||||
|
||||
43
src/app/lib/parseProxy.test.ts
Normal file
43
src/app/lib/parseProxy.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseProxy } from './parseProxy';
|
||||
|
||||
describe('parseProxy', () => {
|
||||
it('parses host and port without explicit protocol', () => {
|
||||
expect(parseProxy('proxy.example.test:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: 'proxy.example.test',
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses socks5 URLs', () => {
|
||||
expect(parseProxy('socks5://127.0.0.1:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: '127.0.0.1',
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses bracketed IPv6 hosts', () => {
|
||||
expect(parseProxy('socks5://[::1]:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: '::1',
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported schemes', () => {
|
||||
expect(() => parseProxy('http://proxy.example.test:8080')).toThrow('SOCKS5');
|
||||
});
|
||||
|
||||
it('rejects missing or invalid ports', () => {
|
||||
expect(() => parseProxy('proxy.example.test')).toThrow('хост и порт');
|
||||
expect(() => parseProxy('proxy.example.test:70000')).toThrow('Формат');
|
||||
});
|
||||
|
||||
it('rejects userinfo credentials', () => {
|
||||
expect(() => parseProxy('socks5://user:password@proxy.example.test:1080')).toThrow(
|
||||
'логином и паролем',
|
||||
);
|
||||
});
|
||||
});
|
||||
34
src/app/lib/parseProxy.ts
Normal file
34
src/app/lib/parseProxy.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface ParsedProxy {
|
||||
protocol: 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function parseProxy(rawValue: string): ParsedProxy {
|
||||
const value = rawValue.trim();
|
||||
if (!value) throw new Error('Введи адрес прокси.');
|
||||
|
||||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(withProtocol);
|
||||
} catch {
|
||||
throw new Error('Формат: socks5://host:port или host:port.');
|
||||
}
|
||||
|
||||
const protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||||
if (protocol !== 'socks5') {
|
||||
throw new Error('Сейчас поддерживается только SOCKS5.');
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
|
||||
}
|
||||
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||
const port = Number(parsed.port);
|
||||
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('Укажи хост и порт прокси.');
|
||||
}
|
||||
|
||||
return { protocol: 'socks5', host, port };
|
||||
}
|
||||
Reference in New Issue
Block a user