From 90b2eb507cb08376cdd5978196be692e331d0aab Mon Sep 17 00:00:00 2001 From: dokril Date: Wed, 22 Jul 2026 00:08:09 +0300 Subject: [PATCH] Refactor application structure and simplify implementation --- .github/workflows/ci.yml | 13 + CONTRIBUTING.md | 38 + LICENSE | 21 + README.md | 8 + eslint.config.js | 21 + package-lock.json | 1155 +++++ package.json | 14 +- scripts/audit-windows-smoke.ps1 | 182 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/adapters/proxifyre.rs | 5 +- src-tauri/src/adapters/singbox.rs | 113 +- src-tauri/src/admin.rs | 89 + src-tauri/src/apply_flow.rs | 594 +++ src-tauri/src/clock.rs | 19 + src-tauri/src/command_dto.rs | 542 ++ src-tauri/src/commands.rs | 4744 +----------------- src-tauri/src/component_detection.rs | 77 +- src-tauri/src/component_status.rs | 156 + src-tauri/src/configuration_use_case.rs | 430 ++ src-tauri/src/lib.rs | 26 +- src-tauri/src/models.rs | 37 + src-tauri/src/powershell.rs | 120 + src-tauri/src/proxifyre_ownership.rs | 104 + src-tauri/src/proxifyre_runtime.rs | 1146 +++++ src-tauri/src/proxifyre_scripts.rs | 647 +++ src-tauri/src/proxy_apply.rs | 258 + src-tauri/src/proxy_probe.rs | 316 ++ src-tauri/src/singbox_config.rs | 125 + src-tauri/src/singbox_runtime.rs | 532 ++ src-tauri/src/singbox_subscription.rs | 377 ++ src-tauri/src/subscription.rs | 356 +- src-tauri/src/validation.rs | 70 +- src-tauri/tests/apply_flow_tests.rs | 423 ++ src-tauri/tests/command_tests.rs | 54 +- src-tauri/tests/component_detection_tests.rs | 68 +- src-tauri/tests/domain_tests.rs | 88 + src-tauri/tests/proxifyre_adapter_tests.rs | 29 + src-tauri/tests/proxifyre_ownership_tests.rs | 145 + src-tauri/tests/singbox_adapter_tests.rs | 67 +- src-tauri/tests/singbox_command_tests.rs | 45 + src-tauri/tests/storage_tests.rs | 3 + src-tauri/tests/subscription_tests.rs | 87 +- src/api/tauriCommands.ts | 168 +- src/app/App.tsx | 2227 +++----- src/app/App.view.test.ts | 79 + src/app/components/ProxiFyreSetupStrip.tsx | 106 +- src/app/components/SummaryStatusControl.tsx | 52 + src/app/hooks/useNoticeLog.ts | 44 + src/app/lib/parseProxy.test.ts | 56 +- src/app/lib/parseProxy.ts | 24 +- src/app/lib/profileItems.ts | 26 + src/app/lib/snapshots.test.ts | 61 + src/app/lib/snapshots.ts | 201 + src/app/readiness.test.ts | 47 + src/app/readiness.ts | 42 +- src/app/viewModel.ts | 1066 +++- src/assets/proxywarden-toggle-off.png | Bin 1152106 -> 0 bytes src/assets/proxywarden-toggle-on.png | Bin 893870 -> 0 bytes src/domain/types.ts | 17 +- src/main.tsx | 13 +- src/styles/app.css | 161 +- src/ui/ActionMenu.tsx | 105 +- src/ui/BusyRing.tsx | 2 +- src/ui/Button.tsx | 37 +- src/ui/DetailsPopover.tsx | 76 +- src/ui/Field.tsx | 16 +- src/ui/HoverDetails.tsx | 8 +- src/ui/IconButton.tsx | 28 +- src/ui/LogDock.tsx | 61 +- src/ui/ServiceControlRow.tsx | 28 +- src/ui/StatusPill.tsx | 11 +- src/ui/Tabs.tsx | 17 +- src/ui/index.ts | 51 +- 74 files changed, 11362 insertions(+), 6814 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 eslint.config.js create mode 100644 scripts/audit-windows-smoke.ps1 create mode 100644 src-tauri/src/admin.rs create mode 100644 src-tauri/src/apply_flow.rs create mode 100644 src-tauri/src/clock.rs create mode 100644 src-tauri/src/command_dto.rs create mode 100644 src-tauri/src/component_status.rs create mode 100644 src-tauri/src/configuration_use_case.rs create mode 100644 src-tauri/src/powershell.rs create mode 100644 src-tauri/src/proxifyre_ownership.rs create mode 100644 src-tauri/src/proxifyre_runtime.rs create mode 100644 src-tauri/src/proxifyre_scripts.rs create mode 100644 src-tauri/src/proxy_apply.rs create mode 100644 src-tauri/src/proxy_probe.rs create mode 100644 src-tauri/src/singbox_config.rs create mode 100644 src-tauri/src/singbox_runtime.rs create mode 100644 src-tauri/src/singbox_subscription.rs create mode 100644 src-tauri/tests/apply_flow_tests.rs create mode 100644 src-tauri/tests/proxifyre_ownership_tests.rs create mode 100644 src/app/App.view.test.ts create mode 100644 src/app/components/SummaryStatusControl.tsx create mode 100644 src/app/hooks/useNoticeLog.ts create mode 100644 src/app/lib/profileItems.ts create mode 100644 src/app/lib/snapshots.test.ts create mode 100644 src/app/lib/snapshots.ts create mode 100644 src/app/readiness.test.ts delete mode 100644 src/assets/proxywarden-toggle-off.png delete mode 100644 src/assets/proxywarden-toggle-on.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89bb407..6615479 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,15 @@ jobs: - name: Install frontend dependencies run: npm ci + - name: Check frontend formatting + run: npm run format:check + + - name: Run frontend lints + run: npm run lint + + - name: Check frontend types + run: npm run typecheck + - name: Run frontend tests run: npm test -- --run @@ -58,3 +67,7 @@ jobs: - name: Plan sing-box installer shell: pwsh run: .\scripts\install-singbox.ps1 -PlanOnly + + - name: Plan Windows smoke evidence capture + shell: pwsh + run: .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8f20688 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Участие в разработке ProxyWarden + +ProxyWarden остается локальной Windows-утилитой. Изменения не должны превращать проект в VPN-провайдер, proxy server, SaaS или облачный control plane. Перед работой прочитайте `AGENTS.md` и релевантный skill из `.agent/skills`. + +## Локальная проверка + +```powershell +npm ci +npm run format:check +npm run lint +npm run typecheck +npm test -- --run +npm run build + +Push-Location src-tauri +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets +Pop-Location + +npm run tauri -- info +& .\scripts\install-control-app.ps1 -PlanOnly +& .\scripts\install-proxyfier.ps1 -PlanOnly +& .\scripts\install-singbox.ps1 -PlanOnly +& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly +``` + +Windows service, UAC, installer и реальный routing нельзя считать проверенными только по unit-тестам. Для таких изменений укажите выполненный ручной сценарий или явно оставьте этот пробел в отчете. + +## Изменения + +- Держите `src/api/tauriCommands.ts` единственным TypeScript facade над Tauri `invoke`. +- Не показывайте subscription URL, credentials, proxy password или `X-HWID` в логах и UI. +- Не добавляйте скрытые install/start/stop/uninstall действия в apply. +- Добавляйте минимальный тест для новой ветвящейся логики. +- Не коммитьте runtime-файлы из `C:\ProgramData\ProxyWarden` и generated output. + +В pull request кратко опишите поведение, затронутые файлы, выполненные проверки и оставшиеся Windows/manual риски. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f4729c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ProxyWarden contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 10ee926..aa26364 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ C:\ProgramData\ProxyWarden\generated\sing-box-config.json Subscription URL считается секретом. UI и diagnostics должны показывать только редактированную/сокращенную версию ссылки. +При загрузке подписки ProxyWarden отправляет провайдеру стандартные идентификационные заголовки приложения и `X-HWID` - случайный постоянный UUID этой установки. Это не серийный номер оборудования, но провайдер может использовать его для связывания запросов одной установки. Проверка маршрута делает HTTPS-запросы через выбранный proxy к Cloudflare и ipify, чтобы подтвердить выход и определить внешний IP. + ## Типовые сценарии ### Внешний SOCKS5 @@ -224,6 +226,10 @@ Browser-preview годится для проверки интерфейса, н Frontend/UI: ```powershell +npm run format:check +npm run lint +npm run typecheck +npm test -- --run npm run build ``` @@ -253,6 +259,8 @@ Installer boundaries: ## Ограничения текущей версии - Основной поддержанный маршрут - SOCKS5. +- Link-подписки разбирают VLESS, VMess, Trojan и Shadowsocks; sing-box JSON также принимает поддержанные proxy outbounds. Неизвестные форматы отклоняются явно. +- Для VLESS outbound без собственного `packet_encoding` генератор добавляет `xudp`; значение, заданное провайдером подписки, не перезаписывается. - ProxiFyre является текущим backend-слоем для per-app routing. - Local sing-box остается опциональным и не требуется для внешнего SOCKS5. - Elevated install/start/stop/uninstall операции считаются реализованными, но требуют дополнительной проверки на реальной Windows-машине с UAC/admin confirmation. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..07d5287 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist/**', 'src-tauri/**'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + globals: { + document: 'readonly', + HTMLElement: 'readonly', + HTMLDivElement: 'readonly', + requestAnimationFrame: 'readonly', + setTimeout: 'readonly', + window: 'readonly', + }, + }, + }, +); diff --git a/package-lock.json b/package-lock.json index 0f3f29a..d2babf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,15 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", + "eslint": "^10.7.0", + "prettier": "^3.9.5", "typescript": "^5.8.0", + "typescript-eslint": "^8.63.0", "vite": "^7.0.0", "vitest": "^3.2.4" } @@ -749,6 +753,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@fontsource-variable/jetbrains-mono": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", @@ -758,6 +890,72 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1518,6 +1716,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1525,6 +1730,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1545,6 +1757,249 @@ "@types/react": "^19.2.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -1681,6 +2136,46 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1691,6 +2186,16 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.41", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", @@ -1704,6 +2209,19 @@ "node": ">=6.0.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -1803,6 +2321,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1838,6 +2371,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.385", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", @@ -1904,6 +2444,164 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1914,6 +2612,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1924,6 +2632,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1942,6 +2671,57 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1967,6 +2747,69 @@ "node": ">=6.9.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1987,6 +2830,27 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2000,6 +2864,46 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2036,6 +2940,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2062,6 +2982,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -2072,6 +2999,76 @@ "node": ">=18" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2138,6 +3135,42 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -2230,6 +3263,29 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2342,6 +3398,32 @@ "node": ">=14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2356,6 +3438,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2387,6 +3493,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -2558,6 +3674,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "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", @@ -2575,12 +3707,35 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index db17b25..974a5ac 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,11 @@ "description": "Standalone Windows desktop proxy management app for ProxyWarden.", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "npm run typecheck && vite build", + "typecheck": "tsc --noEmit", + "lint": "eslint src", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "preview": "vite preview", "test": "vitest", "tauri": "tauri" @@ -20,12 +24,16 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", + "eslint": "^10.7.0", + "prettier": "^3.9.5", "typescript": "^5.8.0", - "vitest": "^3.2.4", - "vite": "^7.0.0" + "typescript-eslint": "^8.63.0", + "vite": "^7.0.0", + "vitest": "^3.2.4" } } diff --git a/scripts/audit-windows-smoke.ps1 b/scripts/audit-windows-smoke.ps1 new file mode 100644 index 0000000..885b191 --- /dev/null +++ b/scripts/audit-windows-smoke.ps1 @@ -0,0 +1,182 @@ +param( + [ValidateSet("PlanOnly", "Capture")] + [string]$Mode = "PlanOnly", + [string]$DataRoot = "C:\ProgramData\ProxyWarden", + [string]$ProxiFyreRoot = "C:\Tools\ProxiFyre", + [string]$SingBoxRoot = "C:\Program Files\ProxyWarden\sing-box", + [string]$ForeignServiceName = "", + [string]$OutputPath = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function New-Result { + param( + [bool]$Success, + [string]$Action, + [bool]$Changed, + [string]$Message, + [hashtable]$Details + ) + + [ordered]@{ + success = $Success + action = $Action + changed = $Changed + message = $Message + details = $Details + } | ConvertTo-Json -Depth 8 +} + +function Get-ServiceEvidence { + param([string[]]$Names) + + $result = @() + foreach ($name in $Names | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) { + $escaped = $name.Replace("'", "''") + $service = Get-CimInstance Win32_Service -Filter "Name='$escaped'" -ErrorAction SilentlyContinue + if ($null -eq $service) { + $result += [ordered]@{ name = $name; found = $false } + continue + } + + $result += [ordered]@{ + name = $service.Name + found = $true + state = $service.State + startMode = $service.StartMode + pathName = $service.PathName + processId = [int]$service.ProcessId + } + } + return $result +} + +function Test-PathUnderRoot { + param([string]$Path, [string]$Root) + + if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Root)) { return $false } + $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\') + $fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\') + return $fullPath.Equals($fullRoot, [StringComparison]::OrdinalIgnoreCase) -or + $fullPath.StartsWith("$fullRoot\", [StringComparison]::OrdinalIgnoreCase) +} + +function Get-ServiceExecutablePath { + param([string]$PathName) + + if ([string]::IsNullOrWhiteSpace($PathName)) { return "" } + $trimmed = $PathName.Trim() + if ($trimmed.StartsWith('"')) { + $closingQuote = $trimmed.IndexOf('"', 1) + if ($closingQuote -gt 1) { return $trimmed.Substring(1, $closingQuote - 1) } + } + return ($trimmed -split '\s+', 2)[0] +} + +function Get-FileEvidence { + param([string]$Root) + + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() } + return @( + Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue | + Select-Object @{N="path";E={$_.FullName}}, @{N="length";E={$_.Length}}, @{N="lastWriteTimeUtc";E={$_.LastWriteTimeUtc.ToString("o")}} + ) +} + +function Get-SecretFindingCategories { + param([string]$Root) + + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() } + $patterns = [ordered]@{ + urlUserInfo = '://[^/\s"'']+@' + credentialQuery = '(?i)[?&](token|key|auth|password|passwd|secret)=[^&\s"'']+' + socksCredentials = '(?i)socks5://[^/\s:@]+:[^/\s@]+@' + hwidHeader = '(?i)x-hwid[^\r\n]*[0-9a-f]{8}-[0-9a-f-]{27,}' + } + + $findings = @() + $files = Get-ChildItem -LiteralPath $Root -Recurse -File -Include *.json,*.log,*.txt -ErrorAction SilentlyContinue + foreach ($file in $files) { + $content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue + if ($null -eq $content) { continue } + foreach ($entry in $patterns.GetEnumerator()) { + if ($content -match $entry.Value) { + $findings += [ordered]@{ path = $file.FullName; category = $entry.Key } + } + } + } + return $findings +} + +try { + $quotedServiceFixture = '"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe" -service' + $quotedExecutable = Get-ServiceExecutablePath -PathName $quotedServiceFixture + if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\sing-box")) { + throw "Quoted service PathName ownership self-test failed." + } + + $plan = [ordered]@{ + mode = $Mode + serviceNames = @("ProxiFyreService", "ProxyWardenSingBox") + foreignServiceName = $ForeignServiceName + roots = [ordered]@{ + data = [IO.Path]::GetFullPath($DataRoot) + proxifyre = [IO.Path]::GetFullPath($ProxiFyreRoot) + singbox = [IO.Path]::GetFullPath($SingBoxRoot) + } + checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan") + } + + if ($Mode -eq "PlanOnly") { + New-Result -Success $true -Action "audit-windows-smoke.plan" -Changed $false -Message "Windows smoke evidence plan is ready." -Details $plan + exit 0 + } + + if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $PWD ("audit-windows-smoke-{0}.json" -f (Get-Date -Format "yyyyMMdd-HHmmss")) + } + $outputFullPath = [IO.Path]::GetFullPath($OutputPath) + $outputDirectory = Split-Path -Parent $outputFullPath + if ([string]::IsNullOrWhiteSpace($outputDirectory)) { throw "OutputPath must include a writable directory." } + New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null + + $serviceNames = @("ProxiFyreService", "ProxyWardenSingBox", $ForeignServiceName) + $services = @(Get-ServiceEvidence -Names $serviceNames) + $ownership = @( + $services | Where-Object found | ForEach-Object { + $expectedRoot = switch ($_.name) { + "ProxiFyreService" { $ProxiFyreRoot } + "ProxyWardenSingBox" { $SingBoxRoot } + default { "" } + } + [ordered]@{ + name = $_.name + expectedManagedRoot = if ($expectedRoot) { [IO.Path]::GetFullPath($expectedRoot) } else { $null } + pathUnderExpectedRoot = if ($expectedRoot) { Test-PathUnderRoot -Path (Get-ServiceExecutablePath -PathName $_.pathName) -Root $expectedRoot } else { $false } + } + } + ) + + $report = [ordered]@{ + capturedAt = (Get-Date).ToUniversalTime().ToString("o") + computerName = $env:COMPUTERNAME + os = (Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture) + services = $services + ownership = $ownership + files = @(Get-FileEvidence -Root $DataRoot) + secretFindingCategories = @(Get-SecretFindingCategories -Root $DataRoot) + } + $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outputFullPath -Encoding UTF8 + + New-Result -Success $true -Action "audit-windows-smoke.capture" -Changed $true -Message "Read-only Windows smoke evidence captured." -Details @{ + outputPath = $outputFullPath + serviceCount = @($services | Where-Object found).Count + fileCount = @($report.files).Count + secretFindingCount = @($report.secretFindingCategories).Count + } +} catch { + New-Result -Success $false -Action "audit-windows-smoke.$($Mode.ToLowerInvariant())" -Changed $false -Message $_.Exception.Message -Details @{} + exit 1 +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9546894..ca37d6c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2324,6 +2324,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "thiserror 2.0.18", "url", "uuid", "winreg", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d7b6f37..b1e1b02 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", percent-encoding = "2" url = "2" uuid = { version = "1", features = ["v4"] } +thiserror = "2" [target.'cfg(windows)'.dependencies] winreg = "0.55" diff --git a/src-tauri/src/adapters/proxifyre.rs b/src-tauri/src/adapters/proxifyre.rs index fdd29d2..70884d3 100644 --- a/src-tauri/src/adapters/proxifyre.rs +++ b/src-tauri/src/adapters/proxifyre.rs @@ -205,7 +205,10 @@ fn app_names_for_profile(profile: &Profile) -> Vec { ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value, }; - if !names.iter().any(|existing| existing == app_name) { + if !names + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(app_name)) + { names.push(app_name.to_string()); } } diff --git a/src-tauri/src/adapters/singbox.rs b/src-tauri/src/adapters/singbox.rs index 3e272e5..8f022ff 100644 --- a/src-tauri/src/adapters/singbox.rs +++ b/src-tauri/src/adapters/singbox.rs @@ -1,12 +1,8 @@ -use crate::models::{LocalSingBoxConfig, SubscriptionCache}; +use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer}; use crate::process::command_no_window; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::{ - env, fs, - path::Path, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::{env, fs, fs::OpenOptions, io::Write, path::Path}; pub const SINGBOX_ADAPTER_ID: &str = "singbox"; pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json"; @@ -43,23 +39,36 @@ impl SingBoxAdapter { checker: &C, ) -> Result where - C: SingBoxConfigChecker, + C: SingBoxConfigChecker + ?Sized, { - let selected_server_tag = request + let selected_server = request .config - .selected_server_tag + .selected_server_id .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) + .and_then(|id| { + request + .subscription_cache + .servers + .iter() + .find(|server| server.id == id) + }) + .or_else(|| { + let tag = request.config.selected_server_tag.as_deref()?; + request + .subscription_cache + .servers + .iter() + .find(|server| server.tag == tag) + }) .ok_or_else(|| { SingBoxConfigError::new( SingBoxConfigErrorKind::MissingSelectedServer, - "Сервер Local sing-box не выбран", + "Сервер Local sing-box не выбран или отсутствует в текущей подписке", ) })?; let vpn_outbound = selected_outbound( &request.subscription_cache.config, - selected_server_tag, + selected_server, &self.vpn_outbound_tag, )?; let generated_config = json!({ @@ -105,7 +114,7 @@ impl SingBoxAdapter { adapter_id: SINGBOX_ADAPTER_ID.to_string(), output_file_name: SINGBOX_OUTPUT_FILE.to_string(), contents, - selected_server_tag: selected_server_tag.to_string(), + selected_server_tag: selected_server.tag.clone(), listen: request.config.listen_host.clone(), listen_port: request.config.listen_port, check, @@ -200,20 +209,37 @@ impl SingBoxConfigChecker for SingBoxCommandChecker { config_json: &str, ) -> Result { let config_path = env::temp_dir().join(format!( - "proxywarden-sing-box-{}-{}.json", - std::process::id(), - now_millis() + "proxywarden-sing-box-{}.json", + uuid::Uuid::new_v4().hyphenated() )); - fs::write(&config_path, config_json).map_err(|error| { - SingBoxConfigError::new( - SingBoxConfigErrorKind::CheckFailed, - format!( - "Не удалось записать временный конфиг sing-box '{}': {error}", - config_path.display() - ), - ) - })?; + { + let mut config_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&config_path) + .map_err(|error| { + SingBoxConfigError::new( + SingBoxConfigErrorKind::CheckFailed, + format!( + "Не удалось создать временный конфиг sing-box '{}': {error}", + config_path.display() + ), + ) + })?; + let write_result = config_file.write_all(config_json.as_bytes()); + drop(config_file); + if let Err(error) = write_result { + let _ = fs::remove_file(&config_path); + return Err(SingBoxConfigError::new( + SingBoxConfigErrorKind::CheckFailed, + format!( + "Не удалось записать временный конфиг sing-box '{}': {error}", + config_path.display() + ), + )); + } + } let output = command_no_window(binary_path) .arg("check") @@ -257,7 +283,7 @@ impl SingBoxConfigChecker for SingBoxCommandChecker { fn selected_outbound( subscription_config: &Value, - selected_server_tag: &str, + selected_server: &SubscriptionServer, vpn_outbound_tag: &str, ) -> Result { let outbounds = subscription_config @@ -272,15 +298,27 @@ fn selected_outbound( let outbound = outbounds .iter() .find(|outbound| { - outbound + let tag_matches = outbound .get("tag") .and_then(Value::as_str) - .is_some_and(|tag| tag.trim() == selected_server_tag) + .is_some_and(|tag| tag.trim() == selected_server.tag); + let server_matches = outbound + .get("server") + .and_then(Value::as_str) + .is_some_and(|server| server.eq_ignore_ascii_case(&selected_server.server)); + let port_matches = outbound + .get("server_port") + .and_then(Value::as_u64) + .is_some_and(|port| port == u64::from(selected_server.server_port)); + tag_matches && server_matches && port_matches }) .ok_or_else(|| { SingBoxConfigError::new( SingBoxConfigErrorKind::MissingSelectedOutbound, - format!("Outbound не найден: {selected_server_tag}"), + format!( + "Outbound не найден: {} ({}:{})", + selected_server.tag, selected_server.server, selected_server.server_port + ), ) })?; let outbound_type = outbound @@ -292,7 +330,8 @@ fn selected_outbound( return Err(SingBoxConfigError::new( SingBoxConfigErrorKind::UnsupportedSelectedOutbound, format!( - "Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'" + "Outbound '{}' имеет неподдерживаемый тип '{outbound_type}'", + selected_server.tag ), )); } @@ -301,7 +340,10 @@ fn selected_outbound( let object = outbound.as_object_mut().ok_or_else(|| { SingBoxConfigError::new( SingBoxConfigErrorKind::UnsupportedSelectedOutbound, - format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"), + format!( + "Outbound '{}' должен быть JSON-объектом", + selected_server.tag + ), ) })?; object.insert( @@ -318,13 +360,6 @@ fn selected_outbound( Ok(outbound) } -fn now_millis() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default() -} - fn command_message(stdout: &str, stderr: &str) -> String { let stdout = stdout.trim(); let stderr = stderr.trim(); diff --git a/src-tauri/src/admin.rs b/src-tauri/src/admin.rs new file mode 100644 index 0000000..9587b7f --- /dev/null +++ b/src-tauri/src/admin.rs @@ -0,0 +1,89 @@ +//! Administrator-state detection and explicit UAC restart boundary. + +use crate::command_dto::{AdminStatusResponse, CommandError}; +use crate::powershell::{ + escape_single as escape_powershell_single, is_elevated as is_running_elevated, + output_message as powershell_output_message, run_command as run_powershell_command, +}; +use std::env; + +pub fn admin_status() -> AdminStatusResponse { + let is_windows = cfg!(windows); + let is_elevated = is_running_elevated(); + let message = if !is_windows { + "Проверка прав администратора нужна только в Windows.".to_string() + } else if is_elevated { + "ProxyWarden уже запущен от имени администратора.".to_string() + } else { + "Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string() + }; + + AdminStatusResponse { + is_windows, + is_elevated, + can_restart_elevated: is_windows && !is_elevated, + message, + } +} + +pub(crate) fn launch_app_as_admin() -> Result<(), CommandError> { + if !cfg!(windows) { + return Err(CommandError::new( + "admin_restart_unsupported", + "Перезапуск от имени администратора доступен только в Windows.", + )); + } + + if is_running_elevated() { + return Ok(()); + } + + let exe_path = env::current_exe().map_err(|error| { + CommandError::new( + "admin_restart_failed", + format!("Не удалось определить путь текущего приложения: {error}"), + ) + })?; + let working_dir = env::current_dir().ok(); + let working_dir_arg = working_dir + .as_ref() + .map(|path| { + format!( + " -WorkingDirectory '{}'", + escape_powershell_single(&path.display().to_string()) + ) + }) + .unwrap_or_default(); + let script = format!( + r#" +$ErrorActionPreference = 'Stop' +try {{ + Start-Process -FilePath '{}' -Verb RunAs{} + exit 0 +}} catch {{ + Write-Error ($_ | Out-String) + exit 1 +}} +"#, + escape_powershell_single(&exe_path.display().to_string()), + working_dir_arg + ); + let output = run_powershell_command(&script).map_err(|error| { + CommandError::new( + "admin_restart_failed", + format!("Не удалось запросить права администратора: {error}"), + ) + })?; + + if output.status.success() { + return Ok(()); + } + + Err(CommandError::new( + "admin_restart_failed", + powershell_output_message( + &output, + "Перезапуск от имени администратора отменен или не был запущен.", + ), + )) +} diff --git a/src-tauri/src/apply_flow.rs b/src-tauri/src/apply_flow.rs new file mode 100644 index 0000000..cc53260 --- /dev/null +++ b/src-tauri/src/apply_flow.rs @@ -0,0 +1,594 @@ +//! Transactional configuration apply use case. +//! +//! The module validates and generates all artifacts before source writes, +//! performs no service lifecycle actions, and attempts rollback when a later +//! write or runtime apply fails. + +use crate::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest}; +use crate::adapters::singbox::{ + SingBoxAdapter, SingBoxConfigChecker, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE, +}; +use crate::clock::Clock; +use crate::component_detection::{ + proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier, + DetectedSingBox, +}; +use crate::models::{ + ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, Profile, ProfileInput, + ProxyProtocol, Target, TargetInput, TargetKind, +}; +use crate::proxy_apply::{HelperApplyRequest, ProxyApplyHelper}; +use crate::safe_fs; +use crate::storage::JsonStorage; +use crate::validation::{normalize_profile, normalize_target, ValidationError}; +use serde::{Deserialize, Serialize}; +use std::{fs, path::Path}; +use thiserror::Error; + +const LOCAL_SINGBOX_TARGET_ID: &str = "local-singbox"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ApplyRouteMode { + External, + LocalSingbox, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyConfigurationInput { + pub route_mode: ApplyRouteMode, + pub profile: ProfileInput, + pub external_target: Option, + #[serde(default = "default_true")] + pub disable_other_profiles: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyPhase { + pub id: String, + pub status: ApplyPhaseStatus, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ApplyPhaseStatus { + Succeeded, + Failed, + RolledBack, + Skipped, + Warning, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyConfigurationResult { + pub success: bool, + pub changed: bool, + pub partial_state: bool, + pub message: String, + pub error_code: Option, + pub generated_config_path: String, + pub singbox_generated_config_path: Option, + pub restart_required: Vec, + pub phases: Vec, +} + +#[derive(Debug, Error)] +pub enum ApplyFlowError { + #[error("Проверьте поля конфигурации")] + Validation { details: Vec }, + #[error("{message}")] + Failure { code: String, message: String }, +} + +impl ApplyFlowError { + pub fn code(&self) -> &str { + match self { + Self::Validation { .. } => "validation_failed", + Self::Failure { code, .. } => code, + } + } + + pub fn details(self) -> Vec { + match self { + Self::Validation { details } => details, + Self::Failure { .. } => Vec::new(), + } + } + + fn failure(code: impl Into, message: impl Into) -> Self { + Self::Failure { + code: code.into(), + message: message.into(), + } + } + + fn validation(details: Vec) -> Self { + Self::Validation { details } + } +} + +pub struct ApplyServices<'a> { + pub proxy_adapter: &'a dyn ProxyRouterAdapter, + pub singbox_adapter: &'a SingBoxAdapter, + pub checker: &'a dyn SingBoxConfigChecker, + pub helper: &'a dyn ProxyApplyHelper, + pub clock: &'a dyn Clock, + pub detected_proxyfier: Option, + pub detected_singbox: Option, +} + +/// Applies one complete routing draft without starting, stopping, installing, +/// uninstalling, or restarting Windows services. +pub fn apply_configuration( + storage: &JsonStorage, + input: ApplyConfigurationInput, + services: ApplyServices<'_>, +) -> Result { + let mut phases = Vec::new(); + let old_profiles = storage + .read_profiles() + .map_err(|error| storage_error("profiles_read_failed", error))?; + let old_targets = storage + .read_targets() + .map_err(|error| storage_error("targets_read_failed", error))?; + + let PreparedApply { + profiles, + targets, + proxy_config, + singbox_config, + } = prepare_apply(storage, input, &services)?; + phases.push(phase( + "preflight", + ApplyPhaseStatus::Succeeded, + "Входные данные и оба generated config проверены до записи.", + )); + + let source_changed = profiles != old_profiles || targets != old_targets; + let proxy_path = storage + .paths() + .generated_dir + .join(&proxy_config.output_file_name); + let singbox_path = singbox_config + .as_ref() + .map(|_| storage.paths().generated_dir.join(SINGBOX_OUTPUT_FILE)); + let old_proxy_contents = fs::read(&proxy_path).ok(); + let old_singbox_contents = singbox_path.as_ref().and_then(|path| fs::read(path).ok()); + let rollback_state = RollbackState { + storage, + old_profiles: &old_profiles, + old_targets: &old_targets, + proxy_path: &proxy_path, + old_proxy_contents: old_proxy_contents.as_deref(), + singbox_path: singbox_path.as_deref(), + old_singbox_contents: old_singbox_contents.as_deref(), + }; + + if let Err(error) = storage.write_targets(&targets) { + let rollback = rollback_source(storage, &old_profiles, &old_targets); + phases.push(phase( + "source-state", + ApplyPhaseStatus::Failed, + "Не удалось сохранить targets.", + )); + phases.push(rollback_phase(&rollback)); + return Ok(failed_result( + "targets_write_failed", + format!("Не удалось сохранить цели: {error}"), + rollback.is_err(), + &proxy_path, + singbox_path.as_deref(), + phases, + )); + } + if let Err(error) = storage.write_profiles(&profiles) { + let rollback = rollback_source(storage, &old_profiles, &old_targets); + phases.push(phase( + "source-state", + ApplyPhaseStatus::Failed, + "Не удалось сохранить profiles.", + )); + phases.push(rollback_phase(&rollback)); + return Ok(failed_result( + "profiles_write_failed", + format!("Не удалось сохранить профили: {error}"), + rollback.is_err(), + &proxy_path, + singbox_path.as_deref(), + phases, + )); + } + phases.push(phase( + "source-state", + ApplyPhaseStatus::Succeeded, + "Profiles и targets сохранены.", + )); + + if let (Some(generated), Some(path)) = (singbox_config.as_ref(), singbox_path.as_ref()) { + if let Err(error) = safe_fs::write_with_backup(path, generated.contents.as_bytes()) { + return Ok(rollback_after_failure( + &rollback_state, + "singbox_config_write_failed", + format!("Не удалось записать generated sing-box config: {error}"), + "singbox-config", + phases, + )); + } + phases.push(phase( + "singbox-config", + ApplyPhaseStatus::Succeeded, + "Generated sing-box config записан; служба не перезапускалась.", + )); + } else { + phases.push(phase( + "singbox-config", + ApplyPhaseStatus::Skipped, + "External SOCKS5 не использует Local sing-box.", + )); + } + + if let Err(error) = safe_fs::write_with_backup(&proxy_path, proxy_config.contents.as_bytes()) { + return Ok(rollback_after_failure( + &rollback_state, + "proxifyre_config_write_failed", + format!("Не удалось записать generated ProxiFyre config: {error}"), + "proxifyre-config", + phases, + )); + } + phases.push(phase( + "proxifyre-config", + ApplyPhaseStatus::Succeeded, + "Generated ProxiFyre config записан.", + )); + + let helper_result = match services.helper.apply_proxy_config(HelperApplyRequest { + adapter_id: &proxy_config.adapter_id, + config_path: &proxy_path, + config_contents: &proxy_config.contents, + }) { + Ok(result) if result.success => result, + Ok(result) => { + return Ok(rollback_after_failure( + &rollback_state, + "proxifyre_apply_failed", + result.message, + "runtime-apply", + phases, + )); + } + Err(error) => { + return Ok(rollback_after_failure( + &rollback_state, + &error.code, + error.message, + "runtime-apply", + phases, + )); + } + }; + phases.push(phase( + "runtime-apply", + ApplyPhaseStatus::Succeeded, + "ProxiFyre config применён без управления службой.", + )); + phases.push(phase( + "service-control", + ApplyPhaseStatus::Skipped, + "Apply не запускает, не останавливает и не перезапускает службы.", + )); + + let mut restart_required = Vec::new(); + if services.detected_proxyfier.is_some() { + restart_required.push(ComponentId::Proxyfier); + } + if singbox_config.is_some() && services.detected_singbox.is_some() { + restart_required.push(ComponentId::Singbox); + } + let message = if restart_required.is_empty() { + helper_result.message.clone() + } else { + "Конфигурация применена. Для загрузки новых файлов явно перезапустите отмеченные службы." + .to_string() + }; + let activity = ActivityEntry { + id: "configuration-applied".to_string(), + at: services.clock.now(), + level: ActivityLevel::Success, + title: "Маршрут применён".to_string(), + message: format!( + "Профилей: {}, приложений: {}. Управление службами не выполнялось.", + proxy_config.enabled_profiles, proxy_config.routed_apps + ), + }; + if let Err(error) = storage.append_activity(activity) { + phases.push(phase( + "activity", + ApplyPhaseStatus::Warning, + format!("Маршрут применён, но запись activity не удалась: {error}"), + )); + } else { + phases.push(phase( + "activity", + ApplyPhaseStatus::Succeeded, + "Activity обновлена.", + )); + } + + Ok(ApplyConfigurationResult { + success: true, + changed: source_changed || helper_result.changed, + partial_state: false, + message, + error_code: None, + generated_config_path: proxy_path.display().to_string(), + singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()), + restart_required, + phases, + }) +} + +struct PreparedApply { + profiles: Vec, + targets: Vec, + proxy_config: crate::adapters::proxy_router::ProxyRouterGeneratedConfig, + singbox_config: Option, +} + +fn prepare_apply( + storage: &JsonStorage, + input: ApplyConfigurationInput, + services: &ApplyServices<'_>, +) -> Result { + if services.detected_proxyfier.is_none() { + return Err(ApplyFlowError::failure( + "proxifyre_not_found", + "ProxiFyre не найден. Установите компонент отдельным явным действием перед apply.", + )); + } + let mut profile_input = input.profile; + let mut targets = storage + .read_targets() + .map_err(|error| storage_error("targets_read_failed", error))?; + let singbox_config = match input.route_mode { + ApplyRouteMode::External => { + let target_input = input.external_target.ok_or_else(|| { + ApplyFlowError::failure( + "external_target_missing", + "Для external маршрута требуется SOCKS5 target.", + ) + })?; + let target = normalize_target(target_input).map_err(ApplyFlowError::validation)?; + profile_input.target_id = target.id.clone(); + upsert_target(&mut targets, target); + None + } + ApplyRouteMode::LocalSingbox => { + let config = storage + .read_local_singbox_config() + .map_err(|error| storage_error("singbox_config_read_failed", error))?; + let cache = storage + .read_singbox_subscription_cache() + .map_err(|error| storage_error("singbox_cache_read_failed", error))? + .ok_or_else(|| { + ApplyFlowError::failure( + "singbox_subscription_cache_missing", + "Сначала загрузите подписку Local sing-box.", + ) + })?; + profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string(); + upsert_target(&mut targets, local_singbox_target(&config)); + Some( + services + .singbox_adapter + .generate_config( + SingBoxGenerationRequest::new( + &config, + &cache, + services + .detected_singbox + .as_ref() + .map(|detected| detected.executable_path.as_path()), + ), + services.checker, + ) + .map_err(|error| { + ApplyFlowError::failure("singbox_preflight_failed", error.message) + })?, + ) + } + }; + + let profile = normalize_profile(profile_input).map_err(ApplyFlowError::validation)?; + let mut profiles = storage + .read_profiles() + .map_err(|error| storage_error("profiles_read_failed", error))?; + if input.disable_other_profiles { + for existing in &mut profiles { + if existing.id != profile.id { + existing.enabled = false; + } + } + } + upsert_profile(&mut profiles, profile); + + let components = vec![ + proxyfier_component_from_detection(services.detected_proxyfier.as_ref()), + singbox_component_from_detection(services.detected_singbox.as_ref()), + ]; + let proxy_config = services + .proxy_adapter + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) + .map_err(|error| ApplyFlowError::failure("proxifyre_preflight_failed", error.message))?; + + Ok(PreparedApply { + profiles, + targets, + proxy_config, + singbox_config, + }) +} + +fn local_singbox_target(config: &LocalSingBoxConfig) -> Target { + Target { + id: LOCAL_SINGBOX_TARGET_ID.to_string(), + name: "Локальный sing-box".to_string(), + kind: TargetKind::Local, + protocol: ProxyProtocol::Socks5, + host: config.listen_host.clone(), + port: config.listen_port, + requires_component: Some(ComponentId::Singbox), + } +} + +fn upsert_profile(profiles: &mut Vec, profile: Profile) { + match profiles + .iter() + .position(|existing| existing.id == profile.id) + { + Some(index) => profiles[index] = profile, + None => profiles.push(profile), + } +} + +fn upsert_target(targets: &mut Vec, target: Target) { + match targets.iter().position(|existing| existing.id == target.id) { + Some(index) => targets[index] = target, + None => targets.push(target), + } +} + +struct RollbackState<'a> { + storage: &'a JsonStorage, + old_profiles: &'a [Profile], + old_targets: &'a [Target], + proxy_path: &'a Path, + old_proxy_contents: Option<&'a [u8]>, + singbox_path: Option<&'a Path>, + old_singbox_contents: Option<&'a [u8]>, +} + +fn rollback_after_failure( + state: &RollbackState<'_>, + code: &str, + message: String, + failed_phase: &str, + mut phases: Vec, +) -> ApplyConfigurationResult { + phases.push(phase(failed_phase, ApplyPhaseStatus::Failed, &message)); + let source_rollback = rollback_source(state.storage, state.old_profiles, state.old_targets); + let proxy_rollback = restore_generated(state.proxy_path, state.old_proxy_contents); + let singbox_rollback = state + .singbox_path + .map(|path| restore_generated(path, state.old_singbox_contents)) + .unwrap_or(Ok(())); + let rollback_ok = source_rollback.is_ok() && proxy_rollback.is_ok() && singbox_rollback.is_ok(); + phases.push(if rollback_ok { + phase( + "rollback", + ApplyPhaseStatus::RolledBack, + "Source state и generated artifacts восстановлены.", + ) + } else { + phase( + "rollback", + ApplyPhaseStatus::Failed, + "Rollback завершился не полностью; проверьте файлы config/generated.", + ) + }); + failed_result( + code, + message, + !rollback_ok, + state.proxy_path, + state.singbox_path, + phases, + ) +} + +fn rollback_source( + storage: &JsonStorage, + profiles: &[Profile], + targets: &[Target], +) -> Result<(), String> { + let targets_result = storage + .write_targets(targets) + .map_err(|error| error.to_string()); + let profiles_result = storage + .write_profiles(profiles) + .map_err(|error| error.to_string()); + targets_result.and(profiles_result) +} + +fn restore_generated(path: &Path, previous: Option<&[u8]>) -> Result<(), String> { + match previous { + Some(contents) => { + safe_fs::write_with_backup(path, contents).map_err(|error| error.to_string()) + } + None => match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + }, + } +} + +fn rollback_phase(result: &Result<(), String>) -> ApplyPhase { + match result { + Ok(()) => phase( + "rollback", + ApplyPhaseStatus::RolledBack, + "Source state восстановлен.", + ), + Err(error) => phase( + "rollback", + ApplyPhaseStatus::Failed, + format!("Не удалось полностью восстановить source state: {error}"), + ), + } +} + +fn failed_result( + code: &str, + message: String, + partial_state: bool, + proxy_path: &Path, + singbox_path: Option<&Path>, + phases: Vec, +) -> ApplyConfigurationResult { + ApplyConfigurationResult { + success: false, + changed: false, + partial_state, + message, + error_code: Some(code.to_string()), + generated_config_path: proxy_path.display().to_string(), + singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()), + restart_required: Vec::new(), + phases, + } +} + +fn phase( + id: impl Into, + status: ApplyPhaseStatus, + message: impl Into, +) -> ApplyPhase { + ApplyPhase { + id: id.into(), + status, + message: message.into(), + } +} + +fn storage_error(code: &str, error: std::io::Error) -> ApplyFlowError { + ApplyFlowError::failure(code, format!("Ошибка storage: {error}")) +} + +fn default_true() -> bool { + true +} diff --git a/src-tauri/src/clock.rs b/src-tauri/src/clock.rs new file mode 100644 index 0000000..50cda36 --- /dev/null +++ b/src-tauri/src/clock.rs @@ -0,0 +1,19 @@ +//! Small injectable time boundary for deterministic activity records. + +use std::time::{SystemTime, UNIX_EPOCH}; + +pub trait Clock { + fn now(&self) -> String; +} + +pub struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + format!("unix:{seconds}") + } +} diff --git a/src-tauri/src/command_dto.rs b/src-tauri/src/command_dto.rs new file mode 100644 index 0000000..b22405d --- /dev/null +++ b/src-tauri/src/command_dto.rs @@ -0,0 +1,542 @@ +//! Serialized Tauri command boundary types. +//! +//! System/domain truth stays in `models`; these DTOs only define the stable +//! camelCase contract exposed to the React webview. + +use crate::adapters::singbox::SingBoxCheckResult; +use crate::models::{ + ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, + Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, + SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind, +}; +use crate::singbox_service::SingBoxSetupStatus; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminStatusResponse { + pub is_windows: bool, + pub is_elevated: bool, + pub can_restart_elevated: bool, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidationIssue { + pub field: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandError { + pub code: String, + pub message: String, + #[serde(default)] + pub details: Vec, +} + +impl CommandError { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + details: Vec::new(), + } + } + + pub fn with_details( + code: impl Into, + message: impl Into, + details: Vec, + ) -> Self { + Self { + code: code.into(), + message: message.into(), + details, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusResponse { + pub route_line: String, + pub active_profile_count: usize, + pub routed_app_count: usize, + pub active_target: Option, + pub components: Vec, + pub recent_activity: Vec, + pub generated_config_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SavedStateResponse { + pub profiles: Vec, + pub targets: Vec, + pub generated_config_path: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartupSnapshotResponse { + pub admin_status: AdminStatusResponse, + pub saved_state: SavedStateResponse, + pub components: Vec, + pub proxifyre_setup_status: ProxiFyreSetupStatusDto, + pub singbox_status: LocalSingBoxStatusResponse, + pub singbox_setup_status: SingBoxSetupStatusDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxiFyreSetupStatusDto { + pub ready: bool, + pub missing_count: usize, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxiFyreSetupItemDto { + pub id: String, + pub name: String, + pub installed: bool, + pub version: Option, + pub details: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxiFyreSetupProgressDto { + pub operation: String, + pub status: String, + pub active_step: Option, + pub percent: u8, + pub message: String, + pub updated_at: Option, +} + +pub type SingBoxSetupStatusDto = SingBoxSetupStatus; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalSingBoxStatusResponse { + pub config: LocalSingBoxConfigDto, + pub cache: Option, + pub component: ComponentStatusDto, + pub generated_config_path: String, + pub lan_listen_host: Option, + #[cfg(debug_assertions)] + pub subscription_identity: SubscriptionRequestIdentityDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalSingBoxConfigDto { + pub subscription_display_url: Option, + pub has_subscription: bool, + pub selected_server_tag: Option, + pub selected_server_id: Option, + pub listen_host: String, + pub listen_port: u16, + pub service_name: String, + pub install_root: String, + pub updated_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionCacheDto { + pub servers: Vec, + pub user_info: serde_json::Map, + pub fetched_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionServerDto { + pub id: String, + pub tag: String, + #[serde(rename = "type")] + pub server_type: String, + pub server: String, + pub server_port: u16, +} + +#[cfg(debug_assertions)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionRequestIdentityDto { + pub headers: Vec, +} + +#[cfg(debug_assertions)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionRequestHeaderDto { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveSingBoxSubscriptionInputDto { + pub subscription_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectSingBoxServerInputDto { + #[serde(default)] + pub id: Option, + pub tag: String, + #[serde(default)] + pub server: Option, + #[serde(default)] + pub server_port: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PingSingBoxServerInputDto { + #[serde(default)] + pub id: Option, + pub tag: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PingProxyTargetInputDto { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PingServerResponse { + pub id: String, + pub tag: String, + pub server: String, + pub server_port: u16, + pub ok: bool, + pub latency: Option, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyProbeResponse { + pub id: String, + pub name: String, + pub url: String, + pub ok: bool, + pub status: Option, + pub latency: Option, + pub ip: Option, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyTargetCheckResponse { + pub tag: String, + pub server: String, + pub server_port: u16, + pub ok: bool, + pub latency: Option, + pub error: Option, + pub probes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerateSingBoxConfigResponse { + pub success: bool, + pub message: String, + pub adapter_id: String, + pub generated_config_path: String, + pub selected_server_tag: String, + pub listen_host: String, + pub listen_port: u16, + pub check: Option, + pub activity: ActivityEntryDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileInputDto { + pub id: Option, + pub name: String, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub protocols: Option>, + #[serde(default)] + pub items: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileItemInputDto { + #[serde(rename = "type")] + pub item_type: String, + pub value: String, + #[serde(default)] + pub recursive: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetInputDto { + pub id: Option, + pub name: String, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub protocol: Option, + pub host: String, + pub port: u32, + #[serde(default)] + pub requires_component: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileDto { + pub id: String, + pub name: String, + pub enabled: bool, + pub target_id: String, + pub protocols: Vec, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileItemDto { + #[serde(rename = "type")] + pub item_type: ProfileItemType, + pub value: String, + pub recursive: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetDto { + pub id: String, + pub name: String, + pub kind: TargetKind, + pub protocol: ProxyProtocol, + pub host: String, + pub port: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_component: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentStatusDto { + pub id: ComponentId, + pub name: String, + pub state: ComponentState, + pub installed: bool, + pub running: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_status: Option, + pub problems: Vec, + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityEntryDto { + pub id: String, + pub at: String, + pub level: ActivityLevel, + pub title: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveProfilePreviewResponse { + pub profile_id: String, + pub apps: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedAppDto { + pub source_type: ProfileItemType, + pub source_value: String, + pub app_name: String, + pub notes: Vec, +} + +impl From for ProfileInput { + fn from(input: ProfileInputDto) -> Self { + Self { + id: input.id, + name: input.name, + enabled: input.enabled.unwrap_or(true), + target_id: input + .target_id + .unwrap_or_else(|| "local-singbox".to_string()), + protocols: input + .protocols + .unwrap_or_else(|| vec!["TCP".to_string(), "UDP".to_string()]), + items: input + .items + .unwrap_or_default() + .into_iter() + .map(ProfileItemInput::from) + .collect(), + } + } +} + +impl From for ProfileItemInput { + fn from(input: ProfileItemInputDto) -> Self { + Self { + item_type: input.item_type, + value: input.value, + recursive: input.recursive, + } + } +} + +impl From for TargetInput { + fn from(input: TargetInputDto) -> Self { + Self { + id: input.id, + name: input.name, + kind: input.kind.unwrap_or_else(|| "external".to_string()), + protocol: input.protocol.unwrap_or_else(|| "socks5".to_string()), + host: input.host, + port: input.port, + requires_component: input.requires_component, + } + } +} + +impl From<&Profile> for ProfileDto { + fn from(profile: &Profile) -> Self { + Self { + id: profile.id.clone(), + name: profile.name.clone(), + enabled: profile.enabled, + target_id: profile.target_id.clone(), + protocols: profile.protocols.clone(), + items: profile.items.iter().map(ProfileItemDto::from).collect(), + } + } +} + +impl From<&ProfileItem> for ProfileItemDto { + fn from(item: &ProfileItem) -> Self { + Self { + item_type: item.item_type.clone(), + value: item.value.clone(), + recursive: item.recursive, + } + } +} + +impl From<&Target> for TargetDto { + fn from(target: &Target) -> Self { + Self { + id: target.id.clone(), + name: target.name.clone(), + kind: target.kind.clone(), + protocol: target.protocol.clone(), + host: target.host.clone(), + port: target.port, + requires_component: target.requires_component.clone(), + } + } +} + +impl From<&ComponentStatus> for ComponentStatusDto { + fn from(component: &ComponentStatus) -> Self { + Self { + id: component.id.clone(), + name: component.name.clone(), + state: component.state.clone(), + installed: component.installed, + running: component.running, + version: component.version.clone(), + path: component.path.clone(), + service_name: component.service_name.clone(), + service_status: component.service_status.clone(), + problems: component.problems.clone(), + actions: component.actions.clone(), + } + } +} + +impl From<&ActivityEntry> for ActivityEntryDto { + fn from(entry: &ActivityEntry) -> Self { + Self { + id: entry.id.clone(), + at: entry.at.clone(), + level: entry.level.clone(), + title: entry.title.clone(), + message: entry.message.clone(), + } + } +} + +impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto { + fn from(config: &LocalSingBoxConfig) -> Self { + Self { + subscription_display_url: config.subscription_display_url(), + has_subscription: config + .subscription_url + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), + selected_server_tag: config.selected_server_tag.clone(), + selected_server_id: config.selected_server_id.clone(), + listen_host: config.listen_host.clone(), + listen_port: config.listen_port, + service_name: config.service_name.clone(), + install_root: config.install_root.clone(), + updated_at: config.updated_at.clone(), + } + } +} + +impl From<&SubscriptionCache> for SubscriptionCacheDto { + fn from(cache: &SubscriptionCache) -> Self { + Self { + servers: cache + .servers + .iter() + .map(SubscriptionServerDto::from) + .collect(), + user_info: cache.user_info.clone(), + fetched_at: cache.fetched_at.clone(), + } + } +} + +impl From<&SubscriptionServer> for SubscriptionServerDto { + fn from(server: &SubscriptionServer) -> Self { + Self { + id: server.id.clone(), + tag: server.tag.clone(), + server_type: server.server_type.clone(), + server: server.server.clone(), + server_port: server.server_port, + } + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4d8395e..1a2bd2f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,94 +1,50 @@ -use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy}; -use crate::adapters::proxy_router::{ - ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, - ProxyRouterRequest, -}; -use crate::adapters::singbox::{ - SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker, - SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest, -}; -use crate::component_detection::{ - default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install, - detect_proxyfier_install_with_host, detect_singbox_install, proxifyre_install_dir_from_app_dir, - proxyfier_component_from_detection, singbox_component_from_detection, - singbox_install_dir_from_app_dir, 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_with_install_root, ensure_safe_singbox_install_dir, - parse_service_command_output as parse_singbox_service_command_output, service_control_script, - ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction, SingBoxSetupStatus, -}; +use crate::adapters::proxifyre::ProxiFyreAdapter; +use crate::adapters::singbox::{SingBoxAdapter, SingBoxCommandChecker}; +pub use crate::admin::admin_status; +use crate::admin::launch_app_as_admin; +use crate::apply_flow::{self, ApplyConfigurationInput, ApplyConfigurationResult, ApplyFlowError}; +use crate::component_detection::{detect_proxyfier_install, detect_singbox_install}; +use crate::singbox_service::{build_singbox_setup_status_with_install_root, SingBoxServiceAction}; use crate::storage::{default_config_root, JsonStorage}; -use crate::subscription; -use crate::validation::{normalize_profile, normalize_target, ValidationError}; -use serde::{Deserialize, Serialize}; -use std::env; -use std::fs; -use std::net::{IpAddr, TcpStream, ToSocketAddrs, UdpSocket}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::time::{Duration, Instant}; -use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::Manager; +use std::path::PathBuf; -const MAIN_PROFILE_ID: &str = "main-profile"; -const MAIN_TARGET_ID: &str = "main-proxy"; -const PROXIFYRE_RELEASE_API_URL: &str = - "https://api.github.com/repos/wiresock/proxifyre/releases/latest"; -const NDISAPI_RELEASE_API_URL: &str = - "https://api.github.com/repos/wiresock/ndisapi/releases/latest"; -const PROXIFYRE_PINNED_RELEASE_TAG: &str = "v2.2.1"; -const NDISAPI_PINNED_RELEASE_TAG: &str = "v3.6.2"; -const NDISAPI_PINNED_INSTALLER_VERSION: &str = "3.6.2.1"; -const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe"; -const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe"; -const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4); -const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2); -const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check"; - -const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[ - ProxyProbeEndpoint { - id: "cloudflare-trace", - name: "Cloudflare Trace", - url: "https://www.cloudflare.com/cdn-cgi/trace", - ip_source: ProbeIpSource::CloudflareTrace, - }, - ProxyProbeEndpoint { - id: "cloudflare-speed", - name: "Cloudflare Speed", - url: "https://speed.cloudflare.com/meta", - ip_source: ProbeIpSource::JsonField("clientIp"), - }, - ProxyProbeEndpoint { - id: "ipify", - name: "ipify", - url: "https://api.ipify.org?format=json", - ip_source: ProbeIpSource::JsonField("ip"), - }, -]; - -#[derive(Debug, Clone, Copy)] -pub struct ProxyProbeEndpoint { - id: &'static str, - name: &'static str, - url: &'static str, - ip_source: ProbeIpSource, -} - -#[derive(Debug, Clone, Copy)] -enum ProbeIpSource { - CloudflareTrace, - JsonField(&'static str), -} +pub use crate::clock::{Clock, SystemClock}; +pub use crate::command_dto::*; +pub use crate::component_status::resolve_component_statuses; +pub use crate::configuration_use_case::{ + build_status, read_activity, read_components, read_profiles, read_saved_state, + read_saved_state_with_proxifyre_config, read_startup_snapshot, read_targets, resolve_preview, + save_profile_to_storage, save_target_to_storage, +}; +pub use crate::proxifyre_runtime::wrap_elevated_package_script; +use crate::proxifyre_runtime::{ + build_proxifyre_setup_status_for_install_dir, control_proxifyre_service, + install_proxifyre_component, proxifyre_install_dir_for_app, read_proxifyre_setup_progress, + singbox_install_dir_for_app, uninstall_proxifyre_component, ServiceControlAction, +}; +pub use crate::proxifyre_scripts::{ + install_proxifyre_script, install_proxifyre_script_for_target, + install_proxifyre_script_with_bundle, uninstall_proxifyre_script, +}; +pub use crate::proxy_apply::{ + apply_profiles_with_services, apply_profiles_with_services_and_detection, + ApplyProfilesResponse, DetectedProxyApplyHelper, HelperApplyRequest, HelperApplyResult, + ProxyApplyHelper, +}; +pub use crate::proxy_probe::{ + ping_proxy_target_endpoint, ping_proxy_target_endpoint_with_probes, ProxyProbeEndpoint, +}; +pub use crate::singbox_config::generate_singbox_config_with_services; +pub use crate::singbox_runtime::singbox_installer_runner_script; +use crate::singbox_runtime::{ + control_singbox_service, install_singbox_component, uninstall_singbox_component, +}; +pub use crate::singbox_subscription::{ + fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage, + ping_all_singbox_servers_in_storage, ping_singbox_server_in_storage, read_singbox_status, + save_singbox_subscription_to_storage, select_singbox_server_in_storage, SubscriptionFetcher, + SystemSubscriptionFetcher, +}; #[derive(Debug, Clone)] pub struct CommandState { @@ -111,504 +67,6 @@ impl Default for CommandState { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CommandError { - pub code: String, - pub message: String, - #[serde(default)] - pub details: Vec, -} - -impl CommandError { - fn new(code: impl Into, message: impl Into) -> Self { - Self { - code: code.into(), - message: message.into(), - details: Vec::new(), - } - } - - fn with_details( - code: impl Into, - message: impl Into, - details: Vec, - ) -> Self { - Self { - code: code.into(), - message: message.into(), - details, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AdminStatusResponse { - pub is_windows: bool, - pub is_elevated: bool, - pub can_restart_elevated: bool, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ValidationIssue { - pub field: String, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StatusResponse { - pub route_line: String, - pub active_profile_count: usize, - pub routed_app_count: usize, - pub active_target: Option, - pub components: Vec, - pub recent_activity: Vec, - pub generated_config_path: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SavedStateResponse { - pub profiles: Vec, - pub targets: Vec, - pub generated_config_path: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StartupSnapshotResponse { - pub admin_status: AdminStatusResponse, - pub saved_state: SavedStateResponse, - pub components: Vec, - pub proxifyre_setup_status: ProxiFyreSetupStatusDto, - pub singbox_status: LocalSingBoxStatusResponse, - pub singbox_setup_status: SingBoxSetupStatusDto, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProxiFyreSetupStatusDto { - pub ready: bool, - pub missing_count: usize, - pub items: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProxiFyreSetupItemDto { - pub id: String, - pub name: String, - pub installed: bool, - pub version: Option, - pub details: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProxiFyreSetupProgressDto { - pub operation: String, - pub status: String, - pub active_step: Option, - pub percent: u8, - pub message: String, - pub updated_at: Option, -} - -pub type SingBoxSetupStatusDto = SingBoxSetupStatus; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LocalSingBoxStatusResponse { - pub config: LocalSingBoxConfigDto, - pub cache: Option, - pub component: ComponentStatusDto, - pub generated_config_path: String, - pub lan_listen_host: Option, - #[cfg(debug_assertions)] - pub subscription_identity: SubscriptionRequestIdentityDto, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LocalSingBoxConfigDto { - pub subscription_display_url: Option, - pub has_subscription: bool, - pub selected_server_tag: Option, - pub listen_host: String, - pub listen_port: u16, - pub service_name: String, - pub install_root: String, - pub updated_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SubscriptionCacheDto { - pub servers: Vec, - pub user_info: serde_json::Map, - pub fetched_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SubscriptionServerDto { - pub tag: String, - #[serde(rename = "type")] - pub server_type: String, - pub server: String, - pub server_port: u16, -} - -#[cfg(debug_assertions)] -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SubscriptionRequestIdentityDto { - pub headers: Vec, -} - -#[cfg(debug_assertions)] -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SubscriptionRequestHeaderDto { - pub name: String, - pub value: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveSingBoxSubscriptionInputDto { - pub subscription_url: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SelectSingBoxServerInputDto { - pub tag: String, - #[serde(default)] - pub server: Option, - #[serde(default)] - pub server_port: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PingSingBoxServerInputDto { - pub tag: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PingProxyTargetInputDto { - pub host: String, - pub port: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PingServerResponse { - pub tag: String, - pub server: String, - pub server_port: u16, - pub ok: bool, - pub latency: Option, - pub error: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProxyProbeResponse { - pub id: String, - pub name: String, - pub url: String, - pub ok: bool, - pub status: Option, - pub latency: Option, - pub ip: Option, - pub error: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProxyTargetCheckResponse { - pub tag: String, - pub server: String, - pub server_port: u16, - pub ok: bool, - pub latency: Option, - pub error: Option, - pub probes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GenerateSingBoxConfigResponse { - pub success: bool, - pub message: String, - pub adapter_id: String, - pub generated_config_path: String, - pub selected_server_tag: String, - pub listen_host: String, - pub listen_port: u16, - pub check: Option, - pub activity: ActivityEntryDto, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProfileInputDto { - pub id: Option, - pub name: String, - #[serde(default)] - pub enabled: Option, - #[serde(default)] - pub target_id: Option, - #[serde(default)] - pub protocols: Option>, - #[serde(default)] - pub items: Option>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ProfileItemInputDto { - #[serde(rename = "type")] - pub item_type: String, - pub value: String, - #[serde(default)] - pub recursive: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TargetInputDto { - pub id: Option, - pub name: String, - #[serde(default)] - pub kind: Option, - #[serde(default)] - pub protocol: Option, - pub host: String, - pub port: u32, - #[serde(default)] - pub requires_component: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProfileDto { - pub id: String, - pub name: String, - pub enabled: bool, - pub target_id: String, - pub protocols: Vec, - pub items: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ProfileItemDto { - #[serde(rename = "type")] - pub item_type: ProfileItemType, - pub value: String, - pub recursive: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TargetDto { - pub id: String, - pub name: String, - pub kind: TargetKind, - pub protocol: ProxyProtocol, - pub host: String, - pub port: u16, - #[serde(skip_serializing_if = "Option::is_none")] - pub requires_component: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ComponentStatusDto { - pub id: ComponentId, - pub name: String, - pub state: ComponentState, - pub installed: bool, - pub running: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub service_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub service_status: Option, - pub problems: Vec, - pub actions: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ActivityEntryDto { - pub id: String, - pub at: String, - pub level: ActivityLevel, - pub title: String, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResolveProfilePreviewResponse { - pub profile_id: String, - pub apps: Vec, - pub warnings: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResolvedAppDto { - pub source_type: ProfileItemType, - pub source_value: String, - pub app_name: String, - pub notes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApplyProfilesResponse { - pub success: bool, - pub changed: bool, - pub message: String, - pub adapter_id: String, - pub generated_config_path: String, - pub enabled_profiles: usize, - pub routed_apps: usize, - pub helper: HelperApplyResult, - pub activity: ActivityEntryDto, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HelperApplyResult { - pub success: bool, - pub changed: bool, - pub action: String, - pub message: String, -} - -pub struct HelperApplyRequest<'a> { - pub adapter_id: &'a str, - pub config_path: &'a Path, - pub config_contents: &'a str, -} - -pub trait ProxyApplyHelper { - fn apply_proxy_config( - &self, - request: HelperApplyRequest<'_>, - ) -> Result; -} - -pub trait SubscriptionFetcher { - fn fetch_subscription( - &self, - url: &str, - identity: &subscription::SubscriptionFetchIdentity, - ) -> Result; -} - -pub struct SystemSubscriptionFetcher; - -impl SubscriptionFetcher for SystemSubscriptionFetcher { - fn fetch_subscription( - &self, - url: &str, - identity: &subscription::SubscriptionFetchIdentity, - ) -> Result { - subscription::fetch_subscription_with_identity(url, identity) - } -} - -#[cfg(debug_assertions)] -fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto { - let identity = subscription::SubscriptionFetchIdentity::default(); - let headers = identity - .request_headers_without_device_hwid() - .into_iter() - .map(|(name, value)| SubscriptionRequestHeaderDto { - name: name.to_string(), - value, - }) - .collect(); - - SubscriptionRequestIdentityDto { headers } -} - -pub trait Clock { - fn now(&self) -> String; -} - -pub struct SystemClock; - -impl Clock for SystemClock { - fn now(&self) -> String { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0); - format!("unix:{seconds}") - } -} - -pub struct DetectedProxyApplyHelper { - host: H, -} - -impl DetectedProxyApplyHelper { - pub fn system() -> Self { - SystemProxyfierDetectionHost.into() - } -} - -impl From for DetectedProxyApplyHelper { - fn from(host: H) -> Self { - Self { host } - } -} - -impl ProxyApplyHelper for DetectedProxyApplyHelper -where - H: ProxyfierDetectionHost, -{ - fn apply_proxy_config( - &self, - request: HelperApplyRequest<'_>, - ) -> Result { - let Some(detected) = detect_proxyfier_install_with_host(&self.host) else { - return staged_apply_result(request); - }; - - apply_to_detected_proxyfier(request, &detected) - } -} - -#[tauri::command] -pub async fn get_status( - state: tauri::State<'_, CommandState>, -) -> Result { - let storage = state.storage(); - tauri::async_runtime::spawn_blocking(move || build_status(&storage)) - .await - .map_err(background_task_error)? -} - -#[tauri::command] -pub fn get_admin_status() -> AdminStatusResponse { - admin_status() -} - #[tauri::command] pub fn restart_as_admin(app: tauri::AppHandle) -> Result<(), CommandError> { launch_app_as_admin()?; @@ -633,34 +91,6 @@ pub fn get_saved_state( read_saved_state(&state.storage()) } -#[tauri::command] -pub fn get_profiles( - state: tauri::State<'_, CommandState>, -) -> Result, CommandError> { - read_profiles(&state.storage()) -} - -#[tauri::command] -pub fn save_profile( - state: tauri::State<'_, CommandState>, - input: ProfileInputDto, -) -> Result { - save_profile_to_storage(&state.storage(), input) -} - -#[tauri::command] -pub fn get_targets(state: tauri::State<'_, CommandState>) -> Result, CommandError> { - read_targets(&state.storage()) -} - -#[tauri::command] -pub fn save_target( - state: tauri::State<'_, CommandState>, - input: TargetInputDto, -) -> Result { - save_target_to_storage(&state.storage(), input) -} - #[tauri::command] pub async fn get_components( state: tauri::State<'_, CommandState>, @@ -718,13 +148,6 @@ pub async fn get_singbox_setup_status( .map_err(background_task_error) } -#[tauri::command] -pub fn resolve_profile_preview( - input: ProfileInputDto, -) -> Result { - resolve_preview(input) -} - #[tauri::command] pub fn save_singbox_subscription( state: tauri::State<'_, CommandState>, @@ -813,48 +236,33 @@ pub async fn generate_singbox_config( } #[tauri::command] -pub async fn apply_profiles( +pub async fn apply_configuration( state: tauri::State<'_, CommandState>, -) -> Result { + input: ApplyConfigurationInput, +) -> Result { let storage = state.storage(); 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) + let detected_proxyfier = detect_proxyfier_install(); + let detected_singbox = detect_singbox_install(); + apply_flow::apply_configuration( + &storage, + input, + apply_flow::ApplyServices { + proxy_adapter: &ProxiFyreAdapter::default(), + singbox_adapter: &SingBoxAdapter::default(), + checker: &SingBoxCommandChecker, + helper: &DetectedProxyApplyHelper::system(), + clock: &SystemClock, + detected_proxyfier, + detected_singbox, + }, + ) + .map_err(apply_flow_error) }) .await .map_err(background_task_error)? } -#[tauri::command] -pub fn get_logs( - state: tauri::State<'_, CommandState>, -) -> Result, CommandError> { - read_activity(&state.storage()) -} - -#[tauri::command] -pub fn open_config_location(state: tauri::State<'_, CommandState>) -> Result { - let storage = state.storage(); - let generated_path = storage - .paths() - .generated_dir - .join("proxifyre-app-config.json"); - let config_path = detect_proxyfier_install() - .and_then(|detected| detected.config_path) - .filter(|path| path.exists()) - .or_else(|| generated_path.exists().then_some(generated_path.clone())); - - let Some(config_path) = config_path else { - return open_folder(&storage.paths().generated_dir); - }; - - open_file_or_select(&config_path)?; - Ok(config_path.display().to_string()) -} - #[tauri::command] pub async fn start_proxifyre_service() -> Result { tauri::async_runtime::spawn_blocking(|| control_proxifyre_service(ServiceControlAction::Start)) @@ -881,8 +289,10 @@ pub async fn install_proxifyre( } #[tauri::command] -pub async fn uninstall_proxifyre() -> Result { - tauri::async_runtime::spawn_blocking(uninstall_proxifyre_component) +pub async fn uninstall_proxifyre( + app: tauri::AppHandle, +) -> Result { + tauri::async_runtime::spawn_blocking(move || uninstall_proxifyre_component(&app)) .await .map_err(background_task_error)? } @@ -939,3698 +349,6 @@ pub async fn uninstall_singbox() -> Result { .map_err(background_task_error)? } -pub fn admin_status() -> AdminStatusResponse { - let is_windows = cfg!(windows); - let is_elevated = is_running_elevated(); - let message = if !is_windows { - "Проверка прав администратора нужна только в Windows.".to_string() - } else if is_elevated { - "ProxyWarden уже запущен от имени администратора.".to_string() - } else { - "Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string() - }; - - AdminStatusResponse { - is_windows, - is_elevated, - can_restart_elevated: is_windows && !is_elevated, - message, - } -} - -fn launch_app_as_admin() -> Result<(), CommandError> { - if !cfg!(windows) { - return Err(CommandError::new( - "admin_restart_unsupported", - "Перезапуск от имени администратора доступен только в Windows.", - )); - } - - if is_running_elevated() { - return Ok(()); - } - - let exe_path = env::current_exe().map_err(|error| { - CommandError::new( - "admin_restart_failed", - format!("Не удалось определить путь текущего приложения: {error}"), - ) - })?; - let working_dir = env::current_dir().ok(); - let working_dir_arg = working_dir - .as_ref() - .map(|path| { - format!( - " -WorkingDirectory '{}'", - escape_powershell_single(&path.display().to_string()) - ) - }) - .unwrap_or_default(); - let script = format!( - r#" -$ErrorActionPreference = 'Stop' -try {{ - Start-Process -FilePath '{}' -Verb RunAs{} - exit 0 -}} catch {{ - Write-Error ($_ | Out-String) - exit 1 -}} -"#, - escape_powershell_single(&exe_path.display().to_string()), - working_dir_arg - ); - let output = run_powershell_command(&script).map_err(|error| { - CommandError::new( - "admin_restart_failed", - format!("Не удалось запросить права администратора: {error}"), - ) - })?; - - if output.status.success() { - return Ok(()); - } - - Err(CommandError::new( - "admin_restart_failed", - powershell_output_message( - &output, - "Перезапуск от имени администратора отменен или не был запущен.", - ), - )) -} - -pub fn build_status(storage: &JsonStorage) -> Result { - let profiles = storage.read_profiles().map_err(storage_error)?; - let targets = storage.read_targets().map_err(storage_error)?; - let components = components_or_defaults(storage)?; - let activity = storage.read_activity().map_err(storage_error)?; - let active_profile_count = profiles.iter().filter(|profile| profile.enabled).count(); - let routed_app_count = profiles - .iter() - .filter(|profile| profile.enabled) - .map(|profile| profile.items.len()) - .sum(); - let active_target = profiles - .iter() - .find(|profile| profile.enabled) - .and_then(|profile| targets.iter().find(|target| target.id == profile.target_id)); - let route_line = route_line(active_target); - - Ok(StatusResponse { - route_line, - active_profile_count, - routed_app_count, - active_target: active_target.map(TargetDto::from), - components: components.iter().map(ComponentStatusDto::from).collect(), - recent_activity: activity - .iter() - .take(10) - .map(ActivityEntryDto::from) - .collect(), - generated_config_path: storage - .paths() - .generated_dir - .join("proxifyre-app-config.json") - .display() - .to_string(), - }) -} - -pub fn read_profiles(storage: &JsonStorage) -> Result, CommandError> { - storage - .read_profiles() - .map_err(storage_error) - .map(|profiles| profiles.iter().map(ProfileDto::from).collect()) -} - -pub fn save_profile_to_storage( - storage: &JsonStorage, - input: ProfileInputDto, -) -> Result { - let profile = normalize_profile(input.into()).map_err(validation_error)?; - let mut profiles = storage.read_profiles().map_err(storage_error)?; - - match profiles - .iter() - .position(|existing| existing.id == profile.id) - { - Some(index) => profiles[index] = profile.clone(), - None => profiles.push(profile.clone()), - } - - storage.write_profiles(&profiles).map_err(storage_error)?; - Ok(ProfileDto::from(&profile)) -} - -pub fn read_targets(storage: &JsonStorage) -> Result, CommandError> { - storage - .read_targets() - .map_err(storage_error) - .map(|targets| targets.iter().map(TargetDto::from).collect()) -} - -pub fn save_target_to_storage( - storage: &JsonStorage, - input: TargetInputDto, -) -> Result { - let target = normalize_target(input.into()).map_err(validation_error)?; - let mut targets = storage.read_targets().map_err(storage_error)?; - - match targets.iter().position(|existing| existing.id == target.id) { - Some(index) => targets[index] = target.clone(), - None => targets.push(target.clone()), - } - - storage.write_targets(&targets).map_err(storage_error)?; - Ok(TargetDto::from(&target)) -} - -pub fn read_components(storage: &JsonStorage) -> Result, CommandError> { - components_or_defaults(storage).map(|components| { - components - .iter() - .map(ComponentStatusDto::from) - .collect::>() - }) -} - -pub fn read_startup_snapshot( - storage: &JsonStorage, -) -> Result { - let detected_proxyfier = detect_proxyfier_install(); - let detected_singbox = detect_singbox_install(); - let saved_state = read_saved_state_with_proxifyre_config( - storage, - detected_proxyfier - .as_ref() - .and_then(|detected| detected.config_path.as_deref()), - )?; - let stored_components = storage.read_components().map_err(storage_error)?; - let components = resolve_component_statuses( - stored_components, - detected_proxyfier.clone(), - detected_singbox.clone(), - ) - .iter() - .map(ComponentStatusDto::from) - .collect(); - let proxifyre_setup_status = build_proxifyre_setup_status_with_detection( - detected_proxyfier.as_ref(), - &default_proxifyre_install_dir(), - ); - let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?; - let singbox_setup_status = build_singbox_setup_status_with_install_root( - detected_singbox.as_ref(), - &default_singbox_install_dir(), - ); - - Ok(StartupSnapshotResponse { - admin_status: admin_status(), - saved_state, - components, - proxifyre_setup_status, - singbox_status, - singbox_setup_status, - }) -} - -pub fn read_activity(storage: &JsonStorage) -> Result, CommandError> { - storage - .read_activity() - .map_err(storage_error) - .map(|entries| entries.iter().map(ActivityEntryDto::from).collect()) -} - -pub fn read_saved_state(storage: &JsonStorage) -> Result { - let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path); - read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref()) -} - -pub fn read_saved_state_with_proxifyre_config( - storage: &JsonStorage, - proxifyre_config_path: Option<&Path>, -) -> Result { - let mut profiles = storage.read_profiles().map_err(storage_error)?; - let mut targets = storage.read_targets().map_err(storage_error)?; - - if should_bootstrap_profiles(&profiles) { - if let Some(imported) = - proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config) - { - profiles = imported.profiles; - upsert_targets(&mut targets, imported.targets); - storage.write_targets(&targets).map_err(storage_error)?; - storage.write_profiles(&profiles).map_err(storage_error)?; - } - } - - Ok(SavedStateResponse { - profiles: profiles.iter().map(ProfileDto::from).collect(), - targets: targets.iter().map(TargetDto::from).collect(), - generated_config_path: storage - .paths() - .generated_dir - .join("proxifyre-app-config.json") - .display() - .to_string(), - }) -} - -struct ImportedSavedState { - profiles: Vec, - targets: Vec, -} - -fn should_bootstrap_profiles(profiles: &[Profile]) -> bool { - !profiles - .iter() - .any(|profile| profile.enabled && !profile.items.is_empty()) -} - -fn import_saved_state_from_proxifyre_config(path: &Path) -> Option { - let contents = fs::read_to_string(path).ok()?; - let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?; - - let proxy_entries = config - .proxies - .iter() - .filter_map(import_proxy_entry) - .collect::>(); - if proxy_entries.is_empty() { - return None; - } - - let single_entry = proxy_entries.len() == 1; - let mut profiles = Vec::with_capacity(proxy_entries.len()); - let mut targets = Vec::with_capacity(proxy_entries.len()); - - for (index, entry) in proxy_entries.into_iter().enumerate() { - let ordinal = index + 1; - let target_id = if single_entry { - MAIN_TARGET_ID.to_string() - } else { - format!("proxifyre-import-target-{ordinal}") - }; - let profile_id = if single_entry { - MAIN_PROFILE_ID.to_string() - } else { - format!("proxifyre-import-profile-{ordinal}") - }; - let profile_name = if single_entry { - "Приложения через прокси".to_string() - } else { - format!("Импорт ProxiFyre {ordinal}") - }; - - targets.push(Target { - id: target_id.clone(), - name: if single_entry { - "Основной прокси".to_string() - } else { - format!("Прокси ProxiFyre {ordinal}") - }, - kind: TargetKind::External, - protocol: ProxyProtocol::Socks5, - host: entry.host, - port: entry.port, - requires_component: None, - }); - profiles.push(Profile { - id: profile_id, - name: profile_name, - enabled: true, - target_id, - protocols: entry.protocols, - items: entry.items, - }); - } - - Some(ImportedSavedState { profiles, targets }) -} - -struct ImportedProxyEntry { - items: Vec, - protocols: Vec, - host: String, - port: u16, -} - -fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option { - let items = proxy - .app_names - .iter() - .filter_map(|name| imported_profile_item(name)) - .collect::>(); - if items.is_empty() { - return None; - } - - let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?; - - Some(ImportedProxyEntry { - items, - protocols: imported_protocols(&proxy.supported_protocols), - host, - port, - }) -} - -fn imported_profile_item(raw_value: &str) -> Option { - let value = raw_value.trim().trim_matches('"'); - if value.is_empty() { - return None; - } - - let looks_like_path = value.contains('\\') || value.contains('/'); - let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") { - ProfileItemType::Exe - } else if looks_like_path { - ProfileItemType::Folder - } else { - ProfileItemType::Process - }; - let value = match item_type { - ProfileItemType::Process => { - let base = value.rsplit(['\\', '/']).next().unwrap_or(value); - if base.to_ascii_lowercase().ends_with(".exe") { - base[..base.len() - 4].to_string() - } else { - base.to_string() - } - } - ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(), - }; - - if value.is_empty() { - return None; - } - - Some(ProfileItem { - recursive: matches!(item_type, ProfileItemType::Folder), - item_type, - value, - }) -} - -fn imported_protocols(values: &[String]) -> Vec { - let mut protocols = Vec::new(); - for value in values { - let protocol = match value.trim().to_ascii_uppercase().as_str() { - "TCP" => Protocol::Tcp, - "UDP" => Protocol::Udp, - _ => continue, - }; - if !protocols.contains(&protocol) { - protocols.push(protocol); - } - } - - if protocols.is_empty() { - vec![Protocol::Tcp, Protocol::Udp] - } else { - protocols - } -} - -fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> { - let endpoint = endpoint.trim(); - let endpoint = if endpoint - .get(.."socks5://".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://")) - { - &endpoint["socks5://".len()..] - } else { - endpoint - }; - if endpoint.is_empty() { - return None; - } - - if let Some(rest) = endpoint.strip_prefix('[') { - let (host, rest) = rest.split_once(']')?; - let port = rest.strip_prefix(':')?.parse::().ok()?; - let host = host.trim(); - return (!host.is_empty()).then(|| (host.to_string(), port)); - } - - let (host, port) = endpoint.rsplit_once(':')?; - let host = host.trim(); - let port = port.trim().parse::().ok()?; - (!host.is_empty()).then(|| (host.to_string(), port)) -} - -fn upsert_targets(targets: &mut Vec, imported_targets: Vec) { - for target in imported_targets { - match targets.iter().position(|existing| existing.id == target.id) { - Some(index) => targets[index] = target, - None => targets.push(target), - } - } -} - -pub fn resolve_preview( - input: ProfileInputDto, -) -> Result { - let profile = normalize_profile(input.into()).map_err(validation_error)?; - let mut warnings = Vec::new(); - let apps = profile - .items - .iter() - .map(|item| resolved_app(item, &mut warnings)) - .collect(); - - Ok(ResolveProfilePreviewResponse { - profile_id: profile.id, - apps, - warnings, - }) -} - -pub fn apply_profiles_with_services( - storage: &JsonStorage, - adapter: &impl ProxyRouterAdapter, - helper: &impl ProxyApplyHelper, - clock: &impl Clock, -) -> Result { - apply_profiles_with_services_and_detection( - storage, - adapter, - helper, - clock, - detect_proxyfier_install(), - detect_singbox_install(), - ) -} - -pub fn apply_profiles_with_services_and_detection( - storage: &JsonStorage, - adapter: &impl ProxyRouterAdapter, - helper: &impl ProxyApplyHelper, - clock: &impl Clock, - detected_proxyfier: Option, - detected_singbox: Option, -) -> Result { - let profiles = storage.read_profiles().map_err(storage_error)?; - let targets = storage.read_targets().map_err(storage_error)?; - let components = - components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?; - let generated = - match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) { - Ok(generated) => generated, - Err(error) => { - let command_error = adapter_error(error); - let activity = activity_for_apply_error(clock, &command_error); - storage.append_activity(activity).map_err(storage_error)?; - return Err(command_error); - } - }; - - let generated_path = storage - .paths() - .generated_dir - .join(generated.output_file_name.as_str()); - write_generated_config(&generated_path, &generated.contents)?; - - let helper_result = helper.apply_proxy_config(HelperApplyRequest { - adapter_id: generated.adapter_id.as_str(), - config_path: &generated_path, - config_contents: generated.contents.as_str(), - })?; - - let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result); - storage - .append_activity(activity.clone()) - .map_err(storage_error)?; - - Ok(ApplyProfilesResponse { - success: helper_result.success, - changed: helper_result.changed, - message: helper_result.message.clone(), - adapter_id: generated.adapter_id, - generated_config_path: generated_path.display().to_string(), - enabled_profiles: generated.enabled_profiles, - routed_apps: generated.routed_apps, - helper: helper_result, - activity: ActivityEntryDto::from(&activity), - }) -} - -pub fn read_singbox_status( - storage: &JsonStorage, -) -> Result { - let detected = detect_singbox_install(); - read_singbox_status_with_detection(storage, detected.as_ref()) -} - -fn read_singbox_status_with_detection( - storage: &JsonStorage, - detected: Option<&DetectedSingBox>, -) -> Result { - let config = storage.read_local_singbox_config().map_err(storage_error)?; - let cache = storage - .read_singbox_subscription_cache() - .map_err(storage_error)?; - let component = singbox_component_from_detection(detected); - - Ok(LocalSingBoxStatusResponse { - config: LocalSingBoxConfigDto::from(&config), - cache: cache.as_ref().map(SubscriptionCacheDto::from), - component: ComponentStatusDto::from(&component), - generated_config_path: storage - .paths() - .generated_dir - .join("sing-box-config.json") - .display() - .to_string(), - lan_listen_host: local_lan_ipv4(), - #[cfg(debug_assertions)] - subscription_identity: subscription_request_identity_for_display(), - }) -} - -pub fn save_singbox_subscription_to_storage( - storage: &JsonStorage, - input: SaveSingBoxSubscriptionInputDto, - clock: &impl Clock, -) -> Result { - let subscription_url = input.subscription_url.trim().to_string(); - validate_subscription_url(&subscription_url)?; - - let mut config = storage.read_local_singbox_config().map_err(storage_error)?; - config.subscription_url = Some(subscription_url); - ensure_device_hwid(&mut config); - config.updated_at = Some(clock.now()); - storage - .write_local_singbox_config(&config) - .map_err(storage_error)?; - - read_singbox_status(storage) -} - -pub fn fetch_singbox_subscription_with_fetcher( - storage: &JsonStorage, - fetcher: &impl SubscriptionFetcher, - clock: &impl Clock, -) -> Result { - let mut config = storage.read_local_singbox_config().map_err(storage_error)?; - let subscription_url = config - .subscription_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .ok_or_else(|| { - CommandError::new( - "singbox_subscription_missing", - "Ссылка на подписку Local sing-box не сохранена.", - ) - })?; - - let device_hwid_created = ensure_device_hwid(&mut config); - if device_hwid_created { - config.updated_at = Some(clock.now()); - storage - .write_local_singbox_config(&config) - .map_err(storage_error)?; - } - - let identity = - subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref()); - let cache = fetcher - .fetch_subscription(&subscription_url, &identity) - .map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?; - let selected_tag = config - .selected_server_tag - .as_deref() - .filter(|tag| cache.servers.iter().any(|server| server.tag == *tag)) - .map(str::to_string) - .or_else(|| cache.servers.first().map(|server| server.tag.clone())); - - config.selected_server_tag = selected_tag; - config.updated_at = Some(clock.now()); - storage - .write_singbox_subscription_cache(&cache) - .map_err(storage_error)?; - storage - .write_local_singbox_config(&config) - .map_err(storage_error)?; - storage - .append_activity(ActivityEntry { - id: "singbox-subscription-fetched".to_string(), - at: clock.now(), - level: ActivityLevel::Success, - title: "Подписка Local sing-box обновлена".to_string(), - message: format!("Серверов найдено: {}", cache.servers.len()), - }) - .map_err(storage_error)?; - - read_singbox_status(storage) -} - -pub fn forget_singbox_subscription_in_storage( - storage: &JsonStorage, - clock: &impl Clock, -) -> Result { - let mut config = storage.read_local_singbox_config().map_err(storage_error)?; - config.subscription_url = None; - config.selected_server_tag = None; - config.updated_at = Some(clock.now()); - storage - .write_local_singbox_config(&config) - .map_err(storage_error)?; - storage - .remove_singbox_subscription_cache() - .map_err(storage_error)?; - - read_singbox_status(storage) -} - -pub fn select_singbox_server_in_storage( - storage: &JsonStorage, - input: SelectSingBoxServerInputDto, - clock: &impl Clock, -) -> Result { - let requested_tag = input.tag.trim().to_string(); - if requested_tag.is_empty() { - return Err(CommandError::new( - "singbox_server_tag_missing", - "Сервер Local sing-box не выбран.", - )); - } - - let cache = storage - .read_singbox_subscription_cache() - .map_err(storage_error)? - .ok_or_else(|| { - CommandError::new( - "singbox_subscription_cache_missing", - "Сначала нужно загрузить подписку Local sing-box.", - ) - })?; - let Some(server) = find_subscription_server( - &cache, - &requested_tag, - input.server.as_deref(), - input.server_port, - ) else { - return Err(CommandError::new( - "singbox_server_not_found", - format!("Сервер Local sing-box '{requested_tag}' не найден в текущей подписке."), - )); - }; - let selected_tag = server.tag.clone(); - - let mut config = storage.read_local_singbox_config().map_err(storage_error)?; - config.selected_server_tag = Some(selected_tag); - config.updated_at = Some(clock.now()); - storage - .write_local_singbox_config(&config) - .map_err(storage_error)?; - - read_singbox_status(storage) -} - -pub fn ping_singbox_server_in_storage( - storage: &JsonStorage, - input: PingSingBoxServerInputDto, -) -> Result { - let tag = input.tag.trim(); - let cache = read_required_singbox_cache(storage)?; - let server = find_subscription_server(&cache, tag, None, None).ok_or_else(|| { - CommandError::new( - "singbox_server_not_found", - format!("Сервер Local sing-box '{tag}' не найден в текущей подписке."), - ) - })?; - - Ok(ping_subscription_server(server)) -} - -pub fn ping_all_singbox_servers_in_storage( - storage: &JsonStorage, -) -> Result, CommandError> { - let cache = read_required_singbox_cache(storage)?; - Ok(cache.servers.iter().map(ping_subscription_server).collect()) -} - -pub fn ping_proxy_target_endpoint( - input: PingProxyTargetInputDto, -) -> Result { - ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES) -} - -pub fn ping_proxy_target_endpoint_with_probes( - input: PingProxyTargetInputDto, - probes: &[ProxyProbeEndpoint], -) -> Result { - let host = input.host.trim(); - if host.is_empty() { - return Err(CommandError::new( - "proxy_target_host_missing", - "Хост внешнего прокси не указан.", - )); - } - - let tcp = ping_endpoint("route-proxy", host, input.port); - if !tcp.ok { - return Ok(ProxyTargetCheckResponse { - tag: "route-proxy".to_string(), - server: host.to_string(), - server_port: input.port, - ok: false, - latency: tcp.latency, - error: tcp.error, - probes: Vec::new(), - }); - } - - let probe_results = run_proxy_probes(host, input.port, probes); - let has_probe_success = probe_results.iter().any(|probe| probe.ok); - let ok = probe_results.is_empty() || has_probe_success; - let error = if ok { - None - } else { - Some( - "SOCKS5 порт доступен, но тестовые HTTP endpoints не ответили через прокси." - .to_string(), - ) - }; - - Ok(ProxyTargetCheckResponse { - tag: "route-proxy".to_string(), - server: host.to_string(), - server_port: input.port, - ok, - latency: tcp.latency, - error, - probes: probe_results, - }) -} - -pub fn generate_singbox_config_with_services( - storage: &JsonStorage, - adapter: &SingBoxAdapter, - checker: &C, - clock: &impl Clock, - binary_path: Option<&Path>, -) -> Result -where - C: SingBoxConfigChecker, -{ - let config = storage.read_local_singbox_config().map_err(storage_error)?; - let cache = read_required_singbox_cache(storage)?; - let generated = adapter - .generate_config( - SingBoxGenerationRequest::new(&config, &cache, binary_path), - checker, - ) - .map_err(singbox_adapter_error)?; - let generated_path = storage - .paths() - .generated_dir - .join(generated.output_file_name.as_str()); - - write_generated_config(&generated_path, &generated.contents)?; - ensure_local_singbox_target(storage, &config)?; - - let activity = activity_for_singbox_generate(clock, &generated, &generated_path); - storage - .append_activity(activity.clone()) - .map_err(storage_error)?; - - Ok(GenerateSingBoxConfigResponse { - success: true, - message: "Конфиг Local sing-box создан".to_string(), - adapter_id: generated.adapter_id, - generated_config_path: generated_path.display().to_string(), - selected_server_tag: generated.selected_server_tag, - listen_host: generated.listen, - listen_port: generated.listen_port, - check: generated.check, - activity: ActivityEntryDto::from(&activity), - }) -} - -fn read_required_singbox_cache(storage: &JsonStorage) -> Result { - storage - .read_singbox_subscription_cache() - .map_err(storage_error)? - .ok_or_else(|| { - CommandError::new( - "singbox_subscription_cache_missing", - "Сначала нужно загрузить подписку Local sing-box.", - ) - }) -} - -fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> { - if subscription_url.is_empty() { - return Err(CommandError::new( - "singbox_subscription_url_missing", - "Ссылка на подписку Local sing-box не указана.", - )); - } - - let parsed = url::Url::parse(subscription_url).map_err(|_| { - CommandError::new( - "singbox_subscription_url_invalid", - "Ссылка на подписку Local sing-box должна быть корректным URL.", - ) - })?; - if !matches!(parsed.scheme(), "http" | "https") { - return Err(CommandError::new( - "singbox_subscription_url_invalid", - "Ссылка на подписку Local sing-box должна начинаться с http:// или https://.", - )); - } - - Ok(()) -} - -fn ensure_device_hwid(config: &mut LocalSingBoxConfig) -> bool { - if config - .device_hwid - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - { - return false; - } - - config.device_hwid = Some(uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase()); - true -} - -fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse { - ping_endpoint(&server.tag, &server.server, server.server_port) -} - -fn ping_endpoint(tag: &str, server: &str, server_port: u16) -> PingServerResponse { - let started = Instant::now(); - let addresses = match (server, server_port).to_socket_addrs() { - Ok(addresses) => addresses.collect::>(), - Err(error) => { - return PingServerResponse { - tag: tag.to_string(), - server: server.to_string(), - server_port, - ok: false, - latency: None, - error: Some(format!("DNS/адрес недоступен: {error}")), - }; - } - }; - - if addresses.is_empty() { - return PingServerResponse { - tag: tag.to_string(), - server: server.to_string(), - server_port, - ok: false, - latency: None, - error: Some("DNS не вернул адреса".to_string()), - }; - } - - let timeout = Duration::from_secs(2); - let mut last_error = None; - for address in addresses { - match TcpStream::connect_timeout(&address, timeout) { - Ok(_) => { - return PingServerResponse { - tag: tag.to_string(), - server: server.to_string(), - server_port, - ok: true, - latency: Some(started.elapsed().as_millis()), - error: None, - }; - } - Err(error) => last_error = Some(error.to_string()), - } - } - - PingServerResponse { - tag: tag.to_string(), - server: server.to_string(), - server_port, - ok: false, - latency: None, - error: last_error, - } -} - -fn run_proxy_probes( - proxy_host: &str, - proxy_port: u16, - probes: &[ProxyProbeEndpoint], -) -> Vec { - if probes.is_empty() { - return Vec::new(); - } - - let proxy_url = socks5h_proxy_url(proxy_host, proxy_port); - let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| { - reqwest::blocking::Client::builder() - .timeout(PROXY_CHECK_TIMEOUT) - .connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT) - .proxy(proxy) - .build() - }) { - Ok(client) => client, - Err(error) => { - return probes - .iter() - .map(|probe| { - failed_probe( - *probe, - format!("Не удалось подготовить SOCKS5 проверку: {error}"), - ) - }) - .collect(); - } - }; - - let handles = probes - .iter() - .copied() - .map(|probe| { - let client = client.clone(); - std::thread::spawn(move || run_proxy_probe(&client, probe)) - }) - .collect::>(); - - handles - .into_iter() - .zip(probes.iter().copied()) - .map(|(handle, probe)| { - handle - .join() - .unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string())) - }) - .collect() -} - -fn run_proxy_probe( - client: &reqwest::blocking::Client, - probe: ProxyProbeEndpoint, -) -> ProxyProbeResponse { - let started = Instant::now(); - let response = match client - .get(probe.url) - .header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT) - .send() - { - Ok(response) => response, - Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")), - }; - - let status = response.status(); - let status_code = status.as_u16(); - let body = match response.text() { - Ok(body) => body, - Err(error) => { - return failed_probe_with_status( - probe, - status_code, - format!("Ответ не прочитан: {error}"), - ) - } - }; - let latency = started.elapsed().as_millis(); - - if !status.is_success() { - return ProxyProbeResponse { - id: probe.id.to_string(), - name: probe.name.to_string(), - url: probe.url.to_string(), - ok: false, - status: Some(status_code), - latency: Some(latency), - ip: None, - error: Some(format!("HTTP {status_code}")), - }; - } - - let ip = extract_probe_ip(probe, &body); - - ProxyProbeResponse { - id: probe.id.to_string(), - name: probe.name.to_string(), - url: probe.url.to_string(), - ok: true, - status: Some(status_code), - latency: Some(latency), - ip, - error: None, - } -} - -fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse { - failed_probe_with_status(probe, 0, error) -} - -fn failed_probe_with_status( - probe: ProxyProbeEndpoint, - status: u16, - error: String, -) -> ProxyProbeResponse { - ProxyProbeResponse { - id: probe.id.to_string(), - name: probe.name.to_string(), - url: probe.url.to_string(), - ok: false, - status: (status > 0).then_some(status), - latency: None, - ip: None, - error: Some(error), - } -} - -fn socks5h_proxy_url(host: &str, port: u16) -> String { - let host = host.trim().trim_start_matches('[').trim_end_matches(']'); - if host.contains(':') { - format!("socks5h://[{host}]:{port}") - } else { - format!("socks5h://{host}:{port}") - } -} - -fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option { - match probe.ip_source { - ProbeIpSource::CloudflareTrace => body - .lines() - .find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)), - ProbeIpSource::JsonField(field) => serde_json::from_str::(body) - .ok() - .and_then(|value| { - value - .get(field) - .and_then(|field| field.as_str()) - .and_then(normalize_ip) - }), - } -} - -fn normalize_ip(value: &str) -> Option { - let candidate = value.trim().trim_matches('"'); - if candidate.parse::().is_ok() { - Some(candidate.to_string()) - } else { - None - } -} - -fn local_lan_ipv4() -> Option { - let socket = UdpSocket::bind("0.0.0.0:0").ok()?; - socket.connect("8.8.8.8:80").ok()?; - let IpAddr::V4(address) = socket.local_addr().ok()?.ip() else { - return None; - }; - if address.is_loopback() || address.is_link_local() || address.is_unspecified() { - return None; - } - Some(address.to_string()) -} - -fn find_subscription_server<'a>( - cache: &'a SubscriptionCache, - requested_tag: &str, - requested_server: Option<&str>, - requested_port: Option, -) -> Option<&'a SubscriptionServer> { - cache - .servers - .iter() - .find(|server| server.tag == requested_tag) - .or_else(|| { - let requested = comparable_server_tag(requested_tag); - cache - .servers - .iter() - .find(|server| comparable_server_tag(&server.tag) == requested) - }) - .or_else(|| { - let server_name = requested_server?.trim(); - let server_port = requested_port?; - cache.servers.iter().find(|server| { - server.server.eq_ignore_ascii_case(server_name) && server.server_port == server_port - }) - }) -} - -fn comparable_server_tag(value: &str) -> String { - value - .chars() - .filter(|ch| !matches!(ch, '\u{fe0e}' | '\u{fe0f}' | '\u{200d}')) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") -} - -fn ensure_local_singbox_target( - storage: &JsonStorage, - config: &LocalSingBoxConfig, -) -> Result<(), CommandError> { - let mut targets = storage.read_targets().map_err(storage_error)?; - let target = Target { - id: "local-singbox".to_string(), - name: "Локальный sing-box".to_string(), - kind: TargetKind::Local, - protocol: ProxyProtocol::Socks5, - host: config.listen_host.clone(), - port: config.listen_port, - requires_component: Some(ComponentId::Singbox), - }; - - match targets.iter().position(|existing| existing.id == target.id) { - Some(index) => targets[index] = target, - None => targets.push(target), - } - - storage.write_targets(&targets).map_err(storage_error) -} - -fn activity_for_singbox_generate( - clock: &impl Clock, - generated: &SingBoxGeneratedConfig, - generated_path: &Path, -) -> ActivityEntry { - ActivityEntry { - id: "singbox-config-generated".to_string(), - at: clock.now(), - level: ActivityLevel::Success, - title: "Конфиг Local sing-box создан".to_string(), - message: format!( - "Сервер: {}, listen: {}:{}, конфиг: {}", - generated.selected_server_tag, - generated.listen, - generated.listen_port, - generated_path.display() - ), - } -} - -fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError { - let code = match error.kind { - SingBoxConfigErrorKind::MissingSelectedServer => "singbox_server_not_selected", - SingBoxConfigErrorKind::MissingSelectedOutbound => "singbox_selected_server_missing", - SingBoxConfigErrorKind::UnsupportedSelectedOutbound => { - "singbox_selected_server_unsupported" - } - SingBoxConfigErrorKind::Serialization => "serialization_error", - SingBoxConfigErrorKind::CheckFailed => "singbox_check_failed", - }; - - CommandError::new(code, error.message) -} - -fn control_singbox_service( - action: SingBoxServiceAction, - config_source: Option<&Path>, -) -> Result { - let Some(detected) = detect_singbox_install() else { - return Err(CommandError::new( - "singbox_not_found", - "Local sing-box не найден на компьютере.", - )); - }; - - let config_target = config_source.map(|_| detected.install_dir.join("config.json")); - let script = service_control_script( - action, - &detected.service_name, - config_source, - config_target.as_deref(), - ); - let output = command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script.as_str(), - ]) - .output() - .map_err(|error| { - CommandError::new( - singbox_service_error_code(action), - format!( - "Не удалось {} службу Local sing-box: {error}", - action.label() - ), - ) - })?; - let result = parse_singbox_service_command_output(&output.stdout).ok_or_else(|| { - CommandError::new( - singbox_service_error_code(action), - singbox_service_script_failed_message(action, output.status.code()), - ) - })?; - - if result.success { - let refreshed = detect_singbox_install(); - let component = singbox_component_from_detection(refreshed.as_ref()); - return Ok(ComponentStatusDto::from(&component)); - } - - if matches!( - result.code.as_str(), - "start_failed" | "stop_failed" | "config_sync_failed" - ) { - run_elevated_singbox_service_command( - action, - &detected.service_name, - config_source, - config_target.as_deref(), - &result, - )?; - let refreshed = detect_singbox_install(); - let component = singbox_component_from_detection(refreshed.as_ref()); - return Ok(ComponentStatusDto::from(&component)); - } - - Err(CommandError::new( - singbox_service_error_code(action), - singbox_service_command_failed_message(action, &result), - )) -} - -fn run_elevated_singbox_service_command( - action: SingBoxServiceAction, - service_name: &str, - config_source: Option<&Path>, - config_target: Option<&Path>, - direct_result: &SingBoxServiceCommandOutput, -) -> Result<(), CommandError> { - let script_path = - write_elevated_singbox_service_script(action, service_name, config_source, config_target)?; - let launch_script = format!( - "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", - escape_powershell_single(&script_path.display().to_string()) - ); - let output = if is_running_elevated() { - run_powershell_file(&script_path) - } else { - run_powershell_command(&launch_script) - }; - - let _ = fs::remove_file(&script_path); - - match output { - Ok(output) if output.status.success() => Ok(()), - Ok(output) => Err(CommandError::new( - singbox_service_error_code(action), - elevated_singbox_service_failed_message(action, direct_result, output.status.code()), - )), - Err(error) => Err(CommandError::new( - singbox_service_error_code(action), - format!( - "Не удалось запросить права администратора, чтобы {} службу Local sing-box: {error}", - action.label() - ), - )), - } -} - -fn write_elevated_singbox_service_script( - action: SingBoxServiceAction, - service_name: &str, - config_source: Option<&Path>, - config_target: Option<&Path>, -) -> Result { - let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service"); - let script = - elevated_singbox_service_script(action, service_name, config_source, config_target); - - write_powershell_script(&script_path, &script).map_err(|error| { - CommandError::new( - singbox_service_error_code(action), - format!( - "Не удалось подготовить временный скрипт для управления Local sing-box '{}': {error}", - script_path.display() - ), - ) - })?; - - Ok(script_path) -} - -fn elevated_singbox_service_script( - action: SingBoxServiceAction, - service_name: &str, - config_source: Option<&Path>, - config_target: Option<&Path>, -) -> String { - let action_name = action.action_name(); - let escaped_service_name = escape_powershell_single(service_name); - let escaped_config_source = config_source - .map(|path| escape_powershell_single(&path.display().to_string())) - .unwrap_or_default(); - let escaped_config_target = config_target - .map(|path| escape_powershell_single(&path.display().to_string())) - .unwrap_or_default(); - - format!( - r#" -$ErrorActionPreference = 'SilentlyContinue' -$serviceName = '{escaped_service_name}' -$action = '{action_name}' -$configSource = '{escaped_config_source}' -$configTarget = '{escaped_config_target}' - -if ($action -eq 'start') {{ - if (-not [string]::IsNullOrWhiteSpace($configSource)) {{ - if (-not (Test-Path -LiteralPath $configSource)) {{ exit 5 }} - if (-not [string]::IsNullOrWhiteSpace($configTarget)) {{ - try {{ - Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop - }} catch {{ - exit 6 - }} - }} - }} - - $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue - if ($null -eq $service) {{ exit 2 }} - if ($service.Status -eq 'Running') {{ exit 0 }} - - Start-Service -Name $serviceName -ErrorAction SilentlyContinue - $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue - if ($null -ne $service) {{ - try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}} - if ($service.Status -eq 'Running') {{ exit 0 }} - }} - - exit 3 -}} - -$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue -if ($null -eq $service) {{ exit 2 }} -if ($service.Status -eq 'Stopped') {{ exit 0 }} - -Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue -$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue -if ($null -ne $service) {{ - try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) }} catch {{}} - if ($service.Status -eq 'Stopped') {{ exit 0 }} -}} - -exit 4 -"# - ) -} - -fn install_singbox_component( - storage: &JsonStorage, - install_dir: &Path, -) -> Result { - let generated_config_path = storage.paths().generated_dir.join("sing-box-config.json"); - run_elevated_singbox_package_script( - SingBoxPackageAction::Install, - include_str!("../../scripts/install-singbox.ps1"), - vec![ - "-InstallRoot".to_string(), - install_dir.display().to_string(), - "-ConfigSource".to_string(), - generated_config_path.display().to_string(), - ], - &storage.paths().state_dir, - )?; - - let refreshed = detect_singbox_install(); - let Some(detected) = refreshed.as_ref() else { - return Err(CommandError::new( - SingBoxPackageAction::Install.error_code(), - "Установка Local sing-box завершилась, но приложение не найдено после проверки.", - )); - }; - - Ok(ComponentStatusDto::from(&singbox_component_from_detection( - Some(detected), - ))) -} - -fn uninstall_singbox_component() -> Result { - let Some(detected) = detect_singbox_install() else { - let component = singbox_component_from_detection(None); - return Ok(ComponentStatusDto::from(&component)); - }; - - ensure_safe_singbox_install_dir(&detected.install_dir).map_err(|message| { - CommandError::new(SingBoxPackageAction::Uninstall.error_code(), message) - })?; - let artifact_dir = default_config_root().join("state"); - run_elevated_singbox_package_script( - SingBoxPackageAction::Uninstall, - include_str!("../../scripts/install-singbox.ps1"), - vec![ - "-InstallRoot".to_string(), - detected.install_dir.display().to_string(), - "-ServiceName".to_string(), - detected.service_name, - "-Uninstall".to_string(), - ], - &artifact_dir, - )?; - - let refreshed = detect_singbox_install(); - if refreshed.is_some() { - return Err(CommandError::new( - SingBoxPackageAction::Uninstall.error_code(), - "Удаление Local sing-box завершилось, но приложение все еще найдено на компьютере.", - )); - } - - let component = singbox_component_from_detection(None); - Ok(ComponentStatusDto::from(&component)) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SingBoxPackageAction { - Install, - Uninstall, -} - -impl SingBoxPackageAction { - fn error_code(self) -> &'static str { - match self { - SingBoxPackageAction::Install => "singbox_install_failed", - SingBoxPackageAction::Uninstall => "singbox_uninstall_failed", - } - } - - fn label(self) -> &'static str { - match self { - SingBoxPackageAction::Install => "установить", - SingBoxPackageAction::Uninstall => "удалить", - } - } - - fn file_label(self) -> &'static str { - match self { - SingBoxPackageAction::Install => "install", - SingBoxPackageAction::Uninstall => "uninstall", - } - } -} - -fn run_elevated_singbox_package_script( - action: SingBoxPackageAction, - installer_body: &str, - installer_args: Vec, - artifact_dir: &Path, -) -> Result<(), CommandError> { - fs::create_dir_all(artifact_dir).map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось создать папку для временных файлов Local sing-box '{}': {error}", - artifact_dir.display() - ), - ) - })?; - - 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( - action.error_code(), - format!( - "Не удалось подготовить установщик Local sing-box '{}': {error}", - installer_path.display() - ), - ) - })?; - write_powershell_script( - &runner_path, - &singbox_installer_runner_script(&installer_path, &result_path, &installer_args), - ) - .map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось подготовить runner Local sing-box '{}': {error}", - runner_path.display() - ), - ) - })?; - - let launch_script = format!( - r#" -$ErrorActionPreference = 'Stop' -$resultPath = '{}' -try {{ - $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') - if ($null -eq $p) {{ - Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 - exit 1 - }} - exit $p.ExitCode -}} catch {{ - Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 - exit 1 -}} -"#, - escape_powershell_single(&result_path.display().to_string()), - escape_powershell_single(&runner_path.display().to_string()) - ); - let output = if is_running_elevated() { - run_powershell_file(&runner_path) - } else { - run_powershell_command(&launch_script) - }; - - let _ = fs::remove_file(&installer_path); - let _ = fs::remove_file(&runner_path); - - match output { - Ok(output) if output.status.success() => { - let _ = fs::remove_file(&result_path); - Ok(()) - } - Ok(output) => { - let details = package_failure_details(&result_path, &output); - let _ = fs::remove_file(&result_path); - Err(CommandError::new( - action.error_code(), - format!( - "Не удалось {} Local sing-box. Код elevated-команды: {}. {details}", - action.label(), - output.status.code().unwrap_or(-1), - ), - )) - } - Err(error) => Err(CommandError::new( - action.error_code(), - format!( - "Не удалось запросить права администратора, чтобы {} Local sing-box: {error}", - action.label() - ), - )), - } -} - -pub fn singbox_installer_runner_script( - installer_path: &Path, - result_path: &Path, - installer_args: &[String], -) -> String { - let args = installer_args - .iter() - .map(|arg| format!("'{}'", escape_powershell_single(arg))) - .collect::>() - .join(", "); - - format!( - r#" -$ErrorActionPreference = 'Stop' -$installerPath = '{}' -$resultPath = '{}' -$stdoutPath = "$resultPath.stdout.log" -$stderrPath = "$resultPath.stderr.log" -$installerArgs = @({args}) -try {{ - $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs 2>&1 - $exitCode = $LASTEXITCODE - Set-Content -LiteralPath $stdoutPath -Value ($output | Out-String) -Encoding UTF8 - if ($exitCode -ne 0) {{ - $stdout = if (Test-Path -LiteralPath $stdoutPath) {{ Get-Content -LiteralPath $stdoutPath -Raw }} else {{ '' }} - $stderr = if (Test-Path -LiteralPath $stderrPath) {{ Get-Content -LiteralPath $stderrPath -Raw }} else {{ '' }} - throw "install-singbox.ps1 завершился с кодом $exitCode. stdout: $stdout stderr: $stderr" - }} - Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 - exit 0 -}} catch {{ - Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 - exit 1 -}} finally {{ - Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue -}} -"#, - escape_powershell_single(&installer_path.display().to_string()), - escape_powershell_single(&result_path.display().to_string()) - ) -} - -fn singbox_service_error_code(action: SingBoxServiceAction) -> &'static str { - match action { - SingBoxServiceAction::Start => "singbox_service_start_failed", - SingBoxServiceAction::Stop => "singbox_service_stop_failed", - } -} - -fn singbox_service_script_failed_message( - action: SingBoxServiceAction, - exit_code: Option, -) -> String { - let exit_code = exit_code - .map(|code| format!(" Код выхода PowerShell: {code}.")) - .unwrap_or_default(); - - format!( - "Не удалось {} службу Local sing-box: команда управления службой не вернула корректный результат.{exit_code}", - action.label() - ) -} - -fn singbox_service_command_failed_message( - action: SingBoxServiceAction, - result: &SingBoxServiceCommandOutput, -) -> String { - let service_name = result - .service_name - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("ProxyWardenSingBox"); - let status = result - .status - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("неизвестен"); - let pid = result - .process_id - .filter(|value| *value > 0) - .map(|value| format!(", PID: {value}")) - .unwrap_or_default(); - - match result.code.as_str() { - "service_not_found" => "Служба Local sing-box не найдена.".to_string(), - "config_source_missing" => { - "Сгенерированный конфиг Local sing-box не найден перед запуском службы.".to_string() - } - "config_sync_failed" => { - "Не удалось обновить config.json службы Local sing-box перед запуском. Попробуй запустить приложение от имени администратора.".to_string() - } - "start_failed" => format!( - "Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора." - ), - "stop_failed" => format!( - "Не удалось остановить службу {service_name}. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc." - ), - _ => format!( - "Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.", - action.label() - ), - } -} - -fn elevated_singbox_service_failed_message( - action: SingBoxServiceAction, - direct_result: &SingBoxServiceCommandOutput, - exit_code: Option, -) -> String { - let exit_code = exit_code - .map(|code| format!(" Код выхода elevated PowerShell: {code}.")) - .unwrap_or_default(); - format!( - "{} Попытка с правами администратора тоже не сработала.{exit_code}", - singbox_service_command_failed_message(action, direct_result) - ) -} - -fn components_or_defaults(storage: &JsonStorage) -> Result, CommandError> { - components_or_defaults_with_detection( - storage, - detect_proxyfier_install(), - detect_singbox_install(), - ) -} - -fn components_or_defaults_with_detection( - storage: &JsonStorage, - detected_proxyfier: Option, - detected_singbox: Option, -) -> Result, CommandError> { - let components = storage.read_components().map_err(storage_error)?; - Ok(resolve_component_statuses( - components, - detected_proxyfier, - detected_singbox, - )) -} - -pub fn resolve_component_statuses( - stored_components: Vec, - detected_proxyfier: Option, - detected_singbox: Option, -) -> Vec { - let mut components = default_components(); - - for component in stored_components { - upsert_component(&mut components, component); - } - - if detected_proxyfier.is_some() { - upsert_component( - &mut components, - proxyfier_component_from_detection(detected_proxyfier.as_ref()), - ); - } - if detected_singbox.is_some() { - upsert_component( - &mut components, - singbox_component_from_detection(detected_singbox.as_ref()), - ); - } - - components -} - -fn default_components() -> Vec { - vec![ - ComponentStatus { - id: ComponentId::ControlApp, - name: "Приложение управления".to_string(), - state: ComponentState::Running, - installed: true, - running: true, - version: None, - path: None, - service_name: None, - service_status: None, - problems: Vec::new(), - actions: vec![ - "Открыть журнал".to_string(), - "Скопировать диагностику".to_string(), - ], - }, - ComponentStatus { - id: ComponentId::Proxyfier, - name: "ProxiFyre".to_string(), - state: ComponentState::Missing, - installed: false, - running: false, - version: None, - path: None, - service_name: Some("ProxiFyreService".to_string()), - service_status: None, - problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()], - actions: vec!["Установить ProxiFyre".to_string()], - }, - ComponentStatus { - id: ComponentId::Singbox, - name: "Локальный sing-box".to_string(), - state: ComponentState::Missing, - installed: false, - running: false, - version: None, - path: None, - service_name: Some(crate::models::DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()), - service_status: None, - problems: Vec::new(), - actions: vec!["Установить локальный sing-box".to_string()], - }, - ] -} - -fn upsert_component(components: &mut Vec, component: ComponentStatus) { - match components - .iter() - .position(|existing| existing.id == component.id) - { - Some(index) => components[index] = component, - None => components.push(component), - } -} - -fn route_line(active_target: Option<&Target>) -> String { - match active_target { - Some(target) if target.id == "local-singbox" => { - format!( - "Выбранные приложения -> ProxiFyre -> локальный sing-box {}:{} -> VPN", - target.host, target.port - ) - } - Some(target) => format!( - "Выбранные приложения -> ProxiFyre -> внешний прокси {}:{}", - target.host, target.port - ), - None => "Выбранные приложения -> ProxiFyre -> внешний прокси".to_string(), - } -} - -fn resolved_app(item: &ProfileItem, warnings: &mut Vec) -> ResolvedAppDto { - let mut notes = Vec::new(); - match item.item_type { - ProfileItemType::Process => notes.push("Имя процесса используется напрямую".to_string()), - ProfileItemType::Folder => { - let note = "Сканирование папок отложено; ProxiFyre получает путь к папке"; - notes.push(note.to_string()); - warnings.push(note.to_string()); - } - ProfileItemType::Exe => { - notes.push("Путь к EXE сохраняется для сопоставления в ProxiFyre".to_string()) - } - } - - ResolvedAppDto { - source_type: item.item_type.clone(), - source_value: item.value.clone(), - app_name: item.value.clone(), - notes, - } -} - -fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> { - safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error) -} - -fn open_file_or_select(path: &Path) -> Result<(), CommandError> { - let status = Command::new("notepad.exe") - .arg(path) - .spawn() - .map_err(|error| { - CommandError::new( - "open_config_failed", - format!("Не удалось открыть конфиг '{}': {error}", path.display()), - ) - })?; - - drop(status); - Ok(()) -} - -fn open_folder(path: &Path) -> Result { - fs::create_dir_all(path).map_err(storage_error)?; - Command::new("explorer.exe") - .arg(path) - .spawn() - .map_err(|error| { - CommandError::new( - "open_config_failed", - format!("Не удалось открыть папку '{}': {error}", path.display()), - ) - })?; - - Ok(path.display().to_string()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ServiceControlAction { - Start, - Stop, -} - -impl ServiceControlAction { - fn error_code(self) -> &'static str { - match self { - ServiceControlAction::Start => "proxifyre_service_start_failed", - ServiceControlAction::Stop => "proxifyre_service_stop_failed", - } - } - - fn label(self) -> &'static str { - match self { - ServiceControlAction::Start => "запустить", - ServiceControlAction::Stop => "остановить", - } - } -} - -fn control_proxifyre_service( - action: ServiceControlAction, -) -> Result { - let Some(detected) = detect_proxyfier_install() else { - return Err(CommandError::new( - "proxifyre_not_found", - "ProxiFyre не найден на компьютере.", - )); - }; - - run_proxifyre_service_command(action, &service_name_candidates(&detected))?; - let refreshed = detect_proxyfier_install(); - let component = proxyfier_component_from_detection(refreshed.as_ref()); - - Ok(ComponentStatusDto::from(&component)) -} - -fn service_name_candidates(detected: &DetectedProxyfier) -> Vec { - let mut names = Vec::new(); - - if let Some(service_name) = &detected.service_name { - names.push(service_name.clone()); - } - for service_name in ["ProxiFyreService", "ProxiFyre"] { - if !names - .iter() - .any(|existing| existing.eq_ignore_ascii_case(service_name)) - { - names.push(service_name.to_string()); - } - } - - names -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ServiceCommandOutput { - success: bool, - code: String, - service_name: Option, - status: Option, - process_id: Option, -} - -fn run_proxifyre_service_command( - action: ServiceControlAction, - service_names: &[String], -) -> Result<(), CommandError> { - let names = service_names - .iter() - .map(|name| format!("'{}'", escape_powershell_single(name))) - .collect::>() - .join(", "); - let action_name = match action { - ServiceControlAction::Start => "start", - ServiceControlAction::Stop => "stop", - }; - let script = format!( - r#" -$ErrorActionPreference = 'Stop' -$names = @({names}) -$action = '{action_name}' -$service = $null - -function Find-ProxiFyreService {{ - foreach ($name in $names) {{ - $candidate = Get-Service -Name $name -ErrorAction SilentlyContinue - if ($null -ne $candidate) {{ return $candidate }} - }} - - return Get-Service | - Where-Object {{ $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' }} | - Select-Object -First 1 -}} - -function Get-ServiceProcessId([string]$name) {{ - $escapedName = $name.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) {{ return 0 }} - return [int]$record.ProcessId -}} - -function Get-ServiceStatus([string]$name) {{ - $current = Get-Service -Name $name -ErrorAction SilentlyContinue - if ($null -eq $current) {{ return $null }} - return $current.Status.ToString() -}} - -function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{ - [PSCustomObject]@{{ - success = $success - code = $code - serviceName = if ($null -ne $service) {{ $service.Name }} else {{ $null }} - status = $status - processId = $processId - }} | ConvertTo-Json -Compress - exit 0 -}} - -$service = Find-ProxiFyreService -if ($null -eq $service) {{ - Write-ServiceResult $false 'service_not_found' $null 0 -}} - -$status = $service.Status.ToString() -$processId = Get-ServiceProcessId $service.Name - -if ($action -eq 'start') {{ - if ($status -eq 'Running') {{ - Write-ServiceResult $true 'already_running' $status $processId - }} - - try {{ - Start-Service -Name $service.Name -ErrorAction Stop - $service = Get-Service -Name $service.Name - $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) - }} catch {{ - Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $service.Name) (Get-ServiceProcessId $service.Name) - }} - - Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $service.Name) -}} - -if ($status -eq 'Stopped') {{ - Write-ServiceResult $true 'already_stopped' $status $processId -}} - -try {{ - if ($service.CanStop) {{ - Stop-Service -Name $service.Name -Force -ErrorAction Stop - }} -}} catch {{}} - -try {{ - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - $null = & sc.exe stop $service.Name 2>$null - }} -}} catch {{}} - -try {{ - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) - }} -}} catch {{}} - -$status = Get-ServiceStatus $service.Name -$processId = Get-ServiceProcessId $service.Name -if ($status -ne 'Stopped' -and $processId -gt 0) {{ - try {{ - $null = & taskkill.exe /PID $processId /F 2>$null - Start-Sleep -Milliseconds 700 - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service) {{ - $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) - }} - }} catch {{}} -}} - -$status = Get-ServiceStatus $service.Name -$processId = Get-ServiceProcessId $service.Name -if ($status -eq 'Stopped') {{ - Write-ServiceResult $true 'stopped' $status $processId -}} - -Write-ServiceResult $false 'stop_failed' $status $processId -"# - ); - - let output = command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script.as_str(), - ]) - .output() - .map_err(|error| { - CommandError::new( - action.error_code(), - format!("Не удалось {} службу ProxiFyre: {error}", action.label()), - ) - })?; - - let result = parse_service_command_output(&output.stdout).ok_or_else(|| { - CommandError::new( - action.error_code(), - service_script_failed_message(action, output.status.code()), - ) - })?; - - if result.success { - return Ok(()); - } - - if matches!(result.code.as_str(), "start_failed" | "stop_failed") { - run_elevated_proxifyre_service_command(action, service_names, &result)?; - return Ok(()); - } - - Err(CommandError::new( - action.error_code(), - service_command_failed_message(action, &result), - )) -} - -fn run_elevated_proxifyre_service_command( - action: ServiceControlAction, - service_names: &[String], - direct_result: &ServiceCommandOutput, -) -> Result<(), CommandError> { - let script_path = write_elevated_service_script(action, service_names)?; - let launch_script = format!( - "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", - escape_powershell_single(&script_path.display().to_string()) - ); - let output = if is_running_elevated() { - run_powershell_file(&script_path) - } else { - run_powershell_command(&launch_script) - }; - - let _ = fs::remove_file(&script_path); - - match output { - Ok(output) if output.status.success() => Ok(()), - Ok(output) => Err(CommandError::new( - action.error_code(), - elevated_service_failed_message(action, direct_result, output.status.code()), - )), - Err(error) => Err(CommandError::new( - action.error_code(), - format!( - "Не удалось запросить права администратора, чтобы {} службу ProxiFyre: {error}", - action.label() - ), - )), - } -} - -fn write_elevated_service_script( - action: ServiceControlAction, - service_names: &[String], -) -> Result { - 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| { - CommandError::new( - action.error_code(), - format!( - "Не удалось подготовить временный скрипт для управления ProxiFyre '{}': {error}", - script_path.display() - ), - ) - })?; - - Ok(script_path) -} - -fn elevated_service_script(action: ServiceControlAction, service_names: &[String]) -> String { - let names = service_names - .iter() - .map(|name| format!("'{}'", escape_powershell_single(name))) - .collect::>() - .join(", "); - let action_name = match action { - ServiceControlAction::Start => "start", - ServiceControlAction::Stop => "stop", - }; - - format!( - r#" -$ErrorActionPreference = 'SilentlyContinue' -$names = @({names}) -$action = '{action_name}' -$service = $null - -foreach ($name in $names) {{ - $service = Get-Service -Name $name -ErrorAction SilentlyContinue - if ($null -ne $service) {{ break }} -}} - -if ($null -eq $service) {{ - $service = Get-Service | - Where-Object {{ $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' }} | - Select-Object -First 1 -}} - -if ($null -eq $service) {{ exit 2 }} - -function Get-ServiceProcessId([string]$name) {{ - $escapedName = $name.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) {{ return 0 }} - return [int]$record.ProcessId -}} - -if ($action -eq 'start') {{ - if ($service.Status -eq 'Running') {{ exit 0 }} - Start-Service -Name $service.Name -ErrorAction SilentlyContinue - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service) {{ - try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}} - if ($service.Status -eq 'Running') {{ exit 0 }} - }} - exit 3 -}} - -if ($service.Status -eq 'Stopped') {{ exit 0 }} - -if ($service.CanStop) {{ - Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue -}} - -$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue -if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - $null = & sc.exe stop $service.Name 2>$null -}} - -$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue -if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}} -}} - -$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue -if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - $processId = Get-ServiceProcessId $service.Name - if ($processId -gt 0) {{ - $null = & taskkill.exe /PID $processId /F 2>$null - Start-Sleep -Milliseconds 700 - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service) {{ - try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}} - }} - }} -}} - -$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue -if ($null -eq $service -or $service.Status -eq 'Stopped') {{ exit 0 }} -exit 4 -"# - ) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProxiFyrePackageAction { - Install, - Uninstall, -} - -impl ProxiFyrePackageAction { - fn error_code(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "proxifyre_install_failed", - ProxiFyrePackageAction::Uninstall => "proxifyre_uninstall_failed", - } - } - - fn label(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "установить", - ProxiFyrePackageAction::Uninstall => "удалить", - } - } - - fn file_label(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "install", - ProxiFyrePackageAction::Uninstall => "uninstall", - } - } - - fn operation(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "install", - ProxiFyrePackageAction::Uninstall => "uninstall", - } - } - - fn start_message(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "Готовлю установку ProxiFyre.", - ProxiFyrePackageAction::Uninstall => "Готовлю удаление ProxiFyre и сетевого драйвера.", - } - } - - fn success_message(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "ProxiFyre и сетевой драйвер готовы.", - ProxiFyrePackageAction::Uninstall => "ProxiFyre и сетевой драйвер удалены.", - } - } -} - -fn install_proxifyre_component( - storage: &JsonStorage, - app: &tauri::AppHandle, -) -> Result { - let generated_config_path = storage - .paths() - .generated_dir - .join("proxifyre-app-config.json"); - let bundled_asset_dir = bundled_proxifyre_asset_dir(app); - let install_dir = proxifyre_install_dir_for_app(app)?; - let script = install_proxifyre_script_for_target( - &generated_config_path, - bundled_asset_dir.as_deref(), - &install_dir, - ); - - run_elevated_package_script( - ProxiFyrePackageAction::Install, - script, - &storage.paths().state_dir, - )?; - - if detect_windows_packet_filter().is_none() { - return Err(CommandError::new( - ProxiFyrePackageAction::Install.error_code(), - "Установка ProxiFyre завершилась, но Windows Packet Filter не найден после проверки.", - )); - } - - let refreshed = detect_proxyfier_install(); - let Some(detected) = refreshed.as_ref() else { - return Err(CommandError::new( - ProxiFyrePackageAction::Install.error_code(), - "Установка ProxiFyre завершилась, но приложение не найдено после проверки.", - )); - }; - - Ok(ComponentStatusDto::from( - &proxyfier_component_from_detection(Some(detected)), - )) -} - -fn bundled_proxifyre_asset_dir(app: &tauri::AppHandle) -> Option { - let mut candidates = Vec::new(); - if let Ok(resource_dir) = app.path().resource_dir() { - candidates.push(resource_dir.join("bundled").join("proxifyre")); - } - candidates.push( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("bundled") - .join("proxifyre"), - ); - - candidates.into_iter().find(|path| path.is_dir()) -} - -fn app_install_dir(app: &tauri::AppHandle) -> Result { - if let Ok(exe_path) = env::current_exe() { - if let Some(parent) = exe_path.parent() { - return Ok(parent.to_path_buf()); - } - } - - app.path().resource_dir().map_err(|error| { - CommandError::new( - "app_install_dir_unavailable", - format!("Не удалось определить папку установки ProxyWarden: {error}"), - ) - }) -} - -fn proxifyre_install_dir_for_app(app: &tauri::AppHandle) -> Result { - Ok(proxifyre_install_dir_from_app_dir(&app_install_dir(app)?)) -} - -fn singbox_install_dir_for_app(app: &tauri::AppHandle) -> Result { - Ok(singbox_install_dir_from_app_dir(&app_install_dir(app)?)) -} - -fn uninstall_proxifyre_component() -> Result { - let detected = detect_proxyfier_install(); - let packet_filter = detect_windows_packet_filter(); - if detected.is_none() && packet_filter.is_none() { - let component = proxyfier_component_from_detection(None); - return Ok(ComponentStatusDto::from(&component)); - } - - if let Some(detected) = detected.as_ref() { - ensure_safe_proxifyre_install_dir(&detected.install_dir)?; - } - let script = uninstall_proxifyre_script(detected.as_ref()); - - let artifact_dir = default_config_root().join("state"); - run_elevated_package_script(ProxiFyrePackageAction::Uninstall, script, &artifact_dir)?; - - let refreshed = detect_proxyfier_install(); - if refreshed.is_some() { - return Err(CommandError::new( - ProxiFyrePackageAction::Uninstall.error_code(), - "Удаление ProxiFyre завершилось, но приложение все еще найдено на компьютере.", - )); - } - if detect_windows_packet_filter().is_some() { - return Err(CommandError::new( - ProxiFyrePackageAction::Uninstall.error_code(), - "Удаление ProxiFyre завершилось, но Windows Packet Filter все еще найден на компьютере.", - )); - } - - let component = proxyfier_component_from_detection(None); - Ok(ComponentStatusDto::from(&component)) -} - -fn build_proxifyre_setup_status_for_install_dir(install_dir: &Path) -> ProxiFyreSetupStatusDto { - let proxifyre = detect_proxyfier_install(); - build_proxifyre_setup_status_with_detection(proxifyre.as_ref(), install_dir) -} - -fn build_proxifyre_setup_status_with_detection( - proxifyre: Option<&DetectedProxyfier>, - default_install_dir: &Path, -) -> ProxiFyreSetupStatusDto { - let vc_runtime = detect_vc_runtime(); - let packet_filter = detect_windows_packet_filter(); - - let vc_runtime_item = setup_item_from_program( - "vc-runtime", - &format!("Microsoft Visual C++ Runtime ({})", runtime_arch_label()), - vc_runtime, - "Нужен для запуска ProxiFyre.exe. Установщик скачает официальный vc_redist от Microsoft.", - ); - let packet_filter_item = setup_item_from_program( - "packet-filter", - "Windows Packet Filter", - packet_filter, - "Сетевой драйвер NT Kernel/WireSock, через который ProxiFyre перехватывает трафик приложений.", - ); - let proxifyre_item = match proxifyre { - Some(detected) => ProxiFyreSetupItemDto { - id: "proxifyre".to_string(), - name: "ProxiFyre".to_string(), - installed: true, - version: Some(proxifyre_service_setup_version(detected)), - details: detected.install_dir.display().to_string(), - }, - None => ProxiFyreSetupItemDto { - id: "proxifyre".to_string(), - name: "ProxiFyre".to_string(), - installed: false, - version: None, - details: format!( - "Будет установлен рядом с ProxyWarden в {}.", - default_install_dir.display() - ), - }, - }; - - let items = vec![vc_runtime_item, packet_filter_item, proxifyre_item]; - let missing_count = items.iter().filter(|item| !item.installed).count(); - - ProxiFyreSetupStatusDto { - ready: missing_count == 0, - missing_count, - items, - } -} - -fn proxifyre_service_setup_version(detected: &DetectedProxyfier) -> String { - match detected.service_status.as_deref() { - Some(status) if status.eq_ignore_ascii_case("running") => "служба запущена".to_string(), - Some(_) => "служба остановлена".to_string(), - None => "служба не установлена".to_string(), - } -} - -fn proxifyre_progress_path(state_dir: &Path) -> PathBuf { - state_dir.join("proxifyre-setup-progress.json") -} - -fn idle_proxifyre_setup_progress() -> ProxiFyreSetupProgressDto { - ProxiFyreSetupProgressDto { - operation: "idle".to_string(), - status: "idle".to_string(), - active_step: None, - percent: 0, - message: "Ожидаю действия пользователя.".to_string(), - updated_at: None, - } -} - -fn read_proxifyre_setup_progress( - storage: &JsonStorage, -) -> Result { - let path = proxifyre_progress_path(&storage.paths().state_dir); - if !path.exists() { - return Ok(idle_proxifyre_setup_progress()); - } - - let contents = fs::read_to_string(&path).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_read_failed", - format!( - "Не удалось прочитать прогресс установки ProxiFyre '{}': {error}", - path.display() - ), - ) - })?; - - serde_json::from_str(&contents).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_parse_failed", - format!( - "Не удалось разобрать прогресс установки ProxiFyre '{}': {error}", - path.display() - ), - ) - }) -} - -fn write_proxifyre_setup_progress( - path: &Path, - operation: &str, - active_step: Option<&str>, - status: &str, - percent: u8, - message: &str, -) -> Result<(), CommandError> { - let progress = ProxiFyreSetupProgressDto { - operation: operation.to_string(), - status: status.to_string(), - active_step: active_step.map(str::to_string), - percent: percent.min(100), - message: message.to_string(), - updated_at: Some(SystemClock.now()), - }; - let bytes = serde_json::to_vec_pretty(&progress).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_write_failed", - format!("Не удалось подготовить прогресс установки ProxiFyre: {error}"), - ) - })?; - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_write_failed", - format!( - "Не удалось создать папку прогресса установки ProxiFyre '{}': {error}", - parent.display() - ), - ) - })?; - } - - let temp_path = safe_fs::temp_path(path); - fs::write(&temp_path, bytes).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_write_failed", - format!( - "Не удалось записать прогресс установки ProxiFyre '{}': {error}", - temp_path.display() - ), - ) - })?; - fs::rename(&temp_path, path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - CommandError::new( - "proxifyre_setup_progress_write_failed", - format!( - "Не удалось обновить прогресс установки ProxiFyre '{}': {error}", - path.display() - ), - ) - }) -} - -fn setup_item_from_program( - id: &str, - name: &str, - program: Option, - missing_details: &str, -) -> ProxiFyreSetupItemDto { - match program { - Some(program) => ProxiFyreSetupItemDto { - id: id.to_string(), - name: name.to_string(), - installed: true, - version: program.display_version, - details: program.display_name, - }, - None => ProxiFyreSetupItemDto { - id: id.to_string(), - name: name.to_string(), - installed: false, - version: None, - details: missing_details.to_string(), - }, - } -} - -#[derive(Debug, Clone)] -struct InstalledProgram { - display_name: String, - display_version: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "PascalCase")] -struct InstalledProgramJson { - display_name: Option, - display_version: Option, -} - -fn detect_vc_runtime() -> Option { - installed_program(&vc_runtime_registry_pattern()) -} - -fn detect_windows_packet_filter() -> Option { - installed_program("Windows Packet Filter|WinpkFilter|NDISAPI") -} - -fn installed_program(pattern: &str) -> Option { - let script = format!( - r#" -$paths = @( - 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' -) -$program = Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | - Where-Object {{ $_.DisplayName -match '{}' }} | - Select-Object -First 1 DisplayName, DisplayVersion -if ($null -ne $program) {{ - $program | ConvertTo-Json -Compress -}} -"#, - escape_powershell_single(pattern) - ); - - let output = command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script.as_str(), - ]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let payload = stdout.trim(); - if payload.is_empty() || payload.eq_ignore_ascii_case("null") { - return None; - } - - let parsed: InstalledProgramJson = serde_json::from_str(payload).ok()?; - let display_name = parsed.display_name?.trim().to_string(); - if display_name.is_empty() { - return None; - } - - Some(InstalledProgram { - display_name, - display_version: parsed - .display_version - .map(|version| version.trim().to_string()) - .filter(|version| !version.is_empty()), - }) -} - -fn vc_runtime_registry_pattern() -> String { - let arch = runtime_arch_label(); - if arch == "ARM64" { - return r"Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)".to_string(); - } - - format!(r"Microsoft Visual C\+\+.*Redistributable.*\({arch}\)") -} - -fn runtime_arch_label() -> &'static str { - if cfg!(target_arch = "aarch64") { - "ARM64" - } else if cfg!(target_arch = "x86") { - "x86" - } else { - "x64" - } -} - -fn run_elevated_package_script( - action: ProxiFyrePackageAction, - body: String, - artifact_dir: &Path, -) -> Result<(), CommandError> { - fs::create_dir_all(artifact_dir).map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось создать папку для временных файлов ProxiFyre '{}': {error}", - artifact_dir.display() - ), - ) - })?; - 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 progress_path = proxifyre_progress_path(artifact_dir); - let _ = write_proxifyre_setup_progress( - &progress_path, - action.operation(), - None, - "running", - 1, - action.start_message(), - ); - let script = - wrap_elevated_package_script_for_action(&body, &result_path, Some(&progress_path), action); - - write_powershell_script(&script_path, &script).map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось подготовить временный скрипт, чтобы {} ProxiFyre '{}': {error}", - action.label(), - script_path.display() - ), - ) - })?; - - let launch_script = format!( - r#" -$ErrorActionPreference = 'Stop' -$resultPath = '{}' -try {{ - $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') - if ($null -eq $p) {{ - Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 - exit 1 - }} - exit $p.ExitCode -}} catch {{ - Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 - exit 1 -}} -"#, - escape_powershell_single(&result_path.display().to_string()), - escape_powershell_single(&script_path.display().to_string()) - ); - let output = if is_running_elevated() { - run_powershell_file(&script_path) - } else { - run_powershell_command(&launch_script) - }; - - let _ = fs::remove_file(&script_path); - - match output { - Ok(output) if output.status.success() => { - let _ = fs::remove_file(&result_path); - let _ = write_proxifyre_setup_progress( - &progress_path, - action.operation(), - None, - "succeeded", - 100, - action.success_message(), - ); - Ok(()) - } - Ok(output) => { - let details = package_failure_details(&result_path, &output); - let _ = fs::remove_file(&result_path); - let _ = write_proxifyre_setup_progress( - &progress_path, - action.operation(), - None, - "failed", - 100, - &details, - ); - Err(CommandError::new( - action.error_code(), - format!( - "Не удалось {} ProxiFyre. Код elevated-команды: {}. {details}", - action.label(), - output.status.code().unwrap_or(-1), - ), - )) - } - Err(error) => { - let message = format!( - "Не удалось запросить права администратора, чтобы {} ProxiFyre: {error}", - action.label() - ); - let _ = write_proxifyre_setup_progress( - &progress_path, - action.operation(), - None, - "failed", - 100, - &message, - ); - Err(CommandError::new(action.error_code(), message)) - } - } -} - -pub fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String { - wrap_elevated_package_script_for_action( - body, - result_path, - None, - ProxiFyrePackageAction::Install, - ) -} - -fn wrap_elevated_package_script_for_action( - body: &str, - result_path: &Path, - progress_path: Option<&Path>, - action: ProxiFyrePackageAction, -) -> String { - let mut script = String::new(); - script.push_str("$ErrorActionPreference = 'Stop'\n"); - script.push_str(&format!( - "$resultPath = '{}'\n", - escape_powershell_single(&result_path.display().to_string()) - )); - script.push_str(&format!( - "$script:progressOperation = '{}'\n", - escape_powershell_single(action.operation()) - )); - script.push_str("$script:progressActiveStep = $null\n"); - if let Some(progress_path) = progress_path { - script.push_str(&format!( - "$progressPath = '{}'\n", - escape_powershell_single(&progress_path.display().to_string()) - )); - script.push_str( - r#" -function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) { - $script:progressOperation = $operation - $script:progressActiveStep = if ([string]::IsNullOrWhiteSpace($activeStep)) { $null } else { $activeStep } - $payload = [ordered]@{ - operation = $operation - status = $status - activeStep = $script:progressActiveStep - percent = [Math]::Max(0, [Math]::Min(100, $percent)) - message = $message - updatedAt = (Get-Date).ToUniversalTime().ToString('o') - } | ConvertTo-Json -Compress - $progressTempPath = "$progressPath.tmp" - Set-Content -LiteralPath $progressTempPath -Value $payload -Encoding UTF8 - Move-Item -LiteralPath $progressTempPath -Destination $progressPath -Force -} -"#, - ); - } else { - script.push_str( - r#" -function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) {} -"#, - ); - } - script.push_str("try {\n"); - script.push_str(body); - script.push_str( - r#" - Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 - exit 0 -} catch { - $message = ($_ | Out-String) - Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'failed' 100 $message - Set-Content -LiteralPath $resultPath -Value $message -Encoding UTF8 - exit 1 -} -"#, - ); - - script -} - -fn write_powershell_script(path: &Path, script: &str) -> std::io::Result<()> { - let mut bytes = Vec::with_capacity(script.len() + 3); - bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); - bytes.extend_from_slice(script.as_bytes()); - fs::write(path, bytes) -} - -fn run_powershell_command(script: &str) -> std::io::Result { - command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script, - ]) - .output() -} - -fn run_powershell_file(script_path: &Path) -> std::io::Result { - command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(script_path) - .output() -} - -fn is_running_elevated() -> bool { - if !cfg!(windows) { - return false; - } - - let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#; - let Ok(output) = run_powershell_command(script) else { - return false; - }; - - output.status.success() - && String::from_utf8_lossy(&output.stdout) - .trim() - .eq_ignore_ascii_case("true") -} - -fn powershell_output_message(output: &Output, fallback: &str) -> String { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if !stderr.is_empty() { - return stderr; - } - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !stdout.is_empty() { - return stdout; - } - - fallback.to_string() -} - -pub fn install_proxifyre_script(generated_config_path: &Path) -> String { - install_proxifyre_script_with_bundle(generated_config_path, None) -} - -pub fn install_proxifyre_script_with_bundle( - generated_config_path: &Path, - bundled_asset_dir: Option<&Path>, -) -> String { - install_proxifyre_script_for_target( - generated_config_path, - bundled_asset_dir, - &default_proxifyre_install_dir(), - ) -} - -pub fn install_proxifyre_script_for_target( - generated_config_path: &Path, - bundled_asset_dir: Option<&Path>, - target_dir: &Path, -) -> String { - let mut script = String::new(); - script.push_str(&format!( - "$targetDir = '{}'\n", - escape_powershell_single(&target_dir.display().to_string()) - )); - script.push_str(&format!( - "$generatedConfigPath = '{}'\n", - escape_powershell_single(&generated_config_path.display().to_string()) - )); - script.push_str(&format!( - "$bundledAssetDir = '{}'\n", - escape_powershell_single( - &bundled_asset_dir - .map(|path| path.display().to_string()) - .unwrap_or_default() - ) - )); - script.push_str("$script:bundledAssetDir = [string]$bundledAssetDir\n"); - script.push_str(&format!( - "$proxifyreReleaseApi = '{}'\n", - escape_powershell_single(PROXIFYRE_RELEASE_API_URL) - )); - script.push_str(&format!( - "$ndisapiReleaseApi = '{}'\n", - escape_powershell_single(NDISAPI_RELEASE_API_URL) - )); - script.push_str(&format!( - "$proxifyrePinnedReleaseTag = '{}'\n", - escape_powershell_single(PROXIFYRE_PINNED_RELEASE_TAG) - )); - script.push_str(&format!( - "$ndisapiPinnedReleaseTag = '{}'\n", - escape_powershell_single(NDISAPI_PINNED_RELEASE_TAG) - )); - script.push_str(&format!( - "$ndisapiPinnedInstallerVersion = '{}'\n", - escape_powershell_single(NDISAPI_PINNED_INSTALLER_VERSION) - )); - script.push_str(&format!( - "$vcRedistX64Url = '{}'\n", - escape_powershell_single(VC_REDIST_X64_URL) - )); - script.push_str(&format!( - "$vcRedistX86Url = '{}'\n", - escape_powershell_single(VC_REDIST_X86_URL) - )); - script.push_str( - r#" - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - - function Get-NativeArchitecture { - $processor = Get-CimInstance Win32_Processor | Select-Object -First 1 - if ($null -ne $processor -and $processor.Architecture -eq 12) { return 'ARM64' } - if ([Environment]::Is64BitOperatingSystem) { return 'x64' } - return 'x86' - } - - function Get-SafeUriForLog([string]$uri) { - try { - $parsed = [Uri]$uri - $port = if ($parsed.IsDefaultPort) { '' } else { ":$($parsed.Port)" } - return "$($parsed.Scheme)://$($parsed.Host)$port$($parsed.AbsolutePath)" - } catch { - return '' - } - } - - function Invoke-ReleaseApi([string]$uri, [string]$label) { - $safeUri = Get-SafeUriForLog $uri - $headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/vnd.github+json' } - $lastError = $null - - foreach ($attempt in 1..3) { - try { - return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 -MaximumRedirection 10 - } catch { - $lastError = $_.Exception.Message - if ($attempt -lt 3) { - Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2)) - } - } - } - - throw "Не удалось получить metadata для $label ($safeUri): $lastError" - } - - function New-ReleaseAsset([string]$name, [string]$url) { - [PSCustomObject]@{ - name = $name - browser_download_url = $url - digest = $null - } - } - - function Resolve-ReleaseAsset([string]$apiUri, [string]$pattern, [string]$label, $fallbackAsset, [int]$fallbackPercent) { - try { - $release = Invoke-ReleaseApi $apiUri $label - return Select-Asset $release.assets $pattern $label - } catch { - $fallbackUri = Get-SafeUriForLog $fallbackAsset.browser_download_url - Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'running' $fallbackPercent "GitHub API недоступен для $label. Пробую прямую ссылку: $fallbackUri" - return $fallbackAsset - } - } - - function Get-PinnedProxiFyreAsset([string]$arch) { - $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } - $name = "ProxiFyre-$proxifyrePinnedReleaseTag-$archLabel-signed.zip" - $url = "https://github.com/wiresock/proxifyre/releases/download/$proxifyrePinnedReleaseTag/$name" - return New-ReleaseAsset $name $url - } - - function Get-PinnedWindowsPacketFilterAsset([string]$arch) { - $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } - $name = "Windows.Packet.Filter.$ndisapiPinnedInstallerVersion.$archLabel.msi" - $url = "https://github.com/wiresock/ndisapi/releases/download/$ndisapiPinnedReleaseTag/$name" - return New-ReleaseAsset $name $url - } - - function Complete-Download([string]$partialPath, [string]$path, [string]$label) { - if (-not (Test-Path -LiteralPath $partialPath)) { - throw "${label}: файл не был создан." - } - - $item = Get-Item -LiteralPath $partialPath - if ($item.Length -le 0) { - throw "${label}: скачанный файл пустой." - } - - Move-Item -LiteralPath $partialPath -Destination $path -Force - } - - function Invoke-WebClientDownload([string]$uri, [string]$partialPath) { - $client = New-Object System.Net.WebClient - try { - $client.Headers.Add('User-Agent', 'proxywarden') - $client.Headers.Add('Accept', 'application/octet-stream,*/*') - $client.DownloadFile($uri, $partialPath) - } finally { - $client.Dispose() - } - } - - function Invoke-CurlDownload([string]$uri, [string]$partialPath) { - $curl = Get-Command 'curl.exe' -ErrorAction SilentlyContinue - if ($null -eq $curl) { - throw 'curl.exe не найден.' - } - - $curlOutput = & $curl.Source --silent --show-error --fail --location --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 180 --user-agent 'proxywarden' --output $partialPath --url $uri 2>&1 - if ($LASTEXITCODE -ne 0) { - $curlMessage = ($curlOutput | Out-String).Trim() - if ([string]::IsNullOrWhiteSpace($curlMessage)) { - throw "curl.exe завершился с кодом $LASTEXITCODE." - } - - throw "curl.exe завершился с кодом ${LASTEXITCODE}: $curlMessage" - } - } - - function Invoke-Download([string]$uri, [string]$path, [string]$label) { - $safeUri = Get-SafeUriForLog $uri - $partialPath = "$path.part" - $headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/octet-stream,*/*' } - $webRequestError = $null - $webClientError = $null - $curlError = $null - - foreach ($attempt in 1..3) { - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - try { - Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $partialPath -Headers $headers -TimeoutSec 180 -MaximumRedirection 10 - Complete-Download $partialPath $path $label - return - } catch { - $webRequestError = $_.Exception.Message - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - if ($attempt -lt 3) { - Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2)) - } - } - } - - try { - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - Invoke-WebClientDownload $uri $partialPath - Complete-Download $partialPath $path $label - return - } catch { - $webClientError = $_.Exception.Message - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - } - - try { - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - Invoke-CurlDownload $uri $partialPath - Complete-Download $partialPath $path $label - return - } catch { - $curlError = $_.Exception.Message - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - } - - $errors = @() - if (-not [string]::IsNullOrWhiteSpace($webRequestError)) { $errors += "Invoke-WebRequest: $webRequestError" } - if (-not [string]::IsNullOrWhiteSpace($webClientError)) { $errors += "WebClient: $webClientError" } - if (-not [string]::IsNullOrWhiteSpace($curlError)) { $errors += "curl.exe: $curlError" } - $details = if ($errors.Count -gt 0) { $errors -join ' | ' } else { 'неизвестная ошибка' } - - throw "Не удалось скачать $label ($safeUri): $details" - } - - function Select-Asset($assets, [string]$pattern, [string]$label) { - $asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 - if ($null -eq $asset) { throw "Не найден подходящий asset для $label ($pattern)." } - return $asset - } - - function Verify-AssetHash([string]$path, $asset) { - if ($asset.digest -match '^sha256:(.+)$') { - $expected = $Matches[1].ToLowerInvariant() - $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $expected) { - throw "SHA256 не совпал для $($asset.name). Ожидалось $expected, получилось $actual." - } - } - } - - function Assert-ExitCode($process, [string]$label) { - if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { - throw "$label завершился с кодом $($process.ExitCode)." - } - } - - function Get-InstalledProgram([string]$pattern) { - $paths = @( - 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' - ) - return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -match $pattern } | - Select-Object -First 1 - } - - function Test-VcRuntime([string]$arch) { - $pattern = if ($arch -eq 'ARM64') { - 'Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)' - } else { - "Microsoft Visual C\+\+.*Redistributable.*\($arch\)" - } - - return $null -ne (Get-InstalledProgram $pattern) - } - - function Test-WindowsPacketFilter { - return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI') - } - - function Get-LogTail([string]$path) { - if (-not (Test-Path -LiteralPath $path)) { return '' } - return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' - } - - function Get-BundledAssetDir { - $dir = [string]$script:bundledAssetDir - if ([string]::IsNullOrWhiteSpace($dir)) { return $null } - if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $null } - return $dir - } - - function Get-BundledAssetManifest { - $assetDir = Get-BundledAssetDir - if ($null -eq $assetDir) { return $null } - $manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json') - if (-not (Test-Path -LiteralPath $manifestPath)) { return $null } - - try { - return Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json - } catch { - throw "Не удалось прочитать manifest встроенных пакетов ProxiFyre: $($_.Exception.Message)" - } - } - - $script:bundledAssetManifest = Get-BundledAssetManifest - - function Get-BundledAssetHash([string]$name) { - if ($null -eq $script:bundledAssetManifest -or $null -eq $script:bundledAssetManifest.files) { - return $null - } - - $entry = $script:bundledAssetManifest.files | - Where-Object { $_.name -eq $name } | - Select-Object -First 1 - if ($null -eq $entry) { return $null } - return [string]$entry.sha256 - } - - function Verify-BundledAssetHash([string]$path, [string]$label) { - $name = [IO.Path]::GetFileName($path) - $expected = Get-BundledAssetHash $name - if ([string]::IsNullOrWhiteSpace($expected)) { - throw "Во встроенном manifest нет SHA256 для $label ($name)." - } - - $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $expected.ToLowerInvariant()) { - throw "SHA256 не совпал для встроенного $label ($name). Ожидалось $expected, получилось $actual." - } - } - - function Get-BundledAsset([string]$pattern, [string]$label) { - $assetDir = Get-BundledAssetDir - if ($null -eq $assetDir) { return $null } - - $asset = Get-ChildItem -LiteralPath $assetDir -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match $pattern } | - Select-Object -First 1 - if ($null -eq $asset) { return $null } - - Verify-BundledAssetHash $asset.FullName $label - return $asset.FullName - } - - function Copy-BundledAsset([string]$sourcePath, [string]$targetPath, [string]$label) { - Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force - $item = Get-Item -LiteralPath $targetPath - if ($item.Length -le 0) { - throw "${label}: встроенный файл пустой." - } - } - - $arch = Get-NativeArchitecture - $workDir = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-proxifyre-install' - $extractDir = Join-Path $workDir 'proxifyre' - Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null - - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 8 'Проверяю сетевой драйвер Windows Packet Filter.' - $packetFilterAlreadyInstalled = Test-WindowsPacketFilter - if (-not $packetFilterAlreadyInstalled) { - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 14 'Готовлю Windows Packet Filter.' - $ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' } - $bundledNdisPath = Get-BundledAsset $ndisPattern 'Windows Packet Filter' - if ($null -ne $bundledNdisPath) { - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Использую встроенный Windows Packet Filter.' - $ndisPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledNdisPath)) - Copy-BundledAsset $bundledNdisPath $ndisPath 'Windows Packet Filter' - } else { - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Скачиваю Windows Packet Filter.' - $ndisAsset = Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter' (Get-PinnedWindowsPacketFilterAsset $arch) 16 - $ndisPath = Join-Path $workDir $ndisAsset.name - Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter' - Verify-AssetHash $ndisPath $ndisAsset - } - $ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log' - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 26 'Устанавливаю Windows Packet Filter.' - $ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden - if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) { - $ndisLogTail = Get-LogTail $ndisLogPath - throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail" - } - } - Write-ProxyWardenProgress 'install' 'packet-filter' 'succeeded' 36 'Сетевой драйвер готов.' - - Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 40 'Проверяю Microsoft Visual C++ Runtime.' - if (-not (Test-VcRuntime $arch)) { - $vcBundledPattern = if ($arch -eq 'x86') { '^vc_redist\.x86\.exe$' } else { '^vc_redist\.x64\.exe$' } - $vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url } - $bundledVcPath = Get-BundledAsset $vcBundledPattern 'Microsoft Visual C++ Runtime' - $vcRedistPath = Join-Path $workDir 'vc_redist.exe' - if ($null -ne $bundledVcPath) { - Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Использую встроенный Microsoft Visual C++ Runtime.' - Copy-BundledAsset $bundledVcPath $vcRedistPath 'Microsoft Visual C++ Runtime' - } else { - Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Скачиваю Microsoft Visual C++ Runtime.' - Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime' - } - Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 54 'Устанавливаю Microsoft Visual C++ Runtime.' - $vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden - if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) { - throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)." - } - } - Write-ProxyWardenProgress 'install' 'vc-runtime' 'succeeded' 62 'Среда запуска готова.' - - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 66 'Готовлю ProxiFyre.' - $proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' } - $bundledProxiFyrePath = Get-BundledAsset $proxifyrePattern 'ProxiFyre' - if ($null -ne $bundledProxiFyrePath) { - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Использую встроенный ProxiFyre.' - $proxifyreZipPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledProxiFyrePath)) - Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath 'ProxiFyre' - } else { - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Скачиваю ProxiFyre.' - $proxifyreAsset = Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre' (Get-PinnedProxiFyreAsset $arch) 68 - $proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name - Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre' - Verify-AssetHash $proxifyreZipPath $proxifyreAsset - } - - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 76 'Распаковываю ProxiFyre.' - Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force - $proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1 - if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' } - - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 82 'Копирую ProxiFyre в папку установки.' - Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force - - $configTarget = Join-Path $targetDir 'app-config.json' - if (Test-Path -LiteralPath $generatedConfigPath) { - Copy-Item -LiteralPath $generatedConfigPath -Destination $configTarget -Force - } elseif (-not (Test-Path -LiteralPath $configTarget)) { - $emptyConfig = '{"logLevel":"Info","bypassLan":true,"proxies":[]}' - Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8 - } - - $markerPath = Join-Path $targetDir 'proxywarden-component.json' - [ordered]@{ - manager = 'ProxyWarden' - component = 'proxifyre' - serviceName = 'ProxiFyreService' - installedAt = (Get-Date).ToString('o') - installRoot = $targetDir - packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled) - } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8 - - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 90 'Устанавливаю и запускаю службу ProxiFyre.' - Push-Location $targetDir - try { - & .\ProxiFyre.exe stop | Out-Null - & .\ProxiFyre.exe uninstall | Out-Null - & .\ProxiFyre.exe install - if ($LASTEXITCODE -ne 0) { throw "ProxiFyre.exe install завершился с кодом $LASTEXITCODE." } - & .\ProxiFyre.exe start - if ($LASTEXITCODE -ne 0) { - Start-Service -Name 'ProxiFyreService' -ErrorAction Stop - } - } finally { - Pop-Location - } - Write-ProxyWardenProgress 'install' 'proxifyre' 'succeeded' 100 'ProxiFyre и сетевой драйвер готовы.' -"#, - ); - - script -} - -pub fn uninstall_proxifyre_script(detected: Option<&DetectedProxyfier>) -> String { - let mut script = String::new(); - let install_dir = detected - .map(|detected| detected.install_dir.display().to_string()) - .unwrap_or_default(); - let executable_path = detected - .map(|detected| detected.executable_path.display().to_string()) - .unwrap_or_default(); - script.push_str(&format!( - "$installDir = '{}'\n", - escape_powershell_single(&install_dir) - )); - script.push_str(&format!( - "$exePath = '{}'\n", - escape_powershell_single(&executable_path) - )); - script.push_str( - r#" - function Get-InstalledProgram([string]$pattern) { - $paths = @( - 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' - ) - return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -match $pattern } | - Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString - } - - function Test-WindowsPacketFilter { - return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI') - } - - function Get-LogTail([string]$path) { - if (-not (Test-Path -LiteralPath $path)) { return '' } - return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' - } - - function Resolve-MsiProductCode($program, [string]$label) { - if ($null -eq $program) { return $null } - if ($program.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$') { - return $program.PSChildName - } - foreach ($candidate in @($program.QuietUninstallString, $program.UninstallString)) { - if ($candidate -match '\{[0-9A-Fa-f-]{36}\}') { - return $Matches[0] - } - } - throw "Не удалось найти MSI product code для $label. Отказываюсь запускать произвольный UninstallString." - } - - function Uninstall-MsiProgram($program, [string]$label, [string]$logPath) { - $productCode = Resolve-MsiProductCode $program $label - if ([string]::IsNullOrWhiteSpace($productCode)) { return } - $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $productCode, '/qn', '/norestart', '/L*v', $logPath) -Wait -PassThru -WindowStyle Hidden - if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) { - $logTail = Get-LogTail $logPath - throw "$label uninstall завершился с кодом $($process.ExitCode). MSI log: $logPath $logTail" - } - } - - function Find-ProxiFyreService { - foreach ($name in @('ProxiFyreService', 'ProxiFyre')) { - $candidate = Get-Service -Name $name -ErrorAction SilentlyContinue - if ($null -ne $candidate) { return $candidate } - } - - return Get-Service | - Where-Object { $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' } | - Select-Object -First 1 - } - - function Get-ServiceProcessId([string]$name) { - $escapedName = $name.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) { return 0 } - return [int]$record.ProcessId - } - - Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 10 'Останавливаю службу ProxiFyre.' - $service = Find-ProxiFyreService - if ($null -ne $service -and $service.Status -ne 'Stopped') { - try { - if ($service.CanStop) { Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue } - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service) { $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) } - } catch {} - } - - $service = Find-ProxiFyreService - if ($null -ne $service -and $service.Status -ne 'Stopped') { - $processId = Get-ServiceProcessId $service.Name - if ($processId -gt 0) { - taskkill.exe /PID $processId /F | Out-Null - Start-Sleep -Milliseconds 700 - } - } - - Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 34 'Удаляю службу и файлы ProxiFyre.' - if (-not [string]::IsNullOrWhiteSpace($exePath) -and (Test-Path -LiteralPath $exePath)) { - Push-Location (Split-Path -Parent $exePath) - try { - & $exePath uninstall | Out-Null - } finally { - Pop-Location - } - } - - $service = Find-ProxiFyreService - if ($null -ne $service) { - sc.exe delete $service.Name | Out-Null - } - - Get-Process -Name 'ProxiFyre' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - - if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) { - Remove-Item -LiteralPath $installDir -Recurse -Force - } - - Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.' - - Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 68 'Проверяю Windows Packet Filter.' - $packetFilter = Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI' - if ($null -ne $packetFilter) { - Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 78 'Удаляю Windows Packet Filter.' - $driverLogPath = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-windows-packet-filter-uninstall.log' - Uninstall-MsiProgram $packetFilter 'Windows Packet Filter' $driverLogPath - } - if (Test-WindowsPacketFilter) { - throw 'Windows Packet Filter все еще найден после удаления. Возможно, Windows требует перезагрузку.' - } - - Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'succeeded' 100 'ProxiFyre и Windows Packet Filter удалены.' -"#, - ); - - script -} - -fn ensure_safe_proxifyre_install_dir(path: &Path) -> Result<(), CommandError> { - let normalized = path - .display() - .to_string() - .replace('/', "\\") - .to_ascii_lowercase(); - let name = path - .file_name() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()) - .unwrap_or_default(); - let marker_path = path.join("proxywarden-component.json"); - let is_proxywarden_component = - name == "proxifyre" && normalized.contains("\\proxywarden\\components\\"); - let is_legacy_proxywarden_child = - name == "proxifyre" && normalized.ends_with("\\proxywarden\\proxifyre"); - let is_legacy_tools_proxifyre = normalized == r"c:\tools\proxifyre"; - let has_proxywarden_marker = marker_path.exists(); - - if path.parent().is_some() - && (is_proxywarden_component - || is_legacy_proxywarden_child - || is_legacy_tools_proxifyre - || has_proxywarden_marker) - { - return Ok(()); - } - - Err(CommandError::new( - ProxiFyrePackageAction::Uninstall.error_code(), - format!( - "Отказываюсь рекурсивно удалять папку ProxiFyre с небезопасным путем: {}", - path.display() - ), - )) -} - -fn apply_to_detected_proxyfier( - request: HelperApplyRequest<'_>, - detected: &DetectedProxyfier, -) -> Result { - let Some(config_path) = &detected.config_path else { - return staged_apply_result(request); - }; - - safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err( - |error| { - CommandError::new( - "proxyfier_apply_failed", - format!( - "Не удалось безопасно записать конфиг ProxiFyre '{}': {error}", - config_path.display() - ), - ) - }, - )?; - - Ok(HelperApplyResult { - success: true, - changed: true, - action: "proxifyre.apply-detected-config".to_string(), - message: format!( - "Сгенерированный конфиг записан в найденную установку ProxiFyre: {}", - config_path.display() - ), - }) -} - -fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result { - Ok(HelperApplyResult { - success: true, - changed: true, - action: format!("{}.stage-generated-config", request.adapter_id), - message: format!( - "Сгенерированный конфиг подготовлен в {}; совместимая установка ProxiFyre не найдена", - request.config_path.display() - ), - }) -} - -fn activity_for_apply( - clock: &impl Clock, - generated: &ProxyRouterGeneratedConfig, - generated_path: &Path, - helper_result: &HelperApplyResult, -) -> ActivityEntry { - let level = if helper_result.success { - ActivityLevel::Success - } else { - ActivityLevel::Error - }; - - ActivityEntry { - id: format!("apply-{}", generated.adapter_id), - at: clock.now(), - level, - title: "Конфиг ProxiFyre создан".to_string(), - message: format!( - "Профилей: {}, приложений: {}, конфиг: {}", - generated.enabled_profiles, - generated.routed_apps, - generated_path.display() - ), - } -} - -fn activity_for_apply_error(clock: &impl Clock, error: &CommandError) -> ActivityEntry { - ActivityEntry { - id: format!("apply-error-{}", error.code), - at: clock.now(), - level: ActivityLevel::Error, - title: "Применение ProxiFyre заблокировано".to_string(), - message: error.message.clone(), - } -} - -fn storage_error(error: std::io::Error) -> CommandError { - CommandError::new("storage_error", error.to_string()) -} - fn background_task_error(error: impl std::fmt::Display) -> CommandError { CommandError::new( "background_task_failed", @@ -4638,325 +356,19 @@ fn background_task_error(error: impl std::fmt::Display) -> CommandError { ) } -fn package_failure_details(result_path: &Path, output: &Output) -> String { - let mut parts = Vec::new(); - - if let Ok(contents) = fs::read_to_string(result_path) { - let details = compact_error_text(&contents); - if !details.is_empty() && !details.eq_ignore_ascii_case("ok") { - parts.push(details); - } - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let stdout = compact_error_text(&stdout); - if !stdout.is_empty() { - parts.push(format!("stdout: {stdout}")); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - let stderr = compact_error_text(&stderr); - if !stderr.is_empty() { - parts.push(format!("stderr: {stderr}")); - } - - if parts.is_empty() { - parts.push( - "Лог elevated-скрипта не создан. Обычно это значит, что окно UAC было отменено или Windows не дала запустить elevated PowerShell." - .to_string(), - ); - } - - parts.join(" ") -} - -fn compact_error_text(value: &str) -> String { - let text = value - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>() - .join(" "); - - let max_chars = 1400; - if text.chars().count() <= max_chars { - return text; - } - - let truncated = text.chars().take(max_chars).collect::(); - format!("{truncated}...") -} - -fn parse_service_command_output(stdout: &[u8]) -> Option { - let stdout = String::from_utf8_lossy(stdout); - let payload = stdout - .lines() - .rev() - .map(str::trim) - .find(|line| line.starts_with('{') && line.ends_with('}'))?; - - serde_json::from_str(payload).ok() -} - -fn service_script_failed_message(action: ServiceControlAction, exit_code: Option) -> String { - let exit_code = exit_code - .map(|code| format!(" Код выхода PowerShell: {code}.")) - .unwrap_or_default(); - - format!( - "Не удалось {} службу ProxiFyre: команда управления службой не вернула корректный результат.{exit_code}", - action.label() - ) -} - -fn service_command_failed_message( - action: ServiceControlAction, - result: &ServiceCommandOutput, -) -> String { - let service_name = result - .service_name - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("ProxiFyre"); - let status = result - .status - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("неизвестен"); - let pid = result - .process_id - .filter(|value| *value > 0) - .map(|value| format!(", PID: {value}")) - .unwrap_or_default(); - - match result.code.as_str() { - "service_not_found" => "Служба ProxiFyre не найдена.".to_string(), - "start_failed" => format!( - "Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора." - ), - "stop_failed" => format!( - "Не удалось остановить службу {service_name} даже после принудительной попытки. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc." - ), - _ => format!( - "Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.", - action.label() - ), - } -} - -fn elevated_service_failed_message( - action: ServiceControlAction, - direct_result: &ServiceCommandOutput, - exit_code: Option, -) -> String { - let service_name = direct_result - .service_name - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("ProxiFyre"); - let status = direct_result - .status - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("неизвестен"); - let pid = direct_result - .process_id - .filter(|value| *value > 0) - .map(|value| format!(", PID: {value}")) - .unwrap_or_default(); - let exit_code = exit_code - .map(|code| format!(" Код elevated-команды: {code}.")) - .unwrap_or_default(); - - format!( - "Не удалось {} службу {service_name} даже после запроса прав администратора. До запроса UAC статус был: {status}{pid}.{exit_code} Если появлялось окно UAC, проверь, что оно было подтверждено.", - action.label() - ) -} - -fn escape_powershell_single(value: &str) -> String { - value.replace('\'', "''") -} - -fn validation_error(errors: Vec) -> CommandError { +fn apply_flow_error(error: ApplyFlowError) -> CommandError { + let code = error.code().to_string(); + let message = error.to_string(); CommandError::with_details( - "validation_error", - "Проверка введенных данных не прошла", - errors + code, + message, + error + .details() .into_iter() - .map(|error| ValidationIssue { - field: error.field, - message: error.message, + .map(|detail| ValidationIssue { + field: detail.field, + message: detail.message, }) .collect(), ) } - -fn adapter_error(error: ProxyRouterError) -> CommandError { - let code = match error.kind { - ProxyRouterErrorKind::EmptyProfileItems => "empty_profile_items", - ProxyRouterErrorKind::MissingTarget => "missing_target", - ProxyRouterErrorKind::MissingRequiredComponent => "missing_required_component", - ProxyRouterErrorKind::RequiredComponentNotRunning => "required_component_not_running", - ProxyRouterErrorKind::UnsupportedTargetProtocol => "unsupported_target_protocol", - ProxyRouterErrorKind::Serialization => "serialization_error", - }; - - CommandError::new(code, error.message) -} - -impl From for ProfileInput { - fn from(input: ProfileInputDto) -> Self { - Self { - id: input.id, - name: input.name, - enabled: input.enabled.unwrap_or(true), - target_id: input - .target_id - .unwrap_or_else(|| "local-singbox".to_string()), - protocols: input - .protocols - .unwrap_or_else(|| vec!["TCP".to_string(), "UDP".to_string()]), - items: input - .items - .unwrap_or_default() - .into_iter() - .map(ProfileItemInput::from) - .collect(), - } - } -} - -impl From for ProfileItemInput { - fn from(input: ProfileItemInputDto) -> Self { - Self { - item_type: input.item_type, - value: input.value, - recursive: input.recursive, - } - } -} - -impl From for TargetInput { - fn from(input: TargetInputDto) -> Self { - Self { - id: input.id, - name: input.name, - kind: input.kind.unwrap_or_else(|| "external".to_string()), - protocol: input.protocol.unwrap_or_else(|| "socks5".to_string()), - host: input.host, - port: input.port, - requires_component: input.requires_component, - } - } -} - -impl From<&Profile> for ProfileDto { - fn from(profile: &Profile) -> Self { - Self { - id: profile.id.clone(), - name: profile.name.clone(), - enabled: profile.enabled, - target_id: profile.target_id.clone(), - protocols: profile.protocols.clone(), - items: profile.items.iter().map(ProfileItemDto::from).collect(), - } - } -} - -impl From<&ProfileItem> for ProfileItemDto { - fn from(item: &ProfileItem) -> Self { - Self { - item_type: item.item_type.clone(), - value: item.value.clone(), - recursive: item.recursive, - } - } -} - -impl From<&Target> for TargetDto { - fn from(target: &Target) -> Self { - Self { - id: target.id.clone(), - name: target.name.clone(), - kind: target.kind.clone(), - protocol: target.protocol.clone(), - host: target.host.clone(), - port: target.port, - requires_component: target.requires_component.clone(), - } - } -} - -impl From<&ComponentStatus> for ComponentStatusDto { - fn from(component: &ComponentStatus) -> Self { - Self { - id: component.id.clone(), - name: component.name.clone(), - state: component.state.clone(), - installed: component.installed, - running: component.running, - version: component.version.clone(), - path: component.path.clone(), - service_name: component.service_name.clone(), - service_status: component.service_status.clone(), - problems: component.problems.clone(), - actions: component.actions.clone(), - } - } -} - -impl From<&ActivityEntry> for ActivityEntryDto { - fn from(entry: &ActivityEntry) -> Self { - Self { - id: entry.id.clone(), - at: entry.at.clone(), - level: entry.level.clone(), - title: entry.title.clone(), - message: entry.message.clone(), - } - } -} - -impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto { - fn from(config: &LocalSingBoxConfig) -> Self { - Self { - subscription_display_url: config.subscription_display_url(), - has_subscription: config - .subscription_url - .as_deref() - .is_some_and(|value| !value.trim().is_empty()), - selected_server_tag: config.selected_server_tag.clone(), - listen_host: config.listen_host.clone(), - listen_port: config.listen_port, - service_name: config.service_name.clone(), - install_root: config.install_root.clone(), - updated_at: config.updated_at.clone(), - } - } -} - -impl From<&SubscriptionCache> for SubscriptionCacheDto { - fn from(cache: &SubscriptionCache) -> Self { - Self { - servers: cache - .servers - .iter() - .map(SubscriptionServerDto::from) - .collect(), - user_info: cache.user_info.clone(), - fetched_at: cache.fetched_at.clone(), - } - } -} - -impl From<&SubscriptionServer> for SubscriptionServerDto { - fn from(server: &SubscriptionServer) -> Self { - Self { - tag: server.tag.clone(), - server_type: server.server_type.clone(), - server: server.server.clone(), - server_port: server.server_port, - } - } -} diff --git a/src-tauri/src/component_detection.rs b/src-tauri/src/component_detection.rs index a77fcf3..42f715f 100644 --- a/src-tauri/src/component_detection.rs +++ b/src-tauri/src/component_detection.rs @@ -30,10 +30,12 @@ pub struct DetectedProxyfier { pub service_status: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct DetectedService { pub name: String, pub status: String, + pub path_name: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -63,6 +65,15 @@ pub trait ProxyfierDetectionHost { fn service_status(&self, service_name: &str) -> Option; + fn service_info(&self, service_name: &str) -> Option { + self.service_status(service_name) + .map(|status| DetectedService { + name: service_name.to_string(), + status, + path_name: None, + }) + } + fn service_running(&self, service_name: &str) -> bool { self.service_status(service_name) .is_some_and(|status| service_status_is_running(&status)) @@ -102,6 +113,15 @@ impl ProxyfierDetectionHost for SystemProxyfierDetectionHost { powershell_text(&script).map(|status| status.to_ascii_lowercase()) } + fn service_info(&self, service_name: &str) -> Option { + let script = format!( + "$s = Get-CimInstance Win32_Service -Filter \"Name='{}'\" -ErrorAction SilentlyContinue; if ($s) {{ [ordered]@{{ name = $s.Name; status = $s.State; pathName = $s.PathName }} | ConvertTo-Json -Compress }}", + escape_powershell_single(service_name) + ); + let json = powershell_text(&script)?; + serde_json::from_str(&json).ok() + } + fn registry_install_entries(&self) -> Vec { read_registry_install_entries() } @@ -148,16 +168,9 @@ pub fn default_singbox_install_dir() -> PathBuf { pub fn detect_proxyfier_install_with_host( host: &impl ProxyfierDetectionHost, ) -> Option { - let detected_service = detect_proxifyre_service(host); - let proxifyre_running = detected_service - .as_ref() - .is_some_and(|service| service_status_is_running(&service.status)); - proxyfier_candidates(host) .into_iter() - .filter_map(|candidate| { - candidate.into_detected(host, proxifyre_running, detected_service.as_ref()) - }) + .filter_map(|candidate| candidate.into_detected(host)) .next() } @@ -332,23 +345,24 @@ struct ProxyfierCandidate { } impl ProxyfierCandidate { - fn into_detected( - self, - host: &impl ProxyfierDetectionHost, - proxifyre_running: bool, - detected_service: Option<&DetectedService>, - ) -> Option { + fn into_detected(self, host: &impl ProxyfierDetectionHost) -> Option { let executable_path = self.install_dir.join(executable_name(&self.engine)); let config_path = config_path(&self.engine, &self.install_dir); if !host.path_exists(&executable_path) { return None; } + let detected_service = detect_proxifyre_service(host, &executable_path); + let proxifyre_running = detected_service + .as_ref() + .is_some_and(|service| service_status_is_running(&service.status)); Some(DetectedProxyfier { service_name: detected_service - .map(|service| service.name.clone()) - .or_else(|| service_name(&self.engine).map(str::to_string)), - service_status: detected_service.map(|service| service.status.clone()), + .as_ref() + .map(|service| service.name.clone()), + service_status: detected_service + .as_ref() + .map(|service| service.status.clone()), engine: self.engine, name: self.name, install_dir: self.install_dir, @@ -539,19 +553,36 @@ fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> { } } -fn detect_proxifyre_service(host: &impl ProxyfierDetectionHost) -> Option { +fn detect_proxifyre_service( + host: &impl ProxyfierDetectionHost, + executable_path: &Path, +) -> Option { for name in ["ProxiFyreService", "ProxiFyre"] { - if let Some(status) = host.service_status(name) { - return Some(DetectedService { - name: name.to_string(), - status: normalize_service_status(&status), + if let Some(mut service) = host.service_info(name) { + let matches_executable = service.path_name.as_deref().is_some_and(|path_name| { + service_path_matches_executable(path_name, executable_path) }); + if matches_executable { + service.status = normalize_service_status(&service.status); + return Some(service); + } } } None } +pub fn service_path_matches_executable(path_name: &str, executable_path: &Path) -> bool { + let path_name = path_name.trim(); + let candidate = if let Some(rest) = path_name.strip_prefix('"') { + rest.split_once('"').map(|(path, _)| path) + } else { + path_name.split_whitespace().next() + }; + + candidate.is_some_and(|candidate| same_path(Path::new(candidate), executable_path)) +} + fn normalize_service_status(status: &str) -> String { status.trim().to_ascii_lowercase() } diff --git a/src-tauri/src/component_status.rs b/src-tauri/src/component_status.rs new file mode 100644 index 0000000..9e16ba7 --- /dev/null +++ b/src-tauri/src/component_status.rs @@ -0,0 +1,156 @@ +//! Live component status resolution and read-only route/profile presentation. + +use crate::command_dto::{CommandError, ResolvedAppDto}; +use crate::component_detection::{ + detect_proxyfier_install, detect_singbox_install, proxyfier_component_from_detection, + singbox_component_from_detection, DetectedProxyfier, DetectedSingBox, +}; +use crate::models::{ + ComponentId, ComponentState, ComponentStatus, ProfileItem, ProfileItemType, Target, +}; +use crate::storage::JsonStorage; + +pub(crate) fn components_or_defaults( + storage: &JsonStorage, +) -> Result, CommandError> { + components_or_defaults_with_detection( + storage, + detect_proxyfier_install(), + detect_singbox_install(), + ) +} + +pub(crate) fn components_or_defaults_with_detection( + storage: &JsonStorage, + detected_proxyfier: Option, + detected_singbox: Option, +) -> Result, CommandError> { + let components = storage.read_components().map_err(storage_error)?; + Ok(resolve_component_statuses( + components, + detected_proxyfier, + detected_singbox, + )) +} + +pub fn resolve_component_statuses( + stored_components: Vec, + detected_proxyfier: Option, + detected_singbox: Option, +) -> Vec { + let mut components = default_components(); + + for component in stored_components { + upsert_component(&mut components, component); + } + + upsert_component( + &mut components, + proxyfier_component_from_detection(detected_proxyfier.as_ref()), + ); + upsert_component( + &mut components, + singbox_component_from_detection(detected_singbox.as_ref()), + ); + + components +} + +fn default_components() -> Vec { + vec![ + ComponentStatus { + id: ComponentId::ControlApp, + name: "Приложение управления".to_string(), + state: ComponentState::Running, + installed: true, + running: true, + version: None, + path: None, + service_name: None, + service_status: None, + problems: Vec::new(), + actions: vec![ + "Открыть журнал".to_string(), + "Скопировать диагностику".to_string(), + ], + }, + ComponentStatus { + id: ComponentId::Proxyfier, + name: "ProxiFyre".to_string(), + state: ComponentState::Missing, + installed: false, + running: false, + version: None, + path: None, + service_name: Some("ProxiFyreService".to_string()), + service_status: None, + problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()], + actions: vec!["Установить ProxiFyre".to_string()], + }, + ComponentStatus { + id: ComponentId::Singbox, + name: "Локальный sing-box".to_string(), + state: ComponentState::Missing, + installed: false, + running: false, + version: None, + path: None, + service_name: Some(crate::models::DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()), + service_status: None, + problems: Vec::new(), + actions: vec!["Установить локальный sing-box".to_string()], + }, + ] +} + +fn upsert_component(components: &mut Vec, component: ComponentStatus) { + match components + .iter() + .position(|existing| existing.id == component.id) + { + Some(index) => components[index] = component, + None => components.push(component), + } +} + +pub(crate) fn route_line(active_target: Option<&Target>) -> String { + match active_target { + Some(target) if target.id == "local-singbox" => { + format!( + "Выбранные приложения -> ProxiFyre -> локальный sing-box {}:{} -> VPN", + target.host, target.port + ) + } + Some(target) => format!( + "Выбранные приложения -> ProxiFyre -> внешний прокси {}:{}", + target.host, target.port + ), + None => "Выбранные приложения -> ProxiFyre -> внешний прокси".to_string(), + } +} + +pub(crate) fn resolved_app(item: &ProfileItem, warnings: &mut Vec) -> ResolvedAppDto { + let mut notes = Vec::new(); + match item.item_type { + ProfileItemType::Process => notes.push("Имя процесса используется напрямую".to_string()), + ProfileItemType::Folder => { + let note = "Сканирование папок отложено; ProxiFyre получает путь к папке"; + notes.push(note.to_string()); + warnings.push(note.to_string()); + } + ProfileItemType::Exe => { + notes.push("Путь к EXE сохраняется для сопоставления в ProxiFyre".to_string()) + } + } + + ResolvedAppDto { + source_type: item.item_type.clone(), + source_value: item.value.clone(), + app_name: item.value.clone(), + notes, + } +} + +fn storage_error(error: std::io::Error) -> CommandError { + CommandError::new("storage_error", error.to_string()) +} diff --git a/src-tauri/src/configuration_use_case.rs b/src-tauri/src/configuration_use_case.rs new file mode 100644 index 0000000..d591460 --- /dev/null +++ b/src-tauri/src/configuration_use_case.rs @@ -0,0 +1,430 @@ +//! Persisted profiles/targets, startup snapshot, ProxiFyre bootstrap import, and preview use cases. + +use crate::adapters::proxifyre::{ProxiFyreConfig, ProxiFyreProxy}; +use crate::admin::admin_status; +use crate::command_dto::*; +use crate::component_detection::{ + default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install, + detect_singbox_install, +}; +use crate::component_status::{ + components_or_defaults, resolve_component_statuses, resolved_app, route_line, +}; +use crate::models::{ + Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind, +}; +use crate::proxifyre_runtime::build_proxifyre_setup_status_with_detection; +use crate::singbox_service::build_singbox_setup_status_with_install_root; +use crate::singbox_subscription::read_singbox_status_with_detection; +use crate::storage::JsonStorage; +use crate::validation::{normalize_profile, normalize_target, ValidationError}; +use std::fs; +use std::path::Path; + +const MAIN_PROFILE_ID: &str = "main-profile"; +const MAIN_TARGET_ID: &str = "main-proxy"; + +pub fn build_status(storage: &JsonStorage) -> Result { + let profiles = storage.read_profiles().map_err(storage_error)?; + let targets = storage.read_targets().map_err(storage_error)?; + let components = components_or_defaults(storage)?; + let activity = storage.read_activity().map_err(storage_error)?; + let active_profile_count = profiles.iter().filter(|profile| profile.enabled).count(); + let routed_app_count = profiles + .iter() + .filter(|profile| profile.enabled) + .map(|profile| profile.items.len()) + .sum(); + let active_target = profiles + .iter() + .find(|profile| profile.enabled) + .and_then(|profile| targets.iter().find(|target| target.id == profile.target_id)); + let route_line = route_line(active_target); + + Ok(StatusResponse { + route_line, + active_profile_count, + routed_app_count, + active_target: active_target.map(TargetDto::from), + components: components.iter().map(ComponentStatusDto::from).collect(), + recent_activity: activity + .iter() + .take(10) + .map(ActivityEntryDto::from) + .collect(), + generated_config_path: storage + .paths() + .generated_dir + .join("proxifyre-app-config.json") + .display() + .to_string(), + }) +} + +pub fn read_profiles(storage: &JsonStorage) -> Result, CommandError> { + storage + .read_profiles() + .map_err(storage_error) + .map(|profiles| profiles.iter().map(ProfileDto::from).collect()) +} + +pub fn save_profile_to_storage( + storage: &JsonStorage, + input: ProfileInputDto, +) -> Result { + let profile = normalize_profile(input.into()).map_err(validation_error)?; + let mut profiles = storage.read_profiles().map_err(storage_error)?; + + match profiles + .iter() + .position(|existing| existing.id == profile.id) + { + Some(index) => profiles[index] = profile.clone(), + None => profiles.push(profile.clone()), + } + + storage.write_profiles(&profiles).map_err(storage_error)?; + Ok(ProfileDto::from(&profile)) +} + +pub fn read_targets(storage: &JsonStorage) -> Result, CommandError> { + storage + .read_targets() + .map_err(storage_error) + .map(|targets| targets.iter().map(TargetDto::from).collect()) +} + +pub fn save_target_to_storage( + storage: &JsonStorage, + input: TargetInputDto, +) -> Result { + let target = normalize_target(input.into()).map_err(validation_error)?; + let mut targets = storage.read_targets().map_err(storage_error)?; + + match targets.iter().position(|existing| existing.id == target.id) { + Some(index) => targets[index] = target.clone(), + None => targets.push(target.clone()), + } + + storage.write_targets(&targets).map_err(storage_error)?; + Ok(TargetDto::from(&target)) +} + +pub fn read_components(storage: &JsonStorage) -> Result, CommandError> { + components_or_defaults(storage).map(|components| { + components + .iter() + .map(ComponentStatusDto::from) + .collect::>() + }) +} + +pub fn read_startup_snapshot( + storage: &JsonStorage, +) -> Result { + let detected_proxyfier = detect_proxyfier_install(); + let detected_singbox = detect_singbox_install(); + let saved_state = read_saved_state_with_proxifyre_config( + storage, + detected_proxyfier + .as_ref() + .and_then(|detected| detected.config_path.as_deref()), + )?; + let stored_components = storage.read_components().map_err(storage_error)?; + let components = resolve_component_statuses( + stored_components, + detected_proxyfier.clone(), + detected_singbox.clone(), + ) + .iter() + .map(ComponentStatusDto::from) + .collect(); + let proxifyre_setup_status = build_proxifyre_setup_status_with_detection( + detected_proxyfier.as_ref(), + &default_proxifyre_install_dir(), + ); + let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?; + let singbox_setup_status = build_singbox_setup_status_with_install_root( + detected_singbox.as_ref(), + &default_singbox_install_dir(), + ); + + Ok(StartupSnapshotResponse { + admin_status: admin_status(), + saved_state, + components, + proxifyre_setup_status, + singbox_status, + singbox_setup_status, + }) +} + +pub fn read_activity(storage: &JsonStorage) -> Result, CommandError> { + storage + .read_activity() + .map_err(storage_error) + .map(|entries| entries.iter().map(ActivityEntryDto::from).collect()) +} + +pub fn read_saved_state(storage: &JsonStorage) -> Result { + let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path); + read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref()) +} + +pub fn read_saved_state_with_proxifyre_config( + storage: &JsonStorage, + proxifyre_config_path: Option<&Path>, +) -> Result { + let mut profiles = storage.read_profiles().map_err(storage_error)?; + let mut targets = storage.read_targets().map_err(storage_error)?; + + if should_bootstrap_profiles(&profiles) { + if let Some(imported) = + proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config) + { + profiles = imported.profiles; + upsert_targets(&mut targets, imported.targets); + storage.write_targets(&targets).map_err(storage_error)?; + storage.write_profiles(&profiles).map_err(storage_error)?; + } + } + + Ok(SavedStateResponse { + profiles: profiles.iter().map(ProfileDto::from).collect(), + targets: targets.iter().map(TargetDto::from).collect(), + generated_config_path: storage + .paths() + .generated_dir + .join("proxifyre-app-config.json") + .display() + .to_string(), + }) +} + +struct ImportedSavedState { + profiles: Vec, + targets: Vec, +} + +fn should_bootstrap_profiles(profiles: &[Profile]) -> bool { + !profiles + .iter() + .any(|profile| profile.enabled && !profile.items.is_empty()) +} + +fn import_saved_state_from_proxifyre_config(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?; + + let proxy_entries = config + .proxies + .iter() + .filter_map(import_proxy_entry) + .collect::>(); + if proxy_entries.is_empty() { + return None; + } + + let single_entry = proxy_entries.len() == 1; + let mut profiles = Vec::with_capacity(proxy_entries.len()); + let mut targets = Vec::with_capacity(proxy_entries.len()); + + for (index, entry) in proxy_entries.into_iter().enumerate() { + let ordinal = index + 1; + let target_id = if single_entry { + MAIN_TARGET_ID.to_string() + } else { + format!("proxifyre-import-target-{ordinal}") + }; + let profile_id = if single_entry { + MAIN_PROFILE_ID.to_string() + } else { + format!("proxifyre-import-profile-{ordinal}") + }; + let profile_name = if single_entry { + "Приложения через прокси".to_string() + } else { + format!("Импорт ProxiFyre {ordinal}") + }; + + targets.push(Target { + id: target_id.clone(), + name: if single_entry { + "Основной прокси".to_string() + } else { + format!("Прокси ProxiFyre {ordinal}") + }, + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host: entry.host, + port: entry.port, + requires_component: None, + }); + profiles.push(Profile { + id: profile_id, + name: profile_name, + enabled: true, + target_id, + protocols: entry.protocols, + items: entry.items, + }); + } + + Some(ImportedSavedState { profiles, targets }) +} + +struct ImportedProxyEntry { + items: Vec, + protocols: Vec, + host: String, + port: u16, +} + +fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option { + let items = proxy + .app_names + .iter() + .filter_map(|name| imported_profile_item(name)) + .collect::>(); + if items.is_empty() { + return None; + } + + let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?; + + Some(ImportedProxyEntry { + items, + protocols: imported_protocols(&proxy.supported_protocols), + host, + port, + }) +} + +fn imported_profile_item(raw_value: &str) -> Option { + let value = raw_value.trim().trim_matches('"'); + if value.is_empty() { + return None; + } + + let looks_like_path = value.contains('\\') || value.contains('/'); + let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") { + ProfileItemType::Exe + } else if looks_like_path { + ProfileItemType::Folder + } else { + ProfileItemType::Process + }; + let value = match item_type { + ProfileItemType::Process => { + let base = value.rsplit(['\\', '/']).next().unwrap_or(value); + if base.to_ascii_lowercase().ends_with(".exe") { + base[..base.len() - 4].to_string() + } else { + base.to_string() + } + } + ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(), + }; + + if value.is_empty() { + return None; + } + + Some(ProfileItem { + recursive: matches!(item_type, ProfileItemType::Folder), + item_type, + value, + }) +} + +fn imported_protocols(values: &[String]) -> Vec { + let mut protocols = Vec::new(); + for value in values { + let protocol = match value.trim().to_ascii_uppercase().as_str() { + "TCP" => Protocol::Tcp, + "UDP" => Protocol::Udp, + _ => continue, + }; + if !protocols.contains(&protocol) { + protocols.push(protocol); + } + } + + if protocols.is_empty() { + vec![Protocol::Tcp, Protocol::Udp] + } else { + protocols + } +} + +fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> { + let endpoint = endpoint.trim(); + let endpoint = if endpoint + .get(.."socks5://".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://")) + { + &endpoint["socks5://".len()..] + } else { + endpoint + }; + if endpoint.is_empty() { + return None; + } + + if let Some(rest) = endpoint.strip_prefix('[') { + let (host, rest) = rest.split_once(']')?; + let port = rest.strip_prefix(':')?.parse::().ok()?; + let host = host.trim(); + return (!host.is_empty()).then(|| (host.to_string(), port)); + } + + let (host, port) = endpoint.rsplit_once(':')?; + let host = host.trim(); + let port = port.trim().parse::().ok()?; + (!host.is_empty()).then(|| (host.to_string(), port)) +} + +fn upsert_targets(targets: &mut Vec, imported_targets: Vec) { + for target in imported_targets { + match targets.iter().position(|existing| existing.id == target.id) { + Some(index) => targets[index] = target, + None => targets.push(target), + } + } +} + +pub fn resolve_preview( + input: ProfileInputDto, +) -> Result { + let profile = normalize_profile(input.into()).map_err(validation_error)?; + let mut warnings = Vec::new(); + let apps = profile + .items + .iter() + .map(|item| resolved_app(item, &mut warnings)) + .collect(); + + Ok(ResolveProfilePreviewResponse { + profile_id: profile.id, + apps, + warnings, + }) +} + +fn storage_error(error: std::io::Error) -> CommandError { + CommandError::new("storage_error", error.to_string()) +} + +fn validation_error(errors: Vec) -> CommandError { + CommandError::with_details( + "validation_error", + "Проверка введенных данных не прошла", + errors + .into_iter() + .map(|error| ValidationIssue { + field: error.field, + message: error.message, + }) + .collect(), + ) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e8c2d9..c69a8b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,27 @@ pub mod activity; +pub mod admin; +pub mod apply_flow; +pub mod clock; +pub mod command_dto; pub mod commands; pub mod component_detection; +pub mod component_status; +pub mod configuration_use_case; pub mod elevated_scripts; pub mod helper; pub mod models; +mod powershell; pub mod process; +pub mod proxifyre_ownership; +pub mod proxifyre_runtime; +pub mod proxifyre_scripts; +pub mod proxy_apply; +pub mod proxy_probe; pub mod safe_fs; +pub mod singbox_config; +pub mod singbox_runtime; pub mod singbox_service; +pub mod singbox_subscription; pub mod storage; pub mod subscription; pub mod validation; @@ -22,21 +37,14 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .manage(commands::CommandState::default()) .invoke_handler(tauri::generate_handler![ - commands::get_status, - commands::get_admin_status, commands::restart_as_admin, commands::get_startup_snapshot, - commands::get_profiles, commands::get_saved_state, - commands::save_profile, - commands::get_targets, - commands::save_target, commands::get_components, commands::get_proxifyre_setup_status, commands::get_proxifyre_setup_progress, commands::get_singbox_status, commands::get_singbox_setup_status, - commands::resolve_profile_preview, commands::save_singbox_subscription, commands::fetch_singbox_subscription, commands::forget_singbox_subscription, @@ -45,9 +53,7 @@ pub fn run() { commands::ping_all_singbox_servers, commands::ping_proxy_target, commands::generate_singbox_config, - commands::apply_profiles, - commands::get_logs, - commands::open_config_location, + commands::apply_configuration, commands::start_proxifyre_service, commands::stop_proxifyre_service, commands::install_proxifyre, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index c114c87..2253837 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -57,6 +57,7 @@ pub enum ComponentState { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ProfileItemInput { #[serde(rename = "type")] pub item_type: String, @@ -66,6 +67,7 @@ pub struct ProfileItemInput { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ProfileInput { pub id: Option, pub name: String, @@ -98,6 +100,7 @@ pub struct Profile { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct TargetInput { pub id: Option, pub name: String, @@ -149,6 +152,8 @@ pub struct LocalSingBoxConfig { pub device_hwid: Option, #[serde(default)] pub selected_server_tag: Option, + #[serde(default)] + pub selected_server_id: Option, #[serde(default = "default_local_singbox_listen_host")] pub listen_host: String, #[serde(default = "default_local_singbox_listen_port")] @@ -181,6 +186,7 @@ impl Default for LocalSingBoxConfig { subscription_url: None, device_hwid: None, selected_server_tag: None, + selected_server_id: None, listen_host: default_local_singbox_listen_host(), listen_port: default_local_singbox_listen_port(), service_name: default_local_singbox_service_name(), @@ -204,6 +210,7 @@ impl SubscriptionCache { pub fn normalize_percent_encoded_tags(&mut self) { for server in &mut self.servers { server.tag = decode_percent_encoded_utf8(&server.tag); + server.ensure_id(); } let Some(outbounds) = self @@ -232,6 +239,8 @@ impl SubscriptionCache { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SubscriptionServer { + #[serde(default)] + pub id: String, pub tag: String, #[serde(rename = "type")] pub server_type: String, @@ -239,6 +248,34 @@ pub struct SubscriptionServer { pub server_port: u16, } +impl SubscriptionServer { + pub fn ensure_id(&mut self) { + if self.id.trim().is_empty() { + self.id = subscription_server_id( + &self.server_type, + &self.tag, + &self.server, + self.server_port, + ); + } + } +} + +pub fn subscription_server_id( + server_type: &str, + tag: &str, + server: &str, + server_port: u16, +) -> String { + format!( + "{}|{}|{}|{}", + server_type.trim().to_ascii_lowercase(), + tag.trim(), + server.trim().to_ascii_lowercase(), + server_port + ) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActivityEntry { pub id: String, diff --git a/src-tauri/src/powershell.rs b/src-tauri/src/powershell.rs new file mode 100644 index 0000000..c410a0f --- /dev/null +++ b/src-tauri/src/powershell.rs @@ -0,0 +1,120 @@ +//! Shared PowerShell execution boundary for fixed ProxyWarden scripts. +//! +//! Callers remain responsible for generating static script templates and for +//! validating every path or service identifier before invoking this module. + +use crate::process::command_no_window; +use std::{fs, path::Path, process::Output}; + +pub(crate) fn write_script(path: &Path, script: &str) -> std::io::Result<()> { + let mut bytes = Vec::with_capacity(script.len() + 3); + bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); + bytes.extend_from_slice(script.as_bytes()); + fs::write(path, bytes) +} + +pub(crate) fn run_command(script: &str) -> std::io::Result { + command_no_window("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]) + .output() +} + +pub(crate) fn run_file(script_path: &Path) -> std::io::Result { + command_no_window("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + ]) + .arg(script_path) + .output() +} + +pub(crate) fn is_elevated() -> bool { + if !cfg!(windows) { + return false; + } + + let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#; + let Ok(output) = run_command(script) else { + return false; + }; + + output.status.success() + && String::from_utf8_lossy(&output.stdout) + .trim() + .eq_ignore_ascii_case("true") +} + +pub(crate) fn output_message(output: &Output, fallback: &str) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !stderr.is_empty() { + return stderr; + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stdout.is_empty() { + return stdout; + } + + fallback.to_string() +} + +pub(crate) fn package_failure_details(result_path: &Path, output: &Output) -> String { + let mut parts = Vec::new(); + + if let Ok(contents) = fs::read_to_string(result_path) { + let details = compact_error_text(&contents); + if !details.is_empty() && !details.eq_ignore_ascii_case("ok") { + parts.push(details); + } + } + + let stdout = compact_error_text(&String::from_utf8_lossy(&output.stdout)); + if !stdout.is_empty() { + parts.push(format!("stdout: {stdout}")); + } + + let stderr = compact_error_text(&String::from_utf8_lossy(&output.stderr)); + if !stderr.is_empty() { + parts.push(format!("stderr: {stderr}")); + } + + if parts.is_empty() { + parts.push( + "Лог elevated-скрипта не создан. Обычно это значит, что окно UAC было отменено или Windows не дала запустить elevated PowerShell." + .to_string(), + ); + } + + parts.join(" ") +} + +fn compact_error_text(value: &str) -> String { + let text = value + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" "); + + const MAX_CHARS: usize = 1400; + if text.chars().count() <= MAX_CHARS { + return text; + } + + format!("{}...", text.chars().take(MAX_CHARS).collect::()) +} + +pub(crate) fn escape_single(value: &str) -> String { + value.replace('\'', "''") +} diff --git a/src-tauri/src/proxifyre_ownership.rs b/src-tauri/src/proxifyre_ownership.rs new file mode 100644 index 0000000..7086882 --- /dev/null +++ b/src-tauri/src/proxifyre_ownership.rs @@ -0,0 +1,104 @@ +//! Ownership proof for destructive ProxiFyre uninstall operations. + +use serde::Deserialize; +use std::{fs, path::Path}; + +pub const PROXIFYRE_MARKER_FILE: &str = "proxywarden-component.json"; +pub const PROXIFYRE_MANAGED_SERVICE_NAME: &str = "ProxiFyreService"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedProxiFyreOwnership { + pub service_name: String, + pub remove_packet_filter: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProxiFyreInstallMarker { + manager: String, + component: String, + service_name: String, + install_root: String, + #[serde(default)] + packet_filter_installed_by_proxy_warden: bool, +} + +pub fn verify_managed_proxifyre_install( + install_dir: &Path, + executable_path: &Path, + expected_install_dir: &Path, +) -> Result { + let install_dir = canonical_path(install_dir, "папку ProxiFyre")?; + let expected_install_dir = canonical_path(expected_install_dir, "ожидаемую папку ProxiFyre")?; + if install_dir != expected_install_dir { + return Err(format!( + "папка {} не является управляемой папкой {}", + install_dir.display(), + expected_install_dir.display() + )); + } + + let has_expected_shape = install_dir + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre")) + && install_dir + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("components")); + if !has_expected_shape { + return Err("управляемая папка должна оканчиваться на components\\ProxiFyre".to_string()); + } + + let executable_path = canonical_path(executable_path, "ProxiFyre.exe")?; + if executable_path.parent() != Some(install_dir.as_path()) + || !executable_path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre.exe")) + { + return Err("обнаруженный ProxiFyre.exe находится вне управляемой папки".to_string()); + } + + let marker_path = install_dir.join(PROXIFYRE_MARKER_FILE); + let marker_text = fs::read_to_string(&marker_path).map_err(|error| { + format!( + "не удалось прочитать marker установки {}: {error}", + marker_path.display() + ) + })?; + let marker: ProxiFyreInstallMarker = serde_json::from_str(&marker_text).map_err(|error| { + format!( + "marker установки {} содержит некорректный JSON: {error}", + marker_path.display() + ) + })?; + + if !marker.manager.eq_ignore_ascii_case("ProxyWarden") + || !marker.component.eq_ignore_ascii_case("proxifyre") + { + return Err("marker установки не подтверждает владение ProxyWarden/ProxiFyre".to_string()); + } + if !marker + .service_name + .eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME) + { + return Err("marker установки содержит неподдерживаемое имя службы".to_string()); + } + + let marker_root = canonical_path(Path::new(&marker.install_root), "installRoot из marker")?; + if marker_root != install_dir { + return Err("installRoot из marker не совпадает с управляемой папкой".to_string()); + } + + Ok(ManagedProxiFyreOwnership { + service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_string(), + remove_packet_filter: marker.packet_filter_installed_by_proxy_warden, + }) +} + +fn canonical_path(path: &Path, label: &str) -> Result { + fs::canonicalize(path) + .map_err(|error| format!("не удалось проверить {label} '{}': {error}", path.display())) +} diff --git a/src-tauri/src/proxifyre_runtime.rs b/src-tauri/src/proxifyre_runtime.rs new file mode 100644 index 0000000..98d969b --- /dev/null +++ b/src-tauri/src/proxifyre_runtime.rs @@ -0,0 +1,1146 @@ +//! Explicit ProxiFyre service, package, setup-status, and UAC orchestration. +//! +//! The webview cannot execute these scripts directly. Tauri handlers call the +//! bounded functions here only after explicit user actions. + +use crate::clock::{Clock, SystemClock}; +use crate::command_dto::*; +use crate::component_detection::{ + detect_proxyfier_install, proxifyre_install_dir_from_app_dir, + proxyfier_component_from_detection, singbox_install_dir_from_app_dir, DetectedProxyfier, +}; +use crate::elevated_scripts; +use crate::powershell::{ + escape_single as escape_powershell_single, is_elevated as is_running_elevated, + package_failure_details, run_command as run_powershell_command, + run_file as run_powershell_file, write_script as write_powershell_script, +}; +use crate::process::command_no_window; +use crate::proxifyre_ownership::verify_managed_proxifyre_install; +use crate::proxifyre_scripts::{install_proxifyre_script_for_target, uninstall_proxifyre_script}; +use crate::safe_fs; +use crate::storage::{default_config_root, JsonStorage}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::{env, fs}; +use tauri::Manager; + +pub(crate) fn control_proxifyre_service( + action: ServiceControlAction, +) -> Result { + let Some(detected) = detect_proxyfier_install() else { + return Err(CommandError::new( + "proxifyre_not_found", + "ProxiFyre не найден на компьютере.", + )); + }; + + let service_name = detected.service_name.as_deref().ok_or_else(|| { + CommandError::new( + action.error_code(), + "Служба ProxiFyre найдена не была или ее PathName не совпадает с обнаруженным ProxiFyre.exe. Управление службой заблокировано.", + ) + })?; + run_proxifyre_service_command(action, service_name, &detected.executable_path)?; + let refreshed = detect_proxyfier_install(); + let component = proxyfier_component_from_detection(refreshed.as_ref()); + + Ok(ComponentStatusDto::from(&component)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ServiceCommandOutput { + success: bool, + code: String, + service_name: Option, + status: Option, + process_id: Option, +} + +fn run_proxifyre_service_command( + action: ServiceControlAction, + service_name: &str, + executable_path: &Path, +) -> Result<(), CommandError> { + let escaped_service_name = escape_powershell_single(service_name); + let executable_path = executable_path.display().to_string(); + let escaped_executable_path = escape_powershell_single(&executable_path); + let action_name = match action { + ServiceControlAction::Start => "start", + ServiceControlAction::Stop => "stop", + }; + let script = format!( + r#" +$ErrorActionPreference = 'Stop' +$serviceName = '{escaped_service_name}' +$exePath = '{escaped_executable_path}' +$action = '{action_name}' +$service = $null + +function Get-ServiceBinaryPath([string]$pathName) {{ + if ([string]::IsNullOrWhiteSpace($pathName)) {{ return $null }} + $pathName = $pathName.Trim() + if ($pathName.StartsWith('"')) {{ + $closingQuote = $pathName.IndexOf('"', 1) + if ($closingQuote -lt 2) {{ return $null }} + return $pathName.Substring(1, $closingQuote - 1) + }} + return ($pathName -split '\s+', 2)[0] +}} + +function Find-ManagedProxiFyreService {{ + $escapedName = $serviceName.Replace("'", "''") + $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue + if ($null -eq $record) {{ return $null }} + $binaryPath = Get-ServiceBinaryPath $record.PathName + if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) {{ return $null }} + return Get-Service -Name $serviceName -ErrorAction SilentlyContinue +}} + +function Get-ServiceProcessId([string]$name) {{ + $escapedName = $name.Replace("'", "''") + $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue + if ($null -eq $record) {{ return 0 }} + return [int]$record.ProcessId +}} + +function Get-ServiceStatus([string]$name) {{ + $current = Get-Service -Name $name -ErrorAction SilentlyContinue + if ($null -eq $current) {{ return $null }} + return $current.Status.ToString() +}} + +function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{ + [PSCustomObject]@{{ + success = $success + code = $code + serviceName = if ($null -ne $service) {{ $service.Name }} else {{ $null }} + status = $status + processId = $processId + }} | ConvertTo-Json -Compress + exit 0 +}} + +$service = Find-ManagedProxiFyreService +if ($null -eq $service) {{ + Write-ServiceResult $false 'service_not_found' $null 0 +}} + +$status = $service.Status.ToString() +$processId = Get-ServiceProcessId $service.Name + +if ($action -eq 'start') {{ + if ($status -eq 'Running') {{ + Write-ServiceResult $true 'already_running' $status $processId + }} + + try {{ + Start-Service -Name $service.Name -ErrorAction Stop + $service = Get-Service -Name $service.Name + $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) + }} catch {{ + Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $service.Name) (Get-ServiceProcessId $service.Name) + }} + + Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $service.Name) +}} + +if ($status -eq 'Stopped') {{ + Write-ServiceResult $true 'already_stopped' $status $processId +}} + +try {{ + if ($service.CanStop) {{ + Stop-Service -Name $service.Name -Force -ErrorAction Stop + }} +}} catch {{}} + +try {{ + $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue + if ($null -ne $service -and $service.Status -ne 'Stopped') {{ + $null = & sc.exe stop $service.Name 2>$null + }} +}} catch {{}} + +try {{ + $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue + if ($null -ne $service -and $service.Status -ne 'Stopped') {{ + $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) + }} +}} catch {{}} + +$status = Get-ServiceStatus $service.Name +$processId = Get-ServiceProcessId $service.Name +if ($status -ne 'Stopped' -and $processId -gt 0) {{ + try {{ + $null = & taskkill.exe /PID $processId /F 2>$null + Start-Sleep -Milliseconds 700 + $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue + if ($null -ne $service) {{ + $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) + }} + }} catch {{}} +}} + +$status = Get-ServiceStatus $service.Name +$processId = Get-ServiceProcessId $service.Name +if ($status -eq 'Stopped') {{ + Write-ServiceResult $true 'stopped' $status $processId +}} + +Write-ServiceResult $false 'stop_failed' $status $processId +"# + ); + + let output = command_no_window("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script.as_str(), + ]) + .output() + .map_err(|error| { + CommandError::new( + action.error_code(), + format!("Не удалось {} службу ProxiFyre: {error}", action.label()), + ) + })?; + + let result = parse_service_command_output(&output.stdout).ok_or_else(|| { + CommandError::new( + action.error_code(), + service_script_failed_message(action, output.status.code()), + ) + })?; + + if result.success { + return Ok(()); + } + + if matches!(result.code.as_str(), "start_failed" | "stop_failed") { + run_elevated_proxifyre_service_command(action, service_name, &executable_path, &result)?; + return Ok(()); + } + + Err(CommandError::new( + action.error_code(), + service_command_failed_message(action, &result), + )) +} + +fn run_elevated_proxifyre_service_command( + action: ServiceControlAction, + service_name: &str, + executable_path: &str, + direct_result: &ServiceCommandOutput, +) -> Result<(), CommandError> { + let script_path = write_elevated_service_script(action, service_name, executable_path)?; + let launch_script = format!( + "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", + escape_powershell_single(&script_path.display().to_string()) + ); + let output = if is_running_elevated() { + run_powershell_file(&script_path) + } else { + run_powershell_command(&launch_script) + }; + + let _ = fs::remove_file(&script_path); + + match output { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(CommandError::new( + action.error_code(), + elevated_service_failed_message(action, direct_result, output.status.code()), + )), + Err(error) => Err(CommandError::new( + action.error_code(), + format!( + "Не удалось запросить права администратора, чтобы {} службу ProxiFyre: {error}", + action.label() + ), + )), + } +} + +fn write_elevated_service_script( + action: ServiceControlAction, + service_name: &str, + executable_path: &str, +) -> Result { + let script_path = elevated_scripts::temp_script_path("proxywarden-proxifyre-service"); + let script = elevated_service_script(action, service_name, executable_path); + + write_powershell_script(&script_path, &script).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось подготовить временный скрипт для управления ProxiFyre '{}': {error}", + script_path.display() + ), + ) + })?; + + Ok(script_path) +} + +fn elevated_service_script( + action: ServiceControlAction, + service_name: &str, + executable_path: &str, +) -> String { + let service_name = escape_powershell_single(service_name); + let executable_path = escape_powershell_single(executable_path); + let action_name = match action { + ServiceControlAction::Start => "start", + ServiceControlAction::Stop => "stop", + }; + + format!( + r#" +$ErrorActionPreference = 'SilentlyContinue' +$serviceName = '{service_name}' +$exePath = '{executable_path}' +$action = '{action_name}' +$service = $null + +function Get-ServiceBinaryPath([string]$pathName) {{ + if ([string]::IsNullOrWhiteSpace($pathName)) {{ return $null }} + $pathName = $pathName.Trim() + if ($pathName.StartsWith('"')) {{ + $closingQuote = $pathName.IndexOf('"', 1) + if ($closingQuote -lt 2) {{ return $null }} + return $pathName.Substring(1, $closingQuote - 1) + }} + return ($pathName -split '\s+', 2)[0] +}} + +function Find-ManagedProxiFyreService {{ + $escapedName = $serviceName.Replace("'", "''") + $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue + if ($null -eq $record) {{ return $null }} + $binaryPath = Get-ServiceBinaryPath $record.PathName + if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) {{ return $null }} + return Get-Service -Name $serviceName -ErrorAction SilentlyContinue +}} + +$service = Find-ManagedProxiFyreService +if ($null -eq $service) {{ exit 2 }} + +function Get-ServiceProcessId([string]$name) {{ + $escapedName = $name.Replace("'", "''") + $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue + if ($null -eq $record) {{ return 0 }} + return [int]$record.ProcessId +}} + +if ($action -eq 'start') {{ + if ($service.Status -eq 'Running') {{ exit 0 }} + Start-Service -Name $service.Name -ErrorAction SilentlyContinue + $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue + if ($null -ne $service) {{ + try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}} + if ($service.Status -eq 'Running') {{ exit 0 }} + }} + exit 3 +}} + +if ($service.Status -eq 'Stopped') {{ exit 0 }} + +if ($service.CanStop) {{ + Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue +}} + +$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue +if ($null -ne $service -and $service.Status -ne 'Stopped') {{ + $null = & sc.exe stop $service.Name 2>$null +}} + +$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue +if ($null -ne $service -and $service.Status -ne 'Stopped') {{ + try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}} +}} + +$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue +if ($null -ne $service -and $service.Status -ne 'Stopped') {{ + $processId = Get-ServiceProcessId $service.Name + if ($processId -gt 0) {{ + $null = & taskkill.exe /PID $processId /F 2>$null + Start-Sleep -Milliseconds 700 + $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue + if ($null -ne $service) {{ + try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}} + }} + }} +}} + +$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue +if ($null -eq $service -or $service.Status -eq 'Stopped') {{ exit 0 }} +exit 4 +"# + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProxiFyrePackageAction { + Install, + Uninstall, +} + +impl ProxiFyrePackageAction { + fn error_code(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "proxifyre_install_failed", + ProxiFyrePackageAction::Uninstall => "proxifyre_uninstall_failed", + } + } + + fn label(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "установить", + ProxiFyrePackageAction::Uninstall => "удалить", + } + } + + fn file_label(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "install", + ProxiFyrePackageAction::Uninstall => "uninstall", + } + } + + fn operation(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "install", + ProxiFyrePackageAction::Uninstall => "uninstall", + } + } + + fn start_message(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "Готовлю установку ProxiFyre.", + ProxiFyrePackageAction::Uninstall => "Готовлю удаление ProxiFyre и сетевого драйвера.", + } + } + + fn success_message(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "ProxiFyre и сетевой драйвер готовы.", + ProxiFyrePackageAction::Uninstall => "ProxiFyre и сетевой драйвер удалены.", + } + } +} + +pub(crate) fn install_proxifyre_component( + storage: &JsonStorage, + app: &tauri::AppHandle, +) -> Result { + let generated_config_path = storage + .paths() + .generated_dir + .join("proxifyre-app-config.json"); + let bundled_asset_dir = bundled_proxifyre_asset_dir(app); + let install_dir = proxifyre_install_dir_for_app(app)?; + let script = install_proxifyre_script_for_target( + &generated_config_path, + bundled_asset_dir.as_deref(), + &install_dir, + ); + + run_elevated_package_script( + ProxiFyrePackageAction::Install, + script, + &storage.paths().state_dir, + )?; + + if detect_windows_packet_filter().is_none() { + return Err(CommandError::new( + ProxiFyrePackageAction::Install.error_code(), + "Установка ProxiFyre завершилась, но Windows Packet Filter не найден после проверки.", + )); + } + + let refreshed = detect_proxyfier_install(); + let Some(detected) = refreshed.as_ref() else { + return Err(CommandError::new( + ProxiFyrePackageAction::Install.error_code(), + "Установка ProxiFyre завершилась, но приложение не найдено после проверки.", + )); + }; + + Ok(ComponentStatusDto::from( + &proxyfier_component_from_detection(Some(detected)), + )) +} + +fn bundled_proxifyre_asset_dir(app: &tauri::AppHandle) -> Option { + let mut candidates = Vec::new(); + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join("bundled").join("proxifyre")); + } + candidates.push( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("bundled") + .join("proxifyre"), + ); + + candidates.into_iter().find(|path| path.is_dir()) +} + +fn app_install_dir(app: &tauri::AppHandle) -> Result { + if let Ok(exe_path) = env::current_exe() { + if let Some(parent) = exe_path.parent() { + return Ok(parent.to_path_buf()); + } + } + + app.path().resource_dir().map_err(|error| { + CommandError::new( + "app_install_dir_unavailable", + format!("Не удалось определить папку установки ProxyWarden: {error}"), + ) + }) +} + +pub(crate) fn proxifyre_install_dir_for_app( + app: &tauri::AppHandle, +) -> Result { + Ok(proxifyre_install_dir_from_app_dir(&app_install_dir(app)?)) +} + +pub(crate) fn singbox_install_dir_for_app(app: &tauri::AppHandle) -> Result { + Ok(singbox_install_dir_from_app_dir(&app_install_dir(app)?)) +} + +pub(crate) fn uninstall_proxifyre_component( + app: &tauri::AppHandle, +) -> Result { + let Some(detected) = detect_proxyfier_install() else { + let component = proxyfier_component_from_detection(None); + return Ok(ComponentStatusDto::from(&component)); + }; + + let expected_install_dir = proxifyre_install_dir_for_app(app)?; + let ownership = verify_managed_proxifyre_install( + &detected.install_dir, + &detected.executable_path, + &expected_install_dir, + ) + .map_err(|reason| { + CommandError::new( + ProxiFyrePackageAction::Uninstall.error_code(), + format!("Удаление ProxiFyre заблокировано: {reason}"), + ) + })?; + let script = uninstall_proxifyre_script(Some(&detected), &ownership); + + let artifact_dir = default_config_root().join("state"); + run_elevated_package_script(ProxiFyrePackageAction::Uninstall, script, &artifact_dir)?; + + let refreshed = detect_proxyfier_install(); + if refreshed.is_some() { + return Err(CommandError::new( + ProxiFyrePackageAction::Uninstall.error_code(), + "Удаление ProxiFyre завершилось, но приложение все еще найдено на компьютере.", + )); + } + if ownership.remove_packet_filter && detect_windows_packet_filter().is_some() { + return Err(CommandError::new( + ProxiFyrePackageAction::Uninstall.error_code(), + "Удаление ProxiFyre завершилось, но Windows Packet Filter все еще найден на компьютере.", + )); + } + + let component = proxyfier_component_from_detection(None); + Ok(ComponentStatusDto::from(&component)) +} + +pub(crate) fn build_proxifyre_setup_status_for_install_dir( + install_dir: &Path, +) -> ProxiFyreSetupStatusDto { + let proxifyre = detect_proxyfier_install(); + build_proxifyre_setup_status_with_detection(proxifyre.as_ref(), install_dir) +} + +pub(crate) fn build_proxifyre_setup_status_with_detection( + proxifyre: Option<&DetectedProxyfier>, + default_install_dir: &Path, +) -> ProxiFyreSetupStatusDto { + let vc_runtime = detect_vc_runtime(); + let packet_filter = detect_windows_packet_filter(); + + let vc_runtime_item = setup_item_from_program( + "vc-runtime", + &format!("Microsoft Visual C++ Runtime ({})", runtime_arch_label()), + vc_runtime, + "Нужен для запуска ProxiFyre.exe. Установщик скачает официальный vc_redist от Microsoft.", + ); + let packet_filter_item = setup_item_from_program( + "packet-filter", + "Windows Packet Filter", + packet_filter, + "Сетевой драйвер NT Kernel/WireSock, через который ProxiFyre перехватывает трафик приложений.", + ); + let proxifyre_item = match proxifyre { + Some(detected) => ProxiFyreSetupItemDto { + id: "proxifyre".to_string(), + name: "ProxiFyre".to_string(), + installed: true, + version: Some(proxifyre_service_setup_version(detected)), + details: detected.install_dir.display().to_string(), + }, + None => ProxiFyreSetupItemDto { + id: "proxifyre".to_string(), + name: "ProxiFyre".to_string(), + installed: false, + version: None, + details: format!( + "Будет установлен рядом с ProxyWarden в {}.", + default_install_dir.display() + ), + }, + }; + + let items = vec![vc_runtime_item, packet_filter_item, proxifyre_item]; + let missing_count = items.iter().filter(|item| !item.installed).count(); + + ProxiFyreSetupStatusDto { + ready: missing_count == 0, + missing_count, + items, + } +} + +fn proxifyre_service_setup_version(detected: &DetectedProxyfier) -> String { + match detected.service_status.as_deref() { + Some(status) if status.eq_ignore_ascii_case("running") => "служба запущена".to_string(), + Some(_) => "служба остановлена".to_string(), + None => "служба не установлена".to_string(), + } +} + +fn proxifyre_progress_path(state_dir: &Path) -> PathBuf { + state_dir.join("proxifyre-setup-progress.json") +} + +fn idle_proxifyre_setup_progress() -> ProxiFyreSetupProgressDto { + ProxiFyreSetupProgressDto { + operation: "idle".to_string(), + status: "idle".to_string(), + active_step: None, + percent: 0, + message: "Ожидаю действия пользователя.".to_string(), + updated_at: None, + } +} + +pub(crate) fn read_proxifyre_setup_progress( + storage: &JsonStorage, +) -> Result { + let path = proxifyre_progress_path(&storage.paths().state_dir); + if !path.exists() { + return Ok(idle_proxifyre_setup_progress()); + } + + let contents = fs::read_to_string(&path).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_read_failed", + format!( + "Не удалось прочитать прогресс установки ProxiFyre '{}': {error}", + path.display() + ), + ) + })?; + + serde_json::from_str(&contents).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_parse_failed", + format!( + "Не удалось разобрать прогресс установки ProxiFyre '{}': {error}", + path.display() + ), + ) + }) +} + +fn write_proxifyre_setup_progress( + path: &Path, + operation: &str, + active_step: Option<&str>, + status: &str, + percent: u8, + message: &str, +) -> Result<(), CommandError> { + let progress = ProxiFyreSetupProgressDto { + operation: operation.to_string(), + status: status.to_string(), + active_step: active_step.map(str::to_string), + percent: percent.min(100), + message: message.to_string(), + updated_at: Some(SystemClock.now()), + }; + let bytes = serde_json::to_vec_pretty(&progress).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_write_failed", + format!("Не удалось подготовить прогресс установки ProxiFyre: {error}"), + ) + })?; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_write_failed", + format!( + "Не удалось создать папку прогресса установки ProxiFyre '{}': {error}", + parent.display() + ), + ) + })?; + } + + let temp_path = safe_fs::temp_path(path); + fs::write(&temp_path, bytes).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_write_failed", + format!( + "Не удалось записать прогресс установки ProxiFyre '{}': {error}", + temp_path.display() + ), + ) + })?; + fs::rename(&temp_path, path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + CommandError::new( + "proxifyre_setup_progress_write_failed", + format!( + "Не удалось обновить прогресс установки ProxiFyre '{}': {error}", + path.display() + ), + ) + }) +} + +fn setup_item_from_program( + id: &str, + name: &str, + program: Option, + missing_details: &str, +) -> ProxiFyreSetupItemDto { + match program { + Some(program) => ProxiFyreSetupItemDto { + id: id.to_string(), + name: name.to_string(), + installed: true, + version: program.display_version, + details: program.display_name, + }, + None => ProxiFyreSetupItemDto { + id: id.to_string(), + name: name.to_string(), + installed: false, + version: None, + details: missing_details.to_string(), + }, + } +} + +#[derive(Debug, Clone)] +struct InstalledProgram { + display_name: String, + display_version: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct InstalledProgramJson { + display_name: Option, + display_version: Option, +} + +fn detect_vc_runtime() -> Option { + installed_program(&vc_runtime_registry_pattern()) +} + +fn detect_windows_packet_filter() -> Option { + installed_program("Windows Packet Filter|WinpkFilter|NDISAPI") +} + +fn installed_program(pattern: &str) -> Option { + let script = format!( + r#" +$paths = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' +) +$program = Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | + Where-Object {{ $_.DisplayName -match '{}' }} | + Select-Object -First 1 DisplayName, DisplayVersion +if ($null -ne $program) {{ + $program | ConvertTo-Json -Compress +}} +"#, + escape_powershell_single(pattern) + ); + + let output = command_no_window("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script.as_str(), + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let payload = stdout.trim(); + if payload.is_empty() || payload.eq_ignore_ascii_case("null") { + return None; + } + + let parsed: InstalledProgramJson = serde_json::from_str(payload).ok()?; + let display_name = parsed.display_name?.trim().to_string(); + if display_name.is_empty() { + return None; + } + + Some(InstalledProgram { + display_name, + display_version: parsed + .display_version + .map(|version| version.trim().to_string()) + .filter(|version| !version.is_empty()), + }) +} + +fn vc_runtime_registry_pattern() -> String { + let arch = runtime_arch_label(); + if arch == "ARM64" { + return r"Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)".to_string(); + } + + format!(r"Microsoft Visual C\+\+.*Redistributable.*\({arch}\)") +} + +fn runtime_arch_label() -> &'static str { + if cfg!(target_arch = "aarch64") { + "ARM64" + } else if cfg!(target_arch = "x86") { + "x86" + } else { + "x64" + } +} + +fn run_elevated_package_script( + action: ProxiFyrePackageAction, + body: String, + artifact_dir: &Path, +) -> Result<(), CommandError> { + fs::create_dir_all(artifact_dir).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось создать папку для временных файлов ProxiFyre '{}': {error}", + artifact_dir.display() + ), + ) + })?; + 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 progress_path = proxifyre_progress_path(artifact_dir); + let _ = write_proxifyre_setup_progress( + &progress_path, + action.operation(), + None, + "running", + 1, + action.start_message(), + ); + let script = + wrap_elevated_package_script_for_action(&body, &result_path, Some(&progress_path), action); + + write_powershell_script(&script_path, &script).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось подготовить временный скрипт, чтобы {} ProxiFyre '{}': {error}", + action.label(), + script_path.display() + ), + ) + })?; + + let launch_script = format!( + r#" +$ErrorActionPreference = 'Stop' +$resultPath = '{}' +try {{ + $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') + if ($null -eq $p) {{ + Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 + exit 1 + }} + exit $p.ExitCode +}} catch {{ + Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 + exit 1 +}} +"#, + escape_powershell_single(&result_path.display().to_string()), + escape_powershell_single(&script_path.display().to_string()) + ); + let output = if is_running_elevated() { + run_powershell_file(&script_path) + } else { + run_powershell_command(&launch_script) + }; + + let _ = fs::remove_file(&script_path); + + match output { + Ok(output) if output.status.success() => { + let _ = fs::remove_file(&result_path); + let _ = write_proxifyre_setup_progress( + &progress_path, + action.operation(), + None, + "succeeded", + 100, + action.success_message(), + ); + Ok(()) + } + Ok(output) => { + let details = package_failure_details(&result_path, &output); + let _ = fs::remove_file(&result_path); + let _ = write_proxifyre_setup_progress( + &progress_path, + action.operation(), + None, + "failed", + 100, + &details, + ); + Err(CommandError::new( + action.error_code(), + format!( + "Не удалось {} ProxiFyre. Код elevated-команды: {}. {details}", + action.label(), + output.status.code().unwrap_or(-1), + ), + )) + } + Err(error) => { + let message = format!( + "Не удалось запросить права администратора, чтобы {} ProxiFyre: {error}", + action.label() + ); + let _ = write_proxifyre_setup_progress( + &progress_path, + action.operation(), + None, + "failed", + 100, + &message, + ); + Err(CommandError::new(action.error_code(), message)) + } + } +} + +pub fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String { + wrap_elevated_package_script_for_action( + body, + result_path, + None, + ProxiFyrePackageAction::Install, + ) +} + +fn wrap_elevated_package_script_for_action( + body: &str, + result_path: &Path, + progress_path: Option<&Path>, + action: ProxiFyrePackageAction, +) -> String { + let mut script = String::new(); + script.push_str("$ErrorActionPreference = 'Stop'\n"); + script.push_str(&format!( + "$resultPath = '{}'\n", + escape_powershell_single(&result_path.display().to_string()) + )); + script.push_str(&format!( + "$script:progressOperation = '{}'\n", + escape_powershell_single(action.operation()) + )); + script.push_str("$script:progressActiveStep = $null\n"); + if let Some(progress_path) = progress_path { + script.push_str(&format!( + "$progressPath = '{}'\n", + escape_powershell_single(&progress_path.display().to_string()) + )); + script.push_str( + r#" +function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) { + $script:progressOperation = $operation + $script:progressActiveStep = if ([string]::IsNullOrWhiteSpace($activeStep)) { $null } else { $activeStep } + $payload = [ordered]@{ + operation = $operation + status = $status + activeStep = $script:progressActiveStep + percent = [Math]::Max(0, [Math]::Min(100, $percent)) + message = $message + updatedAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + $progressTempPath = "$progressPath.tmp" + Set-Content -LiteralPath $progressTempPath -Value $payload -Encoding UTF8 + Move-Item -LiteralPath $progressTempPath -Destination $progressPath -Force +} +"#, + ); + } else { + script.push_str( + r#" +function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) {} +"#, + ); + } + script.push_str("try {\n"); + script.push_str(body); + script.push_str( + r#" + Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 + exit 0 +} catch { + $message = ($_ | Out-String) + Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'failed' 100 $message + Set-Content -LiteralPath $resultPath -Value $message -Encoding UTF8 + exit 1 +} +"#, + ); + + script +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ServiceControlAction { + Start, + Stop, +} + +impl ServiceControlAction { + fn error_code(self) -> &'static str { + match self { + ServiceControlAction::Start => "proxifyre_service_start_failed", + ServiceControlAction::Stop => "proxifyre_service_stop_failed", + } + } + + fn label(self) -> &'static str { + match self { + ServiceControlAction::Start => "запустить", + ServiceControlAction::Stop => "остановить", + } + } +} + +fn parse_service_command_output(stdout: &[u8]) -> Option { + let stdout = String::from_utf8_lossy(stdout); + let payload = stdout + .lines() + .rev() + .map(str::trim) + .find(|line| line.starts_with('{') && line.ends_with('}'))?; + + serde_json::from_str(payload).ok() +} + +fn service_script_failed_message(action: ServiceControlAction, exit_code: Option) -> String { + let exit_code = exit_code + .map(|code| format!(" Код выхода PowerShell: {code}.")) + .unwrap_or_default(); + + format!( + "Не удалось {} службу ProxiFyre: команда управления службой не вернула корректный результат.{exit_code}", + action.label() + ) +} + +fn service_command_failed_message( + action: ServiceControlAction, + result: &ServiceCommandOutput, +) -> String { + let service_name = result + .service_name + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("ProxiFyre"); + let status = result + .status + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("неизвестен"); + let pid = result + .process_id + .filter(|value| *value > 0) + .map(|value| format!(", PID: {value}")) + .unwrap_or_default(); + + match result.code.as_str() { + "service_not_found" => "Служба ProxiFyre не найдена.".to_string(), + "start_failed" => format!( + "Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора." + ), + "stop_failed" => format!( + "Не удалось остановить службу {service_name} даже после принудительной попытки. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc." + ), + _ => format!( + "Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.", + action.label() + ), + } +} + +fn elevated_service_failed_message( + action: ServiceControlAction, + direct_result: &ServiceCommandOutput, + exit_code: Option, +) -> String { + let service_name = direct_result + .service_name + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("ProxiFyre"); + let status = direct_result + .status + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("неизвестен"); + let pid = direct_result + .process_id + .filter(|value| *value > 0) + .map(|value| format!(", PID: {value}")) + .unwrap_or_default(); + let exit_code = exit_code + .map(|code| format!(" Код elevated-команды: {code}.")) + .unwrap_or_default(); + + format!( + "Не удалось {} службу {service_name} даже после запроса прав администратора. До запроса UAC статус был: {status}{pid}.{exit_code} Если появлялось окно UAC, проверь, что оно было подтверждено.", + action.label() + ) +} diff --git a/src-tauri/src/proxifyre_scripts.rs b/src-tauri/src/proxifyre_scripts.rs new file mode 100644 index 0000000..f9e12b7 --- /dev/null +++ b/src-tauri/src/proxifyre_scripts.rs @@ -0,0 +1,647 @@ +//! Static-template PowerShell generation for explicit ProxiFyre package actions. + +use crate::component_detection::{default_proxifyre_install_dir, DetectedProxyfier}; +use crate::powershell::escape_single as escape_powershell_single; +use crate::proxifyre_ownership::ManagedProxiFyreOwnership; +use std::path::Path; + +const PROXIFYRE_RELEASE_API_URL: &str = + "https://api.github.com/repos/wiresock/proxifyre/releases/latest"; +const NDISAPI_RELEASE_API_URL: &str = + "https://api.github.com/repos/wiresock/ndisapi/releases/latest"; +const PROXIFYRE_PINNED_RELEASE_TAG: &str = "v2.2.1"; +const NDISAPI_PINNED_RELEASE_TAG: &str = "v3.6.2"; +const NDISAPI_PINNED_INSTALLER_VERSION: &str = "3.6.2.1"; +const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe"; +const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe"; + +pub fn install_proxifyre_script(generated_config_path: &Path) -> String { + install_proxifyre_script_with_bundle(generated_config_path, None) +} + +pub fn install_proxifyre_script_with_bundle( + generated_config_path: &Path, + bundled_asset_dir: Option<&Path>, +) -> String { + install_proxifyre_script_for_target( + generated_config_path, + bundled_asset_dir, + &default_proxifyre_install_dir(), + ) +} + +pub fn install_proxifyre_script_for_target( + generated_config_path: &Path, + bundled_asset_dir: Option<&Path>, + target_dir: &Path, +) -> String { + let mut script = String::new(); + script.push_str(&format!( + "$targetDir = '{}'\n", + escape_powershell_single(&target_dir.display().to_string()) + )); + script.push_str(&format!( + "$generatedConfigPath = '{}'\n", + escape_powershell_single(&generated_config_path.display().to_string()) + )); + script.push_str(&format!( + "$bundledAssetDir = '{}'\n", + escape_powershell_single( + &bundled_asset_dir + .map(|path| path.display().to_string()) + .unwrap_or_default() + ) + )); + script.push_str("$script:bundledAssetDir = [string]$bundledAssetDir\n"); + script.push_str(&format!( + "$proxifyreReleaseApi = '{}'\n", + escape_powershell_single(PROXIFYRE_RELEASE_API_URL) + )); + script.push_str(&format!( + "$ndisapiReleaseApi = '{}'\n", + escape_powershell_single(NDISAPI_RELEASE_API_URL) + )); + script.push_str(&format!( + "$proxifyrePinnedReleaseTag = '{}'\n", + escape_powershell_single(PROXIFYRE_PINNED_RELEASE_TAG) + )); + script.push_str(&format!( + "$ndisapiPinnedReleaseTag = '{}'\n", + escape_powershell_single(NDISAPI_PINNED_RELEASE_TAG) + )); + script.push_str(&format!( + "$ndisapiPinnedInstallerVersion = '{}'\n", + escape_powershell_single(NDISAPI_PINNED_INSTALLER_VERSION) + )); + script.push_str(&format!( + "$vcRedistX64Url = '{}'\n", + escape_powershell_single(VC_REDIST_X64_URL) + )); + script.push_str(&format!( + "$vcRedistX86Url = '{}'\n", + escape_powershell_single(VC_REDIST_X86_URL) + )); + script.push_str( + r#" + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + function Get-NativeArchitecture { + $processor = Get-CimInstance Win32_Processor | Select-Object -First 1 + if ($null -ne $processor -and $processor.Architecture -eq 12) { return 'ARM64' } + if ([Environment]::Is64BitOperatingSystem) { return 'x64' } + return 'x86' + } + + function Get-SafeUriForLog([string]$uri) { + try { + $parsed = [Uri]$uri + $port = if ($parsed.IsDefaultPort) { '' } else { ":$($parsed.Port)" } + return "$($parsed.Scheme)://$($parsed.Host)$port$($parsed.AbsolutePath)" + } catch { + return '' + } + } + + function Invoke-ReleaseApi([string]$uri, [string]$label) { + $safeUri = Get-SafeUriForLog $uri + $headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/vnd.github+json' } + $lastError = $null + + foreach ($attempt in 1..3) { + try { + return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 -MaximumRedirection 10 + } catch { + $lastError = $_.Exception.Message + if ($attempt -lt 3) { + Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2)) + } + } + } + + throw "Не удалось получить metadata для $label ($safeUri): $lastError" + } + + function New-ReleaseAsset([string]$name, [string]$url) { + [PSCustomObject]@{ + name = $name + browser_download_url = $url + digest = $null + } + } + + function Resolve-ReleaseAsset([string]$apiUri, [string]$pattern, [string]$label, $fallbackAsset, [int]$fallbackPercent) { + try { + $release = Invoke-ReleaseApi $apiUri $label + return Select-Asset $release.assets $pattern $label + } catch { + $fallbackUri = Get-SafeUriForLog $fallbackAsset.browser_download_url + Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'running' $fallbackPercent "GitHub API недоступен для $label. Пробую прямую ссылку: $fallbackUri" + return $fallbackAsset + } + } + + function Get-PinnedProxiFyreAsset([string]$arch) { + $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } + $name = "ProxiFyre-$proxifyrePinnedReleaseTag-$archLabel-signed.zip" + $url = "https://github.com/wiresock/proxifyre/releases/download/$proxifyrePinnedReleaseTag/$name" + return New-ReleaseAsset $name $url + } + + function Get-PinnedWindowsPacketFilterAsset([string]$arch) { + $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } + $name = "Windows.Packet.Filter.$ndisapiPinnedInstallerVersion.$archLabel.msi" + $url = "https://github.com/wiresock/ndisapi/releases/download/$ndisapiPinnedReleaseTag/$name" + return New-ReleaseAsset $name $url + } + + function Complete-Download([string]$partialPath, [string]$path, [string]$label) { + if (-not (Test-Path -LiteralPath $partialPath)) { + throw "${label}: файл не был создан." + } + + $item = Get-Item -LiteralPath $partialPath + if ($item.Length -le 0) { + throw "${label}: скачанный файл пустой." + } + + Move-Item -LiteralPath $partialPath -Destination $path -Force + } + + function Invoke-WebClientDownload([string]$uri, [string]$partialPath) { + $client = New-Object System.Net.WebClient + try { + $client.Headers.Add('User-Agent', 'proxywarden') + $client.Headers.Add('Accept', 'application/octet-stream,*/*') + $client.DownloadFile($uri, $partialPath) + } finally { + $client.Dispose() + } + } + + function Invoke-CurlDownload([string]$uri, [string]$partialPath) { + $curl = Get-Command 'curl.exe' -ErrorAction SilentlyContinue + if ($null -eq $curl) { + throw 'curl.exe не найден.' + } + + $curlOutput = & $curl.Source --silent --show-error --fail --location --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 180 --user-agent 'proxywarden' --output $partialPath --url $uri 2>&1 + if ($LASTEXITCODE -ne 0) { + $curlMessage = ($curlOutput | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($curlMessage)) { + throw "curl.exe завершился с кодом $LASTEXITCODE." + } + + throw "curl.exe завершился с кодом ${LASTEXITCODE}: $curlMessage" + } + } + + function Invoke-Download([string]$uri, [string]$path, [string]$label) { + $safeUri = Get-SafeUriForLog $uri + $partialPath = "$path.part" + $headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/octet-stream,*/*' } + $webRequestError = $null + $webClientError = $null + $curlError = $null + + foreach ($attempt in 1..3) { + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + try { + Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $partialPath -Headers $headers -TimeoutSec 180 -MaximumRedirection 10 + Complete-Download $partialPath $path $label + return + } catch { + $webRequestError = $_.Exception.Message + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + if ($attempt -lt 3) { + Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2)) + } + } + } + + try { + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + Invoke-WebClientDownload $uri $partialPath + Complete-Download $partialPath $path $label + return + } catch { + $webClientError = $_.Exception.Message + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + } + + try { + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + Invoke-CurlDownload $uri $partialPath + Complete-Download $partialPath $path $label + return + } catch { + $curlError = $_.Exception.Message + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + } + + $errors = @() + if (-not [string]::IsNullOrWhiteSpace($webRequestError)) { $errors += "Invoke-WebRequest: $webRequestError" } + if (-not [string]::IsNullOrWhiteSpace($webClientError)) { $errors += "WebClient: $webClientError" } + if (-not [string]::IsNullOrWhiteSpace($curlError)) { $errors += "curl.exe: $curlError" } + $details = if ($errors.Count -gt 0) { $errors -join ' | ' } else { 'неизвестная ошибка' } + + throw "Не удалось скачать $label ($safeUri): $details" + } + + function Select-Asset($assets, [string]$pattern, [string]$label) { + $asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 + if ($null -eq $asset) { throw "Не найден подходящий asset для $label ($pattern)." } + return $asset + } + + function Verify-AssetHash([string]$path, $asset) { + if ($asset.digest -match '^sha256:(.+)$') { + $expected = $Matches[1].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + throw "SHA256 не совпал для $($asset.name). Ожидалось $expected, получилось $actual." + } + } + } + + function Assert-ExitCode($process, [string]$label) { + if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { + throw "$label завершился с кодом $($process.ExitCode)." + } + } + + function Get-InstalledProgram([string]$pattern) { + $paths = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match $pattern } | + Select-Object -First 1 + } + + function Test-VcRuntime([string]$arch) { + $pattern = if ($arch -eq 'ARM64') { + 'Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)' + } else { + "Microsoft Visual C\+\+.*Redistributable.*\($arch\)" + } + + return $null -ne (Get-InstalledProgram $pattern) + } + + function Test-WindowsPacketFilter { + return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI') + } + + function Get-LogTail([string]$path) { + if (-not (Test-Path -LiteralPath $path)) { return '' } + return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' + } + + function Get-BundledAssetDir { + $dir = [string]$script:bundledAssetDir + if ([string]::IsNullOrWhiteSpace($dir)) { return $null } + if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $null } + return $dir + } + + function Get-BundledAssetManifest { + $assetDir = Get-BundledAssetDir + if ($null -eq $assetDir) { return $null } + $manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json') + if (-not (Test-Path -LiteralPath $manifestPath)) { return $null } + + try { + return Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json + } catch { + throw "Не удалось прочитать manifest встроенных пакетов ProxiFyre: $($_.Exception.Message)" + } + } + + $script:bundledAssetManifest = Get-BundledAssetManifest + + function Get-BundledAssetHash([string]$name) { + if ($null -eq $script:bundledAssetManifest -or $null -eq $script:bundledAssetManifest.files) { + return $null + } + + $entry = $script:bundledAssetManifest.files | + Where-Object { $_.name -eq $name } | + Select-Object -First 1 + if ($null -eq $entry) { return $null } + return [string]$entry.sha256 + } + + function Verify-BundledAssetHash([string]$path, [string]$label) { + $name = [IO.Path]::GetFileName($path) + $expected = Get-BundledAssetHash $name + if ([string]::IsNullOrWhiteSpace($expected)) { + throw "Во встроенном manifest нет SHA256 для $label ($name)." + } + + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected.ToLowerInvariant()) { + throw "SHA256 не совпал для встроенного $label ($name). Ожидалось $expected, получилось $actual." + } + } + + function Get-BundledAsset([string]$pattern, [string]$label) { + $assetDir = Get-BundledAssetDir + if ($null -eq $assetDir) { return $null } + + $asset = Get-ChildItem -LiteralPath $assetDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match $pattern } | + Select-Object -First 1 + if ($null -eq $asset) { return $null } + + Verify-BundledAssetHash $asset.FullName $label + return $asset.FullName + } + + function Copy-BundledAsset([string]$sourcePath, [string]$targetPath, [string]$label) { + Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force + $item = Get-Item -LiteralPath $targetPath + if ($item.Length -le 0) { + throw "${label}: встроенный файл пустой." + } + } + + $arch = Get-NativeArchitecture + $workDir = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-proxifyre-install' + $extractDir = Join-Path $workDir 'proxifyre' + Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null + + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 8 'Проверяю сетевой драйвер Windows Packet Filter.' + $packetFilterAlreadyInstalled = Test-WindowsPacketFilter + if (-not $packetFilterAlreadyInstalled) { + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 14 'Готовлю Windows Packet Filter.' + $ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' } + $bundledNdisPath = Get-BundledAsset $ndisPattern 'Windows Packet Filter' + if ($null -ne $bundledNdisPath) { + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Использую встроенный Windows Packet Filter.' + $ndisPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledNdisPath)) + Copy-BundledAsset $bundledNdisPath $ndisPath 'Windows Packet Filter' + } else { + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Скачиваю Windows Packet Filter.' + $ndisAsset = Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter' (Get-PinnedWindowsPacketFilterAsset $arch) 16 + $ndisPath = Join-Path $workDir $ndisAsset.name + Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter' + Verify-AssetHash $ndisPath $ndisAsset + } + $ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log' + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 26 'Устанавливаю Windows Packet Filter.' + $ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden + if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) { + $ndisLogTail = Get-LogTail $ndisLogPath + throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail" + } + } + Write-ProxyWardenProgress 'install' 'packet-filter' 'succeeded' 36 'Сетевой драйвер готов.' + + Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 40 'Проверяю Microsoft Visual C++ Runtime.' + if (-not (Test-VcRuntime $arch)) { + $vcBundledPattern = if ($arch -eq 'x86') { '^vc_redist\.x86\.exe$' } else { '^vc_redist\.x64\.exe$' } + $vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url } + $bundledVcPath = Get-BundledAsset $vcBundledPattern 'Microsoft Visual C++ Runtime' + $vcRedistPath = Join-Path $workDir 'vc_redist.exe' + if ($null -ne $bundledVcPath) { + Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Использую встроенный Microsoft Visual C++ Runtime.' + Copy-BundledAsset $bundledVcPath $vcRedistPath 'Microsoft Visual C++ Runtime' + } else { + Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Скачиваю Microsoft Visual C++ Runtime.' + Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime' + } + Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 54 'Устанавливаю Microsoft Visual C++ Runtime.' + $vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden + if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) { + throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)." + } + } + Write-ProxyWardenProgress 'install' 'vc-runtime' 'succeeded' 62 'Среда запуска готова.' + + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 66 'Готовлю ProxiFyre.' + $proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' } + $bundledProxiFyrePath = Get-BundledAsset $proxifyrePattern 'ProxiFyre' + if ($null -ne $bundledProxiFyrePath) { + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Использую встроенный ProxiFyre.' + $proxifyreZipPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledProxiFyrePath)) + Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath 'ProxiFyre' + } else { + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Скачиваю ProxiFyre.' + $proxifyreAsset = Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre' (Get-PinnedProxiFyreAsset $arch) 68 + $proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name + Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre' + Verify-AssetHash $proxifyreZipPath $proxifyreAsset + } + + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 76 'Распаковываю ProxiFyre.' + Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force + $proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1 + if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' } + + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 82 'Копирую ProxiFyre в папку установки.' + Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force + + $configTarget = Join-Path $targetDir 'app-config.json' + if (Test-Path -LiteralPath $generatedConfigPath) { + Copy-Item -LiteralPath $generatedConfigPath -Destination $configTarget -Force + } elseif (-not (Test-Path -LiteralPath $configTarget)) { + $emptyConfig = '{"logLevel":"Info","bypassLan":true,"proxies":[]}' + Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8 + } + + $markerPath = Join-Path $targetDir 'proxywarden-component.json' + [ordered]@{ + manager = 'ProxyWarden' + component = 'proxifyre' + serviceName = 'ProxiFyreService' + installedAt = (Get-Date).ToString('o') + installRoot = $targetDir + packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled) + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8 + + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 90 'Устанавливаю и запускаю службу ProxiFyre.' + Push-Location $targetDir + try { + & .\ProxiFyre.exe stop | Out-Null + & .\ProxiFyre.exe uninstall | Out-Null + & .\ProxiFyre.exe install + if ($LASTEXITCODE -ne 0) { throw "ProxiFyre.exe install завершился с кодом $LASTEXITCODE." } + & .\ProxiFyre.exe start + if ($LASTEXITCODE -ne 0) { + Start-Service -Name 'ProxiFyreService' -ErrorAction Stop + } + } finally { + Pop-Location + } + Write-ProxyWardenProgress 'install' 'proxifyre' 'succeeded' 100 'ProxiFyre и сетевой драйвер готовы.' +"#, + ); + + script +} + +pub fn uninstall_proxifyre_script( + detected: Option<&DetectedProxyfier>, + ownership: &ManagedProxiFyreOwnership, +) -> String { + let mut script = String::new(); + let install_dir = detected + .map(|detected| detected.install_dir.display().to_string()) + .unwrap_or_default(); + let executable_path = detected + .map(|detected| detected.executable_path.display().to_string()) + .unwrap_or_default(); + script.push_str(&format!( + "$installDir = '{}'\n", + escape_powershell_single(&install_dir) + )); + script.push_str(&format!( + "$exePath = '{}'\n", + escape_powershell_single(&executable_path) + )); + script.push_str(&format!( + "$serviceName = '{}'\n", + escape_powershell_single(&ownership.service_name) + )); + script.push_str(&format!( + "$removePacketFilter = ${}\n", + if ownership.remove_packet_filter { + "true" + } else { + "false" + } + )); + script.push_str( + r#" + function Get-InstalledProgram([string]$pattern) { + $paths = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match $pattern } | + Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString + } + + function Test-WindowsPacketFilter { + return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI') + } + + function Get-LogTail([string]$path) { + if (-not (Test-Path -LiteralPath $path)) { return '' } + return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' + } + + function Resolve-MsiProductCode($program, [string]$label) { + if ($null -eq $program) { return $null } + if ($program.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$') { + return $program.PSChildName + } + foreach ($candidate in @($program.QuietUninstallString, $program.UninstallString)) { + if ($candidate -match '\{[0-9A-Fa-f-]{36}\}') { + return $Matches[0] + } + } + throw "Не удалось найти MSI product code для $label. Отказываюсь запускать произвольный UninstallString." + } + + function Uninstall-MsiProgram($program, [string]$label, [string]$logPath) { + $productCode = Resolve-MsiProductCode $program $label + if ([string]::IsNullOrWhiteSpace($productCode)) { return } + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $productCode, '/qn', '/norestart', '/L*v', $logPath) -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) { + $logTail = Get-LogTail $logPath + throw "$label uninstall завершился с кодом $($process.ExitCode). MSI log: $logPath $logTail" + } + } + + function Get-ServiceBinaryPath([string]$pathName) { + if ([string]::IsNullOrWhiteSpace($pathName)) { return $null } + $pathName = $pathName.Trim() + if ($pathName.StartsWith('"')) { + $closingQuote = $pathName.IndexOf('"', 1) + if ($closingQuote -lt 2) { return $null } + return $pathName.Substring(1, $closingQuote - 1) + } + return ($pathName -split '\s+', 2)[0] + } + + function Find-ManagedProxiFyreService { + $escapedName = $serviceName.Replace("'", "''") + $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue + if ($null -eq $record) { return $null } + $binaryPath = Get-ServiceBinaryPath $record.PathName + if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) { return $null } + return Get-Service -Name $serviceName -ErrorAction SilentlyContinue + } + + function Get-ServiceProcessId([string]$name) { + $escapedName = $name.Replace("'", "''") + $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue + if ($null -eq $record) { return 0 } + return [int]$record.ProcessId + } + + Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 10 'Останавливаю службу ProxiFyre.' + $service = Find-ManagedProxiFyreService + if ($null -ne $service -and $service.Status -ne 'Stopped') { + try { + if ($service.CanStop) { Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue } + $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue + if ($null -ne $service) { $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) } + } catch {} + } + + $service = Find-ManagedProxiFyreService + if ($null -ne $service -and $service.Status -ne 'Stopped') { + $processId = Get-ServiceProcessId $service.Name + if ($processId -gt 0) { + taskkill.exe /PID $processId /F | Out-Null + Start-Sleep -Milliseconds 700 + } + } + + Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 34 'Удаляю службу и файлы ProxiFyre.' + if (-not [string]::IsNullOrWhiteSpace($exePath) -and (Test-Path -LiteralPath $exePath)) { + Push-Location (Split-Path -Parent $exePath) + try { + & $exePath uninstall | Out-Null + } finally { + Pop-Location + } + } + + $service = Find-ManagedProxiFyreService + if ($null -ne $service) { + sc.exe delete $service.Name | Out-Null + } + + if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) { + Remove-Item -LiteralPath $installDir -Recurse -Force + } + + Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.' + + if ($removePacketFilter) { + Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 68 'Проверяю Windows Packet Filter.' + $packetFilter = Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI' + if ($null -ne $packetFilter) { + Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 78 'Удаляю Windows Packet Filter.' + $driverLogPath = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-windows-packet-filter-uninstall.log' + Uninstall-MsiProgram $packetFilter 'Windows Packet Filter' $driverLogPath + } + if (Test-WindowsPacketFilter) { + throw 'Windows Packet Filter все еще найден после удаления. Возможно, Windows требует перезагрузку.' + } + Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'succeeded' 100 'ProxiFyre и принадлежащий ProxyWarden Windows Packet Filter удалены.' + } else { + Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'skipped' 100 'Windows Packet Filter оставлен: marker не подтверждает владение ProxyWarden.' + } +"#, + ); + + script +} diff --git a/src-tauri/src/proxy_apply.rs b/src-tauri/src/proxy_apply.rs new file mode 100644 index 0000000..1b4feab --- /dev/null +++ b/src-tauri/src/proxy_apply.rs @@ -0,0 +1,258 @@ +//! ProxiFyre config apply helper boundary and testable legacy apply fixture. +//! +//! The current webview path uses `apply_flow`; the lower-level fixture remains +//! for adapter/storage integration tests and shares the same detected writer. + +use crate::adapters::proxy_router::{ + ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, + ProxyRouterRequest, +}; +use crate::clock::Clock; +use crate::command_dto::{ActivityEntryDto, CommandError}; +use crate::component_detection::{ + detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install, + DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost, +}; +use crate::component_status::components_or_defaults_with_detection; +use crate::models::{ActivityEntry, ActivityLevel}; +use crate::safe_fs; +use crate::storage::JsonStorage; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyProfilesResponse { + pub success: bool, + pub changed: bool, + pub message: String, + pub adapter_id: String, + pub generated_config_path: String, + pub enabled_profiles: usize, + pub routed_apps: usize, + pub helper: HelperApplyResult, + pub activity: ActivityEntryDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HelperApplyResult { + pub success: bool, + pub changed: bool, + pub action: String, + pub message: String, +} + +pub struct HelperApplyRequest<'a> { + pub adapter_id: &'a str, + pub config_path: &'a Path, + pub config_contents: &'a str, +} + +pub trait ProxyApplyHelper { + fn apply_proxy_config( + &self, + request: HelperApplyRequest<'_>, + ) -> Result; +} + +pub struct DetectedProxyApplyHelper { + host: H, +} + +impl DetectedProxyApplyHelper { + pub fn system() -> Self { + SystemProxyfierDetectionHost.into() + } +} + +impl From for DetectedProxyApplyHelper { + fn from(host: H) -> Self { + Self { host } + } +} + +impl ProxyApplyHelper for DetectedProxyApplyHelper +where + H: ProxyfierDetectionHost, +{ + fn apply_proxy_config( + &self, + request: HelperApplyRequest<'_>, + ) -> Result { + let Some(detected) = detect_proxyfier_install_with_host(&self.host) else { + return staged_apply_result(request); + }; + + apply_to_detected_proxyfier(request, &detected) + } +} + +pub fn apply_profiles_with_services( + storage: &JsonStorage, + adapter: &impl ProxyRouterAdapter, + helper: &impl ProxyApplyHelper, + clock: &impl Clock, +) -> Result { + apply_profiles_with_services_and_detection( + storage, + adapter, + helper, + clock, + detect_proxyfier_install(), + detect_singbox_install(), + ) +} + +pub fn apply_profiles_with_services_and_detection( + storage: &JsonStorage, + adapter: &impl ProxyRouterAdapter, + helper: &impl ProxyApplyHelper, + clock: &impl Clock, + detected_proxyfier: Option, + detected_singbox: Option, +) -> Result { + let profiles = storage.read_profiles().map_err(storage_error)?; + let targets = storage.read_targets().map_err(storage_error)?; + let components = + components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?; + let generated = + match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) { + Ok(generated) => generated, + Err(error) => { + let command_error = adapter_error(error); + let activity = activity_for_apply_error(clock, &command_error); + storage.append_activity(activity).map_err(storage_error)?; + return Err(command_error); + } + }; + + let generated_path = storage + .paths() + .generated_dir + .join(generated.output_file_name.as_str()); + write_generated_config(&generated_path, &generated.contents)?; + + let helper_result = helper.apply_proxy_config(HelperApplyRequest { + adapter_id: generated.adapter_id.as_str(), + config_path: &generated_path, + config_contents: generated.contents.as_str(), + })?; + + let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result); + storage + .append_activity(activity.clone()) + .map_err(storage_error)?; + + Ok(ApplyProfilesResponse { + success: helper_result.success, + changed: helper_result.changed, + message: helper_result.message.clone(), + adapter_id: generated.adapter_id, + generated_config_path: generated_path.display().to_string(), + enabled_profiles: generated.enabled_profiles, + routed_apps: generated.routed_apps, + helper: helper_result, + activity: ActivityEntryDto::from(&activity), + }) +} + +fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> { + safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error) +} + +fn apply_to_detected_proxyfier( + request: HelperApplyRequest<'_>, + detected: &DetectedProxyfier, +) -> Result { + let Some(config_path) = &detected.config_path else { + return staged_apply_result(request); + }; + + safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err( + |error| { + CommandError::new( + "proxyfier_apply_failed", + format!( + "Не удалось безопасно записать конфиг ProxiFyre '{}': {error}", + config_path.display() + ), + ) + }, + )?; + + Ok(HelperApplyResult { + success: true, + changed: true, + action: "proxifyre.apply-detected-config".to_string(), + message: format!( + "Сгенерированный конфиг записан в найденную установку ProxiFyre: {}", + config_path.display() + ), + }) +} + +fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result { + Ok(HelperApplyResult { + success: true, + changed: true, + action: format!("{}.stage-generated-config", request.adapter_id), + message: format!( + "Сгенерированный конфиг подготовлен в {}; совместимая установка ProxiFyre не найдена", + request.config_path.display() + ), + }) +} + +fn activity_for_apply( + clock: &impl Clock, + generated: &ProxyRouterGeneratedConfig, + generated_path: &Path, + helper_result: &HelperApplyResult, +) -> ActivityEntry { + let level = if helper_result.success { + ActivityLevel::Success + } else { + ActivityLevel::Error + }; + + ActivityEntry { + id: format!("apply-{}", generated.adapter_id), + at: clock.now(), + level, + title: "Конфиг ProxiFyre создан".to_string(), + message: format!( + "Профилей: {}, приложений: {}, конфиг: {}", + generated.enabled_profiles, + generated.routed_apps, + generated_path.display() + ), + } +} + +fn activity_for_apply_error(clock: &impl Clock, error: &CommandError) -> ActivityEntry { + ActivityEntry { + id: format!("apply-error-{}", error.code), + at: clock.now(), + level: ActivityLevel::Error, + title: "Применение ProxiFyre заблокировано".to_string(), + message: error.message.clone(), + } +} + +fn storage_error(error: std::io::Error) -> CommandError { + CommandError::new("storage_error", error.to_string()) +} + +fn adapter_error(error: ProxyRouterError) -> CommandError { + let code = match error.kind { + ProxyRouterErrorKind::EmptyProfileItems => "empty_profile_items", + ProxyRouterErrorKind::MissingTarget => "missing_target", + ProxyRouterErrorKind::MissingRequiredComponent => "missing_required_component", + ProxyRouterErrorKind::RequiredComponentNotRunning => "required_component_not_running", + ProxyRouterErrorKind::UnsupportedTargetProtocol => "unsupported_target_protocol", + ProxyRouterErrorKind::Serialization => "serialization_error", + }; + + CommandError::new(code, error.message) +} diff --git a/src-tauri/src/proxy_probe.rs b/src-tauri/src/proxy_probe.rs new file mode 100644 index 0000000..6e37300 --- /dev/null +++ b/src-tauri/src/proxy_probe.rs @@ -0,0 +1,316 @@ +//! TCP and outbound HTTP checks used to verify a configured SOCKS5 route. +//! +//! All functions are blocking. Tauri handlers must call them through +//! `spawn_blocking`; probe URLs are static and never come from webview input. + +use crate::command_dto::{ + CommandError, PingProxyTargetInputDto, PingServerResponse, ProxyProbeResponse, + ProxyTargetCheckResponse, +}; +use std::net::{IpAddr, TcpStream, ToSocketAddrs}; +use std::time::{Duration, Instant}; + +const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4); +const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2); +const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check"; + +const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[ + ProxyProbeEndpoint { + id: "cloudflare-trace", + name: "Cloudflare Trace", + url: "https://www.cloudflare.com/cdn-cgi/trace", + ip_source: ProbeIpSource::CloudflareTrace, + }, + ProxyProbeEndpoint { + id: "cloudflare-speed", + name: "Cloudflare Speed", + url: "https://speed.cloudflare.com/meta", + ip_source: ProbeIpSource::JsonField("clientIp"), + }, + ProxyProbeEndpoint { + id: "ipify", + name: "ipify", + url: "https://api.ipify.org?format=json", + ip_source: ProbeIpSource::JsonField("ip"), + }, +]; + +#[derive(Debug, Clone, Copy)] +pub struct ProxyProbeEndpoint { + id: &'static str, + name: &'static str, + url: &'static str, + ip_source: ProbeIpSource, +} + +#[derive(Debug, Clone, Copy)] +enum ProbeIpSource { + CloudflareTrace, + JsonField(&'static str), +} + +pub fn ping_proxy_target_endpoint( + input: PingProxyTargetInputDto, +) -> Result { + ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES) +} + +pub fn ping_proxy_target_endpoint_with_probes( + input: PingProxyTargetInputDto, + probes: &[ProxyProbeEndpoint], +) -> Result { + let host = input.host.trim(); + if host.is_empty() { + return Err(CommandError::new( + "proxy_target_host_missing", + "Хост внешнего прокси не указан.", + )); + } + + let tcp = ping_endpoint("route-proxy", "route-proxy", host, input.port); + if !tcp.ok { + return Ok(ProxyTargetCheckResponse { + tag: "route-proxy".to_string(), + server: host.to_string(), + server_port: input.port, + ok: false, + latency: tcp.latency, + error: tcp.error, + probes: Vec::new(), + }); + } + + let probe_results = run_proxy_probes(host, input.port, probes); + let has_probe_success = probe_results.iter().any(|probe| probe.ok); + let ok = probe_results.is_empty() || has_probe_success; + let error = (!ok).then(|| { + "SOCKS5 порт доступен, но тестовые HTTP endpoints не ответили через прокси.".to_string() + }); + + Ok(ProxyTargetCheckResponse { + tag: "route-proxy".to_string(), + server: host.to_string(), + server_port: input.port, + ok, + latency: tcp.latency, + error, + probes: probe_results, + }) +} + +pub fn ping_endpoint(id: &str, tag: &str, server: &str, server_port: u16) -> PingServerResponse { + let started = Instant::now(); + let addresses = match (server, server_port).to_socket_addrs() { + Ok(addresses) => addresses.collect::>(), + Err(error) => { + return PingServerResponse { + id: id.to_string(), + tag: tag.to_string(), + server: server.to_string(), + server_port, + ok: false, + latency: None, + error: Some(format!("DNS/адрес недоступен: {error}")), + }; + } + }; + + if addresses.is_empty() { + return PingServerResponse { + id: id.to_string(), + tag: tag.to_string(), + server: server.to_string(), + server_port, + ok: false, + latency: None, + error: Some("DNS не вернул адреса".to_string()), + }; + } + + let timeout = Duration::from_secs(2); + let mut last_error = None; + for address in addresses { + match TcpStream::connect_timeout(&address, timeout) { + Ok(_) => { + return PingServerResponse { + id: id.to_string(), + tag: tag.to_string(), + server: server.to_string(), + server_port, + ok: true, + latency: Some(started.elapsed().as_millis()), + error: None, + }; + } + Err(error) => last_error = Some(error.to_string()), + } + } + + PingServerResponse { + id: id.to_string(), + tag: tag.to_string(), + server: server.to_string(), + server_port, + ok: false, + latency: None, + error: last_error, + } +} + +fn run_proxy_probes( + proxy_host: &str, + proxy_port: u16, + probes: &[ProxyProbeEndpoint], +) -> Vec { + if probes.is_empty() { + return Vec::new(); + } + + let proxy_url = socks5h_proxy_url(proxy_host, proxy_port); + let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| { + reqwest::blocking::Client::builder() + .timeout(PROXY_CHECK_TIMEOUT) + .connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT) + .proxy(proxy) + .build() + }) { + Ok(client) => client, + Err(error) => { + return probes + .iter() + .map(|probe| { + failed_probe( + *probe, + format!("Не удалось подготовить SOCKS5 проверку: {error}"), + ) + }) + .collect(); + } + }; + + let handles = probes + .iter() + .copied() + .map(|probe| { + let client = client.clone(); + std::thread::spawn(move || run_proxy_probe(&client, probe)) + }) + .collect::>(); + + handles + .into_iter() + .zip(probes.iter().copied()) + .map(|(handle, probe)| { + handle + .join() + .unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string())) + }) + .collect() +} + +fn run_proxy_probe( + client: &reqwest::blocking::Client, + probe: ProxyProbeEndpoint, +) -> ProxyProbeResponse { + let started = Instant::now(); + let response = match client + .get(probe.url) + .header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT) + .send() + { + Ok(response) => response, + Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")), + }; + + let status = response.status(); + let status_code = status.as_u16(); + let body = match response.text() { + Ok(body) => body, + Err(error) => { + return failed_probe_with_status( + probe, + status_code, + format!("Ответ не прочитан: {error}"), + ); + } + }; + let latency = started.elapsed().as_millis(); + + if !status.is_success() { + return ProxyProbeResponse { + id: probe.id.to_string(), + name: probe.name.to_string(), + url: probe.url.to_string(), + ok: false, + status: Some(status_code), + latency: Some(latency), + ip: None, + error: Some(format!("HTTP {status_code}")), + }; + } + + ProxyProbeResponse { + id: probe.id.to_string(), + name: probe.name.to_string(), + url: probe.url.to_string(), + ok: true, + status: Some(status_code), + latency: Some(latency), + ip: extract_probe_ip(probe, &body), + error: None, + } +} + +fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse { + failed_probe_with_status(probe, 0, error) +} + +fn failed_probe_with_status( + probe: ProxyProbeEndpoint, + status: u16, + error: String, +) -> ProxyProbeResponse { + ProxyProbeResponse { + id: probe.id.to_string(), + name: probe.name.to_string(), + url: probe.url.to_string(), + ok: false, + status: (status > 0).then_some(status), + latency: None, + ip: None, + error: Some(error), + } +} + +fn socks5h_proxy_url(host: &str, port: u16) -> String { + let host = host.trim().trim_start_matches('[').trim_end_matches(']'); + if host.contains(':') { + format!("socks5h://[{host}]:{port}") + } else { + format!("socks5h://{host}:{port}") + } +} + +fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option { + match probe.ip_source { + ProbeIpSource::CloudflareTrace => body + .lines() + .find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)), + ProbeIpSource::JsonField(field) => serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get(field) + .and_then(|field| field.as_str()) + .and_then(normalize_ip) + }), + } +} + +fn normalize_ip(value: &str) -> Option { + let candidate = value.trim().trim_matches('"'); + candidate + .parse::() + .is_ok() + .then(|| candidate.to_string()) +} diff --git a/src-tauri/src/singbox_config.rs b/src-tauri/src/singbox_config.rs new file mode 100644 index 0000000..3f83188 --- /dev/null +++ b/src-tauri/src/singbox_config.rs @@ -0,0 +1,125 @@ +//! Local sing-box config generation and derived local-target persistence. + +use crate::adapters::singbox::{ + SingBoxAdapter, SingBoxConfigChecker, SingBoxConfigError, SingBoxConfigErrorKind, + SingBoxGeneratedConfig, SingBoxGenerationRequest, +}; +use crate::clock::Clock; +use crate::command_dto::{ActivityEntryDto, CommandError, GenerateSingBoxConfigResponse}; +use crate::models::{ + ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, Target, + TargetKind, +}; +use crate::safe_fs; +use crate::singbox_subscription::read_required_singbox_cache; +use crate::storage::JsonStorage; +use std::path::Path; + +pub fn generate_singbox_config_with_services( + storage: &JsonStorage, + adapter: &SingBoxAdapter, + checker: &C, + clock: &impl Clock, + binary_path: Option<&Path>, +) -> Result +where + C: SingBoxConfigChecker, +{ + let config = storage.read_local_singbox_config().map_err(storage_error)?; + let cache = read_required_singbox_cache(storage)?; + let generated = adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, binary_path), + checker, + ) + .map_err(singbox_adapter_error)?; + let generated_path = storage + .paths() + .generated_dir + .join(generated.output_file_name.as_str()); + + write_generated_config(&generated_path, &generated.contents)?; + ensure_local_singbox_target(storage, &config)?; + + let activity = activity_for_singbox_generate(clock, &generated, &generated_path); + storage + .append_activity(activity.clone()) + .map_err(storage_error)?; + + Ok(GenerateSingBoxConfigResponse { + success: true, + message: "Конфиг Local sing-box создан".to_string(), + adapter_id: generated.adapter_id, + generated_config_path: generated_path.display().to_string(), + selected_server_tag: generated.selected_server_tag, + listen_host: generated.listen, + listen_port: generated.listen_port, + check: generated.check, + activity: ActivityEntryDto::from(&activity), + }) +} + +fn ensure_local_singbox_target( + storage: &JsonStorage, + config: &LocalSingBoxConfig, +) -> Result<(), CommandError> { + let mut targets = storage.read_targets().map_err(storage_error)?; + let target = Target { + id: "local-singbox".to_string(), + name: "Локальный sing-box".to_string(), + kind: TargetKind::Local, + protocol: ProxyProtocol::Socks5, + host: config.listen_host.clone(), + port: config.listen_port, + requires_component: Some(ComponentId::Singbox), + }; + + match targets.iter().position(|existing| existing.id == target.id) { + Some(index) => targets[index] = target, + None => targets.push(target), + } + + storage.write_targets(&targets).map_err(storage_error) +} + +fn activity_for_singbox_generate( + clock: &impl Clock, + generated: &SingBoxGeneratedConfig, + generated_path: &Path, +) -> ActivityEntry { + ActivityEntry { + id: "singbox-config-generated".to_string(), + at: clock.now(), + level: ActivityLevel::Success, + title: "Конфиг Local sing-box создан".to_string(), + message: format!( + "Сервер: {}, listen: {}:{}, конфиг: {}", + generated.selected_server_tag, + generated.listen, + generated.listen_port, + generated_path.display() + ), + } +} + +fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError { + let code = match error.kind { + SingBoxConfigErrorKind::MissingSelectedServer => "singbox_server_not_selected", + SingBoxConfigErrorKind::MissingSelectedOutbound => "singbox_selected_server_missing", + SingBoxConfigErrorKind::UnsupportedSelectedOutbound => { + "singbox_selected_server_unsupported" + } + SingBoxConfigErrorKind::Serialization => "serialization_error", + SingBoxConfigErrorKind::CheckFailed => "singbox_check_failed", + }; + + CommandError::new(code, error.message) +} + +fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> { + safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error) +} + +fn storage_error(error: std::io::Error) -> CommandError { + CommandError::new("storage_error", error.to_string()) +} diff --git a/src-tauri/src/singbox_runtime.rs b/src-tauri/src/singbox_runtime.rs new file mode 100644 index 0000000..81a89f2 --- /dev/null +++ b/src-tauri/src/singbox_runtime.rs @@ -0,0 +1,532 @@ +//! Explicit Local sing-box service and package lifecycle orchestration. +//! +//! These operations may request UAC elevation. Apply configuration never calls +//! this module; install/start/stop/uninstall remain separate user actions. + +use crate::command_dto::{CommandError, ComponentStatusDto}; +use crate::component_detection::{detect_singbox_install, singbox_component_from_detection}; +use crate::elevated_scripts; +use crate::powershell::{ + escape_single as escape_powershell_single, is_elevated as is_running_elevated, + package_failure_details, run_command as run_powershell_command, + run_file as run_powershell_file, write_script as write_powershell_script, +}; +use crate::process::command_no_window; +use crate::singbox_service::{ + ensure_safe_singbox_install_dir, + parse_service_command_output as parse_singbox_service_command_output, service_control_script, + ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction, +}; +use crate::storage::{default_config_root, JsonStorage}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) fn control_singbox_service( + action: SingBoxServiceAction, + config_source: Option<&Path>, +) -> Result { + let Some(detected) = detect_singbox_install() else { + return Err(CommandError::new( + "singbox_not_found", + "Local sing-box не найден на компьютере.", + )); + }; + + let config_target = config_source.map(|_| detected.install_dir.join("config.json")); + let script = service_control_script( + action, + &detected.service_name, + config_source, + config_target.as_deref(), + ); + let output = command_no_window("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script.as_str(), + ]) + .output() + .map_err(|error| { + CommandError::new( + singbox_service_error_code(action), + format!( + "Не удалось {} службу Local sing-box: {error}", + action.label() + ), + ) + })?; + let result = parse_singbox_service_command_output(&output.stdout).ok_or_else(|| { + CommandError::new( + singbox_service_error_code(action), + singbox_service_script_failed_message(action, output.status.code()), + ) + })?; + + if result.success { + let refreshed = detect_singbox_install(); + let component = singbox_component_from_detection(refreshed.as_ref()); + return Ok(ComponentStatusDto::from(&component)); + } + + if matches!( + result.code.as_str(), + "start_failed" | "stop_failed" | "config_sync_failed" + ) { + run_elevated_singbox_service_command( + action, + &detected.service_name, + config_source, + config_target.as_deref(), + &result, + )?; + let refreshed = detect_singbox_install(); + let component = singbox_component_from_detection(refreshed.as_ref()); + return Ok(ComponentStatusDto::from(&component)); + } + + Err(CommandError::new( + singbox_service_error_code(action), + singbox_service_command_failed_message(action, &result), + )) +} + +fn run_elevated_singbox_service_command( + action: SingBoxServiceAction, + service_name: &str, + config_source: Option<&Path>, + config_target: Option<&Path>, + direct_result: &SingBoxServiceCommandOutput, +) -> Result<(), CommandError> { + let script_path = + write_elevated_singbox_service_script(action, service_name, config_source, config_target)?; + let launch_script = format!( + "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", + escape_powershell_single(&script_path.display().to_string()) + ); + let output = if is_running_elevated() { + run_powershell_file(&script_path) + } else { + run_powershell_command(&launch_script) + }; + + let _ = fs::remove_file(&script_path); + + match output { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(CommandError::new( + singbox_service_error_code(action), + elevated_singbox_service_failed_message(action, direct_result, output.status.code()), + )), + Err(error) => Err(CommandError::new( + singbox_service_error_code(action), + format!( + "Не удалось запросить права администратора, чтобы {} службу Local sing-box: {error}", + action.label() + ), + )), + } +} + +fn write_elevated_singbox_service_script( + action: SingBoxServiceAction, + service_name: &str, + config_source: Option<&Path>, + config_target: Option<&Path>, +) -> Result { + let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service"); + let script = + elevated_singbox_service_script(action, service_name, config_source, config_target); + + write_powershell_script(&script_path, &script).map_err(|error| { + CommandError::new( + singbox_service_error_code(action), + format!( + "Не удалось подготовить временный скрипт для управления Local sing-box '{}': {error}", + script_path.display() + ), + ) + })?; + + Ok(script_path) +} + +fn elevated_singbox_service_script( + action: SingBoxServiceAction, + service_name: &str, + config_source: Option<&Path>, + config_target: Option<&Path>, +) -> String { + let action_name = action.action_name(); + let escaped_service_name = escape_powershell_single(service_name); + let escaped_config_source = config_source + .map(|path| escape_powershell_single(&path.display().to_string())) + .unwrap_or_default(); + let escaped_config_target = config_target + .map(|path| escape_powershell_single(&path.display().to_string())) + .unwrap_or_default(); + + format!( + r#" +$ErrorActionPreference = 'SilentlyContinue' +$serviceName = '{escaped_service_name}' +$action = '{action_name}' +$configSource = '{escaped_config_source}' +$configTarget = '{escaped_config_target}' + +if ($action -eq 'start') {{ + if (-not [string]::IsNullOrWhiteSpace($configSource)) {{ + if (-not (Test-Path -LiteralPath $configSource)) {{ exit 5 }} + if (-not [string]::IsNullOrWhiteSpace($configTarget)) {{ + try {{ + Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop + }} catch {{ + exit 6 + }} + }} + }} + + $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue + if ($null -eq $service) {{ exit 2 }} + if ($service.Status -eq 'Running') {{ exit 0 }} + + Start-Service -Name $serviceName -ErrorAction SilentlyContinue + $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue + if ($null -ne $service) {{ + try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}} + if ($service.Status -eq 'Running') {{ exit 0 }} + }} + + exit 3 +}} + +$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +if ($null -eq $service) {{ exit 2 }} +if ($service.Status -eq 'Stopped') {{ exit 0 }} + +Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue +$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +if ($null -ne $service) {{ + try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) }} catch {{}} + if ($service.Status -eq 'Stopped') {{ exit 0 }} +}} + +exit 4 +"# + ) +} + +pub(crate) fn install_singbox_component( + storage: &JsonStorage, + install_dir: &Path, +) -> Result { + let generated_config_path = storage.paths().generated_dir.join("sing-box-config.json"); + run_elevated_singbox_package_script( + SingBoxPackageAction::Install, + include_str!("../../scripts/install-singbox.ps1"), + vec![ + "-InstallRoot".to_string(), + install_dir.display().to_string(), + "-ConfigSource".to_string(), + generated_config_path.display().to_string(), + ], + &storage.paths().state_dir, + )?; + + let refreshed = detect_singbox_install(); + let Some(detected) = refreshed.as_ref() else { + return Err(CommandError::new( + SingBoxPackageAction::Install.error_code(), + "Установка Local sing-box завершилась, но приложение не найдено после проверки.", + )); + }; + + Ok(ComponentStatusDto::from(&singbox_component_from_detection( + Some(detected), + ))) +} + +pub(crate) fn uninstall_singbox_component() -> Result { + let Some(detected) = detect_singbox_install() else { + let component = singbox_component_from_detection(None); + return Ok(ComponentStatusDto::from(&component)); + }; + + ensure_safe_singbox_install_dir(&detected.install_dir).map_err(|message| { + CommandError::new(SingBoxPackageAction::Uninstall.error_code(), message) + })?; + let artifact_dir = default_config_root().join("state"); + run_elevated_singbox_package_script( + SingBoxPackageAction::Uninstall, + include_str!("../../scripts/install-singbox.ps1"), + vec![ + "-InstallRoot".to_string(), + detected.install_dir.display().to_string(), + "-ServiceName".to_string(), + detected.service_name, + "-Uninstall".to_string(), + ], + &artifact_dir, + )?; + + let refreshed = detect_singbox_install(); + if refreshed.is_some() { + return Err(CommandError::new( + SingBoxPackageAction::Uninstall.error_code(), + "Удаление Local sing-box завершилось, но приложение все еще найдено на компьютере.", + )); + } + + let component = singbox_component_from_detection(None); + Ok(ComponentStatusDto::from(&component)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SingBoxPackageAction { + Install, + Uninstall, +} + +impl SingBoxPackageAction { + fn error_code(self) -> &'static str { + match self { + SingBoxPackageAction::Install => "singbox_install_failed", + SingBoxPackageAction::Uninstall => "singbox_uninstall_failed", + } + } + + fn label(self) -> &'static str { + match self { + SingBoxPackageAction::Install => "установить", + SingBoxPackageAction::Uninstall => "удалить", + } + } + + fn file_label(self) -> &'static str { + match self { + SingBoxPackageAction::Install => "install", + SingBoxPackageAction::Uninstall => "uninstall", + } + } +} + +fn run_elevated_singbox_package_script( + action: SingBoxPackageAction, + installer_body: &str, + installer_args: Vec, + artifact_dir: &Path, +) -> Result<(), CommandError> { + fs::create_dir_all(artifact_dir).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось создать папку для временных файлов Local sing-box '{}': {error}", + artifact_dir.display() + ), + ) + })?; + + 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( + action.error_code(), + format!( + "Не удалось подготовить установщик Local sing-box '{}': {error}", + installer_path.display() + ), + ) + })?; + write_powershell_script( + &runner_path, + &singbox_installer_runner_script(&installer_path, &result_path, &installer_args), + ) + .map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось подготовить runner Local sing-box '{}': {error}", + runner_path.display() + ), + ) + })?; + + let launch_script = format!( + r#" +$ErrorActionPreference = 'Stop' +$resultPath = '{}' +try {{ + $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') + if ($null -eq $p) {{ + Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 + exit 1 + }} + exit $p.ExitCode +}} catch {{ + Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 + exit 1 +}} +"#, + escape_powershell_single(&result_path.display().to_string()), + escape_powershell_single(&runner_path.display().to_string()) + ); + let output = if is_running_elevated() { + run_powershell_file(&runner_path) + } else { + run_powershell_command(&launch_script) + }; + + let _ = fs::remove_file(&installer_path); + let _ = fs::remove_file(&runner_path); + + match output { + Ok(output) if output.status.success() => { + let _ = fs::remove_file(&result_path); + Ok(()) + } + Ok(output) => { + let details = package_failure_details(&result_path, &output); + let _ = fs::remove_file(&result_path); + Err(CommandError::new( + action.error_code(), + format!( + "Не удалось {} Local sing-box. Код elevated-команды: {}. {details}", + action.label(), + output.status.code().unwrap_or(-1), + ), + )) + } + Err(error) => Err(CommandError::new( + action.error_code(), + format!( + "Не удалось запросить права администратора, чтобы {} Local sing-box: {error}", + action.label() + ), + )), + } +} + +pub fn singbox_installer_runner_script( + installer_path: &Path, + result_path: &Path, + installer_args: &[String], +) -> String { + let args = installer_args + .iter() + .map(|arg| format!("'{}'", escape_powershell_single(arg))) + .collect::>() + .join(", "); + + format!( + r#" +$ErrorActionPreference = 'Stop' +$installerPath = '{}' +$resultPath = '{}' +$stdoutPath = "$resultPath.stdout.log" +$stderrPath = "$resultPath.stderr.log" +$installerArgs = @({args}) +try {{ + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs 2>&1 + $exitCode = $LASTEXITCODE + Set-Content -LiteralPath $stdoutPath -Value ($output | Out-String) -Encoding UTF8 + if ($exitCode -ne 0) {{ + $stdout = if (Test-Path -LiteralPath $stdoutPath) {{ Get-Content -LiteralPath $stdoutPath -Raw }} else {{ '' }} + $stderr = if (Test-Path -LiteralPath $stderrPath) {{ Get-Content -LiteralPath $stderrPath -Raw }} else {{ '' }} + throw "install-singbox.ps1 завершился с кодом $exitCode. stdout: $stdout stderr: $stderr" + }} + Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 + exit 0 +}} catch {{ + Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 + exit 1 +}} finally {{ + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue +}} +"#, + escape_powershell_single(&installer_path.display().to_string()), + escape_powershell_single(&result_path.display().to_string()) + ) +} + +fn singbox_service_error_code(action: SingBoxServiceAction) -> &'static str { + match action { + SingBoxServiceAction::Start => "singbox_service_start_failed", + SingBoxServiceAction::Stop => "singbox_service_stop_failed", + } +} + +fn singbox_service_script_failed_message( + action: SingBoxServiceAction, + exit_code: Option, +) -> String { + let exit_code = exit_code + .map(|code| format!(" Код выхода PowerShell: {code}.")) + .unwrap_or_default(); + + format!( + "Не удалось {} службу Local sing-box: команда управления службой не вернула корректный результат.{exit_code}", + action.label() + ) +} + +fn singbox_service_command_failed_message( + action: SingBoxServiceAction, + result: &SingBoxServiceCommandOutput, +) -> String { + let service_name = result + .service_name + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("ProxyWardenSingBox"); + let status = result + .status + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("неизвестен"); + let pid = result + .process_id + .filter(|value| *value > 0) + .map(|value| format!(", PID: {value}")) + .unwrap_or_default(); + + match result.code.as_str() { + "service_not_found" => "Служба Local sing-box не найдена.".to_string(), + "config_source_missing" => { + "Сгенерированный конфиг Local sing-box не найден перед запуском службы.".to_string() + } + "config_sync_failed" => { + "Не удалось обновить config.json службы Local sing-box перед запуском. Попробуй запустить приложение от имени администратора.".to_string() + } + "start_failed" => format!( + "Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора." + ), + "stop_failed" => format!( + "Не удалось остановить службу {service_name}. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc." + ), + _ => format!( + "Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.", + action.label() + ), + } +} + +fn elevated_singbox_service_failed_message( + action: SingBoxServiceAction, + direct_result: &SingBoxServiceCommandOutput, + exit_code: Option, +) -> String { + let exit_code = exit_code + .map(|code| format!(" Код выхода elevated PowerShell: {code}.")) + .unwrap_or_default(); + format!( + "{} Попытка с правами администратора тоже не сработала.{exit_code}", + singbox_service_command_failed_message(action, direct_result) + ) +} diff --git a/src-tauri/src/singbox_subscription.rs b/src-tauri/src/singbox_subscription.rs new file mode 100644 index 0000000..ffcecaa --- /dev/null +++ b/src-tauri/src/singbox_subscription.rs @@ -0,0 +1,377 @@ +//! Local sing-box subscription persistence, selection, status, and ping use cases. + +use crate::clock::Clock; +use crate::command_dto::*; +use crate::component_detection::{ + detect_singbox_install, singbox_component_from_detection, DetectedSingBox, +}; +use crate::models::{ + ActivityEntry, ActivityLevel, LocalSingBoxConfig, SubscriptionCache, SubscriptionServer, +}; +use crate::proxy_probe::ping_endpoint; +use crate::storage::JsonStorage; +use crate::subscription; +use std::net::{IpAddr, UdpSocket}; + +pub trait SubscriptionFetcher { + fn fetch_subscription( + &self, + url: &str, + identity: &subscription::SubscriptionFetchIdentity, + ) -> Result; +} + +pub struct SystemSubscriptionFetcher; + +impl SubscriptionFetcher for SystemSubscriptionFetcher { + fn fetch_subscription( + &self, + url: &str, + identity: &subscription::SubscriptionFetchIdentity, + ) -> Result { + subscription::fetch_subscription_with_identity(url, identity) + } +} + +#[cfg(debug_assertions)] +fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto { + let identity = subscription::SubscriptionFetchIdentity::default(); + let headers = identity + .request_headers_without_device_hwid() + .into_iter() + .map(|(name, value)| SubscriptionRequestHeaderDto { + name: name.to_string(), + value, + }) + .collect(); + + SubscriptionRequestIdentityDto { headers } +} + +pub fn read_singbox_status( + storage: &JsonStorage, +) -> Result { + let detected = detect_singbox_install(); + read_singbox_status_with_detection(storage, detected.as_ref()) +} + +pub(crate) fn read_singbox_status_with_detection( + storage: &JsonStorage, + detected: Option<&DetectedSingBox>, +) -> Result { + let config = storage.read_local_singbox_config().map_err(storage_error)?; + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)?; + let component = singbox_component_from_detection(detected); + + Ok(LocalSingBoxStatusResponse { + config: LocalSingBoxConfigDto::from(&config), + cache: cache.as_ref().map(SubscriptionCacheDto::from), + component: ComponentStatusDto::from(&component), + generated_config_path: storage + .paths() + .generated_dir + .join("sing-box-config.json") + .display() + .to_string(), + lan_listen_host: local_lan_ipv4(), + #[cfg(debug_assertions)] + subscription_identity: subscription_request_identity_for_display(), + }) +} + +pub fn save_singbox_subscription_to_storage( + storage: &JsonStorage, + input: SaveSingBoxSubscriptionInputDto, + clock: &impl Clock, +) -> Result { + let subscription_url = input.subscription_url.trim().to_string(); + validate_subscription_url(&subscription_url)?; + + let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + config.subscription_url = Some(subscription_url); + ensure_device_hwid(&mut config); + config.updated_at = Some(clock.now()); + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + + read_singbox_status(storage) +} + +pub fn fetch_singbox_subscription_with_fetcher( + storage: &JsonStorage, + fetcher: &impl SubscriptionFetcher, + clock: &impl Clock, +) -> Result { + let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + let subscription_url = config + .subscription_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + CommandError::new( + "singbox_subscription_missing", + "Ссылка на подписку Local sing-box не сохранена.", + ) + })?; + + let device_hwid_created = ensure_device_hwid(&mut config); + if device_hwid_created { + config.updated_at = Some(clock.now()); + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + } + + let identity = + subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref()); + let cache = fetcher + .fetch_subscription(&subscription_url, &identity) + .map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?; + let selected_server = config + .selected_server_id + .as_deref() + .and_then(|id| cache.servers.iter().find(|server| server.id == id)) + .or_else(|| { + let tag = config.selected_server_tag.as_deref()?; + cache.servers.iter().find(|server| server.tag == tag) + }) + .or_else(|| cache.servers.first()); + + config.selected_server_id = selected_server.map(|server| server.id.clone()); + config.selected_server_tag = selected_server.map(|server| server.tag.clone()); + config.updated_at = Some(clock.now()); + storage + .write_singbox_subscription_cache(&cache) + .map_err(storage_error)?; + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + storage + .append_activity(ActivityEntry { + id: "singbox-subscription-fetched".to_string(), + at: clock.now(), + level: ActivityLevel::Success, + title: "Подписка Local sing-box обновлена".to_string(), + message: format!("Серверов найдено: {}", cache.servers.len()), + }) + .map_err(storage_error)?; + + read_singbox_status(storage) +} + +pub fn forget_singbox_subscription_in_storage( + storage: &JsonStorage, + clock: &impl Clock, +) -> Result { + let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + config.subscription_url = None; + config.selected_server_tag = None; + config.selected_server_id = None; + config.updated_at = Some(clock.now()); + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + storage + .remove_singbox_subscription_cache() + .map_err(storage_error)?; + + read_singbox_status(storage) +} + +pub fn select_singbox_server_in_storage( + storage: &JsonStorage, + input: SelectSingBoxServerInputDto, + clock: &impl Clock, +) -> Result { + let requested_tag = input.tag.trim().to_string(); + let requested_id = input + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + if requested_tag.is_empty() { + return Err(CommandError::new( + "singbox_server_tag_missing", + "Сервер Local sing-box не выбран.", + )); + } + + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)? + .ok_or_else(|| { + CommandError::new( + "singbox_subscription_cache_missing", + "Сначала нужно загрузить подписку Local sing-box.", + ) + })?; + let Some(server) = find_subscription_server( + &cache, + requested_id, + &requested_tag, + input.server.as_deref(), + input.server_port, + ) else { + return Err(CommandError::new( + "singbox_server_not_found", + format!("Сервер Local sing-box '{requested_tag}' не найден в текущей подписке."), + )); + }; + let selected_tag = server.tag.clone(); + let selected_id = server.id.clone(); + + let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + config.selected_server_tag = Some(selected_tag); + config.selected_server_id = Some(selected_id); + config.updated_at = Some(clock.now()); + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + + read_singbox_status(storage) +} + +pub fn ping_singbox_server_in_storage( + storage: &JsonStorage, + input: PingSingBoxServerInputDto, +) -> Result { + let tag = input.tag.trim(); + let id = input + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + let cache = read_required_singbox_cache(storage)?; + let server = find_subscription_server(&cache, id, tag, None, None).ok_or_else(|| { + CommandError::new( + "singbox_server_not_found", + format!("Сервер Local sing-box '{tag}' не найден в текущей подписке."), + ) + })?; + + Ok(ping_subscription_server(server)) +} + +pub fn ping_all_singbox_servers_in_storage( + storage: &JsonStorage, +) -> Result, CommandError> { + let cache = read_required_singbox_cache(storage)?; + Ok(cache.servers.iter().map(ping_subscription_server).collect()) +} + +pub(crate) fn read_required_singbox_cache( + storage: &JsonStorage, +) -> Result { + storage + .read_singbox_subscription_cache() + .map_err(storage_error)? + .ok_or_else(|| { + CommandError::new( + "singbox_subscription_cache_missing", + "Сначала нужно загрузить подписку Local sing-box.", + ) + }) +} + +fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> { + if subscription_url.is_empty() { + return Err(CommandError::new( + "singbox_subscription_url_missing", + "Ссылка на подписку Local sing-box не указана.", + )); + } + + let parsed = url::Url::parse(subscription_url).map_err(|_| { + CommandError::new( + "singbox_subscription_url_invalid", + "Ссылка на подписку Local sing-box должна быть корректным URL.", + ) + })?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(CommandError::new( + "singbox_subscription_url_invalid", + "Ссылка на подписку Local sing-box должна начинаться с http:// или https://.", + )); + } + + Ok(()) +} + +fn ensure_device_hwid(config: &mut LocalSingBoxConfig) -> bool { + if config + .device_hwid + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + return false; + } + + config.device_hwid = Some(uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase()); + true +} + +fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse { + ping_endpoint(&server.id, &server.tag, &server.server, server.server_port) +} + +fn local_lan_ipv4() -> Option { + let socket = UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("8.8.8.8:80").ok()?; + let IpAddr::V4(address) = socket.local_addr().ok()?.ip() else { + return None; + }; + if address.is_loopback() || address.is_link_local() || address.is_unspecified() { + return None; + } + Some(address.to_string()) +} + +fn find_subscription_server<'a>( + cache: &'a SubscriptionCache, + requested_id: Option<&str>, + requested_tag: &str, + requested_server: Option<&str>, + requested_port: Option, +) -> Option<&'a SubscriptionServer> { + requested_id + .and_then(|id| cache.servers.iter().find(|server| server.id == id)) + .or_else(|| { + cache + .servers + .iter() + .find(|server| server.tag == requested_tag) + }) + .or_else(|| { + let requested = comparable_server_tag(requested_tag); + cache + .servers + .iter() + .find(|server| comparable_server_tag(&server.tag) == requested) + }) + .or_else(|| { + let server_name = requested_server?.trim(); + let server_port = requested_port?; + cache.servers.iter().find(|server| { + server.server.eq_ignore_ascii_case(server_name) && server.server_port == server_port + }) + }) +} + +fn comparable_server_tag(value: &str) -> String { + value + .chars() + .filter(|ch| !matches!(ch, '\u{fe0e}' | '\u{fe0f}' | '\u{200d}')) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn storage_error(error: std::io::Error) -> CommandError { + CommandError::new("storage_error", error.to_string()) +} diff --git a/src-tauri/src/subscription.rs b/src-tauri/src/subscription.rs index 0aeb411..6ee0548 100644 --- a/src-tauri/src/subscription.rs +++ b/src-tauri/src/subscription.rs @@ -1,8 +1,7 @@ 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::net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs}; use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; use url::Url; @@ -11,6 +10,7 @@ const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsock const DEFAULT_APP_NAME: &str = "ProxyWarden"; const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const SUBSCRIPTION_MAX_REDIRECTS: usize = 5; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubscriptionError { @@ -155,69 +155,129 @@ pub fn fetch_subscription_with_identity_and_policy( ) -> Result { let parsed_url = Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?; - validate_subscription_fetch_url(&parsed_url, policy)?; + let mut current_url = 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() + for redirect_count in 0..=SUBSCRIPTION_MAX_REDIRECTS { + validate_subscription_fetch_url(¤t_url, policy)?; + let client = subscription_client_for_url(¤t_url, policy)?; + let mut request = client.get(current_url.clone()); + + for (name, value) in identity.request_headers_without_device_hwid() { + request = request.header(name, value); } - }); - let client = reqwest::blocking::Client::builder() + if let Some(device_hwid) = identity + .device_hwid + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + request = request.header("x-hwid", device_hwid); + } + + let response = request.send().map_err(|error| { + SubscriptionError::new(format!("Subscription request failed: {error}")) + })?; + let status = response.status(); + if status.is_redirection() { + if redirect_count == SUBSCRIPTION_MAX_REDIRECTS { + return Err(SubscriptionError::new( + "Subscription request exceeded redirect limit", + )); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + SubscriptionError::new("Subscription redirect has no valid Location header") + })?; + current_url = current_url + .join(location) + .map_err(|_| SubscriptionError::new("Subscription redirect URL is invalid"))?; + continue; + } + if !status.is_success() { + return Err(SubscriptionError::new(format!( + "Subscription request failed: HTTP {}", + status.as_u16() + ))); + } + + let user_info = parse_user_info( + response + .headers() + .get("subscription-userinfo") + .and_then(|value| value.to_str().ok()), + ); + let body = response.text().map_err(|error| { + SubscriptionError::new(format!("Subscription body read failed: {error}")) + })?; + let parsed = parse_subscription_body(&body)?; + + return Ok(SubscriptionCache { + config: parsed.config, + servers: parsed.servers, + user_info, + fetched_at: now_timestamp(), + }); + } + + Err(SubscriptionError::new( + "Subscription request could not complete", + )) +} + +fn subscription_client_for_url( + parsed_url: &Url, + policy: SubscriptionFetchPolicy, +) -> Result { + let mut builder = 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); + .redirect(reqwest::redirect::Policy::none()); - for (name, value) in identity.request_headers_without_device_hwid() { - request = request.header(name, value); + if !policy.allow_unsafe_local_urls { + let host = parsed_url + .host_str() + .ok_or_else(|| SubscriptionError::new("Subscription URL has no host"))?; + if host.parse::().is_err() { + let port = parsed_url + .port_or_known_default() + .ok_or_else(|| SubscriptionError::new("Subscription URL has no resolvable port"))?; + let addresses = (host, port) + .to_socket_addrs() + .map_err(|error| { + SubscriptionError::new(format!( + "Subscription host DNS resolution failed: {error}" + )) + })? + .collect::>(); + validate_resolved_subscription_addresses(&addresses)?; + builder = builder.resolve_to_addrs(host, &addresses); + } } - if let Some(device_hwid) = identity - .device_hwid - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - request = request.header("x-hwid", device_hwid); - } - - let response = request - .send() - .map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?; - - let status = response.status(); - if !status.is_success() { - return Err(SubscriptionError::new(format!( - "Subscription request failed: HTTP {}", - status.as_u16() - ))); - } - - let user_info = parse_user_info( - response - .headers() - .get("subscription-userinfo") - .and_then(|value| value.to_str().ok()), - ); - let body = response.text().map_err(|error| { - SubscriptionError::new(format!("Subscription body read failed: {error}")) - })?; - let parsed = parse_subscription_body(&body)?; - - Ok(SubscriptionCache { - config: parsed.config, - servers: parsed.servers, - user_info, - fetched_at: now_timestamp(), + builder.build().map_err(|error| { + SubscriptionError::new(format!("Subscription client setup failed: {error}")) }) } +pub fn validate_resolved_subscription_addresses( + addresses: &[SocketAddr], +) -> Result<(), SubscriptionError> { + if addresses.is_empty() { + return Err(SubscriptionError::new( + "Subscription host DNS resolution returned no addresses", + )); + } + if addresses.iter().any(|address| is_unsafe_ip(address.ip())) { + return Err(SubscriptionError::new( + "Subscription host resolves to a local, private, link-local, multicast, or metadata address", + )); + } + Ok(()) +} + fn validate_subscription_fetch_url( parsed_url: &Url, policy: SubscriptionFetchPolicy, @@ -286,23 +346,171 @@ fn parse_link_subscription(body: &str) -> Result { let links = decoded .lines() .map(str::trim) - .filter(|line| line.starts_with("vless://")) + .filter(|line| { + ["vless://", "trojan://", "ss://", "vmess://"] + .iter() + .any(|scheme| line.starts_with(scheme)) + }) .collect::>(); if links.is_empty() { return Err(SubscriptionError::new( - "Subscription does not contain JSON config or VLESS links", + "Subscription does not contain JSON config or supported VLESS, VMess, Trojan, or Shadowsocks links", )); } let outbounds = links .into_iter() - .map(parse_vless_url) + .map(|link| { + if link.starts_with("vless://") { + parse_vless_url(link) + } else if link.starts_with("trojan://") { + parse_trojan_url(link) + } else if link.starts_with("ss://") { + parse_shadowsocks_url(link) + } else { + parse_vmess_url(link) + } + }) .collect::, _>>()?; Ok(json!({ "outbounds": outbounds })) } +fn parse_trojan_url(raw_url: &str) -> Result { + let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Trojan URL"))?; + let password = parsed.username().trim().to_string(); + let server = parsed.host_str().map(str::to_string).unwrap_or_default(); + let server_port = parsed.port_or_known_default().unwrap_or(443); + if password.is_empty() || server.is_empty() { + return Err(SubscriptionError::new( + "Trojan URL misses password, host or port", + )); + } + let tag = parsed + .fragment() + .map(decode_percent_encoded_utf8) + .unwrap_or_else(|| "trojan-out".to_string()); + let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone()); + + Ok(json!({ + "type": "trojan", + "tag": tag, + "server": server, + "server_port": server_port, + "password": password, + "tls": { + "enabled": true, + "server_name": server_name + } + })) +} + +fn parse_shadowsocks_url(raw_url: &str) -> Result { + let parsed = + Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Shadowsocks URL"))?; + let server = parsed.host_str().map(str::to_string).unwrap_or_default(); + let server_port = parsed.port().unwrap_or(8388); + let credentials = match parsed.password() { + Some(password) => format!("{}:{password}", parsed.username()), + None => decode_base64_text(parsed.username()).ok_or_else(|| { + SubscriptionError::new("Shadowsocks credentials are not valid base64") + })?, + }; + let (method, password) = credentials + .split_once(':') + .ok_or_else(|| SubscriptionError::new("Shadowsocks URL misses method or password"))?; + if method.trim().is_empty() || password.is_empty() || server.is_empty() { + return Err(SubscriptionError::new( + "Shadowsocks URL misses method, password, host or port", + )); + } + let tag = parsed + .fragment() + .map(decode_percent_encoded_utf8) + .unwrap_or_else(|| "shadowsocks-out".to_string()); + + Ok(json!({ + "type": "shadowsocks", + "tag": tag, + "server": server, + "server_port": server_port, + "method": method, + "password": password + })) +} + +fn parse_vmess_url(raw_url: &str) -> Result { + let payload = raw_url + .strip_prefix("vmess://") + .and_then(|value| value.split('#').next()) + .ok_or_else(|| SubscriptionError::new("Invalid VMess URL"))?; + let decoded = decode_base64_text(payload) + .ok_or_else(|| SubscriptionError::new("VMess payload is not valid base64"))?; + let source: Value = serde_json::from_str(&decoded) + .map_err(|_| SubscriptionError::new("VMess payload is not valid JSON"))?; + let server = source + .get("add") + .and_then(Value::as_str) + .unwrap_or_default(); + let server_port = source + .get("port") + .and_then(|value| value.as_u64().or_else(|| value.as_str()?.parse().ok())) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(443); + let uuid = source.get("id").and_then(Value::as_str).unwrap_or_default(); + if server.is_empty() || uuid.is_empty() { + return Err(SubscriptionError::new( + "VMess payload misses host, port or uuid", + )); + } + let tag = source + .get("ps") + .and_then(Value::as_str) + .map(decode_percent_encoded_utf8) + .unwrap_or_else(|| "vmess-out".to_string()); + let security = source + .get("scy") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or("auto"); + let mut outbound = json!({ + "type": "vmess", + "tag": tag, + "server": server, + "server_port": server_port, + "uuid": uuid, + "security": security + }); + if source.get("tls").and_then(Value::as_str) == Some("tls") { + let server_name = source + .get("sni") + .or_else(|| source.get("host")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(server); + outbound["tls"] = json!({ "enabled": true, "server_name": server_name }); + } + if source.get("net").and_then(Value::as_str) == Some("ws") { + let path = source + .get("path") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or("/"); + let host = source + .get("host") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + outbound["transport"] = json!({ + "type": "ws", + "path": path, + "headers": host.map(|host| json!({ "Host": host })).unwrap_or_else(|| json!({})) + }); + } + + Ok(outbound) +} + fn parse_vless_url(raw_url: &str) -> Result { if !raw_url.starts_with("vless://") { return Err(SubscriptionError::new("VLESS URL must start with vless://")); @@ -399,6 +607,7 @@ fn server_from_outbound(outbound: &Value) -> Option { .unwrap_or_else(|| format!("{server_type}-{server}")); Some(SubscriptionServer { + id: outbound_server_id(outbound), tag, server_type, server, @@ -406,6 +615,14 @@ fn server_from_outbound(outbound: &Value) -> Option { }) } +fn outbound_server_id(outbound: &Value) -> String { + let bytes = serde_json::to_vec(outbound).unwrap_or_default(); + let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + format!("pw-{hash:016x}") +} + fn maybe_decode_base64(content: &str) -> String { let compact = content.split_whitespace().collect::(); if compact.is_empty() @@ -419,7 +636,11 @@ fn maybe_decode_base64(content: &str) -> String { for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] { if let Ok(decoded) = engine.decode(compact.as_bytes()) { if let Ok(decoded) = String::from_utf8(decoded) { - if decoded.contains("vless://") || decoded.contains('{') { + if ["vless://", "vmess://", "trojan://", "ss://"] + .iter() + .any(|scheme| decoded.contains(scheme)) + || decoded.contains('{') + { return decoded; } } @@ -429,6 +650,23 @@ fn maybe_decode_base64(content: &str) -> String { content.to_string() } +fn decode_base64_text(value: &str) -> Option { + let value = value.trim(); + for engine in [ + general_purpose::STANDARD, + general_purpose::STANDARD_NO_PAD, + general_purpose::URL_SAFE, + general_purpose::URL_SAFE_NO_PAD, + ] { + if let Ok(decoded) = engine.decode(value.as_bytes()) { + if let Ok(decoded) = String::from_utf8(decoded) { + return Some(decoded); + } + } + } + None +} + fn query_value(url: &Url, key: &str) -> Option { url.query_pairs() .find(|(name, _)| name == key) diff --git a/src-tauri/src/validation.rs b/src-tauri/src/validation.rs index 854fa48..70a52c3 100644 --- a/src-tauri/src/validation.rs +++ b/src-tauri/src/validation.rs @@ -25,11 +25,18 @@ fn clean(value: &str) -> String { fn slug(value: &str, fallback: &str) -> String { let mut output = String::new(); let mut previous_dash = false; + let mut has_non_ascii = false; for ch in value.trim().to_lowercase().chars() { if ch.is_ascii_alphanumeric() { output.push(ch); previous_dash = false; + } else if ch.is_alphanumeric() { + has_non_ascii = true; + if !previous_dash { + output.push('-'); + previous_dash = true; + } } else if !previous_dash { output.push('-'); previous_dash = true; @@ -37,13 +44,56 @@ fn slug(value: &str, fallback: &str) -> String { } let output = output.trim_matches('-').to_string(); - if output.is_empty() { - fallback.to_string() + let base = if output.is_empty() { fallback } else { &output }; + if has_non_ascii { + format!("{base}-{:016x}", stable_hash(value.trim().as_bytes())) } else { - output + base.to_string() } } +fn stable_hash(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf29ce484222325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }) +} + +fn valid_proxy_host(value: &str) -> bool { + !value.is_empty() + && !value.contains("://") + && !value.chars().any(|ch| { + ch.is_whitespace() || ch.is_control() || matches!(ch, '/' | '\\' | '@' | '?' | '#') + }) + && url::Host::parse(value).is_ok() +} + +fn valid_windows_item_path(value: &str, item_type: &ProfileItemType) -> bool { + if value + .chars() + .any(|ch| ch.is_control() || matches!(ch, '"' | '<' | '>' | '|' | '?' | '*')) + { + return false; + } + let bytes = value.as_bytes(); + let absolute_drive = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/'); + let unc = value.starts_with(r"\\"); + let environment_root = value.starts_with('%') + && value[1..].find('%').is_some_and(|index| { + value + .as_bytes() + .get(index + 2) + .is_some_and(|ch| matches!(ch, b'\\' | b'/')) + }); + let path_shape_valid = absolute_drive || unc || environment_root; + + path_shape_valid + && (!matches!(item_type, ProfileItemType::Exe) + || value.to_ascii_lowercase().ends_with(".exe")) +} + fn process_name(value: &str) -> String { let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim(); base.strip_suffix(".exe") @@ -149,6 +199,15 @@ pub fn normalize_profile(input: ProfileInput) -> ValidationResult { errors.push(error("items.value", "Укажите значение элемента профиля")); continue; } + if matches!(item_type, ProfileItemType::Folder | ProfileItemType::Exe) + && !valid_windows_item_path(&value, &item_type) + { + errors.push(error( + "items.value", + "Укажите абсолютный Windows-путь; для exe путь должен оканчиваться на .exe", + )); + continue; + } let recursive = matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true); @@ -183,6 +242,11 @@ pub fn normalize_target(input: TargetInput) -> ValidationResult { } if host.is_empty() { errors.push(error("host", "Укажите хост цели")); + } else if !valid_proxy_host(&host) { + errors.push(error( + "host", + "Укажите только IP-адрес или имя хоста без схемы, пути и учетных данных", + )); } if input.port == 0 || input.port > u16::MAX as u32 { errors.push(error("port", "Порт цели должен быть от 1 до 65535")); diff --git a/src-tauri/tests/apply_flow_tests.rs b/src-tauri/tests/apply_flow_tests.rs new file mode 100644 index 0000000..83ee140 --- /dev/null +++ b/src-tauri/tests/apply_flow_tests.rs @@ -0,0 +1,423 @@ +use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter; +use proxywarden_lib::adapters::singbox::{ + SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError, +}; +use proxywarden_lib::apply_flow::{ + apply_configuration, ApplyConfigurationInput, ApplyPhaseStatus, ApplyRouteMode, ApplyServices, +}; +use proxywarden_lib::commands::{ + Clock, CommandError, HelperApplyRequest, HelperApplyResult, ProxyApplyHelper, +}; +use proxywarden_lib::component_detection::{DetectedProxyfier, ProxyfierEngine}; +use proxywarden_lib::models::{ + LocalSingBoxConfig, Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, + Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetInput, + TargetKind, +}; +use proxywarden_lib::storage::JsonStorage; +use std::{cell::Cell, fs, path::Path}; + +#[test] +fn external_apply_commits_one_source_state_without_service_control() { + let fixture = ApplyFixture::new("external-success"); + fixture.seed_old_state(); + let helper = RecordingHelper::success(); + + let result = + run_apply(&fixture.storage, external_input(), &helper).expect("preflight should succeed"); + + assert!(result.success); + assert!(!result.partial_state); + assert_eq!(helper.calls.get(), 1); + assert!(result.phases.iter().any(|phase| { + phase.id == "service-control" && phase.status == ApplyPhaseStatus::Skipped + })); + let profiles = fixture.storage.read_profiles().expect("read profiles"); + let targets = fixture.storage.read_targets().expect("read targets"); + assert!(profiles + .iter() + .any(|profile| profile.id == "main-profile" && profile.enabled)); + assert!(profiles + .iter() + .any(|profile| profile.id == "legacy" && !profile.enabled)); + assert!(targets.iter().any(|target| { + target.id == "main-proxy" && target.host == "proxy.example.test" && target.port == 1080 + })); + assert!(Path::new(&result.generated_config_path).exists()); +} + +#[test] +fn preflight_failure_does_not_write_source_or_call_helper() { + let fixture = ApplyFixture::new("preflight-failure"); + fixture.seed_old_state(); + let before_profiles = fixture.storage.read_profiles().expect("profiles before"); + let before_targets = fixture.storage.read_targets().expect("targets before"); + let helper = RecordingHelper::success(); + let mut input = external_input(); + input.external_target.as_mut().expect("target").host = + "socks5://unsafe.example.test".to_string(); + + let error = run_apply(&fixture.storage, input, &helper) + .expect_err("invalid target should fail before writes"); + + assert_eq!(error.code(), "validation_failed"); + assert_eq!(helper.calls.get(), 0); + assert_eq!( + fixture.storage.read_profiles().expect("profiles after"), + before_profiles + ); + assert_eq!( + fixture.storage.read_targets().expect("targets after"), + before_targets + ); +} + +#[test] +fn backend_blocks_apply_when_proxifyre_is_not_detected() { + let fixture = ApplyFixture::new("missing-proxifyre"); + fixture.seed_old_state(); + let helper = RecordingHelper::success(); + let proxy_adapter = ProxiFyreAdapter::default(); + let singbox_adapter = SingBoxAdapter::default(); + + let error = apply_configuration( + &fixture.storage, + external_input(), + ApplyServices { + proxy_adapter: &proxy_adapter, + singbox_adapter: &singbox_adapter, + checker: &NoopChecker, + helper: &helper, + clock: &FixedClock, + detected_proxyfier: None, + detected_singbox: None, + }, + ) + .expect_err("backend must not trust frontend readiness"); + + assert_eq!(error.code(), "proxifyre_not_found"); + assert_eq!(helper.calls.get(), 0); +} + +#[test] +fn apply_command_contract_uses_camel_case_nested_dtos() { + let input: ApplyConfigurationInput = serde_json::from_value(serde_json::json!({ + "routeMode": "external", + "profile": { + "id": "main-profile", + "name": "Main", + "enabled": true, + "targetId": "main-proxy", + "protocols": ["TCP"], + "items": [{ "type": "process", "value": "Discord.exe" }] + }, + "externalTarget": { + "id": "main-proxy", + "name": "Proxy", + "kind": "external", + "protocol": "socks5", + "host": "proxy.example.test", + "port": 1080 + }, + "disableOtherProfiles": true + })) + .expect("typed Tauri input should deserialize"); + + assert_eq!(input.profile.target_id, "main-proxy"); + assert_eq!(input.profile.items[0].item_type, "process"); + assert_eq!( + input.external_target.expect("target").host, + "proxy.example.test" + ); +} + +#[test] +fn helper_failure_rolls_back_source_and_generated_artifact() { + let fixture = ApplyFixture::new("helper-rollback"); + fixture.seed_old_state(); + let before_profiles = fixture.storage.read_profiles().expect("profiles before"); + let before_targets = fixture.storage.read_targets().expect("targets before"); + let generated_path = fixture + .storage + .paths() + .generated_dir + .join("proxifyre-app-config.json"); + fs::create_dir_all(generated_path.parent().expect("generated parent")) + .expect("create generated dir"); + fs::write(&generated_path, b"old-generated").expect("seed generated config"); + + let helper = RecordingHelper::failure(); + let result = run_apply(&fixture.storage, external_input(), &helper) + .expect("runtime failure should return phase result"); + + assert!(!result.success); + assert!(!result.partial_state); + assert_eq!(result.error_code.as_deref(), Some("fixture_apply_failed")); + assert!(result + .phases + .iter() + .any(|phase| phase.status == ApplyPhaseStatus::RolledBack)); + assert_eq!( + fixture.storage.read_profiles().expect("profiles after"), + before_profiles + ); + assert_eq!( + fixture.storage.read_targets().expect("targets after"), + before_targets + ); + assert_eq!( + fs::read(&generated_path).expect("generated after"), + b"old-generated" + ); +} + +#[test] +fn local_apply_with_missing_running_service_stops_at_preflight() { + let fixture = ApplyFixture::new("local-service-preflight"); + fixture.seed_old_state(); + fixture + .storage + .write_local_singbox_config(&LocalSingBoxConfig { + subscription_url: Some("https://sub.example.test/list".to_string()), + selected_server_id: Some("fixture-server".to_string()), + selected_server_tag: Some("fixture".to_string()), + ..LocalSingBoxConfig::default() + }) + .expect("write local config"); + fixture + .storage + .write_singbox_subscription_cache(&SubscriptionCache { + config: serde_json::json!({ + "outbounds": [{ + "type": "vless", + "tag": "fixture", + "server": "edge.example.test", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111" + }] + }), + servers: vec![SubscriptionServer { + id: "fixture-server".to_string(), + tag: "fixture".to_string(), + server_type: "vless".to_string(), + server: "edge.example.test".to_string(), + server_port: 443, + }], + user_info: serde_json::Map::new(), + fetched_at: "fixture".to_string(), + }) + .expect("write cache"); + let helper = RecordingHelper::success(); + let before_profiles = fixture.storage.read_profiles().expect("profiles before"); + + let error = run_apply( + &fixture.storage, + ApplyConfigurationInput { + route_mode: ApplyRouteMode::LocalSingbox, + profile: profile_input(), + external_target: None, + disable_other_profiles: true, + }, + &helper, + ) + .expect_err("stopped/missing Local sing-box must block preflight"); + + assert_eq!(error.code(), "proxifyre_preflight_failed"); + assert_eq!(helper.calls.get(), 0); + assert_eq!( + fixture.storage.read_profiles().expect("profiles after"), + before_profiles + ); +} + +fn external_input() -> ApplyConfigurationInput { + ApplyConfigurationInput { + route_mode: ApplyRouteMode::External, + profile: profile_input(), + external_target: Some(TargetInput { + id: Some("main-proxy".to_string()), + name: "Основной прокси".to_string(), + kind: "external".to_string(), + protocol: "socks5".to_string(), + host: "proxy.example.test".to_string(), + port: 1080, + requires_component: None, + }), + disable_other_profiles: true, + } +} + +fn profile_input() -> ProfileInput { + ProfileInput { + id: Some("main-profile".to_string()), + name: "Приложения через прокси".to_string(), + enabled: true, + target_id: String::new(), + protocols: vec!["TCP".to_string(), "UDP".to_string()], + items: vec![ProfileItemInput { + item_type: "process".to_string(), + value: "Discord.exe".to_string(), + recursive: None, + }], + } +} + +fn run_apply( + storage: &JsonStorage, + input: ApplyConfigurationInput, + helper: &dyn ProxyApplyHelper, +) -> Result< + proxywarden_lib::apply_flow::ApplyConfigurationResult, + proxywarden_lib::apply_flow::ApplyFlowError, +> { + let proxy_adapter = ProxiFyreAdapter::default(); + let singbox_adapter = SingBoxAdapter::default(); + apply_configuration( + storage, + input, + ApplyServices { + proxy_adapter: &proxy_adapter, + singbox_adapter: &singbox_adapter, + checker: &NoopChecker, + helper, + clock: &FixedClock, + detected_proxyfier: Some(test_proxyfier()), + detected_singbox: None, + }, + ) +} + +fn test_proxyfier() -> DetectedProxyfier { + DetectedProxyfier { + engine: ProxyfierEngine::ProxiFyre, + name: "ProxiFyre".to_string(), + install_dir: r"C:\Program Files\ProxyWarden\components\ProxiFyre".into(), + executable_path: r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe".into(), + config_path: Some( + r"C:\Program Files\ProxyWarden\components\ProxiFyre\app-config.json".into(), + ), + running: true, + service_name: Some("ProxiFyreService".to_string()), + service_status: Some("running".to_string()), + } +} + +struct RecordingHelper { + calls: Cell, + succeed: bool, +} + +impl RecordingHelper { + fn success() -> Self { + Self { + calls: Cell::new(0), + succeed: true, + } + } + + fn failure() -> Self { + Self { + calls: Cell::new(0), + succeed: false, + } + } +} + +impl ProxyApplyHelper for RecordingHelper { + fn apply_proxy_config( + &self, + _request: HelperApplyRequest<'_>, + ) -> Result { + self.calls.set(self.calls.get() + 1); + if self.succeed { + Ok(HelperApplyResult { + success: true, + changed: true, + action: "apply".to_string(), + message: "fixture applied".to_string(), + }) + } else { + Err(CommandError { + code: "fixture_apply_failed".to_string(), + message: "fixture helper failed".to_string(), + details: Vec::new(), + }) + } + } +} + +struct NoopChecker; + +impl SingBoxConfigChecker for NoopChecker { + fn check_config( + &self, + _binary_path: &Path, + _config_json: &str, + ) -> Result { + Ok(SingBoxCheckResult { + checked: true, + success: true, + message: "fixture valid".to_string(), + }) + } +} + +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> String { + "2026-07-11T00:00:00Z".to_string() + } +} + +struct ApplyFixture { + root: std::path::PathBuf, + storage: JsonStorage, +} + +impl ApplyFixture { + fn new(label: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "proxywarden-apply-flow-{label}-{}", + uuid::Uuid::new_v4().hyphenated() + )); + Self { + storage: JsonStorage::new(root.clone()), + root, + } + } + + fn seed_old_state(&self) { + self.storage + .write_profiles(&[Profile { + id: "legacy".to_string(), + name: "Legacy".to_string(), + enabled: true, + target_id: "legacy-target".to_string(), + protocols: vec![Protocol::Tcp], + items: vec![ProfileItem { + item_type: ProfileItemType::Process, + value: "legacy".to_string(), + recursive: false, + }], + }]) + .expect("seed profiles"); + self.storage + .write_targets(&[Target { + id: "legacy-target".to_string(), + name: "Legacy".to_string(), + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host: "legacy.example.test".to_string(), + port: 1080, + requires_component: None, + }]) + .expect("seed targets"); + } +} + +impl Drop for ApplyFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} diff --git a/src-tauri/tests/command_tests.rs b/src-tauri/tests/command_tests.rs index 478f91c..3d7ca57 100644 --- a/src-tauri/tests/command_tests.rs +++ b/src-tauri/tests/command_tests.rs @@ -13,6 +13,7 @@ use proxywarden_lib::models::{ self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind, }; +use proxywarden_lib::proxifyre_ownership::ManagedProxiFyreOwnership; use proxywarden_lib::storage::JsonStorage; use std::collections::HashSet; use std::fs; @@ -358,7 +359,7 @@ fn proxifyre_uninstall_script_parses_as_powershell() { }; let script = commands::wrap_elevated_package_script( - &commands::uninstall_proxifyre_script(Some(&detected)), + &commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true)), &root.join("uninstall.log"), ); let script_path = root.join("uninstall.ps1"); @@ -397,8 +398,13 @@ fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() { service_name: Some("ProxiFyreService".to_string()), service_status: Some("running".to_string()), }; - let script = commands::uninstall_proxifyre_script(Some(&detected)); + let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true)); + assert!(script.contains("function Find-ManagedProxiFyreService")); + assert!(script.contains("Get-CimInstance Win32_Service")); + assert!(script.contains("[StringComparison]::OrdinalIgnoreCase")); + assert!(!script.contains("function Find-ProxiFyreService")); + assert!(!script.contains("Where-Object { $_.Name -match 'ProxiFyre|Proxifyre'")); assert!(script.contains("function Resolve-MsiProductCode($program, [string]$label)")); assert!(script.contains("Отказываюсь запускать произвольный UninstallString")); assert!(script.contains("Start-Process -FilePath 'msiexec.exe'")); @@ -413,6 +419,29 @@ fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() { assert!(proxifyre_step < packet_filter_step); } +#[test] +fn proxifyre_uninstall_script_leaves_shared_packet_filter_installed() { + let detected = DetectedProxyfier { + engine: ProxyfierEngine::ProxiFyre, + name: "ProxiFyre".to_string(), + install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"), + executable_path: PathBuf::from( + r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe", + ), + config_path: None, + running: false, + service_name: Some("ProxiFyreService".to_string()), + service_status: Some("stopped".to_string()), + }; + + let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(false)); + + assert!(script.contains("$removePacketFilter = $false")); + assert!(script.contains("if ($removePacketFilter)")); + assert!(script.contains("Windows Packet Filter оставлен")); + assert!(!script.contains("Get-Process -Name 'ProxiFyre'")); +} + #[test] fn singbox_runner_preserves_installer_args_with_spaces() { let script = commands::singbox_installer_runner_script( @@ -540,6 +569,20 @@ fn component_status_merges_detected_existing_proxifyre() { assert!(proxyfier.problems.is_empty()); } +#[test] +fn component_status_does_not_keep_stale_installed_state_when_detection_is_missing() { + let components = resolve_component_statuses(vec![proxyfier_running()], None, None); + let proxyfier = components + .iter() + .find(|component| component.id == ComponentId::Proxyfier) + .expect("proxyfier component"); + + assert_eq!(proxyfier.state, ComponentState::Missing); + assert!(!proxyfier.installed); + assert!(!proxyfier.running); + assert_eq!(proxyfier.path, None); +} + #[test] fn detected_proxy_apply_helper_writes_proxifyre_app_config() { let root = test_root("detected-proxifyre"); @@ -782,3 +825,10 @@ fn singbox_missing() -> ComponentStatus { actions: vec!["Установить локальный sing-box".to_string()], } } + +fn managed_ownership(remove_packet_filter: bool) -> ManagedProxiFyreOwnership { + ManagedProxiFyreOwnership { + service_name: "ProxiFyreService".to_string(), + remove_packet_filter, + } +} diff --git a/src-tauri/tests/component_detection_tests.rs b/src-tauri/tests/component_detection_tests.rs index a91d4fd..7d33281 100644 --- a/src-tauri/tests/component_detection_tests.rs +++ b/src-tauri/tests/component_detection_tests.rs @@ -15,7 +15,10 @@ fn detects_existing_proxifyre_from_registry_install_location() { .with_registry("ProxiFyre", r"C:\Tools\ProxiFyre") .with_path(r"C:\Tools\ProxiFyre") .with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") - .with_service("ProxiFyreService"); + .with_service_path( + "ProxiFyreService", + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#, + ); let detected = detect_proxyfier_install_with_host(&host) .expect("existing ProxiFyre install should be detected"); @@ -82,7 +85,10 @@ fn reports_stopped_proxifyre_service_when_executable_exists() { let host = MockHost::new() .with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre") .with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") - .with_stopped_service("ProxiFyreService"); + .with_stopped_service_path( + "ProxiFyreService", + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#, + ); let detected = detect_proxyfier_install_with_host(&host).expect("proxifyre executable should be detected"); @@ -105,6 +111,37 @@ fn missing_proxyfier_returns_install_action_status() { assert_eq!(component.actions, vec!["Установить ProxiFyre"]); } +#[test] +fn ignores_known_service_name_when_path_points_to_foreign_binary() { + let host = MockHost::new() + .with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre") + .with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") + .with_service_path( + "ProxiFyreService", + r#""C:\Foreign\ProxiFyre.exe" --service"#, + ); + + let detected = + detect_proxyfier_install_with_host(&host).expect("executable should still be detected"); + + assert!(!detected.running); + assert_eq!(detected.service_status, None); +} + +#[test] +fn ignores_known_service_name_without_path_metadata() { + let host = MockHost::new() + .with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre") + .with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") + .with_service("ProxiFyreService"); + + let detected = + detect_proxyfier_install_with_host(&host).expect("executable should still be detected"); + + assert!(!detected.running); + assert_eq!(detected.service_status, None); +} + #[test] fn detects_running_local_singbox_from_default_install_root_and_service() { let host = MockHost::new() @@ -176,6 +213,7 @@ struct MockHost { paths: HashSet, processes: HashSet, services: HashMap, + service_paths: HashMap, registry: Vec, } @@ -205,9 +243,19 @@ impl MockHost { self } - fn with_stopped_service(mut self, service: &str) -> Self { + fn with_service_path(mut self, service: &str, path_name: &str) -> Self { + self.services + .insert(service.to_ascii_lowercase(), "running".to_string()); + self.service_paths + .insert(service.to_ascii_lowercase(), path_name.to_string()); + self + } + + fn with_stopped_service_path(mut self, service: &str, path_name: &str) -> Self { self.services .insert(service.to_ascii_lowercase(), "stopped".to_string()); + self.service_paths + .insert(service.to_ascii_lowercase(), path_name.to_string()); self } @@ -241,6 +289,20 @@ impl ProxyfierDetectionHost for MockHost { .cloned() } + fn service_info( + &self, + service_name: &str, + ) -> Option { + let key = service_name.to_ascii_lowercase(); + self.services.get(&key).map(|status| { + proxywarden_lib::component_detection::DetectedService { + name: service_name.to_string(), + status: status.clone(), + path_name: self.service_paths.get(&key).cloned(), + } + }) + } + fn registry_install_entries(&self) -> Vec { self.registry.clone() } diff --git a/src-tauri/tests/domain_tests.rs b/src-tauri/tests/domain_tests.rs index 0d6e424..e180326 100644 --- a/src-tauri/tests/domain_tests.rs +++ b/src-tauri/tests/domain_tests.rs @@ -121,3 +121,91 @@ fn rejects_malformed_target_fields() { assert!(error.iter().any(|item| item.field == "protocol")); assert!(error.iter().any(|item| item.field == "requires_component")); } + +#[test] +fn unicode_names_receive_distinct_stable_ids() { + let profile = normalize_profile(ProfileInput { + id: None, + name: "Игры".to_string(), + enabled: true, + target_id: "main-proxy".to_string(), + protocols: vec!["TCP".to_string()], + items: vec![ProfileItemInput { + item_type: "process".to_string(), + value: "game.exe".to_string(), + recursive: None, + }], + }) + .expect("unicode profile should normalize"); + let other = normalize_profile(ProfileInput { + id: None, + name: "Работа".to_string(), + enabled: true, + target_id: "main-proxy".to_string(), + protocols: vec!["TCP".to_string()], + items: vec![ProfileItemInput { + item_type: "process".to_string(), + value: "work.exe".to_string(), + recursive: None, + }], + }) + .expect("second unicode profile should normalize"); + + assert!(profile.id.starts_with("profile-")); + assert!(other.id.starts_with("profile-")); + assert_ne!(profile.id, other.id); +} + +#[test] +fn rejects_host_with_scheme_credentials_or_path() { + for host in [ + "socks5://proxy.example.test", + "user@proxy.example.test", + "proxy.example.test/path", + ] { + let error = normalize_target(TargetInput { + id: None, + name: "Invalid host".to_string(), + kind: "external".to_string(), + protocol: "socks5".to_string(), + host: host.to_string(), + port: 1080, + requires_component: None, + }) + .expect_err("host must not contain URL syntax"); + + assert!(error.iter().any(|item| item.field == "host")); + } +} + +#[test] +fn rejects_relative_or_non_executable_profile_paths() { + let error = normalize_profile(ProfileInput { + id: None, + name: "Invalid paths".to_string(), + enabled: true, + target_id: "main-proxy".to_string(), + protocols: vec!["TCP".to_string()], + items: vec![ + ProfileItemInput { + item_type: "folder".to_string(), + value: r"relative\folder".to_string(), + recursive: None, + }, + ProfileItemInput { + item_type: "exe".to_string(), + value: r"C:\Games\game.txt".to_string(), + recursive: None, + }, + ], + }) + .expect_err("unsafe path shapes should fail validation"); + + assert_eq!( + error + .iter() + .filter(|item| item.field == "items.value") + .count(), + 2 + ); +} diff --git a/src-tauri/tests/proxifyre_adapter_tests.rs b/src-tauri/tests/proxifyre_adapter_tests.rs index d90718f..88e6531 100644 --- a/src-tauri/tests/proxifyre_adapter_tests.rs +++ b/src-tauri/tests/proxifyre_adapter_tests.rs @@ -83,6 +83,35 @@ fn includes_folder_paths_when_generating_proxifyre_config() { ); } +#[test] +fn deduplicates_windows_app_names_case_insensitively() { + let adapter = ProxiFyreAdapter::default(); + let mut profile = discord_profile("home-gateway"); + profile.items.extend([ + ProfileItem { + item_type: ProfileItemType::Process, + value: "discord".to_string(), + recursive: false, + }, + ProfileItem { + item_type: ProfileItemType::Exe, + value: "DISCORD".to_string(), + recursive: false, + }, + ]); + let profiles = vec![profile]; + let targets = vec![external_socks5_target()]; + + let generated = adapter + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &[])) + .expect("Windows app names should generate"); + let config: ProxiFyreConfig = + serde_json::from_str(&generated.contents).expect("generated config json"); + + assert_eq!(config.proxies[0].app_names, vec!["Discord"]); + assert_eq!(generated.routed_apps, 1); +} + #[test] fn blocks_local_singbox_target_when_required_component_is_missing() { let adapter = ProxiFyreAdapter::default(); diff --git a/src-tauri/tests/proxifyre_ownership_tests.rs b/src-tauri/tests/proxifyre_ownership_tests.rs new file mode 100644 index 0000000..1ce88c9 --- /dev/null +++ b/src-tauri/tests/proxifyre_ownership_tests.rs @@ -0,0 +1,145 @@ +use proxywarden_lib::proxifyre_ownership::verify_managed_proxifyre_install; +use serde_json::json; +use std::{fs, path::PathBuf}; + +#[test] +fn accepts_matching_managed_install_and_returns_packet_filter_ownership() { + let fixture = ManagedInstallFixture::new("owned"); + fixture.write_marker(true, &fixture.install_dir); + + let ownership = verify_managed_proxifyre_install( + &fixture.install_dir, + &fixture.executable_path, + &fixture.install_dir, + ) + .expect("matching marker should prove ownership"); + + assert_eq!(ownership.service_name, "ProxiFyreService"); + assert!(ownership.remove_packet_filter); +} + +#[test] +fn rejects_install_outside_expected_managed_directory() { + let fixture = ManagedInstallFixture::new("unexpected-root"); + fixture.write_marker(true, &fixture.install_dir); + let other_root = fixture + .root + .join("other") + .join("components") + .join("ProxiFyre"); + fs::create_dir_all(&other_root).expect("other root should be created"); + + let error = verify_managed_proxifyre_install( + &fixture.install_dir, + &fixture.executable_path, + &other_root, + ) + .expect_err("a detected portable install must not be recursively removed"); + + assert!(error.contains("не является управляемой папкой")); +} + +#[test] +fn rejects_marker_with_mismatched_install_root() { + let fixture = ManagedInstallFixture::new("mismatched-marker"); + fixture.write_marker(false, &fixture.root); + + let error = verify_managed_proxifyre_install( + &fixture.install_dir, + &fixture.executable_path, + &fixture.install_dir, + ) + .expect_err("marker installRoot must match the managed directory"); + + assert!(error.contains("installRoot из marker")); +} + +#[test] +fn rejects_marker_with_foreign_service_name() { + let fixture = ManagedInstallFixture::new("foreign-service"); + fixture.write_custom_marker(json!({ + "manager": "ProxyWarden", + "component": "proxifyre", + "serviceName": "ForeignProxyService", + "installRoot": fixture.install_dir, + "packetFilterInstalledByProxyWarden": true + })); + + let error = verify_managed_proxifyre_install( + &fixture.install_dir, + &fixture.executable_path, + &fixture.install_dir, + ) + .expect_err("foreign service name must not be trusted"); + + assert!(error.contains("неподдерживаемое имя службы")); +} + +#[test] +fn missing_packet_filter_flag_defaults_to_not_owned() { + let fixture = ManagedInstallFixture::new("shared-driver"); + fixture.write_custom_marker(json!({ + "manager": "ProxyWarden", + "component": "proxifyre", + "serviceName": "ProxiFyreService", + "installRoot": fixture.install_dir + })); + + let ownership = verify_managed_proxifyre_install( + &fixture.install_dir, + &fixture.executable_path, + &fixture.install_dir, + ) + .expect("valid marker without ownership flag should remain safe"); + + assert!(!ownership.remove_packet_filter); +} + +struct ManagedInstallFixture { + root: PathBuf, + install_dir: PathBuf, + executable_path: PathBuf, +} + +impl ManagedInstallFixture { + fn new(label: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "proxywarden-ownership-{label}-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let install_dir = root.join("components").join("ProxiFyre"); + let executable_path = install_dir.join("ProxiFyre.exe"); + fs::create_dir_all(&install_dir).expect("managed install directory should be created"); + fs::write(&executable_path, b"fixture").expect("fixture executable should be written"); + + Self { + root, + install_dir, + executable_path, + } + } + + fn write_marker(&self, packet_filter_owned: bool, install_root: &std::path::Path) { + self.write_custom_marker(json!({ + "manager": "ProxyWarden", + "component": "proxifyre", + "serviceName": "ProxiFyreService", + "installRoot": install_root, + "packetFilterInstalledByProxyWarden": packet_filter_owned + })); + } + + fn write_custom_marker(&self, marker: serde_json::Value) { + fs::write( + self.install_dir.join("proxywarden-component.json"), + serde_json::to_vec_pretty(&marker).expect("marker should serialize"), + ) + .expect("marker should be written"); + } +} + +impl Drop for ManagedInstallFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} diff --git a/src-tauri/tests/singbox_adapter_tests.rs b/src-tauri/tests/singbox_adapter_tests.rs index 5a6c392..46805fe 100644 --- a/src-tauri/tests/singbox_adapter_tests.rs +++ b/src-tauri/tests/singbox_adapter_tests.rs @@ -91,6 +91,7 @@ fn blocks_config_when_server_is_not_selected() { let adapter = SingBoxAdapter::default(); let mut config = local_singbox_config("nl-1"); config.selected_server_tag = None; + config.selected_server_id = None; let cache = subscription_cache(); let checker = RecordingChecker::ok("should not run"); @@ -108,8 +109,9 @@ fn blocks_config_when_server_is_not_selected() { #[test] fn blocks_config_when_selected_outbound_is_missing() { let adapter = SingBoxAdapter::default(); - let config = local_singbox_config("missing-server"); - let cache = subscription_cache(); + let config = local_singbox_config("nl-1"); + let mut cache = subscription_cache(); + cache.config = serde_json::json!({ "outbounds": [] }); let checker = RecordingChecker::ok("should not run"); let error = adapter @@ -120,10 +122,67 @@ fn blocks_config_when_selected_outbound_is_missing() { .expect_err("missing outbound should block config"); assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound); - assert!(error.message.contains("missing-server")); + assert!(error.message.contains("nl-1")); assert!(checker.calls.borrow().is_empty()); } +#[test] +fn duplicate_tags_generate_the_outbound_selected_by_stable_id() { + let adapter = SingBoxAdapter::default(); + let mut config = local_singbox_config("shared-name"); + config.selected_server_id = Some("vless|shared-name|second.example.test|8443".to_string()); + let cache = SubscriptionCache { + config: serde_json::json!({ + "outbounds": [ + { + "type": "vless", + "tag": "shared-name", + "server": "first.example.test", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111" + }, + { + "type": "vless", + "tag": "shared-name", + "server": "second.example.test", + "server_port": 8443, + "uuid": "22222222-2222-2222-2222-222222222222" + } + ] + }), + servers: vec![ + SubscriptionServer { + id: "vless|shared-name|first.example.test|443".to_string(), + tag: "shared-name".to_string(), + server_type: "vless".to_string(), + server: "first.example.test".to_string(), + server_port: 443, + }, + SubscriptionServer { + id: "vless|shared-name|second.example.test|8443".to_string(), + tag: "shared-name".to_string(), + server_type: "vless".to_string(), + server: "second.example.test".to_string(), + server_port: 8443, + }, + ], + user_info: serde_json::Map::new(), + fetched_at: "2026-07-11T00:00:00Z".to_string(), + }; + + let generated = adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, None), + &RecordingChecker::ok("not used"), + ) + .expect("stable id should resolve the second duplicate tag"); + let value: serde_json::Value = + serde_json::from_str(&generated.contents).expect("generated config should parse"); + + assert_eq!(value["outbounds"][0]["server"], "second.example.test"); + assert_eq!(value["outbounds"][0]["server_port"], 8443); +} + #[test] fn propagates_failed_singbox_check_as_structured_error() { let adapter = SingBoxAdapter::default(); @@ -208,6 +267,7 @@ fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig { subscription_url: Some("https://sub.example.test/list".to_string()), device_hwid: None, selected_server_tag: Some(selected_server_tag.to_string()), + selected_server_id: Some(format!("vless|{selected_server_tag}|nl.example.test|443")), listen_host: "127.0.0.1".to_string(), listen_port: 1080, service_name: "ProxyWardenSingBox".to_string(), @@ -234,6 +294,7 @@ fn subscription_cache() -> SubscriptionCache { ] }), servers: vec![SubscriptionServer { + id: "vless|nl-1|nl.example.test|443".to_string(), tag: "nl-1".to_string(), server_type: "vless".to_string(), server: "nl.example.test".to_string(), diff --git a/src-tauri/tests/singbox_command_tests.rs b/src-tauri/tests/singbox_command_tests.rs index 22254c6..6160188 100644 --- a/src-tauri/tests/singbox_command_tests.rs +++ b/src-tauri/tests/singbox_command_tests.rs @@ -208,6 +208,7 @@ fn selects_server_from_cached_subscription() { let status = select_singbox_server_in_storage( &storage, SelectSingBoxServerInputDto { + id: Some("trojan|de-1|de.example.test|443".to_string()), tag: "de-1".to_string(), server: None, server_port: None, @@ -220,7 +221,47 @@ fn selects_server_from_cached_subscription() { .expect("read local sing-box config"); assert_eq!(status.config.selected_server_tag, Some("de-1".to_string())); + assert_eq!( + status.config.selected_server_id, + Some("trojan|de-1|de.example.test|443".to_string()) + ); assert_eq!(config.selected_server_tag, Some("de-1".to_string())); + assert_eq!( + config.selected_server_id, + Some("trojan|de-1|de.example.test|443".to_string()) + ); + + cleanup(&root); +} + +#[test] +fn selects_duplicate_tag_by_stable_server_id() { + let root = test_root("select-duplicate-tag"); + let storage = JsonStorage::new(root.clone()); + let mut cache = sample_cache(); + cache.servers[1].tag = "nl-1".to_string(); + cache.servers[1].id = "trojan|nl-1|de.example.test|443".to_string(); + storage + .write_singbox_subscription_cache(&cache) + .expect("write cache"); + + let status = select_singbox_server_in_storage( + &storage, + SelectSingBoxServerInputDto { + id: Some("trojan|nl-1|de.example.test|443".to_string()), + tag: "nl-1".to_string(), + server: Some("de.example.test".to_string()), + server_port: Some(443), + }, + &FixedClock, + ) + .expect("stable id should select the second duplicate tag"); + + assert_eq!( + status.config.selected_server_id, + Some("trojan|nl-1|de.example.test|443".to_string()) + ); + assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string())); cleanup(&root); } @@ -236,6 +277,7 @@ fn selects_server_by_endpoint_when_display_tag_is_sanitized() { let status = select_singbox_server_in_storage( &storage, SelectSingBoxServerInputDto { + id: None, tag: "Умный".to_string(), server: Some("media.example.test".to_string()), server_port: Some(443), @@ -465,12 +507,14 @@ fn sample_cache() -> SubscriptionCache { }), servers: vec![ SubscriptionServer { + id: "vless|nl-1|nl.example.test|443".to_string(), tag: "nl-1".to_string(), server_type: "vless".to_string(), server: "nl.example.test".to_string(), server_port: 443, }, SubscriptionServer { + id: "trojan|de-1|de.example.test|443".to_string(), tag: "de-1".to_string(), server_type: "trojan".to_string(), server: "de.example.test".to_string(), @@ -496,6 +540,7 @@ fn sample_cache_with_flag_tag() -> SubscriptionCache { ] }), servers: vec![SubscriptionServer { + id: "vless|Умный 🇳🇱->🇷🇺|media.example.test|443".to_string(), tag: "Умный 🇳🇱->🇷🇺".to_string(), server_type: "vless".to_string(), server: "media.example.test".to_string(), diff --git a/src-tauri/tests/storage_tests.rs b/src-tauri/tests/storage_tests.rs index de057db..ecee2c5 100644 --- a/src-tauri/tests/storage_tests.rs +++ b/src-tauri/tests/storage_tests.rs @@ -54,6 +54,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() { subscription_url: Some("https://sub.example.test/path?token=secret".to_string()), device_hwid: Some("hwid-abcdef1234".to_string()), selected_server_tag: Some("nl-1".to_string()), + selected_server_id: Some("vless|nl-1|nl.example.test|443".to_string()), listen_host: "127.0.0.1".to_string(), listen_port: 1080, service_name: "ProxyWardenSingBox".to_string(), @@ -133,6 +134,7 @@ fn reads_percent_encoded_singbox_tags_as_utf8() { ] }), servers: vec![SubscriptionServer { + id: String::new(), tag: encoded_tag.to_string(), server_type: "vless".to_string(), server: "nl.example.test".to_string(), @@ -390,6 +392,7 @@ fn sample_subscription_cache() -> SubscriptionCache { ] }), servers: vec![SubscriptionServer { + id: "vless|nl-1|nl.example.test|443".to_string(), tag: "nl-1".to_string(), server_type: "vless".to_string(), server: "nl.example.test".to_string(), diff --git a/src-tauri/tests/subscription_tests.rs b/src-tauri/tests/subscription_tests.rs index 60f0533..aa5bfb1 100644 --- a/src-tauri/tests/subscription_tests.rs +++ b/src-tauri/tests/subscription_tests.rs @@ -1,11 +1,11 @@ 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, + self, parse_subscription_body, parse_user_info, validate_resolved_subscription_addresses, + SubscriptionFetchIdentity, SubscriptionFetchPolicy, }; use std::io::{Read, Write}; -use std::net::TcpListener; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; use std::time::Duration; #[test] @@ -27,6 +27,29 @@ fn parses_singbox_json_config_servers() { assert_eq!(parsed.servers[1].server_port, 8443); } +#[test] +fn server_ids_are_opaque_and_distinguish_credentials_on_same_endpoint() { + let parsed = parse_subscription_body( + r#"{ + "outbounds": [ + { "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "11111111-1111-1111-1111-111111111111" }, + { "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "22222222-2222-2222-2222-222222222222" } + ] + }"#, + ) + .expect("duplicate endpoint subscription should parse"); + + assert_ne!(parsed.servers[0].id, parsed.servers[1].id); + assert!(parsed + .servers + .iter() + .all(|server| server.id.starts_with("pw-"))); + assert!(parsed + .servers + .iter() + .all(|server| !server.id.contains("11111111"))); +} + #[test] fn parses_base64_vless_link_list() { let link = sample_vless_link("nl-1"); @@ -42,6 +65,45 @@ fn parses_base64_vless_link_list() { assert_eq!(outbound["packet_encoding"], "xudp"); } +#[test] +fn parses_trojan_shadowsocks_and_vmess_link_formats() { + let vmess_payload = serde_json::json!({ + "v": "2", + "ps": "VMess NL", + "add": "vmess.example.test", + "port": "443", + "id": "33333333-3333-3333-3333-333333333333", + "scy": "auto", + "net": "ws", + "host": "cdn.example.test", + "path": "/ws", + "tls": "tls", + "sni": "vmess.example.test" + }); + let vmess_link = format!( + "vmess://{}", + general_purpose::STANDARD_NO_PAD.encode(vmess_payload.to_string()) + ); + let body = format!( + "trojan://secret@trojan.example.test:443?sni=edge.example.test#Trojan%20DE\nss://aes-256-gcm:password@ss.example.test:8388#SS%20US\n{vmess_link}" + ); + + let parsed = parse_subscription_body(&body).expect("supported link formats should parse"); + + assert_eq!(parsed.servers.len(), 3); + assert_eq!(parsed.servers[0].server_type, "trojan"); + assert_eq!(parsed.servers[0].tag, "Trojan DE"); + assert_eq!( + parsed.config["outbounds"][0]["tls"]["server_name"], + "edge.example.test" + ); + assert_eq!(parsed.servers[1].server_type, "shadowsocks"); + assert_eq!(parsed.config["outbounds"][1]["method"], "aes-256-gcm"); + assert_eq!(parsed.servers[2].server_type, "vmess"); + assert_eq!(parsed.config["outbounds"][2]["transport"]["type"], "ws"); + assert_eq!(parsed.config["outbounds"][2]["tls"]["enabled"], true); +} + #[test] fn decodes_percent_encoded_vless_fragment_tag() { let link = sample_vless_link( @@ -111,6 +173,25 @@ fn rejects_unsafe_local_subscription_urls_before_network() { } } +#[test] +fn rejects_dns_results_containing_private_or_metadata_addresses() { + for ip in [ + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)), + ] { + let error = validate_resolved_subscription_addresses(&[SocketAddr::new(ip, 443)]) + .expect_err("unsafe resolved address should be blocked"); + assert!(error.message.contains("resolves to")); + } + + validate_resolved_subscription_addresses(&[SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), + 443, + )]) + .expect("public resolved address should be accepted"); +} + #[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"); diff --git a/src/api/tauriCommands.ts b/src/api/tauriCommands.ts index f33c88e..223591e 100644 --- a/src/api/tauriCommands.ts +++ b/src/api/tauriCommands.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; +import { invoke } from "@tauri-apps/api/core"; import type { ActivityEntry, ComponentStatus, @@ -9,7 +9,7 @@ import type { SubscriptionServer, Target, TargetInput, -} from '../domain/types'; +} from "../domain/types"; export interface CommandError { code: string; @@ -20,16 +20,6 @@ export interface CommandError { }>; } -export interface StatusResponse { - routeLine: string; - activeProfileCount: number; - routedAppCount: number; - activeTarget?: Target; - components: ComponentStatus[]; - recentActivity: ActivityEntry[]; - generatedConfigPath: string; -} - export interface AdminStatusResponse { isWindows: boolean; isElevated: boolean; @@ -67,8 +57,8 @@ export interface ProxiFyreSetupStatus { } export interface ProxiFyreSetupProgress { - operation: 'idle' | 'install' | 'uninstall' | string; - status: 'idle' | 'running' | 'succeeded' | 'failed' | string; + operation: "idle" | "install" | "uninstall" | string; + status: "idle" | "running" | "succeeded" | "failed" | string; activeStep?: string; percent: number; message: string; @@ -102,6 +92,7 @@ export interface SubscriptionRequestHeader { } export interface PingServerResponse { + id: string; tag: string; server: string; serverPort: number; @@ -110,6 +101,34 @@ export interface PingServerResponse { error?: string; } +export type ApplyPhaseStatus = + "succeeded" | "failed" | "rolledback" | "skipped" | "warning"; + +export interface ApplyPhase { + id: string; + status: ApplyPhaseStatus; + message: string; +} + +export interface ApplyConfigurationInput { + routeMode: "external" | "local-singbox"; + profile: ProfileInput; + externalTarget?: TargetInput; + disableOtherProfiles?: boolean; +} + +export interface ApplyConfigurationResult { + success: boolean; + changed: boolean; + partialState: boolean; + message: string; + errorCode?: string; + generatedConfigPath: string; + singboxGeneratedConfigPath?: string; + restartRequired: Array<"control-app" | "proxyfier" | "singbox">; + phases: ApplyPhase[]; +} + export interface ProxyProbeResponse { id: string; name: string; @@ -147,98 +166,60 @@ export interface GenerateSingBoxConfigResponse { activity: ActivityEntry; } -export interface HelperApplyResult { - success: boolean; - changed: boolean; - action: string; - message: string; -} - -export interface ApplyProfilesResponse { - success: boolean; - changed: boolean; - message: string; - adapterId: string; - generatedConfigPath: string; - enabledProfiles: number; - routedApps: number; - helper: HelperApplyResult; - activity: ActivityEntry; -} - -export function getStatus(): Promise { - return invoke('get_status'); -} - -export function getAdminStatus(): Promise { - return invoke('get_admin_status'); -} - export function restartAsAdmin(): Promise { - return invoke('restart_as_admin'); + return invoke("restart_as_admin"); } export function getStartupSnapshot(): Promise { - return invoke('get_startup_snapshot'); + return invoke("get_startup_snapshot"); } export function getSavedState(): Promise { - return invoke('get_saved_state'); -} - -export function getProfiles(): Promise { - return invoke('get_profiles'); -} - -export function saveProfile(input: ProfileInput): Promise { - return invoke('save_profile', { input }); -} - -export function getTargets(): Promise { - return invoke('get_targets'); -} - -export function saveTarget(input: TargetInput): Promise { - return invoke('save_target', { input }); + return invoke("get_saved_state"); } export function getComponents(): Promise { - return invoke('get_components'); + return invoke("get_components"); } export function getProxiFyreSetupStatus(): Promise { - return invoke('get_proxifyre_setup_status'); + return invoke("get_proxifyre_setup_status"); } export function getProxiFyreSetupProgress(): Promise { - return invoke('get_proxifyre_setup_progress'); + return invoke("get_proxifyre_setup_progress"); } export function getSingBoxStatus(): Promise { - return invoke('get_singbox_status'); + return invoke("get_singbox_status"); } export function getSingBoxSetupStatus(): Promise { - return invoke('get_singbox_setup_status'); + return invoke("get_singbox_setup_status"); } -export function saveSingBoxSubscription(subscriptionUrl: string): Promise { - return invoke('save_singbox_subscription', { +export function saveSingBoxSubscription( + subscriptionUrl: string, +): Promise { + return invoke("save_singbox_subscription", { input: { subscriptionUrl }, }); } export function fetchSingBoxSubscription(): Promise { - return invoke('fetch_singbox_subscription'); + return invoke("fetch_singbox_subscription"); } export function forgetSingBoxSubscription(): Promise { - return invoke('forget_singbox_subscription'); + return invoke("forget_singbox_subscription"); } -export function selectSingBoxServer(server: SubscriptionServer): Promise { - return invoke('select_singbox_server', { +export function selectSingBoxServer( + server: SubscriptionServer, +): Promise { + return invoke("select_singbox_server", { input: { + id: server.id, tag: server.tag, server: server.server, serverPort: server.serverPort, @@ -246,62 +227,65 @@ export function selectSingBoxServer(server: SubscriptionServer): Promise { - return invoke('ping_singbox_server', { - input: { tag }, +export function pingSingBoxServer( + server: SubscriptionServer, +): Promise { + return invoke("ping_singbox_server", { + input: { id: server.id, tag: server.tag }, }); } export function pingAllSingBoxServers(): Promise { - return invoke('ping_all_singbox_servers'); + return invoke("ping_all_singbox_servers"); } -export function pingProxyTarget(host: string, port: number): Promise { - return invoke('ping_proxy_target', { +export function pingProxyTarget( + host: string, + port: number, +): Promise { + return invoke("ping_proxy_target", { input: { host, port }, }); } export function generateSingBoxConfig(): Promise { - return invoke('generate_singbox_config'); + return invoke("generate_singbox_config"); } -export function applyProfiles(): Promise { - return invoke('apply_profiles'); -} - -export function openConfigLocation(): Promise { - return invoke('open_config_location'); +export function applyConfiguration( + input: ApplyConfigurationInput, +): Promise { + return invoke("apply_configuration", { input }); } export function startProxiFyreService(): Promise { - return invoke('start_proxifyre_service'); + return invoke("start_proxifyre_service"); } export function stopProxiFyreService(): Promise { - return invoke('stop_proxifyre_service'); + return invoke("stop_proxifyre_service"); } export function installProxiFyre(): Promise { - return invoke('install_proxifyre'); + return invoke("install_proxifyre"); } export function uninstallProxiFyre(): Promise { - return invoke('uninstall_proxifyre'); + return invoke("uninstall_proxifyre"); } export function startSingBoxService(): Promise { - return invoke('start_singbox_service'); + return invoke("start_singbox_service"); } export function stopSingBoxService(): Promise { - return invoke('stop_singbox_service'); + return invoke("stop_singbox_service"); } export function installSingBox(): Promise { - return invoke('install_singbox'); + return invoke("install_singbox"); } export function uninstallSingBox(): Promise { - return invoke('uninstall_singbox'); + return invoke("uninstall_singbox"); } diff --git a/src/app/App.tsx b/src/app/App.tsx index f29a7c2..5b5348d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,8 +1,23 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; -import { open } from '@tauri-apps/plugin-dialog'; -import { Cpu, FileCode2, FolderOpen, Gauge, Link2, ShieldAlert, Trash2, Wand2 } from 'lucide-react'; import { - applyProfiles, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { + Cpu, + FileCode2, + FolderOpen, + Gauge, + Link2, + ShieldAlert, + Trash2, + Wand2, +} from "lucide-react"; +import { + applyConfiguration, fetchSingBoxSubscription, forgetSingBoxSubscription, generateSingBoxConfig, @@ -19,9 +34,7 @@ import { pingProxyTarget, pingSingBoxServer, restartAsAdmin, - saveProfile, saveSingBoxSubscription, - saveTarget, selectSingBoxServer, startProxiFyreService, startSingBoxService, @@ -30,140 +43,115 @@ import { uninstallProxiFyre, uninstallSingBox, type AdminStatusResponse, - type ApplyProfilesResponse, type LocalSingBoxStatusResponse, type PingServerResponse, - type ProxyProbeResponse, type ProxyTargetCheckResponse, type ProxiFyreSetupProgress, type ProxiFyreSetupStatus, type SingBoxSetupStatus, -} 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 { ProxiFyreSetupStrip } from './components/ProxiFyreSetupStrip'; -import { parseProxy, type ParsedProxy } from './lib/parseProxy'; -import { getApplyReadiness } from './readiness'; -import { serviceControlState } from './viewModel'; +} from "../api/tauriCommands"; +import type { + ComponentStatus, + Profile, + SubscriptionServer, + Target, +} from "../domain/types"; +import { + BusyRing, + Button, + DetailsPopover, + IconButton, + LogDock, + ServiceControlRow, + Tabs, +} from "../ui"; +import { ProxiFyreSetupStrip } from "./components/ProxiFyreSetupStrip"; +import { SummaryStatusControl } from "./components/SummaryStatusControl"; +import { useNoticeLog } from "./hooks/useNoticeLog"; +import { parseProxy, type ParsedProxy } from "./lib/parseProxy"; +import { + itemTypeLabel, + normalizeItemValue, + type DraftItemType, +} from "./lib/profileItems"; +import { + configChangeRows, + configSnapshotFromUi, + displayServerTag, + type ConfigSnapshot, + type RouteMode, +} from "./lib/snapshots"; +import { getApplyReadiness } from "./readiness"; +import { + serviceControlState, + systemSummaryState, + connectionCheckView, + summaryRouteFlow, + summaryRouteChainSegments, + routeChainSegments, + safeProxyError, + pingTone, + proxyCheckNoticeKind, + proxyCheckNoticeTitle, + proxyCheckText, + changesApplyButtonLabel, + routeProxyCheckTarget, + targetForUi, + targetForExternalProxy, + itemsForProfiles, + formatProxy, + profileItemInput, + emptyItemMessage, + localSetupProgress, + proxyfierTitle, + proxyfierDetails, + singBoxDetails, + singBoxDetailLines, + componentDetails, + noticeFromConfigurationApply, + upsertComponent, + sameValue, + formatLogTime, + serverTooltip, + pingSummary, + errorMessage, + type ConnectionCheckView, + type DraftItem, + type RouteChainInput, +} from "./viewModel"; -type DraftItemType = Extract; -type ProxiFyreAction = 'start' | 'stop' | 'restart' | 'install' | 'uninstall'; -type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'generate' | 'ping'; -type RouteMode = 'external' | 'local-singbox'; -type ServiceVisualState = 'active' | 'settling' | null; -type PanelId = 'summary' | 'proxifyre' | 'proxy'; -type StatusTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted'; -type TabTransitionDirection = 'left' | 'right'; -type SummaryRouteFlow = 'proxy' | 'direct' | 'idle'; - -interface DraftItem { - id: string; - type: DraftItemType; - value: string; -} - -interface Notice { - kind: 'success' | 'error' | 'info'; - title: string; - text: string; -} - -interface LogEntry extends Notice { - id: string; - at: number; -} - -interface ConfigSnapshotItem { - type: DraftItemType; - value: string; -} - -interface ConfigSnapshot { - routeMode: RouteMode; - proxy: string; - selectedServerTag: string; - items: ConfigSnapshotItem[]; -} - -interface ConnectionCheckView { - tone: StatusTone; - title: string; - text: string; - endpoint: string; - details: string[]; - probes: ConnectionProbeView[]; - disabledReason?: string; - loading: boolean; -} - -interface ConnectionProbeView { - id: string; - label: string; - value: string; - tone: StatusTone; -} - -interface RouteChainSegment { - id: string; - label: string; - value: string; - tone: StatusTone; - details: string[]; -} - -interface PendingChangeRow { - id: string; - label: string; - before?: string; - after: string; - tone?: 'added' | 'removed' | 'changed'; -} - -interface ConnectionCheckInput { - routeMode: RouteMode; - proxyInput: string; - proxyCheck: ProxyTargetCheckResponse | null; - singbox: ComponentStatus | undefined; - singBoxStatus: LocalSingBoxStatusResponse | null; - selectedServer: SubscriptionServer | null; - isDetectingComponents: boolean; - isProxyChecking: boolean; -} - -interface RouteChainInput { - routeMode: RouteMode; - proxyInput: string; - proxyfier: ComponentStatus | undefined; - singbox: ComponentStatus | undefined; - singBoxStatus: LocalSingBoxStatusResponse | null; - selectedServer: SubscriptionServer | null; - appCount: number; - isDetectingComponents: boolean; -} - -const MAIN_TARGET_ID = 'main-proxy'; -const MAIN_PROFILE_ID = 'main-profile'; -const LOCAL_SINGBOX_TARGET_ID = 'local-singbox'; -const LOG_VISIBLE_MS = 6500; -const PANEL_ORDER: PanelId[] = ['proxifyre', 'summary', 'proxy']; +type ProxiFyreAction = "start" | "stop" | "restart" | "install" | "uninstall"; +type SingBoxAction = + | "start" + | "stop" + | "install" + | "uninstall" + | "fetch" + | "forget" + | "generate" + | "ping"; +type ServiceVisualState = "active" | "settling" | null; +type PanelId = "summary" | "proxifyre" | "proxy"; +type TabTransitionDirection = "left" | "right"; +const MAIN_TARGET_ID = "main-proxy"; +const MAIN_PROFILE_ID = "main-profile"; +const LOCAL_SINGBOX_TARGET_ID = "local-singbox"; +const PANEL_ORDER: PanelId[] = ["proxifyre", "summary", "proxy"]; const SHOW_DEV_SUBSCRIPTION_IDENTITY = import.meta.env.DEV; -const proxyWardenToggleOnImage = new URL('../assets/proxywarden-toggle-on.png', import.meta.url).href; -const proxyWardenToggleOffImage = new URL('../assets/proxywarden-toggle-off.png', import.meta.url).href; - const fallbackComponents: ComponentStatus[] = [ { - id: 'proxyfier', - name: 'ProxiFyre', - state: 'missing', + id: "proxyfier", + name: "ProxiFyre", + state: "missing", installed: false, running: false, - problems: ['ProxiFyre не найден'], + problems: ["ProxiFyre не найден"], actions: [], }, { - id: 'singbox', - name: 'Локальный sing-box', - state: 'missing', + id: "singbox", + name: "Локальный sing-box", + state: "missing", installed: false, running: false, problems: [], @@ -172,74 +160,111 @@ const fallbackComponents: ComponentStatus[] = [ ]; export function App() { - const [activePanel, setActivePanel] = useState('summary'); - const [tabTransitionDirection, setTabTransitionDirection] = useState('right'); - const [proxyInput, setProxyInput] = useState(''); - const [routeMode, setRouteMode] = useState('external'); + const [activePanel, setActivePanel] = useState("summary"); + const [tabTransitionDirection, setTabTransitionDirection] = + useState("right"); + const [proxyInput, setProxyInput] = useState(""); + const [routeMode, setRouteMode] = useState("external"); const [profileId, setProfileId] = useState(MAIN_PROFILE_ID); const [targetId, setTargetId] = useState(MAIN_TARGET_ID); const [items, setItems] = useState([]); - const [appliedSnapshot, setAppliedSnapshot] = useState(null); - const [loadedProfiles, setLoadedProfiles] = useState([]); + const [appliedSnapshot, setAppliedSnapshot] = useState( + null, + ); const [isProcessInputOpen, setIsProcessInputOpen] = useState(false); - const [processInput, setProcessInput] = useState(''); - const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null); - const [components, setComponents] = useState(fallbackComponents); - const [setupStatus, setSetupStatus] = useState(null); - const [setupProgress, setSetupProgress] = useState(null); - const [singBoxStatus, setSingBoxStatus] = useState(null); - const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); - const [subscriptionInput, setSubscriptionInput] = useState(''); - const [serverPings, setServerPings] = useState>({}); - const [proxyCheck, setProxyCheck] = useState(null); - const [adminStatus, setAdminStatus] = useState(null); - const [generatedConfigPath, setGeneratedConfigPath] = useState(''); - const [logEntries, setLogEntries] = useState([]); - const [activeLogId, setActiveLogId] = useState(null); - const [isLogOpen, setIsLogOpen] = useState(false); + const [processInput, setProcessInput] = useState(""); + const [pickerAction, setPickerAction] = useState<"exe" | "folder" | null>( + null, + ); + const [components, setComponents] = + useState(fallbackComponents); + const [setupStatus, setSetupStatus] = useState( + null, + ); + const [setupProgress, setSetupProgress] = + useState(null); + const [singBoxStatus, setSingBoxStatus] = + useState(null); + const [singBoxSetupStatus, setSingBoxSetupStatus] = + useState(null); + const [subscriptionInput, setSubscriptionInput] = useState(""); + const [serverPings, setServerPings] = useState< + Record + >({}); + const [proxyCheck, setProxyCheck] = useState( + null, + ); + const [adminStatus, setAdminStatus] = useState( + null, + ); + const [, setGeneratedConfigPath] = useState(""); + const { + entries: logEntries, + activeEntry: activeLog, + open: isLogOpen, + showNotice, + toggle: toggleLog, + } = useNoticeLog(); const [isLoading, setIsLoading] = useState(true); const [isDetectingComponents, setIsDetectingComponents] = useState(true); const [isApplying, setIsApplying] = useState(false); const [isRestartingAsAdmin, setIsRestartingAsAdmin] = useState(false); const [isProxyChecking, setIsProxyChecking] = useState(false); const [serverPingTag, setServerPingTag] = useState(null); - const [serviceAction, setServiceAction] = useState(null); - const [singBoxAction, setSingBoxAction] = useState(null); + const [serviceAction, setServiceAction] = useState( + null, + ); + const [singBoxAction, setSingBoxAction] = useState( + null, + ); const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false); const [isSingBoxMenuOpen, setIsSingBoxMenuOpen] = useState(false); - const [serviceVisualState, setServiceVisualState] = useState(null); + const [serviceVisualState, setServiceVisualState] = + useState(null); const serviceVisualTimerRef = useRef(null); const proxyfier = useMemo( - () => components.find((component) => component.id === 'proxyfier'), + () => components.find((component) => component.id === "proxyfier"), [components], ); const singbox = useMemo( - () => singBoxStatus?.component ?? components.find((component) => component.id === 'singbox'), + () => + singBoxStatus?.component ?? + components.find((component) => component.id === "singbox"), [components, singBoxStatus], ); - const activeLog = useMemo( - () => logEntries.find((entry) => entry.id === activeLogId) ?? null, - [activeLogId, logEntries], - ); const isSingBoxInstalled = Boolean(singbox?.installed); const selectedServerTag = singBoxStatus?.config.selectedServerTag; + const selectedServerId = singBoxStatus?.config.selectedServerId; const selectedServer = useMemo( - () => singBoxStatus?.cache?.servers.find((server) => server.tag === selectedServerTag) ?? null, - [selectedServerTag, singBoxStatus], + () => + singBoxStatus?.cache?.servers.find((server) => + selectedServerId + ? server.id === selectedServerId + : server.tag === selectedServerTag, + ) ?? null, + [selectedServerId, selectedServerTag, singBoxStatus], ); const currentSnapshot = useMemo( - () => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag), - [items, proxyInput, routeMode, selectedServerTag], + () => + configSnapshotFromUi( + routeMode, + proxyInput, + items, + selectedServerId, + selectedServerTag, + ), + [items, proxyInput, routeMode, selectedServerId, selectedServerTag], ); const pendingChanges = useMemo( - () => appliedSnapshot ? configChangeRows(appliedSnapshot, currentSnapshot) : [], + () => + appliedSnapshot ? configChangeRows(appliedSnapshot, currentSnapshot) : [], [appliedSnapshot, currentSnapshot], ); const hasUnappliedChanges = pendingChanges.length > 0; const hasAdminPrompt = Boolean(adminStatus?.canRestartElevated); const shellStyle = hasUnappliedChanges - ? ({ '--change-row-count': String(pendingChanges.length) } as CSSProperties) + ? ({ "--change-row-count": String(pendingChanges.length) } as CSSProperties) : undefined; const systemSummary = systemSummaryState({ isLoading, @@ -264,17 +289,8 @@ export function App() { }, []); useEffect(() => { - if (!activeLogId) return undefined; - - const timer = window.setTimeout(() => { - setActiveLogId((current) => (current === activeLogId ? null : current)); - }, LOG_VISIBLE_MS); - - return () => window.clearTimeout(timer); - }, [activeLogId]); - - useEffect(() => { - if (serviceAction !== 'install' && serviceAction !== 'uninstall') return undefined; + if (serviceAction !== "install" && serviceAction !== "uninstall") + return undefined; let cancelled = false; const pollProgress = async () => { @@ -316,9 +332,9 @@ export function App() { ); } catch { showNotice({ - kind: 'info', - title: 'Режим предпросмотра', - text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.', + kind: "info", + title: "Режим предпросмотра", + text: "Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.", }); } finally { setIsLoading(false); @@ -334,8 +350,8 @@ export function App() { } catch (error) { setIsRestartingAsAdmin(false); showNotice({ - kind: 'error', - title: 'Перезапуск отменен', + kind: "error", + title: "Перезапуск отменен", text: errorMessage(error), }); } @@ -348,49 +364,56 @@ export function App() { singBoxStatusForSnapshot = singBoxStatus, ) { const activeProfiles = profiles.filter((profile) => profile.enabled); - const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID); + const mainProfile = profiles.find( + (profile) => profile.id === MAIN_PROFILE_ID, + ); const activeProfile = mainProfile ?? activeProfiles[0]; const activeTarget = targetForUi(targets, activeProfile); const externalTarget = targetForExternalProxy(targets); const editableProfiles = mainProfile ? [mainProfile] : activeProfiles; - const savedProxyInput = externalTarget ? formatProxy(externalTarget) : ''; + const savedProxyInput = externalTarget ? formatProxy(externalTarget) : ""; const savedItems = itemsForProfiles(editableProfiles); const savedRouteMode = - activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID - ? 'local-singbox' - : 'external'; + activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || + activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID + ? "local-singbox" + : "external"; setProxyInput(savedProxyInput); setProxyCheck(null); setItems(savedItems); - setLoadedProfiles(profiles); setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID); setTargetId(externalTarget?.id ?? MAIN_TARGET_ID); setRouteMode(savedRouteMode); setGeneratedConfigPath(generatedPath); - setAppliedSnapshot(configSnapshotFromUi( - savedRouteMode, - savedProxyInput, - savedItems, - singBoxStatusForSnapshot?.config.selectedServerTag, - )); + setAppliedSnapshot( + configSnapshotFromUi( + savedRouteMode, + savedProxyInput, + savedItems, + singBoxStatusForSnapshot?.config.selectedServerId, + singBoxStatusForSnapshot?.config.selectedServerTag, + ), + ); } function addItem(type: DraftItemType, rawValue: string) { const value = normalizeItemValue(rawValue, type); if (!value) { showNotice({ - kind: 'error', - title: 'Нечего добавить', + kind: "error", + title: "Нечего добавить", text: emptyItemMessage(type), }); return false; } - if (items.some((item) => item.type === type && sameValue(item.value, value))) { + if ( + items.some((item) => item.type === type && sameValue(item.value, value)) + ) { showNotice({ - kind: 'info', - title: 'Уже добавлено', + kind: "info", + title: "Уже добавлено", text: value, }); return false; @@ -399,7 +422,7 @@ export function App() { setItems((current) => [ ...current, { - id: `${type}-${Date.now()}`, + id: `${type}-${crypto.randomUUID()}`, type, value, }, @@ -408,8 +431,8 @@ export function App() { } function addProcess() { - if (addItem('process', processInput)) { - setProcessInput(''); + if (addItem("process", processInput)) { + setProcessInput(""); setIsProcessInputOpen(false); } } @@ -433,11 +456,13 @@ export function App() { const currentIndex = PANEL_ORDER.indexOf(activePanel); const nextIndex = PANEL_ORDER.indexOf(nextPanel); - setTabTransitionDirection(nextIndex > currentIndex ? 'right' : 'left'); + setTabTransitionDirection(nextIndex > currentIndex ? "right" : "left"); setActivePanel(nextPanel); } - async function pickAndAddItem(type: Extract) { + async function pickAndAddItem( + type: Extract, + ) { setPickerAction(type); try { const selectedPath = await pickPath(type); @@ -446,8 +471,8 @@ export function App() { } } catch (error) { showNotice({ - kind: 'error', - title: type === 'exe' ? 'EXE не выбран' : 'Папка не выбрана', + kind: "error", + title: type === "exe" ? "EXE не выбран" : "Папка не выбрана", text: errorMessage(error), }); } finally { @@ -458,18 +483,19 @@ export function App() { async function updateConfig() { let parsedProxy: ParsedProxy | null = null; try { - if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.'); - if (routeMode === 'external') { + if (!items.length) + throw new Error("Добавь хотя бы один процесс, EXE-файл или папку."); + if (routeMode === "external") { parsedProxy = parseProxy(proxyInput); } else if (!isSingBoxInstalled) { - throw new Error('Сначала установи Local sing-box.'); + throw new Error("Сначала установи Local sing-box."); } else if (!singBoxStatus?.config.selectedServerTag) { - throw new Error('Выбери сервер Local sing-box.'); + throw new Error("Выбери сервер Local sing-box."); } } catch (error) { showNotice({ - kind: 'error', - title: 'Проверь данные', + kind: "error", + title: "Проверь данные", text: errorMessage(error), }); return; @@ -477,40 +503,38 @@ export function App() { setIsApplying(true); try { - let singBoxGeneratedPath = ''; - if (routeMode === 'external') { - if (!parsedProxy) throw new Error('Прокси не разобран.'); - await saveTarget({ - id: targetId, - name: 'Основной прокси', - kind: 'external', - protocol: parsedProxy.protocol, - host: parsedProxy.host, - port: parsedProxy.port, - }); - } else { - const singBoxResult = await generateSingBoxConfig(); - singBoxGeneratedPath = singBoxResult.generatedConfigPath; - await ensureSingBoxRunningForApply(); - } - - await saveProfile({ - id: profileId, - name: 'Приложения через прокси', - enabled: true, - targetId: routeMode === 'local-singbox' ? LOCAL_SINGBOX_TARGET_ID : targetId, - protocols: ['TCP', 'UDP'], - items: items.map(profileItemInput), + if (routeMode === "external" && !parsedProxy) + throw new Error("Прокси не разобран."); + const result = await applyConfiguration({ + routeMode, + profile: { + id: profileId, + name: "Приложения через прокси", + enabled: true, + targetId: + routeMode === "local-singbox" ? LOCAL_SINGBOX_TARGET_ID : targetId, + protocols: ["TCP", "UDP"], + items: items.map(profileItemInput), + }, + externalTarget: parsedProxy + ? { + id: targetId, + name: "Основной прокси", + kind: "external", + protocol: parsedProxy.protocol, + host: parsedProxy.host, + port: parsedProxy.port, + } + : undefined, + disableOtherProfiles: true, }); - await Promise.all( - loadedProfiles - .filter((profile) => profile.enabled && profile.id !== profileId) - .map((profile) => saveProfile(profileInputFromProfile(profile, false))), - ); - - const result = await applyProfiles(); - await restartProxiFyreAfterApply(); - const [saved, detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([ + const [ + saved, + detectedComponents, + detectedSetupStatus, + detectedSingBoxStatus, + detectedSingBoxSetupStatus, + ] = await Promise.all([ getSavedState(), getComponents(), getProxiFyreSetupStatus(), @@ -518,16 +542,20 @@ export function App() { getSingBoxSetupStatus(), ]); - applySavedState(saved.profiles, saved.targets, result.generatedConfigPath); + applySavedState( + saved.profiles, + saved.targets, + result.generatedConfigPath, + ); setComponents(detectedComponents); setSetupStatus(detectedSetupStatus); setSingBoxStatus(detectedSingBoxStatus); setSingBoxSetupStatus(detectedSingBoxSetupStatus); - showNotice(routeMode === 'local-singbox' ? noticeFromLocalApply(result, singBoxGeneratedPath) : noticeFromApply(result)); + showNotice(noticeFromConfigurationApply(result)); } catch (error) { showNotice({ - kind: 'error', - title: 'Конфиг не обновлен', + kind: "error", + title: "Конфиг не обновлен", text: errorMessage(error), }); } finally { @@ -535,32 +563,8 @@ export function App() { } } - async function ensureSingBoxRunningForApply() { - if (routeMode !== 'local-singbox' || !singbox?.installed) return; - - setIsSingBoxMenuOpen(false); - try { - await nextFrame(); - if (singbox.running) { - setSingBoxAction('stop'); - const stopped = await stopSingBoxService(); - setComponents((current) => upsertComponent(current, stopped)); - } - - setSingBoxAction('start'); - const component = await startSingBoxService(); - setComponents((current) => upsertComponent(current, component)); - const status = await refreshSingBoxState(); - if (!status.component.running) { - throw new Error('Local sing-box установлен, но служба не запустилась.'); - } - } finally { - setSingBoxAction(null); - } - } - async function setProxiFyreServiceRunning(shouldRun: boolean) { - const action = shouldRun ? 'start' : 'stop'; + const action = shouldRun ? "start" : "stop"; setServiceAction(action); setIsServiceMenuOpen(false); startServiceVisual(); @@ -572,14 +576,14 @@ export function App() { setComponents((current) => upsertComponent(current, component)); showNotice({ - kind: 'success', - title: shouldRun ? 'Служба запущена' : 'Служба остановлена', + kind: "success", + title: shouldRun ? "Служба запущена" : "Служба остановлена", text: proxyfierDetails(component, false), }); } catch (error) { showNotice({ - kind: 'error', - title: shouldRun ? 'Служба не запущена' : 'Служба не остановлена', + kind: "error", + title: shouldRun ? "Служба не запущена" : "Служба не остановлена", text: errorMessage(error), }); } finally { @@ -589,9 +593,21 @@ export function App() { } async function installProxiFyrePackage() { - setServiceAction('install'); + const confirmed = window.confirm( + "Установить ProxiFyre? ProxyWarden запросит права администратора, скачает ProxiFyre, Windows Packet Filter и при необходимости Visual C++ Runtime, затем создаст и запустит Windows-службу.", + ); + if (!confirmed) return; + + setServiceAction("install"); setIsServiceMenuOpen(false); - setSetupProgress(localSetupProgress('install', 'packet-filter', 1, 'Готовлю установку сетевого драйвера.')); + setSetupProgress( + localSetupProgress( + "install", + "packet-filter", + 1, + "Готовлю установку сетевого драйвера.", + ), + ); startServiceVisual(); try { await nextFrame(); @@ -604,15 +620,17 @@ export function App() { setSetupStatus(detectedSetupStatus); setSetupProgress(detectedProgress); showNotice({ - kind: 'success', - title: 'ProxiFyre установлен', + kind: "success", + title: "ProxiFyre установлен", text: proxyfierDetails(component, false), }); } catch (error) { - void getProxiFyreSetupProgress().then(setSetupProgress).catch(() => undefined); + void getProxiFyreSetupProgress() + .then(setSetupProgress) + .catch(() => undefined); showNotice({ - kind: 'error', - title: 'ProxiFyre не установлен', + kind: "error", + title: "ProxiFyre не установлен", text: errorMessage(error), }); } finally { @@ -623,13 +641,20 @@ export function App() { async function uninstallProxiFyrePackage() { const confirmed = window.confirm( - 'Удалить ProxiFyre и Windows Packet Filter с компьютера? Это остановит службу, удалит папку ProxiFyre и сетевой драйвер. Другие программы WireSock могут перестать работать до повторной установки драйвера.', + "Удалить ProxiFyre и Windows Packet Filter с компьютера? Это остановит службу, удалит папку ProxiFyre и сетевой драйвер. Другие программы WireSock могут перестать работать до повторной установки драйвера.", ); if (!confirmed) return; - setServiceAction('uninstall'); + setServiceAction("uninstall"); setIsServiceMenuOpen(false); - setSetupProgress(localSetupProgress('uninstall', 'proxifyre', 1, 'Готовлю удаление ProxiFyre и сетевого драйвера.')); + setSetupProgress( + localSetupProgress( + "uninstall", + "proxifyre", + 1, + "Готовлю удаление ProxiFyre и сетевого драйвера.", + ), + ); startServiceVisual(); try { await nextFrame(); @@ -642,15 +667,17 @@ export function App() { setSetupStatus(detectedSetupStatus); setSetupProgress(detectedProgress); showNotice({ - kind: 'success', - title: 'ProxiFyre удален', - text: 'Служба, папка установки ProxiFyre и Windows Packet Filter удалены.', + kind: "success", + title: "ProxiFyre удален", + text: "Служба, папка установки ProxiFyre и Windows Packet Filter удалены.", }); } catch (error) { - void getProxiFyreSetupProgress().then(setSetupProgress).catch(() => undefined); + void getProxiFyreSetupProgress() + .then(setSetupProgress) + .catch(() => undefined); showNotice({ - kind: 'error', - title: 'ProxiFyre не удален', + kind: "error", + title: "ProxiFyre не удален", text: errorMessage(error), }); } finally { @@ -660,7 +687,11 @@ export function App() { } async function refreshSingBoxState() { - const [detectedSingBoxStatus, detectedSingBoxSetupStatus, detectedComponents] = await Promise.all([ + const [ + detectedSingBoxStatus, + detectedSingBoxSetupStatus, + detectedComponents, + ] = await Promise.all([ getSingBoxStatus(), getSingBoxSetupStatus(), getComponents(), @@ -672,24 +703,26 @@ export function App() { } async function setSingBoxServiceRunning(shouldRun: boolean) { - const action: SingBoxAction = shouldRun ? 'start' : 'stop'; + const action: SingBoxAction = shouldRun ? "start" : "stop"; setSingBoxAction(action); setIsSingBoxMenuOpen(false); try { await nextFrame(); - const component = shouldRun ? await startSingBoxService() : await stopSingBoxService(); + const component = shouldRun + ? await startSingBoxService() + : await stopSingBoxService(); setComponents((current) => upsertComponent(current, component)); await refreshSingBoxState(); setProxyCheck(null); showNotice({ - kind: 'success', - title: shouldRun ? 'sing-box запущен' : 'sing-box остановлен', + kind: "success", + title: shouldRun ? "sing-box запущен" : "sing-box остановлен", text: componentDetails(component, false), }); } catch (error) { showNotice({ - kind: 'error', - title: shouldRun ? 'sing-box не запущен' : 'sing-box не остановлен', + kind: "error", + title: shouldRun ? "sing-box не запущен" : "sing-box не остановлен", text: errorMessage(error), }); } finally { @@ -698,7 +731,12 @@ export function App() { } async function installSingBoxPackage() { - setSingBoxAction('install'); + const confirmed = window.confirm( + "Установить Local sing-box? ProxyWarden запросит права администратора, скачает sing-box и WinSW, затем создаст и запустит Windows-службу ProxyWardenSingBox.", + ); + if (!confirmed) return; + + setSingBoxAction("install"); setIsSingBoxMenuOpen(false); try { await nextFrame(); @@ -707,14 +745,14 @@ export function App() { await refreshSingBoxState(); setProxyCheck(null); showNotice({ - kind: 'success', - title: 'Local sing-box установлен', + kind: "success", + title: "Local sing-box установлен", text: componentDetails(component, false), }); } catch (error) { showNotice({ - kind: 'error', - title: 'Local sing-box не установлен', + kind: "error", + title: "Local sing-box не установлен", text: errorMessage(error), }); } finally { @@ -724,11 +762,11 @@ export function App() { async function uninstallSingBoxPackage() { const confirmed = window.confirm( - 'Удалить Local sing-box с компьютера? Будет удалена служба и папка установки sing-box.', + "Удалить Local sing-box с компьютера? Будет удалена служба и папка установки sing-box.", ); if (!confirmed) return; - setSingBoxAction('uninstall'); + setSingBoxAction("uninstall"); setIsSingBoxMenuOpen(false); try { await nextFrame(); @@ -737,14 +775,14 @@ export function App() { await refreshSingBoxState(); setProxyCheck(null); showNotice({ - kind: 'success', - title: 'Local sing-box удален', - text: 'Служба и папка установки Local sing-box удалены.', + kind: "success", + title: "Local sing-box удален", + text: "Служба и папка установки Local sing-box удалены.", }); } catch (error) { showNotice({ - kind: 'error', - title: 'Local sing-box не удален', + kind: "error", + title: "Local sing-box не удален", text: errorMessage(error), }); } finally { @@ -756,14 +794,14 @@ export function App() { const subscriptionUrl = subscriptionInput.trim(); if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) { showNotice({ - kind: 'error', - title: 'Ссылка не указана', - text: 'Вставь ссылку подписки Local sing-box.', + kind: "error", + title: "Ссылка не указана", + text: "Вставь ссылку подписки Local sing-box.", }); return; } - setSingBoxAction('fetch'); + setSingBoxAction("fetch"); try { if (subscriptionUrl) { await saveSingBoxSubscription(subscriptionUrl); @@ -771,18 +809,18 @@ export function App() { const status = await fetchSingBoxSubscription(); setSingBoxStatus(status); setComponents((current) => upsertComponent(current, status.component)); - setSubscriptionInput(''); + setSubscriptionInput(""); setServerPings({}); setProxyCheck(null); showNotice({ - kind: 'success', - title: 'Подписка обновлена', + kind: "success", + title: "Подписка обновлена", text: `Серверов: ${status.cache?.servers.length ?? 0}`, }); } catch (error) { showNotice({ - kind: 'error', - title: 'Подписка не обновлена', + kind: "error", + title: "Подписка не обновлена", text: errorMessage(error), }); } finally { @@ -791,7 +829,7 @@ export function App() { } async function forgetSingBoxSubscriptionData() { - setSingBoxAction('forget'); + setSingBoxAction("forget"); setIsSingBoxMenuOpen(false); try { const status = await forgetSingBoxSubscription(); @@ -800,14 +838,14 @@ export function App() { setServerPings({}); setProxyCheck(null); showNotice({ - kind: 'info', - title: 'Подписка очищена', - text: 'Ссылка, cache и выбранный сервер Local sing-box удалены.', + kind: "info", + title: "Подписка очищена", + text: "Ссылка, cache и выбранный сервер Local sing-box удалены.", }); } catch (error) { showNotice({ - kind: 'error', - title: 'Подписка не очищена', + kind: "error", + title: "Подписка не очищена", text: errorMessage(error), }); } finally { @@ -823,27 +861,29 @@ export function App() { setProxyCheck(null); } catch (error) { showNotice({ - kind: 'error', - title: 'Сервер не выбран', + kind: "error", + title: "Сервер не выбран", text: errorMessage(error), }); } } async function pingSingBoxServers() { - setSingBoxAction('ping'); + setSingBoxAction("ping"); try { const results = await pingAllSingBoxServers(); - setServerPings(Object.fromEntries(results.map((result) => [result.tag, result]))); + setServerPings( + Object.fromEntries(results.map((result) => [result.id, result])), + ); showNotice({ - kind: 'info', - title: 'Ping завершен', + kind: "info", + title: "Ping завершен", text: pingSummary(results), }); } catch (error) { showNotice({ - kind: 'error', - title: 'Ping не выполнен', + kind: "error", + title: "Ping не выполнен", text: errorMessage(error), }); } finally { @@ -852,22 +892,22 @@ export function App() { } async function pingSingleSingBoxServer(server: SubscriptionServer) { - setServerPingTag(server.tag); + setServerPingTag(server.id); try { - const result = await pingSingBoxServer(server.tag); + const result = await pingSingBoxServer(server); setServerPings((current) => ({ ...current, - [result.tag]: result, + [result.id]: result, })); showNotice({ - kind: result.ok ? 'success' : 'error', - title: result.ok ? 'Сервер отвечает' : 'Сервер не ответил', + kind: result.ok ? "success" : "error", + title: result.ok ? "Сервер отвечает" : "Сервер не ответил", text: serverTooltip(server, result), }); } catch (error) { showNotice({ - kind: 'error', - title: 'Ping не выполнен', + kind: "error", + title: "Ping не выполнен", text: errorMessage(error), }); } finally { @@ -881,8 +921,8 @@ export function App() { target = routeProxyCheckTarget(routeMode, proxyInput, singBoxStatus); } catch (error) { showNotice({ - kind: 'error', - title: 'Маршрут не проверен', + kind: "error", + title: "Маршрут не проверен", text: errorMessage(error), }); return; @@ -899,8 +939,8 @@ export function App() { }); } catch (error) { showNotice({ - kind: 'error', - title: 'Маршрут не проверен', + kind: "error", + title: "Маршрут не проверен", text: errorMessage(error), }); } finally { @@ -908,38 +948,20 @@ export function App() { } } - async function restartProxiFyreAfterApply() { - if (!proxyfier?.installed || !proxyfier.running) return; - - setServiceAction('restart'); - setIsServiceMenuOpen(false); - startServiceVisual(); - try { - await nextFrame(); - const stopped = await stopProxiFyreService(); - setComponents((current) => upsertComponent(current, stopped)); - const started = await startProxiFyreService(); - setComponents((current) => upsertComponent(current, started)); - } finally { - setServiceAction(null); - settleServiceVisual(); - } - } - async function generateSingBoxNow() { - setSingBoxAction('generate'); + setSingBoxAction("generate"); try { const result = await generateSingBoxConfig(); await refreshSingBoxState(); showNotice({ - kind: 'success', - title: 'Конфиг sing-box создан', + kind: "success", + title: "Конфиг sing-box создан", text: result.generatedConfigPath, }); } catch (error) { showNotice({ - kind: 'error', - title: 'Конфиг sing-box не создан', + kind: "error", + title: "Конфиг sing-box не создан", text: errorMessage(error), }); } finally { @@ -953,7 +975,7 @@ export function App() { serviceVisualTimerRef.current = null; } - setServiceVisualState('active'); + setServiceVisualState("active"); } function settleServiceVisual() { @@ -961,24 +983,13 @@ export function App() { window.clearTimeout(serviceVisualTimerRef.current); } - setServiceVisualState('settling'); + setServiceVisualState("settling"); serviceVisualTimerRef.current = window.setTimeout(() => { setServiceVisualState(null); serviceVisualTimerRef.current = null; }, 700); } - function showNotice(notice: Notice) { - const entry: LogEntry = { - ...notice, - id: `log-${Date.now()}-${Math.random().toString(36).slice(2)}`, - at: Date.now(), - }; - - setLogEntries((current) => [entry, ...current].slice(0, 40)); - setActiveLogId(entry.id); - } - function renderAdminPrompt() { if (!hasAdminPrompt) return null; @@ -1008,12 +1019,19 @@ export function App() { function renderTabs() { const tabs: Array<{ id: PanelId; label: string }> = [ - { id: 'proxifyre', label: 'ProxiFyre' }, - { id: 'summary', label: 'ProxyWarden' }, - { id: 'proxy', label: 'VPN / Прокси' }, + { id: "proxifyre", label: "ProxiFyre" }, + { id: "summary", label: "ProxyWarden" }, + { id: "proxy", label: "VPN / Прокси" }, ]; - return ; + return ( + + ); } function renderSummaryPanel() { @@ -1026,8 +1044,19 @@ export function App() { >

ProxyWarden

- {renderSummaryStatusControl()} - {renderRouteChain('vertical')} + void setProxiFyreServiceRunning(running)} + /> + {renderRouteChain("vertical")}
); @@ -1035,29 +1064,47 @@ export function App() { function renderProxiFyreCard() { const state = serviceControlState(proxyfier, isDetectingComponents); - const visualState = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : null; + const visualState = + serviceVisualState === "active" + ? "working" + : serviceVisualState === "settling" + ? "settling" + : null; const packetFilterInstalled = Boolean( - setupStatus?.items.some((item) => item.id === 'packet-filter' && item.installed), + setupStatus?.items.some( + (item) => item.id === "packet-filter" && item.installed, + ), ); - const canCleanupSetup = Boolean(proxyfier?.installed || packetFilterInstalled); - const shouldInstallProxiFyre = !proxyfier?.installed || !proxyfier.serviceStatus; + const canCleanupSetup = Boolean( + proxyfier?.installed || packetFilterInstalled, + ); + const shouldInstallProxiFyre = + !proxyfier?.installed || !proxyfier.serviceStatus; const primaryAction = shouldInstallProxiFyre ? { - label: proxyfier?.installed ? 'Переустановить' : 'Установить', + label: proxyfier?.installed ? "Переустановить" : "Установить", onClick: () => void installProxiFyrePackage(), - variant: 'primary' as const, - loading: serviceAction === 'install', - loadingLabel: 'Устанавливаю', + variant: "primary" as const, + loading: serviceAction === "install", + loadingLabel: "Устанавливаю", disabled: isDetectingComponents || Boolean(serviceAction), } : { - label: proxyfier.running ? 'Остановить' : 'Запустить', + label: proxyfier.running ? "Остановить" : "Запустить", onClick: () => void setProxiFyreServiceRunning(!proxyfier.running), - variant: proxyfier.running ? 'danger' as const : 'neutral' as const, - loading: serviceAction === 'start' || serviceAction === 'stop' || serviceAction === 'restart', - loadingLabel: serviceAction === 'restart' - ? 'Перезапускаю' - : serviceAction === 'start' ? 'Запускаю' : 'Останавливаю', + variant: proxyfier.running + ? ("danger" as const) + : ("neutral" as const), + loading: + serviceAction === "start" || + serviceAction === "stop" || + serviceAction === "restart", + loadingLabel: + serviceAction === "restart" + ? "Перезапускаю" + : serviceAction === "start" + ? "Запускаю" + : "Останавливаю", disabled: isDetectingComponents || Boolean(serviceAction), }; @@ -1069,24 +1116,35 @@ export function App() { title={proxyfierTitle(proxyfier, isDetectingComponents)} detail={proxyfierDetails(proxyfier, isDetectingComponents)} primaryAction={primaryAction} - menu={canCleanupSetup ? { - label: 'Дополнительные действия ProxiFyre', - open: isServiceMenuOpen, - onOpenChange: setIsServiceMenuOpen, - disabled: isDetectingComponents || Boolean(serviceAction), - items: [{ - label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre и драйвер', - danger: true, - disabled: Boolean(serviceAction), - onClick: () => void uninstallProxiFyrePackage(), - }], - } : undefined} + menu={ + canCleanupSetup + ? { + label: "Дополнительные действия ProxiFyre", + open: isServiceMenuOpen, + onOpenChange: setIsServiceMenuOpen, + disabled: isDetectingComponents || Boolean(serviceAction), + items: [ + { + label: + serviceAction === "uninstall" + ? "Удаляю..." + : "Удалить ProxiFyre и драйвер", + danger: true, + disabled: Boolean(serviceAction), + onClick: () => void uninstallProxiFyrePackage(), + }, + ], + } + : undefined + } /> ); } function renderProxiFyreSetupStrip() { - return ; + return ( + + ); } function renderAppsSection() { @@ -1105,9 +1163,9 @@ export function App() { value={processInput} onChange={(event) => setProcessInput(event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter') addProcess(); - if (event.key === 'Escape') { - setProcessInput(''); + if (event.key === "Enter") addProcess(); + if (event.key === "Escape") { + setProcessInput(""); setIsProcessInputOpen(false); } }} @@ -1122,7 +1180,7 @@ export function App() { type="button" variant="neutral" onClick={() => { - setProcessInput(''); + setProcessInput(""); setIsProcessInputOpen(false); }} > @@ -1151,18 +1209,18 @@ export function App() { void pickAndAddItem('exe')} + onClick={() => void pickAndAddItem("exe")} disabled={Boolean(pickerAction)} - loading={pickerAction === 'exe'} + loading={pickerAction === "exe"} label="Добавить EXE-файл" icon={} /> void pickAndAddItem('folder')} + onClick={() => void pickAndAddItem("folder")} disabled={Boolean(pickerAction)} - loading={pickerAction === 'folder'} + loading={pickerAction === "folder"} label="Добавить папку" icon={} /> @@ -1188,81 +1246,66 @@ export function App() { {itemTypeLabel(item.type)} - )) ) : ( -
Список пуст. Добавь первое приложение сверху.
+
+ Список пуст. Добавь первое приложение сверху. +
)} ); } - function renderSummaryStatusControl() { - const installed = Boolean(proxyfier?.installed); - const running = Boolean(proxyfier?.running); - const working = serviceAction === 'start' || serviceAction === 'stop' || serviceAction === 'restart'; - const stateLabel = working || systemSummary.tone === 'checking' - ? 'Проверяю' - : systemSummary.tone === 'ok' ? 'Работает' : 'Не работает'; - const buttonAriaLabel = !installed - ? 'ProxiFyre не установлен' - : running ? 'Отключить ProxyWarden' : 'Включить ProxyWarden'; - const imageSrc = running ? proxyWardenToggleOnImage : proxyWardenToggleOffImage; - - return ( -
- - {stateLabel} -
- ); - } - function renderChangesDock() { if (!hasUnappliedChanges || !appliedSnapshot) return null; - const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null; + const externalProxyError = + routeMode === "external" ? safeProxyError(proxyInput) : null; const readiness = getApplyReadiness({ routeMode, appCount: items.length, proxiFyreInstalled: Boolean(proxyfier?.installed), singBoxInstalled: isSingBoxInstalled, + singBoxRunning: Boolean(singbox?.running), selectedServerTag: singBoxStatus?.config.selectedServerTag, externalProxyValue: proxyInput, externalProxyError, busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction), }); - const applyButtonText = isLoading || isDetectingComponents - ? 'Проверяю готовность' - : readiness.ready - ? changesApplyButtonLabel(isApplying, singBoxAction, serviceAction) - : readiness.title ?? 'Применение недоступно'; + const applyButtonText = + isLoading || isDetectingComponents + ? "Проверяю готовность" + : readiness.ready + ? changesApplyButtonLabel(isApplying, singBoxAction, serviceAction) + : (readiness.title ?? "Применение недоступно"); return (