diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ba575d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto + +*.md text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.json text eol=lf +*.rs text eol=lf +*.toml text eol=lf +*.css text eol=lf +*.html text eol=lf + +*.ps1 text eol=crlf + +*.png binary +*.ico binary +*.icns binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51b6032 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +node_modules/ +dist/ +src-tauri/target/ +src-tauri/gen/ + +# Local planning/evidence generated during agent work. +docs/audits/ +docs/goals/ +docs/superpowers/ +docs/*-brief.md + +# Runtime/generated local state. +*.log +*.tmp +*.bak +.env +.env.* + +# OS/editor noise. +.DS_Store +Thumbs.db +.idea/ +.vscode/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1d6de75 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# Инструкции для агентов + +## Контекст проекта + +ProxyWarden - standalone Windows desktop client в корне репозитория. Это Tauri 2 + React/TypeScript UI + Rust backend для маршрутизации выбранных Windows-приложений через внешний SOCKS5-прокси или опциональный Local sing-box. + +Не возвращать старую идею `APP_MODE=windows` и не подключать Windows-клиент к отдельному Node gateway/server. Текущий рабочий путь - `src`, `src-tauri`, `scripts` в корне репозитория. + +## Основные инварианты + +- Три компонента должны оставаться разделенными: Control App, ProxiFyre, Local sing-box. +- ProxiFyre - обязательный слой для per-app routing; Local sing-box - необязательный runtime. +- Внешний SOCKS5 flow должен работать без установленного Local sing-box. +- Profile apply не должен скрыто устанавливать, удалять, запускать или чинить компоненты. Install/start/stop/uninstall - только явные действия пользователя. +- Source of truth - JSON под `C:\ProgramData\ProxyWarden\config` и `state`. +- `C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json` и `sing-box-config.json` - derived artifacts, их можно пересоздать. +- Subscription URL и другие секреты нельзя показывать полностью в UI, diagnostics или логах. +- Summary panel должен оставаться read-only: без apply/install/start/stop/delete/input/subscription mutations. + +## Структура + +- `src/app/App.tsx` - основная React-оркестрация, вкладки `Сводка`, `ProxiFyre`, `VPN / Прокси`, вызовы Tauri-команд и transient UI state. +- `src/app/readiness.ts` - gating применимости маршрута. Не обходить его локальными проверками в JSX. +- `src/app/viewModel.ts` - маленькие display/view-model helpers. +- `src/ui/*` - общие presentational-компоненты. Для новых кнопок, вкладок, service rows, pills, полей и лог-дока сначала расширять эти компоненты. +- `src/api/tauriCommands.ts` - единственная TypeScript-обертка над `invoke(...)`; держать DTO в синхронизации с Rust. +- `src/domain/types.ts` - TypeScript-зеркало доменных DTO. +- `src-tauri/src/models.rs` - Rust-модели и default values. +- `src-tauri/src/validation.rs` - нормализация входов. +- `src-tauri/src/storage.rs` и `activity.rs` - JSON storage, backup/tmp writes, activity cap/sort. +- `src-tauri/src/adapters/proxy_router.rs` - adapter boundary для proxy-router. +- `src-tauri/src/adapters/proxifyre.rs` - первый adapter, генерирует ProxiFyre `app-config.json`. +- `src-tauri/src/adapters/singbox.rs` - генерация локального `sing-box` конфига из subscription cache и выбранного сервера. +- `src-tauri/src/component_detection.rs` - detection ProxiFyre/Proxifier/Local sing-box. +- `src-tauri/src/commands.rs` - Tauri command handlers, installer/service orchestration, structured errors. +- `src-tauri/src/main.rs` - реальная Tauri entrypoint-регистрация команд. +- `src-tauri/src/lib.rs` сейчас scaffold/stale; не считать его источником регистрации команд без отдельной cleanup-задачи. +- `scripts/*.ps1` - явные installer entrypoints. `-PlanOnly` должен возвращать structured JSON без side effects. + +## Правила изменений + +- Не создавать второй источник правды для профилей, targets, components, subscription или activity. +- Не писать generated config напрямую из React. +- Не парсить raw PowerShell/stdout в UI. Backend/helper boundary должен возвращать structured JSON/error DTO. +- Не привязывать UI напрямую к деталям ProxiFyre, если изменение относится к общему proxy-router поведению. +- Не делать Local sing-box обязательным для external target. +- Для service/install операций сохранять UAC/admin boundary и человекочитаемые ошибки. +- При удалении install folders сохранять safe-path checks; не ослаблять рекурсивное удаление. +- В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`. +- Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов. + +## Проверка + +Минимум для frontend/UI: + +```powershell +npm run build +``` + +Rust/backend: + +```powershell +cd D:\repos\ProxyWarden\src-tauri +cargo test +``` + +Tauri/toolchain: + +```powershell +npm run tauri -- info +npm run tauri -- dev +npm run tauri -- build +``` + +Installer boundaries: + +```powershell +& .\scripts\install-control-app.ps1 -PlanOnly +& .\scripts\install-proxyfier.ps1 -PlanOnly +& .\scripts\install-singbox.ps1 -PlanOnly +``` + +Для UI-изменений проверять browser-preview на desktop и narrow viewport. Browser-preview не доказывает native Tauri commands или elevated service lane. + +## Известные риски + +- Реальные elevated install/start/stop/uninstall операции для ProxiFyre и Local sing-box считаются `implemented but unproven`, пока они не проверены на Windows с UAC/admin confirmation. +- Исторические planning/evidence файлы лежат в ignored `docs`-папках и не должны попадать в коммиты. +- Старые документы могут ссылаться на `apps/windows-client`; текущая структура репозитория - standalone client в корне. diff --git a/README.md b/README.md index f7950cc..86737e6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,142 @@ # ProxyWarden +ProxyWarden - это Windows-приложение для маршрутизации выбранных программ через прокси или VPN-сервер. Оно не меняет глобальные настройки прокси в Windows: вы сами выбираете, какие приложения должны идти через маршрут, например Discord, Telegram, браузер, игру или конкретный `.exe`. + +## Что умеет приложение + +- добавлять приложения по имени процесса, папке или конкретному EXE-файлу; +- отправлять выбранные приложения через внешний SOCKS5-прокси; +- при необходимости поднимать локальный `sing-box` и использовать сервер из подписки; +- показывать состояние компонентов: установлен ли ProxiFyre, запущены ли службы, выбран ли сервер; +- генерировать конфиги для ProxiFyre и `sing-box` из сохраненных настроек; +- не устанавливать скрыто лишние компоненты при применении профиля. + +## Из чего состоит ProxyWarden + +ProxyWarden разделен на три независимые части. + +**Control App** - само desktop-приложение. В нем вы настраиваете маршрут, выбираете приложения, смотрите статус и запускаете явные действия. + +**ProxiFyre** - обязательный слой для маршрутизации отдельных Windows-приложений. Он заставляет выбранные программы ходить через SOCKS5-прокси даже тогда, когда сами программы не умеют работать с прокси. + +**Local sing-box** - необязательный локальный VPN/proxy runtime. Он нужен только если вы хотите вставить subscription URL, выбрать сервер и получить локальный SOCKS5 endpoint `127.0.0.1:1080`. Если у вас уже есть внешний SOCKS5-прокси, `sing-box` можно не устанавливать. + +## Типичный сценарий с внешним прокси + +1. Запустите ProxyWarden. +2. Установите или проверьте ProxiFyre. +3. На вкладке `VPN / Прокси` выберите `Внешний прокси`. +4. Введите адрес в формате `host:port` или `socks5://host:port`. +5. На вкладке `ProxiFyre` добавьте приложения, которые нужно маршрутизировать. +6. Нажмите `Применить в ProxiFyre`. + +Результат: выбранные приложения идут через внешний SOCKS5-прокси. Local sing-box для этого сценария не нужен. + +## Типичный сценарий с Local sing-box + +1. Запустите ProxyWarden. +2. Установите ProxiFyre. +3. Установите Local sing-box. +4. Вставьте subscription URL, загрузите список серверов и выберите сервер. +5. Добавьте приложения для маршрутизации. +6. Примените маршрут. + +Результат: выбранные приложения идут по цепочке: + +```text +Приложения -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> выбранный сервер +``` + +## Где хранятся настройки + +Пользовательские настройки хранятся в `C:\ProgramData\ProxyWarden`: + +```text +C:\ProgramData\ProxyWarden\config\profiles.json +C:\ProgramData\ProxyWarden\config\targets.json +C:\ProgramData\ProxyWarden\config\components.json +C:\ProgramData\ProxyWarden\config\local-singbox.json +C:\ProgramData\ProxyWarden\state\activity.json +C:\ProgramData\ProxyWarden\state\singbox-subscription-cache.json +``` + +Сгенерированные файлы лежат отдельно и могут быть пересозданы: + +```text +C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json +C:\ProgramData\ProxyWarden\generated\sing-box-config.json +``` + +Важно: редактировать вручную лучше исходные настройки, а не generated-файлы. При записи настроек приложение создает backup рядом с исходным JSON. + +## Установка из исходников + +Нужны: + +- Windows 10/11; +- Node.js и npm; +- Rust через rustup; +- Visual Studio Build Tools с MSVC и Windows SDK; +- Microsoft Edge WebView2 Runtime. + +Команды: + +```powershell +cd D:\repos\ProxyWarden +npm install +npm run tauri -- dev +``` + +Собрать установочный пакет: + +```powershell +npm run tauri -- build +``` + +Запустить только browser-preview без нативных Tauri-команд: + +```powershell +npm run dev -- --host 127.0.0.1 +``` + +В browser-preview можно проверить интерфейс, но нельзя управлять Windows-службами и нативными компонентами. + +## Явные installer-скрипты + +В репозитории есть отдельные entrypoint-скрипты: + +```powershell +& .\scripts\install-control-app.ps1 -PlanOnly +& .\scripts\install-proxyfier.ps1 -PlanOnly +& .\scripts\install-singbox.ps1 -PlanOnly +``` + +`-PlanOnly` показывает план в JSON и ничего не устанавливает. Реальная установка требует прав администратора. Компоненты устанавливаются отдельно: применение профиля не должно незаметно устанавливать Control App, ProxiFyre или Local sing-box. + +## Проверка для разработчика + +Frontend: + +```powershell +npm run build +``` + +Rust-тесты: + +```powershell +cd D:\repos\ProxyWarden\src-tauri +cargo test +``` + +Информация Tauri: + +```powershell +npm run tauri -- info +``` + +## Ограничения текущей версии + +- Поддерживается основной путь через SOCKS5. +- ProxiFyre является текущим backend-слоем маршрутизации; архитектура оставляет место для другого proxy-router adapter. +- Local sing-box остается опциональным и не требуется для внешнего SOCKS5-прокси. +- Реальные elevated-операции установки, удаления и управления Windows-службами нужно проверять на Windows с правами администратора. diff --git a/index.html b/index.html new file mode 100644 index 0000000..1179dfc --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + ProxyWarden + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..45f8033 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2126 @@ +{ + "name": "proxywarden", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "proxywarden", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.7.1", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.8.0", + "vite": "^7.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.41", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", + "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.385", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", + "integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3018c15 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "proxywarden", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Standalone Windows desktop proxy management app for ProxyWarden.", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.7.1", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.8.0", + "vite": "^7.0.0" + } +} diff --git a/scripts/install-control-app.ps1 b/scripts/install-control-app.ps1 new file mode 100644 index 0000000..a49e460 --- /dev/null +++ b/scripts/install-control-app.ps1 @@ -0,0 +1,79 @@ +param( + [string]$InstallRoot = "C:\Program Files\ProxyWarden\ControlApp", + [string]$DataRoot = "C:\ProgramData\ProxyWarden", + [switch]$PlanOnly, + [switch]$Force +) + +$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 6 +} + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Ensure-Directory { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -ItemType Directory -Path $Path -Force | Out-Null + return $true + } + return $false +} + +try { + $details = @{ + installRoot = $InstallRoot + dataRoot = $DataRoot + planOnly = [bool]$PlanOnly + } + + if ($PlanOnly) { + New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details + exit 0 + } + + if (-not (Test-IsAdministrator)) { + New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details + exit 1 + } + + $changed = $false + $changed = (Ensure-Directory -Path $InstallRoot) -or $changed + $changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed + $changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed + $changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed + + $markerPath = Join-Path $InstallRoot "install-control-app.marker.json" + if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) { + @{ component = "control-app"; installedAt = (Get-Date).ToString("o") } | + ConvertTo-Json -Depth 4 | + Set-Content -LiteralPath $markerPath -Encoding UTF8 + $changed = $true + } + + $details.markerPath = $markerPath + New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details +} catch { + New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message + exit 1 +} diff --git a/scripts/install-proxyfier.ps1 b/scripts/install-proxyfier.ps1 new file mode 100644 index 0000000..521c438 --- /dev/null +++ b/scripts/install-proxyfier.ps1 @@ -0,0 +1,96 @@ +param( + [string]$InstallRoot = "C:\Tools\ProxiFyre", + [string]$PackagePath = "", + [string]$ServiceName = "ProxiFyreService", + [switch]$PlanOnly, + [switch]$Force +) + +$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 6 +} + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Backup-File { + param([string]$Path) + if (Test-Path -LiteralPath $Path) { + $backup = "$Path.bak" + Copy-Item -LiteralPath $Path -Destination $backup -Force + return $backup + } + return $null +} + +try { + $details = @{ + installRoot = $InstallRoot + packagePath = $PackagePath + serviceName = $ServiceName + planOnly = [bool]$PlanOnly + } + + if ($PlanOnly) { + New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details + exit 0 + } + + if (-not (Test-IsAdministrator)) { + New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details + exit 1 + } + + if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) { + New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details + exit 2 + } + + $changed = $false + if (-not (Test-Path -LiteralPath $InstallRoot)) { + New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null + $changed = $true + } + + $configPath = Join-Path $InstallRoot "app-config.json" + $backupPath = Backup-File -Path $configPath + if ($backupPath) { + $details.backupPath = $backupPath + } + + $markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json" + if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) { + @{ + component = "proxyfier" + packagePath = $PackagePath + serviceName = $ServiceName + installedAt = (Get-Date).ToString("o") + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8 + $changed = $true + } + + $details.markerPath = $markerPath + New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details +} catch { + New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message + exit 1 +} diff --git a/scripts/install-singbox.ps1 b/scripts/install-singbox.ps1 new file mode 100644 index 0000000..594eaf8 --- /dev/null +++ b/scripts/install-singbox.ps1 @@ -0,0 +1,270 @@ +param( + [string]$InstallRoot = "C:\Program Files\ProxyWarden\sing-box", + [string]$ServiceName = "ProxyWardenSingBox", + [string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json", + [switch]$PlanOnly, + [switch]$Force, + [switch]$Uninstall +) + +$ErrorActionPreference = "Stop" + +$SingBoxReleaseApi = "https://api.github.com/repos/SagerNet/sing-box/releases/latest" +$WinSwReleaseApi = "https://api.github.com/repos/winsw/winsw/releases/latest" +$WrapperFile = "$ServiceName.exe" +$ConfigFile = "config.json" + +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 Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +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 "amd64" } + return "386" +} + +function Get-WinSwArchitecture { + param([string]$Arch) + if ($Arch -eq "arm64") { return "arm64" } + if ($Arch -eq "386") { return "x86" } + return "x64" +} + +function Invoke-Download { + param([string]$Uri, [string]$Path) + Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Path -Headers @{ "User-Agent" = "proxywarden" } +} + +function Select-Asset { + param( + [object[]]$Assets, + [string]$Pattern, + [string]$Label + ) + + $asset = $Assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1 + if ($null -eq $asset) { + throw "Не найден release asset для $Label по шаблону $Pattern." + } + return $asset +} + +function Test-SafeInstallRoot { + param([string]$Path) + $full = [System.IO.Path]::GetFullPath($Path).TrimEnd("\") + $leaf = Split-Path -Leaf $full + $parent = Split-Path -Parent $full + if ($leaf -ne "sing-box") { return $false } + return $parent -match "\\ProxyWarden$|\\proxywarden$" +} + +function Backup-File { + param([string]$Path) + if (Test-Path -LiteralPath $Path) { + $backup = "$Path.bak" + Copy-Item -LiteralPath $Path -Destination $backup -Force + return $backup + } + return $null +} + +function Write-Utf8NoBomFile { + param( + [string]$Path, + [string]$Value + ) + + $encoding = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($Path, $Value, $encoding) +} + +function Write-WinSwConfig { + param( + [string]$Root, + [string]$Name + ) + + $xmlPath = Join-Path $Root "$Name.xml" + $logDir = Join-Path $Root "logs" + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + $xml = @" + + $Name + ProxyWarden Local sing-box + Local sing-box runtime managed by ProxyWarden. + %BASE%\sing-box.exe + run -c "%BASE%\config.json" + %BASE%\logs + + 10485760 + 4 + + + +"@ + Write-Utf8NoBomFile -Path $xmlPath -Value $xml + return $xmlPath +} + +function Stop-And-Uninstall-Service { + param( + [string]$Root, + [string]$Name + ) + + $wrapper = Join-Path $Root "$Name.exe" + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if ($null -ne $service -and $service.Status -ne "Stopped") { + Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if ($null -ne $service) { + try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) } catch {} + } + } + + if (Test-Path -LiteralPath $wrapper) { + Push-Location $Root + try { & $wrapper uninstall | Out-Null } finally { Pop-Location } + } + + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if ($null -ne $service) { + sc.exe delete $Name | Out-Null + } +} + +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $installRootFull = [System.IO.Path]::GetFullPath($InstallRoot) + $details = @{ + installRoot = $installRootFull + serviceName = $ServiceName + configSource = $ConfigSource + singboxReleaseApi = $SingBoxReleaseApi + winswReleaseApi = $WinSwReleaseApi + planOnly = [bool]$PlanOnly + uninstall = [bool]$Uninstall + } + + if ($PlanOnly) { + $details.items = @( + @{ id = "sing-box-binary"; name = "sing-box.exe"; source = $SingBoxReleaseApi; target = (Join-Path $installRootFull "sing-box.exe") }, + @{ id = "winsw-wrapper"; name = $WrapperFile; source = $WinSwReleaseApi; target = (Join-Path $installRootFull $WrapperFile) }, + @{ id = "windows-service"; name = $ServiceName; target = "Windows Service" }, + @{ id = "config"; name = $ConfigFile; source = $ConfigSource; target = (Join-Path $installRootFull $ConfigFile) } + ) + New-Result -Success $true -Action "install-singbox.plan" -Changed $false -Message "Local sing-box install plan is ready." -Details $details + exit 0 + } + + if (-not (Test-IsAdministrator)) { + New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details + exit 1 + } + + if ($Uninstall) { + if (-not (Test-SafeInstallRoot -Path $installRootFull)) { + New-Result -Success $false -Action "uninstall-singbox" -Changed $false -Message "Unsafe InstallRoot for recursive uninstall." -Details $details + exit 2 + } + + Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName + if (Test-Path -LiteralPath $installRootFull) { + Remove-Item -LiteralPath $installRootFull -Recurse -Force + } + New-Result -Success $true -Action "uninstall-singbox" -Changed $true -Message "Local sing-box service and install folder were removed." -Details $details + exit 0 + } + + $changed = $false + New-Item -ItemType Directory -Path $installRootFull -Force | Out-Null + $workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("proxywarden-singbox-" + [guid]::NewGuid().ToString("N")) + $extractDir = Join-Path $workDir "extract" + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + try { + $arch = Get-NativeArchitecture + $winswArch = Get-WinSwArchitecture -Arch $arch + $details.architecture = $arch + $details.winswArchitecture = $winswArch + + $singboxRelease = Invoke-RestMethod -Uri $SingBoxReleaseApi -Headers @{ "User-Agent" = "proxywarden" } + $singboxAsset = Select-Asset $singboxRelease.assets "windows-$arch\.zip$" "sing-box" + $singboxZip = Join-Path $workDir $singboxAsset.name + Invoke-Download $singboxAsset.browser_download_url $singboxZip + Expand-Archive -LiteralPath $singboxZip -DestinationPath $extractDir -Force + $singboxExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter "sing-box.exe" | Select-Object -First 1 + if ($null -eq $singboxExe) { throw "В архиве sing-box не найден sing-box.exe." } + Copy-Item -LiteralPath $singboxExe.FullName -Destination (Join-Path $installRootFull "sing-box.exe") -Force + $changed = $true + + $winswRelease = Invoke-RestMethod -Uri $WinSwReleaseApi -Headers @{ "User-Agent" = "proxywarden" } + $winswAsset = Select-Asset $winswRelease.assets "WinSW-$winswArch\.exe$" "WinSW" + Invoke-Download $winswAsset.browser_download_url (Join-Path $installRootFull $WrapperFile) + $changed = $true + + $configTarget = Join-Path $installRootFull $ConfigFile + $backupPath = Backup-File -Path $configTarget + if ($backupPath) { $details.backupPath = $backupPath } + if (Test-Path -LiteralPath $ConfigSource) { + Copy-Item -LiteralPath $ConfigSource -Destination $configTarget -Force + } elseif (-not (Test-Path -LiteralPath $configTarget)) { + Write-Utf8NoBomFile -Path $configTarget -Value '{"log":{"level":"info","timestamp":true},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"direct"}}' + } + + $xmlPath = Write-WinSwConfig -Root $installRootFull -Name $ServiceName + $details.configPath = $configTarget + $details.wrapperConfigPath = $xmlPath + + if ($Force) { + Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName + } + + Push-Location $installRootFull + try { + $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if ($null -eq $service) { + & ".\$WrapperFile" install + if ($LASTEXITCODE -ne 0) { throw "WinSW install завершился с кодом $LASTEXITCODE." } + $changed = $true + } + & ".\$WrapperFile" start + if ($LASTEXITCODE -ne 0) { + Start-Service -Name $ServiceName -ErrorAction Stop + } + } finally { + Pop-Location + } + } finally { + if (Test-Path -LiteralPath $workDir) { + Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box service is installed and started." -Details $details +} catch { + New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message + exit 1 +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..e0c66e8 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,4850 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proxywarden" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "reqwest 0.12.28", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "url", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..2b44b96 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "proxywarden" +version = "0.1.0" +description = "Standalone Windows desktop proxy management app for ProxyWarden." +authors = ["ProxyWarden"] +edition = "2021" + +[lib] +name = "proxywarden_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri-plugin-dialog = "2.7.1" +base64 = "0.22" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } +url = "2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..802d2a2 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability for the main ProxyWarden Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.", + "windows": ["main"], + "permissions": ["core:default", "dialog:allow-open"] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..90a975b Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..16fce22 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..3c701b3 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..077f48d Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..e94ae22 Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..b3b886c Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..844afa8 Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..392d54a Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..1c289fb Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..b7b750f Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..89d7b89 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..114e394 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..39d0dac Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..0434861 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..33e1845 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ef3edab Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..2df7fcf Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..9d3c42d Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..05e9383 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..51f37ec Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..5ca8107 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b193555 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b1255c1 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..816057d Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..61a1100 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a6532c3 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..3d2a501 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..601a4d5 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8831810 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..0af0872 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..2211580 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..6fb9a70 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..102bf0c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..f2b425a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..f2b425a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..b8e4f13 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..1134b3d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..549b066 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..549b066 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..3c5daf3 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..f2b425a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..2036ec9 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..2036ec9 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..abd0ca8 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..023a8c2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..abd0ca8 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..305f34c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..f7b45f2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..a8e8b4b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..9dde93b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/activity.rs b/src-tauri/src/activity.rs new file mode 100644 index 0000000..6c8cc28 --- /dev/null +++ b/src-tauri/src/activity.rs @@ -0,0 +1,23 @@ +use crate::models::ActivityEntry; + +pub const DEFAULT_ACTIVITY_LIMIT: usize = 200; + +pub fn sort_activity_desc(mut entries: Vec) -> Vec { + entries.sort_by(|left, right| right.at.cmp(&left.at).then_with(|| right.id.cmp(&left.id))); + entries +} + +pub fn cap_activity(entries: Vec, limit: usize) -> Vec { + let mut entries = sort_activity_desc(entries); + entries.truncate(limit); + entries +} + +pub fn append_activity( + mut entries: Vec, + entry: ActivityEntry, + limit: usize, +) -> Vec { + entries.push(entry); + cap_activity(entries, limit) +} diff --git a/src-tauri/src/adapters/proxifyre.rs b/src-tauri/src/adapters/proxifyre.rs new file mode 100644 index 0000000..2839bbc --- /dev/null +++ b/src-tauri/src/adapters/proxifyre.rs @@ -0,0 +1,245 @@ +#[cfg(not(test))] +use crate::adapters::proxy_router::{ + ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, + ProxyRouterRequest, +}; +use crate::models::{ + ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol, + ProxyProtocol, Target, +}; +#[cfg(test)] +use crate::proxy_router::{ + ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, + ProxyRouterRequest, +}; +use serde::{Deserialize, Serialize}; + +pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre"; +pub const PROXIFYRE_OUTPUT_FILE: &str = "proxifyre-app-config.json"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxiFyreAdapter { + log_level: String, + bypass_lan: bool, +} + +impl ProxiFyreAdapter { + pub fn new(log_level: impl Into, bypass_lan: bool) -> Self { + Self { + log_level: log_level.into(), + bypass_lan, + } + } + + pub fn generate_proxifyre_config( + &self, + request: ProxyRouterRequest<'_>, + ) -> Result { + let mut proxies = Vec::new(); + + for profile in request.profiles.iter().filter(|profile| profile.enabled) { + let target = find_target(profile, request.targets)?; + ensure_target_supported(profile, target, request.components)?; + + let app_names = app_names_for_profile(profile); + if app_names.is_empty() { + return Err(ProxyRouterError::new( + ProxyRouterErrorKind::EmptyProfileItems, + format!( + "В профиле '{}' нет приложений для маршрутизации", + profile.id + ), + )); + } + + proxies.push(ProxiFyreProxy { + app_names, + socks5_proxy_endpoint: format!("{}:{}", target.host, target.port), + supported_protocols: protocols_for_profile(profile), + }); + } + + Ok(ProxiFyreConfig { + log_level: self.log_level.clone(), + bypass_lan: self.bypass_lan, + proxies, + }) + } +} + +impl Default for ProxiFyreAdapter { + fn default() -> Self { + Self::new("Info", true) + } +} + +impl ProxyRouterAdapter for ProxiFyreAdapter { + fn id(&self) -> &'static str { + PROXIFYRE_ADAPTER_ID + } + + fn output_file_name(&self) -> &'static str { + PROXIFYRE_OUTPUT_FILE + } + + fn generate_config( + &self, + request: ProxyRouterRequest<'_>, + ) -> Result { + let config = self.generate_proxifyre_config(request)?; + let enabled_profiles = config.proxies.len(); + let routed_apps = config + .proxies + .iter() + .map(|proxy| proxy.app_names.len()) + .sum(); + let contents = serde_json::to_string_pretty(&config).map_err(|error| { + ProxyRouterError::new( + ProxyRouterErrorKind::Serialization, + format!("Не удалось сериализовать конфиг ProxiFyre: {error}"), + ) + })?; + + Ok(ProxyRouterGeneratedConfig { + adapter_id: self.id().to_string(), + output_file_name: self.output_file_name().to_string(), + contents, + enabled_profiles, + routed_apps, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProxiFyreConfig { + #[serde(rename = "logLevel")] + pub log_level: String, + #[serde(rename = "bypassLan")] + pub bypass_lan: bool, + pub proxies: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProxiFyreProxy { + #[serde(rename = "appNames")] + pub app_names: Vec, + #[serde(rename = "socks5ProxyEndpoint")] + pub socks5_proxy_endpoint: String, + #[serde(rename = "supportedProtocols")] + pub supported_protocols: Vec, +} + +fn find_target<'a>( + profile: &Profile, + targets: &'a [Target], +) -> Result<&'a Target, ProxyRouterError> { + targets + .iter() + .find(|target| target.id == profile.target_id) + .ok_or_else(|| { + ProxyRouterError::new( + ProxyRouterErrorKind::MissingTarget, + format!( + "Профиль '{}' ссылается на отсутствующую цель '{}'", + profile.id, profile.target_id + ), + ) + }) +} + +fn ensure_target_supported( + profile: &Profile, + target: &Target, + components: &[ComponentStatus], +) -> Result<(), ProxyRouterError> { + if target.protocol != ProxyProtocol::Socks5 { + return Err(ProxyRouterError::new( + ProxyRouterErrorKind::UnsupportedTargetProtocol, + format!( + "Цель '{}' использует HTTP, но ProxiFyre требует SOCKS5", + target.id + ), + )); + } + + if let Some(required_component) = &target.requires_component { + let Some(status) = components + .iter() + .find(|component| &component.id == required_component) + else { + return Err(ProxyRouterError::new( + ProxyRouterErrorKind::MissingRequiredComponent, + format!( + "Цель '{}' профиля '{}' требует отсутствующий компонент '{}'", + target.id, + profile.id, + component_id_label(required_component) + ), + )); + }; + + if !component_is_running(status) { + return Err(ProxyRouterError::new( + ProxyRouterErrorKind::RequiredComponentNotRunning, + format!( + "Цель '{}' профиля '{}' требует запущенный компонент '{}'", + target.id, + profile.id, + component_id_label(required_component) + ), + )); + } + } + + Ok(()) +} + +fn component_is_running(status: &ComponentStatus) -> bool { + status.installed && status.running && status.state == ComponentState::Running +} + +fn app_names_for_profile(profile: &Profile) -> Vec { + let mut names = Vec::new(); + + for item in &profile.items { + let value = item.value.trim(); + if value.is_empty() { + continue; + } + + let app_name = match item.item_type { + ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value, + }; + + if !names.iter().any(|existing| existing == app_name) { + names.push(app_name.to_string()); + } + } + + names +} + +fn protocols_for_profile(profile: &Profile) -> Vec { + let mut protocols = Vec::new(); + + for protocol in &profile.protocols { + let value = match protocol { + Protocol::Tcp => "TCP", + Protocol::Udp => "UDP", + }; + + if !protocols.iter().any(|existing| existing == value) { + protocols.push(value.to_string()); + } + } + + protocols +} + +fn component_id_label(component_id: &ComponentId) -> &'static str { + match component_id { + ComponentId::ControlApp => "control-app", + ComponentId::Proxyfier => "proxyfier", + ComponentId::Singbox => "singbox", + } +} diff --git a/src-tauri/src/adapters/proxy_router.rs b/src-tauri/src/adapters/proxy_router.rs new file mode 100644 index 0000000..f9e0162 --- /dev/null +++ b/src-tauri/src/adapters/proxy_router.rs @@ -0,0 +1,67 @@ +use crate::models::{ComponentStatus, Profile, Target}; + +#[derive(Debug, Clone, Copy)] +pub struct ProxyRouterRequest<'a> { + pub profiles: &'a [Profile], + pub targets: &'a [Target], + pub components: &'a [ComponentStatus], +} + +impl<'a> ProxyRouterRequest<'a> { + pub fn new( + profiles: &'a [Profile], + targets: &'a [Target], + components: &'a [ComponentStatus], + ) -> Self { + Self { + profiles, + targets, + components, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyRouterGeneratedConfig { + pub adapter_id: String, + pub output_file_name: String, + pub contents: String, + pub enabled_profiles: usize, + pub routed_apps: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyRouterError { + pub kind: ProxyRouterErrorKind, + pub message: String, +} + +impl ProxyRouterError { + pub fn new(kind: ProxyRouterErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProxyRouterErrorKind { + EmptyProfileItems, + MissingTarget, + MissingRequiredComponent, + RequiredComponentNotRunning, + UnsupportedTargetProtocol, + Serialization, +} + +pub trait ProxyRouterAdapter { + fn id(&self) -> &'static str; + + fn output_file_name(&self) -> &'static str; + + fn generate_config( + &self, + request: ProxyRouterRequest<'_>, + ) -> Result; +} diff --git a/src-tauri/src/adapters/singbox.rs b/src-tauri/src/adapters/singbox.rs new file mode 100644 index 0000000..0bcc83b --- /dev/null +++ b/src-tauri/src/adapters/singbox.rs @@ -0,0 +1,338 @@ +use crate::models::{LocalSingBoxConfig, SubscriptionCache}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + env, fs, + path::Path, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub const SINGBOX_ADAPTER_ID: &str = "singbox"; +pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json"; +pub const DEFAULT_MIXED_INBOUND_TAG: &str = "proxywarden-mixed-in"; +pub const DEFAULT_VPN_OUTBOUND_TAG: &str = "vpn"; +pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct"; +pub const DEFAULT_BLOCK_OUTBOUND_TAG: &str = "block"; + +const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SingBoxAdapter { + log_level: String, + inbound_tag: String, + vpn_outbound_tag: String, +} + +impl SingBoxAdapter { + pub fn new( + log_level: impl Into, + inbound_tag: impl Into, + vpn_outbound_tag: impl Into, + ) -> Self { + Self { + log_level: log_level.into(), + inbound_tag: inbound_tag.into(), + vpn_outbound_tag: vpn_outbound_tag.into(), + } + } + + pub fn generate_config( + &self, + request: SingBoxGenerationRequest<'_>, + checker: &C, + ) -> Result + where + C: SingBoxConfigChecker, + { + let selected_server_tag = request + .config + .selected_server_tag + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SingBoxConfigError::new( + SingBoxConfigErrorKind::MissingSelectedServer, + "Сервер Local sing-box не выбран", + ) + })?; + let vpn_outbound = selected_outbound( + &request.subscription_cache.config, + selected_server_tag, + &self.vpn_outbound_tag, + )?; + let generated_config = json!({ + "log": { + "disabled": false, + "level": self.log_level, + "timestamp": true + }, + "inbounds": [ + { + "type": "mixed", + "tag": self.inbound_tag, + "listen": request.config.listen_host, + "listen_port": request.config.listen_port, + "users": [], + "set_system_proxy": false + } + ], + "outbounds": [ + vpn_outbound, + { "type": "direct", "tag": DEFAULT_DIRECT_OUTBOUND_TAG }, + { "type": "block", "tag": DEFAULT_BLOCK_OUTBOUND_TAG } + ], + "route": { + "rules": [ + { "ip_is_private": true, "outbound": DEFAULT_DIRECT_OUTBOUND_TAG } + ], + "final": self.vpn_outbound_tag + } + }); + let contents = serde_json::to_string_pretty(&generated_config).map_err(|error| { + SingBoxConfigError::new( + SingBoxConfigErrorKind::Serialization, + format!("Не удалось сериализовать конфиг sing-box: {error}"), + ) + })?; + let check = match request.binary_path { + Some(binary_path) => Some(checker.check_config(binary_path, &contents)?), + None => None, + }; + + Ok(SingBoxGeneratedConfig { + adapter_id: SINGBOX_ADAPTER_ID.to_string(), + output_file_name: SINGBOX_OUTPUT_FILE.to_string(), + contents, + selected_server_tag: selected_server_tag.to_string(), + listen: request.config.listen_host.clone(), + listen_port: request.config.listen_port, + check, + }) + } +} + +impl Default for SingBoxAdapter { + fn default() -> Self { + Self::new("info", DEFAULT_MIXED_INBOUND_TAG, DEFAULT_VPN_OUTBOUND_TAG) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct SingBoxGenerationRequest<'a> { + pub config: &'a LocalSingBoxConfig, + pub subscription_cache: &'a SubscriptionCache, + pub binary_path: Option<&'a Path>, +} + +impl<'a> SingBoxGenerationRequest<'a> { + pub fn new( + config: &'a LocalSingBoxConfig, + subscription_cache: &'a SubscriptionCache, + binary_path: Option<&'a Path>, + ) -> Self { + Self { + config, + subscription_cache, + binary_path, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SingBoxGeneratedConfig { + pub adapter_id: String, + pub output_file_name: String, + pub contents: String, + pub selected_server_tag: String, + pub listen: String, + pub listen_port: u16, + pub check: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SingBoxCheckResult { + pub checked: bool, + pub success: bool, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SingBoxConfigError { + pub kind: SingBoxConfigErrorKind, + pub message: String, +} + +impl SingBoxConfigError { + pub fn new(kind: SingBoxConfigErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SingBoxConfigErrorKind { + MissingSelectedServer, + MissingSelectedOutbound, + UnsupportedSelectedOutbound, + Serialization, + CheckFailed, +} + +pub trait SingBoxConfigChecker { + fn check_config( + &self, + binary_path: &Path, + config_json: &str, + ) -> Result; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct SingBoxCommandChecker; + +impl SingBoxConfigChecker for SingBoxCommandChecker { + fn check_config( + &self, + binary_path: &Path, + config_json: &str, + ) -> Result { + let config_path = env::temp_dir().join(format!( + "proxywarden-sing-box-{}-{}.json", + std::process::id(), + now_millis() + )); + + fs::write(&config_path, config_json).map_err(|error| { + SingBoxConfigError::new( + SingBoxConfigErrorKind::CheckFailed, + format!( + "Не удалось записать временный конфиг sing-box '{}': {error}", + config_path.display() + ), + ) + })?; + + let output = Command::new(binary_path) + .arg("check") + .arg("-c") + .arg(&config_path) + .output() + .map_err(|error| { + let _ = fs::remove_file(&config_path); + SingBoxConfigError::new( + SingBoxConfigErrorKind::CheckFailed, + format!( + "Не удалось выполнить '{} check': {error}", + binary_path.display() + ), + ) + })?; + let _ = fs::remove_file(&config_path); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let message = command_message(&stdout, &stderr); + + if !output.status.success() { + return Err(SingBoxConfigError::new( + SingBoxConfigErrorKind::CheckFailed, + format!("Проверка sing-box не прошла: {message}"), + )); + } + + Ok(SingBoxCheckResult { + checked: true, + success: true, + message: if message.is_empty() { + "Проверка sing-box прошла успешно".to_string() + } else { + message + }, + }) + } +} + +fn selected_outbound( + subscription_config: &Value, + selected_server_tag: &str, + vpn_outbound_tag: &str, +) -> Result { + let outbounds = subscription_config + .get("outbounds") + .and_then(Value::as_array) + .ok_or_else(|| { + SingBoxConfigError::new( + SingBoxConfigErrorKind::MissingSelectedOutbound, + "В cache подписки нет outbounds", + ) + })?; + let outbound = outbounds + .iter() + .find(|outbound| { + outbound + .get("tag") + .and_then(Value::as_str) + .is_some_and(|tag| tag.trim() == selected_server_tag) + }) + .ok_or_else(|| { + SingBoxConfigError::new( + SingBoxConfigErrorKind::MissingSelectedOutbound, + format!("Outbound не найден: {selected_server_tag}"), + ) + })?; + let outbound_type = outbound + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + + if !SUPPORTED_PROXY_TYPES.contains(&outbound_type) { + return Err(SingBoxConfigError::new( + SingBoxConfigErrorKind::UnsupportedSelectedOutbound, + format!( + "Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'" + ), + )); + } + + let mut outbound = outbound.clone(); + let object = outbound.as_object_mut().ok_or_else(|| { + SingBoxConfigError::new( + SingBoxConfigErrorKind::UnsupportedSelectedOutbound, + format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"), + ) + })?; + object.insert( + "tag".to_string(), + Value::String(vpn_outbound_tag.to_string()), + ); + if outbound_type == "vless" && !object.contains_key("packet_encoding") { + object.insert( + "packet_encoding".to_string(), + Value::String("xudp".to_string()), + ); + } + + 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(); + + match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => String::new(), + (false, true) => stdout.to_string(), + (true, false) => stderr.to_string(), + (false, false) => format!("{stdout}\n{stderr}"), + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..0f00896 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,3530 @@ +#[cfg(not(test))] +use crate::adapters::proxifyre::ProxiFyreAdapter; +#[cfg(not(test))] +use crate::adapters::proxy_router::{ + ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, + ProxyRouterRequest, +}; +#[cfg(not(test))] +use crate::adapters::singbox::{ + SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker, + SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest, +}; +use crate::component_detection::{ + detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install, + proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier, + DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost, +}; +use crate::models::{ + ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, + Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, + SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind, +}; +#[cfg(test)] +use crate::proxifyre::ProxiFyreAdapter; +#[cfg(test)] +use crate::proxy_router::{ + ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, + ProxyRouterRequest, +}; +#[cfg(test)] +use crate::singbox::{ + SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker, + SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest, +}; +use crate::singbox_service::{ + build_singbox_setup_status, ensure_safe_singbox_install_dir, + parse_service_command_output as parse_singbox_service_command_output, service_control_script, + ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction, SingBoxSetupStatus, +}; +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}; + +const PROXIFYRE_INSTALL_DIR: &str = r"C:\Tools\ProxiFyre"; +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 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"; + +#[derive(Debug, Clone)] +pub struct CommandState { + root: PathBuf, +} + +impl CommandState { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn storage(&self) -> JsonStorage { + JsonStorage::new(self.root.clone()) + } +} + +impl Default for CommandState { + fn default() -> Self { + Self::new(default_config_root()) + } +} + +#[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 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, 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, +} + +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, +} + +#[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, +} + +#[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 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, + 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, + ) -> Result; +} + +pub struct SystemSubscriptionFetcher; + +impl SubscriptionFetcher for SystemSubscriptionFetcher { + fn fetch_subscription( + &self, + url: &str, + ) -> Result { + subscription::fetch_subscription(url) + } +} + +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 StagedApplyHelper; + +impl ProxyApplyHelper for StagedApplyHelper { + fn apply_proxy_config( + &self, + request: HelperApplyRequest<'_>, + ) -> Result { + Ok(HelperApplyResult { + success: true, + changed: true, + action: format!("{}.stage-generated-config", request.adapter_id), + message: format!( + "Сгенерированный конфиг подготовлен в {}; интеграция привилегированного помощника еще не подключена", + request.config_path.display() + ), + }) + } +} + +pub struct DetectedProxyApplyHelper { + host: H, +} + +impl DetectedProxyApplyHelper { + pub fn system() -> Self { + Self { + host: SystemProxyfierDetectionHost, + } + } +} + +impl DetectedProxyApplyHelper { + pub fn new(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_saved_state( + state: tauri::State<'_, CommandState>, +) -> Result { + 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>, +) -> Result, CommandError> { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || read_components(&storage)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn get_proxifyre_setup_status() -> Result { + tauri::async_runtime::spawn_blocking(build_proxifyre_setup_status) + .await + .map_err(background_task_error) +} + +#[tauri::command] +pub async fn get_singbox_status( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || read_singbox_status(&storage)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn get_singbox_setup_status() -> Result { + tauri::async_runtime::spawn_blocking(|| { + build_singbox_setup_status(detect_singbox_install().as_ref()) + }) + .await + .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>, + input: SaveSingBoxSubscriptionInputDto, +) -> Result { + save_singbox_subscription_to_storage(&state.storage(), input, &SystemClock) +} + +#[tauri::command] +pub async fn fetch_singbox_subscription( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || { + fetch_singbox_subscription_with_fetcher(&storage, &SystemSubscriptionFetcher, &SystemClock) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub fn forget_singbox_subscription( + state: tauri::State<'_, CommandState>, +) -> Result { + forget_singbox_subscription_in_storage(&state.storage(), &SystemClock) +} + +#[tauri::command] +pub fn select_singbox_server( + state: tauri::State<'_, CommandState>, + input: SelectSingBoxServerInputDto, +) -> Result { + select_singbox_server_in_storage(&state.storage(), input, &SystemClock) +} + +#[tauri::command] +pub fn ping_singbox_server( + state: tauri::State<'_, CommandState>, + input: PingSingBoxServerInputDto, +) -> Result { + ping_singbox_server_in_storage(&state.storage(), input) +} + +#[tauri::command] +pub fn ping_all_singbox_servers( + state: tauri::State<'_, CommandState>, +) -> Result, CommandError> { + ping_all_singbox_servers_in_storage(&state.storage()) +} + +#[tauri::command] +pub fn ping_proxy_target(input: PingProxyTargetInputDto) -> Result { + ping_proxy_target_endpoint(input) +} + +#[tauri::command] +pub fn generate_singbox_config( + state: tauri::State<'_, CommandState>, +) -> Result { + let detected = detect_singbox_install(); + let binary_path = detected + .as_ref() + .map(|detected| detected.executable_path.as_path()); + generate_singbox_config_with_services( + &state.storage(), + &SingBoxAdapter::default(), + &SingBoxCommandChecker, + &SystemClock, + binary_path, + ) +} + +#[tauri::command] +pub fn apply_profiles( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + let adapter = ProxiFyreAdapter::default(); + let helper = DetectedProxyApplyHelper::system(); + let clock = SystemClock; + + apply_profiles_with_services(&storage, &adapter, &helper, &clock) +} + +#[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)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn stop_proxifyre_service() -> Result { + tauri::async_runtime::spawn_blocking(|| control_proxifyre_service(ServiceControlAction::Stop)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn install_proxifyre( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || install_proxifyre_component(&storage)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn uninstall_proxifyre() -> Result { + tauri::async_runtime::spawn_blocking(uninstall_proxifyre_component) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn start_singbox_service( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || { + let detected = detect_singbox_install(); + let binary_path = detected + .as_ref() + .map(|detected| detected.executable_path.as_path()); + let generated = generate_singbox_config_with_services( + &storage, + &SingBoxAdapter::default(), + &SingBoxCommandChecker, + &SystemClock, + binary_path, + )?; + let generated_path = PathBuf::from(generated.generated_config_path); + control_singbox_service(SingBoxServiceAction::Start, Some(generated_path.as_path())) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn stop_singbox_service() -> Result { + tauri::async_runtime::spawn_blocking(|| control_singbox_service(SingBoxServiceAction::Stop, None)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn install_singbox( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || install_singbox_component(&storage)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn uninstall_singbox() -> Result { + tauri::async_runtime::spawn_blocking(uninstall_singbox_component) + .await + .map_err(background_task_error)? +} + +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_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 { + Ok(SavedStateResponse { + profiles: read_profiles(storage)?, + targets: read_targets(storage)?, + generated_config_path: storage + .paths() + .generated_dir + .join("proxifyre-app-config.json") + .display() + .to_string(), + }) +} + +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 { + 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 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 config = storage.read_local_singbox_config().map_err(storage_error)?; + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)?; + let detected = detect_singbox_install(); + let component = singbox_component_from_detection(detected.as_ref()); + + 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(), + }) +} + +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); + 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 cache = fetcher + .fetch_subscription(&subscription_url) + .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 { + let host = input.host.trim(); + if host.is_empty() { + return Err(CommandError::new( + "proxy_target_host_missing", + "Хост внешнего прокси не указан.", + )); + } + + Ok(ping_endpoint("external-proxy", host, input.port)) +} + +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 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 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::new("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 = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + launch_script.as_str(), + ]) + .output(); + + 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 nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + let script_path = env::temp_dir().join(format!("proxywarden-singbox-service-{nonce}.ps1")); + let script = 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) -> 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![ + "-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> { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + fs::create_dir_all(artifact_dir).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось создать папку для временных файлов Local sing-box '{}': {error}", + artifact_dir.display() + ), + ) + })?; + + let installer_path = artifact_dir.join(format!( + "proxywarden-singbox-{}-{nonce}.ps1", + action.file_label() + )); + let runner_path = artifact_dir.join(format!( + "proxywarden-singbox-{}-{nonce}.runner.ps1", + action.file_label() + )); + let result_path = artifact_dir.join(format!( + "proxywarden-singbox-{}-{nonce}.log", + action.file_label() + )); + + 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 = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + launch_script.as_str(), + ]) + .output(); + + 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(crate) 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> { + let components = storage.read_components().map_err(storage_error)?; + Ok(resolve_component_statuses( + components, + detect_proxyfier_install(), + detect_singbox_install(), + )) +} + +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, + 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, + 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, + 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> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(storage_error)?; + } + fs::write(path, contents).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::new("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 = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + launch_script.as_str(), + ]) + .output(); + + 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 nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + let script_path = env::temp_dir().join(format!("proxywarden-proxifyre-service-{nonce}.ps1")); + let script = 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 install_proxifyre_component(storage: &JsonStorage) -> Result { + let generated_config_path = storage + .paths() + .generated_dir + .join("proxifyre-app-config.json"); + let script = install_proxifyre_script(&generated_config_path); + + run_elevated_package_script( + ProxiFyrePackageAction::Install, + script, + &storage.paths().state_dir, + )?; + + 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 uninstall_proxifyre_component() -> Result { + let Some(detected) = detect_proxyfier_install() else { + let component = proxyfier_component_from_detection(None); + return Ok(ComponentStatusDto::from(&component)); + }; + + ensure_safe_proxifyre_install_dir(&detected.install_dir)?; + let script = uninstall_proxifyre_script(&detected.install_dir, &detected.executable_path); + + 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 завершилось, но приложение все еще найдено на компьютере.", + )); + } + + let component = proxyfier_component_from_detection(None); + Ok(ComponentStatusDto::from(&component)) +} + +fn build_proxifyre_setup_status() -> ProxiFyreSetupStatusDto { + let vc_runtime = detect_vc_runtime(); + let packet_filter = detect_windows_packet_filter(); + let proxifyre = detect_proxyfier_install(); + + 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(if detected.running { + "служба запущена".to_string() + } else { + "служба не запущена".to_string() + }), + details: detected.install_dir.display().to_string(), + }, + None => ProxiFyreSetupItemDto { + id: "proxifyre".to_string(), + name: "ProxiFyre".to_string(), + installed: false, + version: None, + details: format!( + "Будет скачан с GitHub и распакован в {}.", + PROXIFYRE_INSTALL_DIR + ), + }, + }; + + 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 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::new("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> { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + fs::create_dir_all(artifact_dir).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось создать папку для временных файлов ProxiFyre '{}': {error}", + artifact_dir.display() + ), + ) + })?; + let script_path = artifact_dir.join(format!( + "proxywarden-proxifyre-{}-{nonce}.ps1", + action.file_label() + )); + let result_path = artifact_dir.join(format!( + "proxywarden-proxifyre-{}-{nonce}.log", + action.file_label() + )); + let script = wrap_elevated_package_script(&body, &result_path); + + 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 = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + launch_script.as_str(), + ]) + .output(); + + let _ = fs::remove_file(&script_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!( + "Не удалось {} ProxiFyre. Код elevated-команды: {}. {details}", + action.label(), + output.status.code().unwrap_or(-1), + ), + )) + } + Err(error) => Err(CommandError::new( + action.error_code(), + format!( + "Не удалось запросить права администратора, чтобы {} ProxiFyre: {error}", + action.label() + ), + )), + } +} + +pub(crate) fn wrap_elevated_package_script(body: &str, result_path: &Path) -> 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("try {\n"); + script.push_str(body); + script.push_str( + r#" + Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 + exit 0 +} catch { + $message = ($_ | Out-String) + 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) +} + +pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String { + let mut script = String::new(); + script.push_str(&format!( + "$targetDir = '{}'\n", + escape_powershell_single(PROXIFYRE_INSTALL_DIR) + )); + script.push_str(&format!( + "$generatedConfigPath = '{}'\n", + escape_powershell_single(&generated_config_path.display().to_string()) + )); + 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!( + "$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 Invoke-Download([string]$uri, [string]$path) { + Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $path -Headers @{ 'User-Agent' = 'proxywarden' } + } + + 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 ' ' + } + + $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 + + if (-not (Test-VcRuntime $arch)) { + $vcRedistPath = Join-Path $workDir 'vc_redist.exe' + $vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url } + Invoke-Download $vcRedistUrl $vcRedistPath + $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)." + } + } + + if (-not (Test-WindowsPacketFilter)) { + $ndisRelease = Invoke-RestMethod -Uri $ndisapiReleaseApi -Headers @{ 'User-Agent' = 'proxywarden' } + $ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' } + $ndisAsset = Select-Asset $ndisRelease.assets $ndisPattern 'Windows Packet Filter' + $ndisPath = Join-Path $workDir $ndisAsset.name + $ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log' + Invoke-Download $ndisAsset.browser_download_url $ndisPath + Verify-AssetHash $ndisPath $ndisAsset + $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" + } + } + + $proxifyreRelease = Invoke-RestMethod -Uri $proxifyreReleaseApi -Headers @{ 'User-Agent' = 'proxywarden' } + $proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' } + $proxifyreAsset = Select-Asset $proxifyreRelease.assets $proxifyrePattern 'ProxiFyre' + $proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name + Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath + Verify-AssetHash $proxifyreZipPath $proxifyreAsset + + 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.' } + + 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 + } + + 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 + } +"#, + ); + + script +} + +fn uninstall_proxifyre_script(install_dir: &Path, executable_path: &Path) -> String { + let mut script = String::new(); + script.push_str(&format!( + "$installDir = '{}'\n", + escape_powershell_single(&install_dir.display().to_string()) + )); + script.push_str(&format!( + "$exePath = '{}'\n", + escape_powershell_single(&executable_path.display().to_string()) + )); + script.push_str( + r#" + 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 + } + + $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 + } + } + + if (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 (Test-Path -LiteralPath $installDir) { + Remove-Item -LiteralPath $installDir -Recurse -Force + } +"#, + ); + + script +} + +fn ensure_safe_proxifyre_install_dir(path: &Path) -> Result<(), CommandError> { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + + if path.parent().is_some() && name.contains("proxifyre") { + 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); + }; + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + CommandError::new( + "proxyfier_apply_failed", + format!( + "Не удалось создать папку конфига ProxiFyre '{}': {error}", + parent.display() + ), + ) + })?; + } + + if config_path.exists() { + let backup_path = config_path.with_file_name(format!( + "{}.bak", + config_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("app-config.json") + )); + fs::copy(config_path, backup_path).map_err(|error| { + CommandError::new( + "proxyfier_apply_failed", + format!( + "Не удалось создать backup текущего конфига ProxiFyre '{}': {error}", + config_path.display() + ), + ) + })?; + } + + fs::write(config_path, request.config_contents).map_err(|error| { + CommandError::new( + "proxyfier_apply_failed", + format!( + "Не удалось записать конфиг ProxiFyre '{}': {error}", + config_path.display() + ), + ) + })?; + + Ok(HelperApplyResult { + success: true, + 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", + format!("Фоновая проверка не завершилась: {error}"), + ) +} + +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 { + CommandError::with_details( + "validation_error", + "Проверка введенных данных не прошла", + errors + .into_iter() + .map(|error| ValidationIssue { + field: error.field, + message: error.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(), + 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 new file mode 100644 index 0000000..fa6b96f --- /dev/null +++ b/src-tauri/src/component_detection.rs @@ -0,0 +1,564 @@ +use crate::models::{ + ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, + DEFAULT_LOCAL_SINGBOX_SERVICE_NAME, +}; +use serde::Deserialize; +use std::{ + env, + path::{Path, PathBuf}, + process::Command, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProxyfierEngine { + ProxiFyre, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedProxyfier { + pub engine: ProxyfierEngine, + pub name: String, + pub install_dir: PathBuf, + pub executable_path: PathBuf, + pub config_path: Option, + pub running: bool, + pub service_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedSingBox { + pub install_dir: PathBuf, + pub executable_path: PathBuf, + pub wrapper_path: PathBuf, + pub binary_exists: bool, + pub wrapper_exists: bool, + pub running: bool, + pub service_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryInstallEntry { + pub display_name: String, + pub install_location: Option, + pub display_icon: Option, +} + +pub trait ProxyfierDetectionHost { + fn env_var(&self, name: &str) -> Option; + + fn path_exists(&self, path: &Path) -> bool; + + fn process_running(&self, process_name: &str) -> bool; + + fn service_running(&self, service_name: &str) -> bool; + + fn registry_install_entries(&self) -> Vec; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemProxyfierDetectionHost; + +impl ProxyfierDetectionHost for SystemProxyfierDetectionHost { + fn env_var(&self, name: &str) -> Option { + env::var(name).ok().filter(|value| !value.trim().is_empty()) + } + + fn path_exists(&self, path: &Path) -> bool { + path.exists() + } + + fn process_running(&self, process_name: &str) -> bool { + let process_name = process_name.trim_end_matches(".exe"); + let script = format!( + "if (Get-Process -Name '{}' -ErrorAction SilentlyContinue) {{ 'true' }} else {{ 'false' }}", + escape_powershell_single(process_name) + ); + + powershell_bool(&script) + } + + fn service_running(&self, service_name: &str) -> bool { + let script = format!( + "$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s -and $s.Status -eq 'Running') {{ 'true' }} else {{ 'false' }}", + escape_powershell_single(service_name) + ); + + powershell_bool(&script) + } + + fn registry_install_entries(&self) -> Vec { + read_registry_install_entries() + } +} + +pub fn detect_proxyfier_install() -> Option { + detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost) +} + +pub fn detect_proxyfier_install_with_host( + host: &impl ProxyfierDetectionHost, +) -> Option { + let proxifyre_running = host.process_running("ProxiFyre.exe") + || host.service_running("ProxiFyreService") + || host.service_running("ProxiFyre"); + + proxyfier_candidates(host) + .into_iter() + .filter_map(|candidate| candidate.into_detected(host, proxifyre_running)) + .next() +} + +pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>) -> ComponentStatus { + match detected { + Some(proxyfier) => detected_proxyfier_component(proxyfier), + None => missing_proxyfier_component(), + } +} + +pub fn detect_singbox_install() -> Option { + detect_singbox_install_with_host(&SystemProxyfierDetectionHost) +} + +pub fn detect_singbox_install_with_host( + host: &impl ProxyfierDetectionHost, +) -> Option { + let running = host.process_running("sing-box.exe") + || host.service_running(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME); + + singbox_candidates(host) + .into_iter() + .filter_map(|install_dir| detected_singbox_from_dir(host, install_dir, running)) + .next() +} + +pub fn singbox_component_from_detection(detected: Option<&DetectedSingBox>) -> ComponentStatus { + match detected { + Some(singbox) => detected_singbox_component(singbox), + None => missing_singbox_component(), + } +} + +fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus { + let state = if proxyfier.running { + ComponentState::Running + } else { + ComponentState::Installed + }; + let actions = match proxyfier.engine { + ProxyfierEngine::ProxiFyre => { + if proxyfier.running { + vec![ + "Применить сгенерированный конфиг".to_string(), + "Открыть папку конфига".to_string(), + "Остановить".to_string(), + ] + } else { + vec![ + "Применить сгенерированный конфиг".to_string(), + "Открыть папку конфига".to_string(), + "Запустить".to_string(), + ] + } + } + }; + + ComponentStatus { + id: ComponentId::Proxyfier, + name: "ProxiFyre".to_string(), + state, + installed: true, + running: proxyfier.running, + version: Some(match proxyfier.engine { + ProxyfierEngine::ProxiFyre => "ProxiFyre найден".to_string(), + }), + path: Some(proxyfier.install_dir.display().to_string()), + problems: Vec::new(), + actions, + } +} + +fn missing_proxyfier_component() -> ComponentStatus { + ComponentStatus { + id: ComponentId::Proxyfier, + name: "ProxiFyre".to_string(), + state: ComponentState::Missing, + installed: false, + running: false, + version: None, + path: None, + problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()], + actions: vec!["Установить ProxiFyre".to_string()], + } +} + +fn detected_singbox_component(singbox: &DetectedSingBox) -> ComponentStatus { + let state = if singbox.running { + ComponentState::Running + } else { + ComponentState::Stopped + }; + let actions = if singbox.running { + vec![ + "Сгенерировать конфиг".to_string(), + "Остановить".to_string(), + "Открыть папку".to_string(), + ] + } else { + vec![ + "Сгенерировать конфиг".to_string(), + "Запустить".to_string(), + "Открыть папку".to_string(), + ] + }; + let problems = if singbox.running { + Vec::new() + } else { + vec!["Служба Local sing-box остановлена".to_string()] + }; + + ComponentStatus { + id: ComponentId::Singbox, + name: "Local sing-box".to_string(), + state, + installed: true, + running: singbox.running, + version: Some("sing-box найден".to_string()), + path: Some(singbox.executable_path.display().to_string()), + problems, + actions, + } +} + +fn missing_singbox_component() -> ComponentStatus { + ComponentStatus { + id: ComponentId::Singbox, + name: "Local sing-box".to_string(), + state: ComponentState::Missing, + installed: false, + running: false, + version: None, + path: None, + problems: Vec::new(), + actions: vec!["Установить Local sing-box".to_string()], + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProxyfierCandidate { + engine: ProxyfierEngine, + name: String, + install_dir: PathBuf, +} + +impl ProxyfierCandidate { + fn into_detected( + self, + host: &impl ProxyfierDetectionHost, + proxifyre_running: bool, + ) -> Option { + let executable_path = self.install_dir.join(executable_name(&self.engine)); + let config_path = config_path(&self.engine, &self.install_dir); + let exists = host.path_exists(&self.install_dir) + || host.path_exists(&executable_path) + || config_path + .as_ref() + .is_some_and(|path| host.path_exists(path)); + + if !exists { + return None; + } + + Some(DetectedProxyfier { + service_name: service_name(&self.engine).map(str::to_string), + engine: self.engine, + name: self.name, + install_dir: self.install_dir, + executable_path, + config_path, + running: proxifyre_running, + }) + } +} + +fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec { + let mut candidates = Vec::new(); + + push_env_candidate( + &mut candidates, + host, + ProxyfierEngine::ProxiFyre, + "ProxiFyre", + "PROXYWARDEN_PROXIFYRE_ROOT", + ); + for entry in host.registry_install_entries() { + if let Some(engine) = engine_from_name(&entry.display_name) { + let install_dir = entry + .install_location + .or_else(|| entry.display_icon.and_then(|path| executable_parent(&path))); + if let Some(install_dir) = install_dir { + push_candidate( + &mut candidates, + ProxyfierCandidate { + name: entry.display_name, + engine, + install_dir, + }, + ); + } + } + } + + for install_dir in common_install_dirs(host, "ProxiFyre") { + push_candidate( + &mut candidates, + ProxyfierCandidate { + engine: ProxyfierEngine::ProxiFyre, + name: "ProxiFyre".to_string(), + install_dir, + }, + ); + } + candidates +} + +fn push_env_candidate( + candidates: &mut Vec, + host: &impl ProxyfierDetectionHost, + engine: ProxyfierEngine, + name: &str, + env_name: &str, +) { + if let Some(path) = host.env_var(env_name) { + push_candidate( + candidates, + ProxyfierCandidate { + engine, + name: name.to_string(), + install_dir: PathBuf::from(path), + }, + ); + } +} + +fn push_candidate(candidates: &mut Vec, candidate: ProxyfierCandidate) { + if !candidates.iter().any(|existing| { + existing.engine == candidate.engine + && same_path(&existing.install_dir, &candidate.install_dir) + }) { + candidates.push(candidate); + } +} + +fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec { + let mut dirs = vec![PathBuf::from(format!(r"C:\Tools\{folder_name}"))]; + + for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] { + if let Some(root) = host.env_var(env_name) { + dirs.push(PathBuf::from(root).join(folder_name)); + } + } + + dirs +} + +fn singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec { + let mut candidates = Vec::new(); + + if let Some(path) = host.env_var("PROXYWARDEN_SINGBOX_ROOT") { + push_path_candidate(&mut candidates, PathBuf::from(path)); + } + push_path_candidate( + &mut candidates, + PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT), + ); + push_path_candidate( + &mut candidates, + PathBuf::from(r"C:\Tools\ProxyWarden\sing-box"), + ); + for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] { + if let Some(root) = host.env_var(env_name) { + push_path_candidate( + &mut candidates, + PathBuf::from(&root).join("ProxyWarden").join("sing-box"), + ); + push_path_candidate(&mut candidates, PathBuf::from(root).join("sing-box")); + } + } + + candidates +} + +fn push_path_candidate(candidates: &mut Vec, candidate: PathBuf) { + if !candidates + .iter() + .any(|existing| same_path(existing, &candidate)) + { + candidates.push(candidate); + } +} + +fn detected_singbox_from_dir( + host: &impl ProxyfierDetectionHost, + install_dir: PathBuf, + running: bool, +) -> Option { + let executable_path = install_dir.join("sing-box.exe"); + let wrapper_path = install_dir.join("ProxyWardenSingBox.exe"); + let binary_exists = host.path_exists(&executable_path); + let wrapper_exists = host.path_exists(&wrapper_path); + let exists = host.path_exists(&install_dir) || binary_exists || wrapper_exists; + + if !exists { + return None; + } + + Some(DetectedSingBox { + install_dir, + executable_path, + wrapper_path, + binary_exists, + wrapper_exists, + running, + service_name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(), + }) +} + +fn executable_name(engine: &ProxyfierEngine) -> &'static str { + match engine { + ProxyfierEngine::ProxiFyre => "ProxiFyre.exe", + } +} + +fn config_path(engine: &ProxyfierEngine, install_dir: &Path) -> Option { + match engine { + ProxyfierEngine::ProxiFyre => Some(install_dir.join("app-config.json")), + } +} + +fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> { + match engine { + ProxyfierEngine::ProxiFyre => Some("ProxiFyreService"), + } +} + +fn engine_from_name(name: &str) -> Option { + let normalized = name.to_ascii_lowercase(); + if normalized.contains("proxifyre") { + Some(ProxyfierEngine::ProxiFyre) + } else { + None + } +} + +fn executable_parent(path: &Path) -> Option { + path.parent().map(Path::to_path_buf) +} + +fn same_path(left: &Path, right: &Path) -> bool { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + +fn powershell_bool(script: &str) -> bool { + Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .output() + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .is_some_and(|stdout| stdout.trim().eq_ignore_ascii_case("true")) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RegistryInstallJson { + display_name: Option, + install_location: Option, + display_icon: Option, +} + +fn read_registry_install_entries() -> Vec { + let script = r#" +$paths = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' +) +$items = foreach ($path in $paths) { + Get-ItemProperty -Path $path -ErrorAction SilentlyContinue +} +$items | + Where-Object { $_.DisplayName -match 'ProxiFyre' } | + Select-Object DisplayName,InstallLocation,DisplayIcon | + ConvertTo-Json -Compress +"#; + + let Ok(output) = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .output() + else { + return Vec::new(); + }; + + if !output.status.success() { + return Vec::new(); + } + + let Ok(stdout) = String::from_utf8(output.stdout) else { + return Vec::new(); + }; + let stdout = stdout.trim(); + if stdout.is_empty() { + return Vec::new(); + } + + parse_registry_json(stdout) +} + +fn parse_registry_json(json: &str) -> Vec { + let Ok(value) = serde_json::from_str::(json) else { + return Vec::new(); + }; + + match value { + serde_json::Value::Array(entries) => entries + .into_iter() + .filter_map(registry_entry_from_value) + .collect(), + entry => registry_entry_from_value(entry).into_iter().collect(), + } +} + +fn registry_entry_from_value(value: serde_json::Value) -> Option { + let parsed = serde_json::from_value::(value).ok()?; + let display_name = parsed.display_name?; + Some(RegistryInstallEntry { + display_name, + install_location: parsed + .install_location + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from), + display_icon: parsed + .display_icon + .and_then(|value| display_icon_path(&value)), + }) +} + +fn display_icon_path(value: &str) -> Option { + let trimmed = value.trim().trim_matches('"'); + if trimmed.is_empty() { + return None; + } + + let without_icon_index = trimmed + .split_once(',') + .map(|(path, _)| path) + .unwrap_or(trimmed) + .trim() + .trim_matches('"'); + + Some(PathBuf::from(without_icon_index)) +} + +fn escape_powershell_single(value: &str) -> String { + value.replace('\'', "''") +} diff --git a/src-tauri/src/helper.rs b/src-tauri/src/helper.rs new file mode 100644 index 0000000..1334c29 --- /dev/null +++ b/src-tauri/src/helper.rs @@ -0,0 +1,184 @@ +use crate::models::ComponentId; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum HelperAction { + #[serde(rename = "install-control-app")] + InstallControlApp, + #[serde(rename = "install-proxyfier")] + InstallProxyfier, + #[serde(rename = "install-singbox")] + InstallSingbox, + #[serde(rename = "proxyfier.apply")] + ProxyfierApply, + #[serde(rename = "service.status")] + ServiceStatus, + #[serde(rename = "service.start")] + ServiceStart, + #[serde(rename = "service.stop")] + ServiceStop, + #[serde(rename = "service.restart")] + ServiceRestart, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HelperRequest { + pub action: HelperAction, + #[serde(skip_serializing_if = "Option::is_none")] + pub component: Option, + #[serde(default)] + pub payload: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HelperResponse { + pub success: bool, + pub action: HelperAction, + pub changed: bool, + pub message: String, + #[serde(default)] + pub details: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelperCommandSpec { + pub program: PathBuf, + pub args: Vec, + pub stdin: String, + pub requires_elevation: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelperCommandOutput { + pub status_code: i32, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HelperError { + pub code: String, + pub message: String, +} + +impl HelperError { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } +} + +pub trait HelperCommandRunner { + fn run(&self, spec: &HelperCommandSpec) -> Result; +} + +#[derive(Debug, Clone)] +pub struct StructuredHelper { + helper_program: PathBuf, + runner: R, +} + +impl StructuredHelper +where + R: HelperCommandRunner, +{ + pub fn new(helper_program: impl Into, runner: R) -> Self { + Self { + helper_program: helper_program.into(), + runner, + } + } + + pub fn runner(&self) -> &R { + &self.runner + } + + pub fn execute(&self, request: &HelperRequest) -> Result { + let stdin = serde_json::to_string(request) + .map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?; + let spec = HelperCommandSpec { + program: self.helper_program.clone(), + args: vec!["--json".to_string()], + stdin, + requires_elevation: helper_action_requires_elevation(&request.action), + }; + let output = self.runner.run(&spec)?; + + if output.status_code != 0 { + return Err(HelperError::new( + "helper_exit", + format!( + "Помощник завершился с кодом {}: {}", + output.status_code, output.stderr + ), + )); + } + + parse_helper_response(&output.stdout) + } +} + +pub fn parse_helper_response(stdout: &str) -> Result { + serde_json::from_str(stdout).map_err(|error| { + HelperError::new( + "helper_response_decode", + format!("Помощник вернул не JSON или некорректный JSON: {error}"), + ) + }) +} + +pub fn install_request(component: ComponentId) -> HelperRequest { + let action = match component { + ComponentId::ControlApp => HelperAction::InstallControlApp, + ComponentId::Proxyfier => HelperAction::InstallProxyfier, + ComponentId::Singbox => HelperAction::InstallSingbox, + }; + + HelperRequest { + action, + component: Some(component), + payload: json!({}), + } +} + +pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest { + HelperRequest { + action, + component: Some(component), + payload: json!({}), + } +} + +pub fn proxifyre_apply_request( + config_path: impl AsRef, + service_name: impl Into, +) -> HelperRequest { + HelperRequest { + action: HelperAction::ProxyfierApply, + component: Some(ComponentId::Proxyfier), + payload: json!({ + "configPath": config_path.as_ref().display().to_string(), + "serviceName": service_name.into(), + }), + } +} + +pub fn helper_action_requires_elevation(action: &HelperAction) -> bool { + matches!( + action, + HelperAction::InstallControlApp + | HelperAction::InstallProxyfier + | HelperAction::InstallSingbox + | HelperAction::ProxyfierApply + | HelperAction::ServiceStart + | HelperAction::ServiceStop + | HelperAction::ServiceRestart + ) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..008d525 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,5 @@ +pub fn run() { + tauri::Builder::default() + .run(tauri::generate_context!()) + .expect("не удалось запустить клиент ProxyWarden"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..f251a66 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,71 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod activity; +mod commands; +mod component_detection; +mod models; +mod singbox_service; +mod storage; +mod subscription; +mod validation; + +mod adapters { + pub mod proxifyre; + pub mod proxy_router; + pub mod singbox; +} + +#[cfg(test)] +pub(crate) mod proxifyre { + pub use crate::adapters::proxifyre::*; +} + +#[cfg(test)] +pub(crate) mod proxy_router { + pub use crate::adapters::proxy_router::*; +} + +#[cfg(test)] +pub(crate) mod singbox { + pub use crate::adapters::singbox::*; +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .manage(commands::CommandState::default()) + .invoke_handler(tauri::generate_handler![ + commands::get_status, + 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_singbox_status, + commands::get_singbox_setup_status, + commands::resolve_profile_preview, + commands::save_singbox_subscription, + commands::fetch_singbox_subscription, + commands::forget_singbox_subscription, + commands::select_singbox_server, + commands::ping_singbox_server, + commands::ping_all_singbox_servers, + commands::ping_proxy_target, + commands::generate_singbox_config, + commands::apply_profiles, + commands::get_logs, + commands::open_config_location, + commands::start_proxifyre_service, + commands::stop_proxifyre_service, + commands::install_proxifyre, + commands::uninstall_proxifyre, + commands::start_singbox_service, + commands::stop_singbox_service, + commands::install_singbox, + commands::uninstall_singbox + ]) + .run(tauri::generate_context!()) + .expect("не удалось запустить клиент ProxyWarden"); +} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs new file mode 100644 index 0000000..8c0ea6e --- /dev/null +++ b/src-tauri/src/models.rs @@ -0,0 +1,273 @@ +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1"; +pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080; +pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox"; +pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\ProxyWarden\sing-box"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Protocol { + Tcp, + Udp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProfileItemType { + Process, + Folder, + Exe, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TargetKind { + Local, + External, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProxyProtocol { + Socks5, + Http, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ComponentId { + ControlApp, + Proxyfier, + Singbox, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ComponentState { + Installed, + Missing, + Stopped, + Running, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileItemInput { + #[serde(rename = "type")] + pub item_type: String, + pub value: String, + #[serde(default)] + pub recursive: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileInput { + pub id: Option, + pub name: String, + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_target_id")] + pub target_id: String, + #[serde(default = "default_protocols")] + pub protocols: Vec, + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileItem { + #[serde(rename = "type")] + pub item_type: ProfileItemType, + pub value: String, + pub recursive: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Profile { + 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 TargetInput { + pub id: Option, + pub name: String, + #[serde(default = "default_target_kind")] + pub kind: String, + #[serde(default = "default_proxy_protocol")] + pub protocol: String, + pub host: String, + pub port: u32, + #[serde(default)] + pub requires_component: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Target { + pub id: String, + pub name: String, + pub kind: TargetKind, + pub protocol: ProxyProtocol, + pub host: String, + pub port: u16, + pub requires_component: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComponentStatus { + pub id: ComponentId, + pub name: String, + pub state: ComponentState, + pub installed: bool, + pub running: bool, + pub version: Option, + pub path: Option, + #[serde(default)] + pub problems: Vec, + #[serde(default)] + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalSingBoxConfig { + #[serde(default)] + pub subscription_url: Option, + #[serde(default)] + pub selected_server_tag: Option, + #[serde(default = "default_local_singbox_listen_host")] + pub listen_host: String, + #[serde(default = "default_local_singbox_listen_port")] + pub listen_port: u16, + #[serde(default = "default_local_singbox_service_name")] + pub service_name: String, + #[serde(default = "default_local_singbox_install_root")] + pub install_root: String, + #[serde(default)] + pub updated_at: Option, +} + +impl LocalSingBoxConfig { + pub fn subscription_display_url(&self) -> Option { + self.subscription_url + .as_deref() + .map(redact_subscription_url) + } +} + +impl Default for LocalSingBoxConfig { + fn default() -> Self { + Self { + subscription_url: None, + selected_server_tag: None, + listen_host: default_local_singbox_listen_host(), + listen_port: default_local_singbox_listen_port(), + service_name: default_local_singbox_service_name(), + install_root: default_local_singbox_install_root(), + updated_at: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SubscriptionCache { + pub config: serde_json::Value, + #[serde(default)] + pub servers: Vec, + #[serde(default)] + pub user_info: serde_json::Map, + pub fetched_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubscriptionServer { + pub tag: String, + #[serde(rename = "type")] + pub server_type: String, + pub server: String, + pub server_port: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActivityEntry { + 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 = "lowercase")] +pub enum ActivityLevel { + Info, + Warning, + Error, + Success, +} + +fn default_enabled() -> bool { + true +} + +fn default_target_id() -> String { + "local-singbox".to_string() +} + +fn default_protocols() -> Vec { + vec!["TCP".to_string(), "UDP".to_string()] +} + +fn default_target_kind() -> String { + "external".to_string() +} + +fn default_proxy_protocol() -> String { + "socks5".to_string() +} + +fn default_local_singbox_listen_host() -> String { + DEFAULT_LOCAL_SINGBOX_LISTEN_HOST.to_string() +} + +fn default_local_singbox_listen_port() -> u16 { + DEFAULT_LOCAL_SINGBOX_LISTEN_PORT +} + +fn default_local_singbox_service_name() -> String { + DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string() +} + +fn default_local_singbox_install_root() -> String { + DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string() +} + +pub fn redact_subscription_url(raw_url: &str) -> String { + let trimmed = raw_url.trim(); + if trimmed.is_empty() { + return String::new(); + } + + match trimmed.split_once("://") { + Some((scheme, rest)) => { + let host = rest + .split(['/', '?', '#']) + .next() + .filter(|value| !value.is_empty()) + .unwrap_or("subscription"); + format!("{scheme}://{host}/...") + } + None => { + let visible = trimmed.chars().take(18).collect::(); + if trimmed.chars().count() <= 18 { + "***".to_string() + } else { + format!("{visible}...") + } + } + } +} diff --git a/src-tauri/src/singbox_service.rs b/src-tauri/src/singbox_service.rs new file mode 100644 index 0000000..0ca27da --- /dev/null +++ b/src-tauri/src/singbox_service.rs @@ -0,0 +1,273 @@ +use crate::component_detection::DetectedSingBox; +use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +pub const SINGBOX_RELEASE_API_URL: &str = + "https://api.github.com/repos/SagerNet/sing-box/releases/latest"; +pub const WINSW_RELEASE_API_URL: &str = "https://api.github.com/repos/winsw/winsw/releases/latest"; +pub const WINSW_WRAPPER_FILE: &str = "ProxyWardenSingBox.exe"; +pub const SINGBOX_BINARY_FILE: &str = "sing-box.exe"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingBoxServiceAction { + Start, + Stop, +} + +impl SingBoxServiceAction { + pub fn action_name(self) -> &'static str { + match self { + SingBoxServiceAction::Start => "start", + SingBoxServiceAction::Stop => "stop", + } + } + + pub fn label(self) -> &'static str { + match self { + SingBoxServiceAction::Start => "запустить", + SingBoxServiceAction::Stop => "остановить", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SingBoxSetupStatus { + pub ready: bool, + pub missing_count: usize, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SingBoxSetupItem { + pub id: String, + pub name: String, + pub installed: bool, + pub version: Option, + pub details: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServiceCommandOutput { + pub success: bool, + pub code: String, + pub service_name: Option, + pub status: Option, + pub process_id: Option, +} + +pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus { + let install_root = detected + .map(|singbox| singbox.install_dir.display().to_string()) + .unwrap_or_else(|| DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string()); + let binary_item = match detected { + Some(singbox) if singbox.binary_exists => SingBoxSetupItem { + id: "sing-box-binary".to_string(), + name: "sing-box".to_string(), + installed: true, + version: Some("binary найден".to_string()), + details: singbox.executable_path.display().to_string(), + }, + _ => SingBoxSetupItem { + id: "sing-box-binary".to_string(), + name: "sing-box".to_string(), + installed: false, + version: None, + details: format!( + "Будет скачан из GitHub releases SagerNet/sing-box и установлен в {install_root}." + ), + }, + }; + let wrapper_item = match detected { + Some(singbox) if singbox.wrapper_exists => SingBoxSetupItem { + id: "winsw-wrapper".to_string(), + name: "WinSW service wrapper".to_string(), + installed: true, + version: Some("wrapper найден".to_string()), + details: singbox.wrapper_path.display().to_string(), + }, + _ => SingBoxSetupItem { + id: "winsw-wrapper".to_string(), + name: "WinSW service wrapper".to_string(), + installed: false, + version: None, + details: format!( + "Будет скачан из GitHub releases winsw/winsw как {WINSW_WRAPPER_FILE}." + ), + }, + }; + let service_item = match detected { + Some(singbox) if singbox.running => SingBoxSetupItem { + id: "windows-service".to_string(), + name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(), + installed: true, + version: Some("служба запущена".to_string()), + details: format!("Служба {}", singbox.service_name), + }, + Some(singbox) => SingBoxSetupItem { + id: "windows-service".to_string(), + name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(), + installed: true, + version: Some("служба остановлена".to_string()), + details: format!("Служба {}", singbox.service_name), + }, + None => SingBoxSetupItem { + id: "windows-service".to_string(), + name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(), + installed: false, + version: None, + details: "Будет создана Windows-служба Local sing-box.".to_string(), + }, + }; + + let items = vec![binary_item, wrapper_item, service_item]; + let missing_count = items.iter().filter(|item| !item.installed).count(); + + SingBoxSetupStatus { + ready: missing_count == 0, + missing_count, + items, + } +} + +pub 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() +} + +pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> { + let normalized = path + .display() + .to_string() + .replace('/', "\\") + .to_ascii_lowercase(); + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + if file_name == "sing-box" + && (normalized.contains("\\proxywarden\\") || normalized.contains("\\proxywarden\\")) + { + return Ok(()); + } + + Err(format!( + "Отказываюсь рекурсивно удалять Local sing-box с небезопасным путем: {}", + path.display() + )) +} + +pub fn service_control_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 = 'Stop' +$serviceName = '{escaped_service_name}' +$action = '{action_name}' +$configSource = '{escaped_config_source}' +$configTarget = '{escaped_config_target}' + +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 = $serviceName + status = $status + processId = $processId + }} | ConvertTo-Json -Compress + exit 0 +}} + +function Sync-ServiceConfig {{ + if ($action -ne 'start' -or [string]::IsNullOrWhiteSpace($configSource)) {{ return }} + if (-not (Test-Path -LiteralPath $configSource)) {{ + Write-ServiceResult $false 'config_source_missing' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName) + }} + if ([string]::IsNullOrWhiteSpace($configTarget)) {{ return }} + + try {{ + Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop + }} catch {{ + Write-ServiceResult $false 'config_sync_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName) + }} +}} + +$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +if ($null -eq $service) {{ + Write-ServiceResult $false 'service_not_found' $null 0 +}} + +if ($action -eq 'start') {{ + Sync-ServiceConfig + + if ($service.Status -eq 'Running') {{ + Write-ServiceResult $true 'already_running' $service.Status.ToString() (Get-ServiceProcessId $serviceName) + }} + + try {{ + Start-Service -Name $serviceName -ErrorAction Stop + $service = Get-Service -Name $serviceName -ErrorAction Stop + $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) + }} catch {{ + Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName) + }} + + Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $serviceName) +}} + +if ($service.Status -eq 'Stopped') {{ + Write-ServiceResult $true 'already_stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName) +}} + +try {{ + Stop-Service -Name $serviceName -Force -ErrorAction Stop + $service = Get-Service -Name $serviceName -ErrorAction Stop + $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) +}} catch {{ + Write-ServiceResult $false 'stop_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName) +}} + +Write-ServiceResult ($service.Status -eq 'Stopped') 'stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName) +"# + ) +} + +fn escape_powershell_single(value: &str) -> String { + value.replace('\'', "''") +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..f5e494a --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,228 @@ +use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT}; +use crate::models::{ + ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target, +}; +use serde::{de::DeserializeOwned, Serialize}; +use std::fs; +use std::io::{self, ErrorKind}; +use std::path::{Path, PathBuf}; + +pub fn default_config_root() -> PathBuf { + PathBuf::from(r"C:\ProgramData\ProxyWarden") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoragePaths { + pub root: PathBuf, + pub config_dir: PathBuf, + pub state_dir: PathBuf, + pub generated_dir: PathBuf, + pub profiles_file: PathBuf, + pub targets_file: PathBuf, + pub components_file: PathBuf, + pub local_singbox_file: PathBuf, + pub singbox_subscription_cache_file: PathBuf, + pub activity_file: PathBuf, +} + +impl StoragePaths { + pub fn new(root: impl Into) -> Self { + let root = root.into(); + let config_dir = root.join("config"); + let state_dir = root.join("state"); + let generated_dir = root.join("generated"); + + Self { + root, + profiles_file: config_dir.join("profiles.json"), + targets_file: config_dir.join("targets.json"), + components_file: config_dir.join("components.json"), + local_singbox_file: config_dir.join("local-singbox.json"), + singbox_subscription_cache_file: state_dir.join("singbox-subscription-cache.json"), + activity_file: state_dir.join("activity.json"), + config_dir, + state_dir, + generated_dir, + } + } +} + +impl Default for StoragePaths { + fn default() -> Self { + Self::new(default_config_root()) + } +} + +#[derive(Debug, Clone)] +pub struct JsonStorage { + paths: StoragePaths, + activity_limit: usize, +} + +impl JsonStorage { + pub fn new(root: impl Into) -> Self { + Self::with_activity_limit(root, DEFAULT_ACTIVITY_LIMIT) + } + + pub fn with_activity_limit(root: impl Into, activity_limit: usize) -> Self { + Self { + paths: StoragePaths::new(root), + activity_limit, + } + } + + pub fn paths(&self) -> &StoragePaths { + &self.paths + } + + pub fn ensure_dirs(&self) -> io::Result<()> { + fs::create_dir_all(&self.paths.config_dir)?; + fs::create_dir_all(&self.paths.state_dir)?; + fs::create_dir_all(&self.paths.generated_dir)?; + Ok(()) + } + + pub fn read_profiles(&self) -> io::Result> { + self.read_json_or_default(&self.paths.profiles_file) + } + + pub fn write_profiles(&self, profiles: &[Profile]) -> io::Result<()> { + self.write_json(&self.paths.profiles_file, profiles) + } + + pub fn read_targets(&self) -> io::Result> { + self.read_json_or_default(&self.paths.targets_file) + } + + pub fn write_targets(&self, targets: &[Target]) -> io::Result<()> { + self.write_json(&self.paths.targets_file, targets) + } + + pub fn read_components(&self) -> io::Result> { + self.read_json_or_default(&self.paths.components_file) + } + + pub fn write_components(&self, components: &[ComponentStatus]) -> io::Result<()> { + self.write_json(&self.paths.components_file, components) + } + + pub fn read_local_singbox_config(&self) -> io::Result { + self.read_json_or_default(&self.paths.local_singbox_file) + } + + pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> { + self.write_json(&self.paths.local_singbox_file, config) + } + + pub fn read_singbox_subscription_cache(&self) -> io::Result> { + self.read_optional_json(&self.paths.singbox_subscription_cache_file) + } + + pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> { + self.write_json(&self.paths.singbox_subscription_cache_file, cache) + } + + pub fn remove_singbox_subscription_cache(&self) -> io::Result<()> { + match fs::remove_file(&self.paths.singbox_subscription_cache_file) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } + } + + pub fn read_activity(&self) -> io::Result> { + let entries = self.read_json_or_default(&self.paths.activity_file)?; + Ok(cap_activity(entries, self.activity_limit)) + } + + pub fn write_activity(&self, entries: &[ActivityEntry]) -> io::Result<()> { + let entries = cap_activity(entries.to_vec(), self.activity_limit); + self.write_json(&self.paths.activity_file, &entries) + } + + pub fn append_activity(&self, entry: ActivityEntry) -> io::Result> { + let entries = self.read_activity()?; + let entries = append_activity(entries, entry, self.activity_limit); + self.write_json(&self.paths.activity_file, &entries)?; + Ok(entries) + } + + fn read_json_or_default(&self, path: &Path) -> io::Result + where + T: DeserializeOwned + Default, + { + match fs::read_to_string(path) { + Ok(contents) => match serde_json::from_str(&contents) { + Ok(value) => Ok(value), + Err(_) => Ok(T::default()), + }, + Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()), + Err(error) => Err(error), + } + } + + fn write_json(&self, path: &Path, value: &T) -> io::Result<()> + where + T: Serialize + ?Sized, + { + let contents = serde_json::to_vec_pretty(value) + .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?; + write_atomic(path, &contents) + } + + fn read_optional_json(&self, path: &Path) -> io::Result> + where + T: DeserializeOwned, + { + match fs::read_to_string(path) { + Ok(contents) => Ok(serde_json::from_str(&contents).ok()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } +} + +impl Default for JsonStorage { + fn default() -> Self { + Self::new(default_config_root()) + } +} + +pub fn backup_path(path: &Path) -> PathBuf { + sibling_with_suffix(path, "bak") +} + +fn temp_path(path: &Path) -> PathBuf { + sibling_with_suffix(path, "tmp") +} + +fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("storage.json"); + + path.with_file_name(format!("{file_name}.{suffix}")) +} + +fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let temp_path = temp_path(path); + fs::write(&temp_path, contents)?; + + if path.exists() { + fs::copy(path, backup_path(path))?; + fs::remove_file(path)?; + } + + match fs::rename(&temp_path, path) { + Ok(()) => Ok(()), + Err(error) => { + let _ = fs::remove_file(&temp_path); + Err(error) + } + } +} diff --git a/src-tauri/src/subscription.rs b/src-tauri/src/subscription.rs new file mode 100644 index 0000000..6de1eb8 --- /dev/null +++ b/src-tauri/src/subscription.rs @@ -0,0 +1,269 @@ +use crate::models::{SubscriptionCache, SubscriptionServer}; +use base64::{engine::general_purpose, Engine}; +use serde_json::{json, Map, Value}; +use std::time::{SystemTime, UNIX_EPOCH}; +use url::Url; + +const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptionError { + pub message: String, +} + +impl SubscriptionError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl std::fmt::Display for SubscriptionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for SubscriptionError {} + +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedSubscription { + pub config: Value, + pub servers: Vec, +} + +pub fn parse_subscription_body(body: &str) -> Result { + let config = match serde_json::from_str::(body) { + Ok(value) => value, + Err(_) => parse_link_subscription(body)?, + }; + let servers = servers_from_config(&config)?; + + Ok(ParsedSubscription { config, servers }) +} + +pub fn parse_user_info(header_value: Option<&str>) -> Map { + let mut result = Map::new(); + let Some(header_value) = header_value else { + return result; + }; + + for part in header_value.split(';') { + let Some((key, value)) = part.trim().split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + if let Ok(parsed) = value.trim().parse::() { + result.insert(key.to_string(), Value::Number(parsed.into())); + } + } + + result +} + +pub fn fetch_subscription(url: &str) -> Result { + let parsed_url = + Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?; + if !matches!(parsed_url.scheme(), "http" | "https") { + return Err(SubscriptionError::new( + "Subscription URL must use http or https", + )); + } + + let response = reqwest::blocking::Client::new() + .get(parsed_url) + .header("user-agent", "singbox") + .header("x-device-os", std::env::consts::OS) + .header("x-device-model", "proxywarden") + .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(), + }) +} + +fn parse_link_subscription(body: &str) -> Result { + let decoded = maybe_decode_base64(body); + let links = decoded + .lines() + .map(str::trim) + .filter(|line| line.starts_with("vless://")) + .collect::>(); + + if links.is_empty() { + return Err(SubscriptionError::new( + "Subscription does not contain JSON config or VLESS links", + )); + } + + let outbounds = links + .into_iter() + .map(parse_vless_url) + .collect::, _>>()?; + + Ok(json!({ "outbounds": outbounds })) +} + +fn parse_vless_url(raw_url: &str) -> Result { + if !raw_url.starts_with("vless://") { + return Err(SubscriptionError::new("VLESS URL must start with vless://")); + } + + let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?; + let tag = parsed.fragment().unwrap_or("vless-out").to_string(); + let uuid = 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); + let public_key = query_value(&parsed, "pbk").unwrap_or_default(); + let short_id = query_value(&parsed, "sid").unwrap_or_default(); + let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone()); + let fingerprint = query_value(&parsed, "fp").unwrap_or_else(|| "chrome".to_string()); + let flow = query_value(&parsed, "flow").unwrap_or_default(); + + if uuid.is_empty() || server.is_empty() { + return Err(SubscriptionError::new( + "VLESS URL misses uuid, host or port", + )); + } + + if public_key.is_empty() || short_id.is_empty() { + return Err(SubscriptionError::new( + "VLESS REALITY parameters pbk and sid are required", + )); + } + + Ok(json!({ + "type": "vless", + "tag": tag, + "server": server, + "server_port": server_port, + "uuid": uuid, + "flow": flow, + "tls": { + "enabled": true, + "server_name": server_name, + "utls": { + "enabled": true, + "fingerprint": fingerprint + }, + "reality": { + "enabled": true, + "public_key": public_key, + "short_id": short_id + } + }, + "packet_encoding": "xudp" + })) +} + +fn servers_from_config(config: &Value) -> Result, SubscriptionError> { + let servers = config + .get("outbounds") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(server_from_outbound) + .collect::>(); + + if servers.is_empty() { + return Err(SubscriptionError::new( + "No supported proxy outbounds found in subscription", + )); + } + + Ok(servers) +} + +fn server_from_outbound(outbound: &Value) -> Option { + let server_type = outbound.get("type")?.as_str()?.to_string(); + if !SUPPORTED_PROXY_TYPES.contains(&server_type.as_str()) { + return None; + } + + let server = outbound + .get("server") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let server_port = outbound + .get("server_port") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(443); + let tag = outbound + .get("tag") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("{server_type}-{server}")); + + Some(SubscriptionServer { + tag, + server_type, + server, + server_port, + }) +} + +fn maybe_decode_base64(content: &str) -> String { + let compact = content.split_whitespace().collect::(); + if compact.is_empty() + || !compact + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '-' | '_')) + { + return content.to_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('{') { + return decoded; + } + } + } + } + + content.to_string() +} + +fn query_value(url: &Url, key: &str) -> Option { + url.query_pairs() + .find(|(name, _)| name == key) + .map(|(_, value)| value.into_owned()) +} + +fn now_timestamp() -> 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/validation.rs b/src-tauri/src/validation.rs new file mode 100644 index 0000000..854fa48 --- /dev/null +++ b/src-tauri/src/validation.rs @@ -0,0 +1,223 @@ +use crate::models::{ + ComponentId, Profile, ProfileInput, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, + Target, TargetInput, TargetKind, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationError { + pub field: String, + pub message: String, +} + +pub type ValidationResult = Result>; + +fn error(field: impl Into, message: impl Into) -> ValidationError { + ValidationError { + field: field.into(), + message: message.into(), + } +} + +fn clean(value: &str) -> String { + value.trim().to_string() +} + +fn slug(value: &str, fallback: &str) -> String { + let mut output = String::new(); + let mut previous_dash = false; + + for ch in value.trim().to_lowercase().chars() { + if ch.is_ascii_alphanumeric() { + output.push(ch); + previous_dash = false; + } else if !previous_dash { + output.push('-'); + previous_dash = true; + } + } + + let output = output.trim_matches('-').to_string(); + if output.is_empty() { + fallback.to_string() + } else { + output + } +} + +fn process_name(value: &str) -> String { + let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim(); + base.strip_suffix(".exe") + .or_else(|| base.strip_suffix(".EXE")) + .unwrap_or(base) + .trim() + .to_string() +} + +pub fn parse_protocol(value: &str) -> Result { + match value.trim().to_ascii_uppercase().as_str() { + "TCP" => Ok(Protocol::Tcp), + "UDP" => Ok(Protocol::Udp), + _ => Err(error( + "protocols", + format!("Неподдерживаемый протокол: {value}"), + )), + } +} + +pub fn parse_profile_item_type(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "process" => Ok(ProfileItemType::Process), + "folder" => Ok(ProfileItemType::Folder), + "exe" => Ok(ProfileItemType::Exe), + _ => Err(error( + "items.type", + format!("Неподдерживаемый тип элемента: {value}"), + )), + } +} + +pub fn parse_target_kind(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "local" => Ok(TargetKind::Local), + "external" => Ok(TargetKind::External), + _ => Err(error("kind", format!("Неподдерживаемый тип цели: {value}"))), + } +} + +pub fn parse_proxy_protocol(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "socks5" => Ok(ProxyProtocol::Socks5), + "http" => Ok(ProxyProtocol::Http), + _ => Err(error( + "protocol", + format!("Неподдерживаемый протокол прокси: {value}"), + )), + } +} + +pub fn parse_component_id(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "control-app" | "controlapp" => Ok(ComponentId::ControlApp), + "proxyfier" => Ok(ComponentId::Proxyfier), + "singbox" | "sing-box" => Ok(ComponentId::Singbox), + _ => Err(error( + "requires_component", + format!("Неподдерживаемый компонент: {value}"), + )), + } +} + +pub fn normalize_profile(input: ProfileInput) -> ValidationResult { + let mut errors = Vec::new(); + let name = clean(&input.name); + if name.is_empty() { + errors.push(error("name", "Укажите название профиля")); + } + + let target_id = clean(&input.target_id); + if target_id.is_empty() { + errors.push(error("target_id", "Укажите цель профиля")); + } + + let mut protocols = Vec::new(); + for value in input.protocols { + match parse_protocol(&value) { + Ok(protocol) if !protocols.contains(&protocol) => protocols.push(protocol), + Ok(_) => {} + Err(err) => errors.push(err), + } + } + if protocols.is_empty() { + errors.push(error("protocols", "Выберите хотя бы один протокол")); + } + + let mut items = Vec::new(); + for raw_item in input.items { + let item_type = match parse_profile_item_type(&raw_item.item_type) { + Ok(item_type) => item_type, + Err(err) => { + errors.push(err); + continue; + } + }; + + let value = match item_type { + ProfileItemType::Process => process_name(&raw_item.value), + ProfileItemType::Folder | ProfileItemType::Exe => clean(&raw_item.value), + }; + if value.is_empty() { + errors.push(error("items.value", "Укажите значение элемента профиля")); + continue; + } + + let recursive = + matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true); + items.push(ProfileItem { + item_type, + value, + recursive, + }); + } + + if !errors.is_empty() { + return Err(errors); + } + + Ok(Profile { + id: slug(input.id.as_deref().unwrap_or(&name), "profile"), + name, + enabled: input.enabled, + target_id, + protocols, + items, + }) +} + +pub fn normalize_target(input: TargetInput) -> ValidationResult { + let mut errors = Vec::new(); + let name = clean(&input.name); + let host = clean(&input.host); + + if name.is_empty() { + errors.push(error("name", "Укажите название цели")); + } + if host.is_empty() { + errors.push(error("host", "Укажите хост цели")); + } + if input.port == 0 || input.port > u16::MAX as u32 { + errors.push(error("port", "Порт цели должен быть от 1 до 65535")); + } + + let kind = parse_target_kind(&input.kind).unwrap_or_else(|err| { + errors.push(err); + TargetKind::External + }); + let protocol = parse_proxy_protocol(&input.protocol).unwrap_or_else(|err| { + errors.push(err); + ProxyProtocol::Socks5 + }); + let requires_component = match input.requires_component { + Some(value) if !value.trim().is_empty() => match parse_component_id(&value) { + Ok(component) => Some(component), + Err(err) => { + errors.push(err); + None + } + }, + _ => None, + }; + + if !errors.is_empty() { + return Err(errors); + } + + Ok(Target { + id: slug(input.id.as_deref().unwrap_or(&name), "target"), + name, + kind, + protocol, + host, + port: input.port as u16, + requires_component, + }) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..870fefc --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "ProxyWarden", + "version": "0.1.0", + "identifier": "ru.dokops.proxywarden.windows", + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build", + "devUrl": "http://127.0.0.1:5173", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "ProxyWarden", + "width": 1120, + "height": 760, + "minWidth": 760, + "minHeight": 560, + "resizable": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.ico" + ] + } +} diff --git a/src-tauri/tests/command_tests.rs b/src-tauri/tests/command_tests.rs new file mode 100644 index 0000000..a1d4d8e --- /dev/null +++ b/src-tauri/tests/command_tests.rs @@ -0,0 +1,528 @@ +#[path = "../src/activity.rs"] +mod activity; +#[path = "../src/commands.rs"] +mod commands; +#[path = "../src/component_detection.rs"] +mod component_detection; +#[path = "../src/models.rs"] +mod models; +#[path = "../src/adapters/proxifyre.rs"] +mod proxifyre; +#[path = "../src/adapters/proxy_router.rs"] +mod proxy_router; +#[path = "../src/adapters/singbox.rs"] +mod singbox; +#[path = "../src/singbox_service.rs"] +mod singbox_service; +#[path = "../src/storage.rs"] +mod storage; +#[path = "../src/subscription.rs"] +mod subscription; +#[path = "../src/validation.rs"] +mod validation; + +use commands::{ + apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview, + save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper, + HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, + TargetInputDto, +}; +use component_detection::{ + DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry, +}; +use models::{ + ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol, + ProxyProtocol, Target, TargetKind, +}; +use proxifyre::ProxiFyreAdapter; +use std::collections::HashSet; +use std::fs; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::process::Command as ProcessCommand; +use std::time::{SystemTime, UNIX_EPOCH}; +use storage::JsonStorage; + +#[test] +fn save_commands_normalize_and_persist_profile_and_target() { + let root = test_root("save"); + let storage = JsonStorage::new(root.clone()); + + let target = save_target_to_storage( + &storage, + TargetInputDto { + id: Some("Home Gateway".to_string()), + name: " Home Gateway ".to_string(), + kind: Some("external".to_string()), + protocol: Some("socks5".to_string()), + host: " 192.168.50.111 ".to_string(), + port: 8080, + requires_component: None, + }, + ) + .expect("target command should normalize"); + let profile = save_profile_to_storage( + &storage, + ProfileInputDto { + id: Some("Discord".to_string()), + name: " Discord ".to_string(), + enabled: Some(true), + target_id: Some("home-gateway".to_string()), + protocols: Some(vec!["tcp".to_string(), "UDP".to_string()]), + items: Some(vec![ProfileItemInputDto { + item_type: "process".to_string(), + value: "Discord.exe".to_string(), + recursive: None, + }]), + }, + ) + .expect("profile command should normalize"); + + assert_eq!(target.id, "home-gateway"); + assert_eq!(profile.id, "discord"); + assert_eq!(profile.target_id, "home-gateway"); + assert_eq!(profile.items[0].value, "Discord"); + + let status = build_status(&storage).expect("status command should read stored state"); + assert_eq!( + status.route_line, + "Выбранные приложения -> ProxiFyre -> внешний прокси 192.168.50.111:8080" + ); + assert_eq!(status.active_profile_count, 1); + assert_eq!(status.routed_app_count, 1); + + cleanup(&root); +} + +#[test] +fn resolve_preview_returns_structured_apps_without_filesystem_scan() { + let preview = resolve_preview(ProfileInputDto { + id: Some("Game".to_string()), + name: "Game".to_string(), + enabled: Some(true), + target_id: Some("home-gateway".to_string()), + protocols: Some(vec!["TCP".to_string()]), + items: Some(vec![ + ProfileItemInputDto { + item_type: "process".to_string(), + value: "Discord.exe".to_string(), + recursive: None, + }, + ProfileItemInputDto { + item_type: "folder".to_string(), + value: r"C:\Games\Launcher".to_string(), + recursive: Some(true), + }, + ]), + }) + .expect("preview should normalize profile input"); + + assert_eq!(preview.profile_id, "game"); + assert_eq!(preview.apps.len(), 2); + assert_eq!(preview.apps[0].app_name, "Discord"); + assert_eq!(preview.apps[1].source_type, ProfileItemType::Folder); + assert!(preview + .warnings + .iter() + .any(|warning| warning.contains("Сканирование папок отложено"))); +} + +#[test] +fn ping_proxy_target_reports_open_tcp_endpoint() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener"); + let port = listener.local_addr().expect("read local addr").port(); + + let result = commands::ping_proxy_target_endpoint(commands::PingProxyTargetInputDto { + host: "127.0.0.1".to_string(), + port, + }) + .expect("ping should return response"); + + assert_eq!(result.tag, "external-proxy"); + assert_eq!(result.server, "127.0.0.1"); + assert_eq!(result.server_port, port); + assert!(result.ok); + assert!(result.latency.is_some()); +} + +#[test] +#[cfg(windows)] +fn proxifyre_install_script_parses_as_powershell() { + let root = test_root("proxifyre-install-script"); + fs::create_dir_all(&root).expect("test root should be created"); + + let script = commands::wrap_elevated_package_script( + &commands::install_proxifyre_script(&root.join("proxifyre-app-config.json")), + &root.join("install.log"), + ); + let script_path = root.join("install.ps1"); + let mut script_bytes = vec![0xEF, 0xBB, 0xBF]; + script_bytes.extend_from_slice(script.as_bytes()); + fs::write(&script_path, script_bytes).expect("script should be written"); + + let escaped_path = script_path.display().to_string().replace('\'', "''"); + let parser = format!( + "$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}" + ); + let output = ProcessCommand::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &parser]) + .output() + .expect("powershell parser should run"); + + assert!( + output.status.success(), + "install script should parse\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + cleanup(&root); +} + +#[test] +fn singbox_runner_preserves_installer_args_with_spaces() { + let script = commands::singbox_installer_runner_script( + Path::new(r"C:\ProgramData\ProxyWarden\state\install-singbox.ps1"), + Path::new(r"C:\ProgramData\ProxyWarden\state\install.log"), + &[ + "-InstallRoot".to_string(), + r"C:\Program Files\ProxyWarden\sing-box".to_string(), + "-ServiceName".to_string(), + "ProxyWardenSingBox".to_string(), + "-Uninstall".to_string(), + ], + ); + + assert!(script.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'")); + assert!(script.contains("& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs")); + assert!(!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList")); +} + +#[test] +fn apply_generates_derived_config_and_records_activity_with_mock_helper() { + let root = test_root("apply"); + let storage = JsonStorage::new(root.clone()); + storage + .write_profiles(&[discord_profile("home-gateway")]) + .expect("write profiles"); + storage + .write_targets(&[external_socks5_target()]) + .expect("write targets"); + storage + .write_components(&[proxyfier_running(), singbox_missing()]) + .expect("write components"); + + let response = apply_profiles_with_services( + &storage, + &ProxiFyreAdapter::default(), + &MockApplyHelper, + &FixedClock, + ) + .expect("apply command should generate config and call helper"); + + let generated_path = PathBuf::from(&response.generated_config_path); + let generated_contents = fs::read_to_string(&generated_path).expect("read generated config"); + let activity = storage.read_activity().expect("read activity"); + + assert!(response.success); + assert!(response.changed); + assert_eq!(response.adapter_id, "proxifyre"); + assert_eq!(response.enabled_profiles, 1); + assert_eq!(response.routed_apps, 1); + assert_eq!(response.helper.action, "proxyfier.apply.mock"); + assert!(generated_contents.contains("\"appNames\"")); + assert!(generated_contents.contains("Discord")); + assert!(generated_path.ends_with("proxifyre-app-config.json")); + assert_eq!(activity.len(), 1); + assert_eq!(activity[0].at, "2026-07-03T00:00:00Z"); + assert_eq!(activity[0].title, "Конфиг ProxiFyre создан"); + + cleanup(&root); +} + +#[test] +fn apply_blocks_local_singbox_target_when_component_is_missing() { + let root = test_root("missing-singbox"); + let storage = JsonStorage::new(root.clone()); + storage + .write_profiles(&[discord_profile("local-singbox")]) + .expect("write profiles"); + storage + .write_targets(&[local_singbox_target()]) + .expect("write targets"); + storage + .write_components(&[singbox_missing()]) + .expect("write components"); + + let error = apply_profiles_with_services( + &storage, + &ProxiFyreAdapter::default(), + &MockApplyHelper, + &FixedClock, + ) + .expect_err("missing sing-box should block local target apply"); + let activity = storage.read_activity().expect("read blocked activity"); + + assert_eq!(error.code, "required_component_not_running"); + assert_eq!(activity.len(), 1); + assert_eq!(activity[0].level, models::ActivityLevel::Error); + assert_eq!(activity[0].title, "Применение ProxiFyre заблокировано"); + + cleanup(&root); +} + +#[test] +fn component_status_merges_detected_existing_proxifyre() { + let components = resolve_component_statuses( + Vec::new(), + Some(DetectedProxyfier { + engine: ProxyfierEngine::ProxiFyre, + name: "ProxiFyre".to_string(), + install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"), + executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"), + config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")), + running: true, + service_name: Some("ProxiFyreService".to_string()), + }), + None, + ); + let proxyfier = components + .iter() + .find(|component| component.id == ComponentId::Proxyfier) + .expect("proxyfier component"); + + assert_eq!(proxyfier.state, ComponentState::Running); + assert!(proxyfier.installed); + assert!(proxyfier.running); + assert_eq!(proxyfier.path, Some(r"C:\Tools\ProxiFyre".to_string())); + assert!(proxyfier.problems.is_empty()); +} + +#[test] +fn detected_proxy_apply_helper_writes_proxifyre_app_config() { + let root = test_root("detected-proxifyre"); + let install_dir = root.join("ProxiFyre"); + fs::create_dir_all(&install_dir).expect("install dir"); + fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe"); + fs::write(install_dir.join("app-config.json"), "{}").expect("existing config"); + let generated_config = root.join("generated").join("proxifyre-app-config.json"); + let host = DetectionHost::new() + .with_registry("ProxiFyre", &install_dir) + .with_path(&install_dir) + .with_path(&install_dir.join("ProxiFyre.exe")); + let helper = DetectedProxyApplyHelper::new(host); + + let result = helper + .apply_proxy_config(HelperApplyRequest { + adapter_id: "proxifyre", + config_path: &generated_config, + config_contents: r#"{"proxies":[]}"#, + }) + .expect("detected helper should apply"); + + let applied = + fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config"); + + assert!(result.success); + assert!(result.changed); + assert_eq!(result.action, "proxifyre.apply-detected-config"); + assert_eq!(applied, r#"{"proxies":[]}"#); + assert!(install_dir.join("app-config.json.bak").exists()); + + cleanup(&root); +} + +#[test] +fn detected_proxy_apply_helper_ignores_plain_proxifier_install() { + let root = test_root("detected-proxifier"); + let install_dir = root.join("Proxifier"); + fs::create_dir_all(&install_dir).expect("install dir"); + fs::write(install_dir.join("Proxifier.exe"), "mock exe").expect("mock exe"); + let generated_config = root.join("generated").join("proxifyre-app-config.json"); + let host = DetectionHost::new() + .with_registry("Proxifier", &install_dir) + .with_path(&install_dir) + .with_path(&install_dir.join("Proxifier.exe")); + let helper = DetectedProxyApplyHelper::new(host); + + let result = helper + .apply_proxy_config(HelperApplyRequest { + adapter_id: "proxifyre", + config_path: &generated_config, + config_contents: r#"{"proxies":[]}"#, + }) + .expect("plain Proxifier should be ignored and config should be staged"); + + assert!(result.success); + assert!(result.changed); + assert_eq!(result.action, "proxifyre.stage-generated-config"); + assert!(result + .message + .contains("совместимая установка ProxiFyre не найдена")); + + cleanup(&root); +} + +struct MockApplyHelper; + +impl ProxyApplyHelper for MockApplyHelper { + fn apply_proxy_config( + &self, + request: HelperApplyRequest<'_>, + ) -> Result { + assert_eq!(request.adapter_id, "proxifyre"); + assert!(request.config_contents.contains("Discord")); + assert!(request.config_path.ends_with("proxifyre-app-config.json")); + + Ok(HelperApplyResult { + success: true, + changed: true, + action: "proxyfier.apply.mock".to_string(), + message: "Mock helper accepted generated ProxiFyre config".to_string(), + }) + } +} + +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> String { + "2026-07-03T00:00:00Z".to_string() + } +} + +#[derive(Default)] +struct DetectionHost { + paths: HashSet, + registry: Vec, +} + +impl DetectionHost { + fn new() -> Self { + Self::default() + } + + fn with_path(mut self, path: &Path) -> Self { + self.paths.insert(normalize_path(path)); + self + } + + fn with_registry(mut self, display_name: &str, install_location: &Path) -> Self { + self.registry.push(RegistryInstallEntry { + display_name: display_name.to_string(), + install_location: Some(install_location.to_path_buf()), + display_icon: None, + }); + self + } +} + +impl ProxyfierDetectionHost for DetectionHost { + fn env_var(&self, _name: &str) -> Option { + None + } + + fn path_exists(&self, path: &Path) -> bool { + self.paths.contains(&normalize_path(path)) + } + + fn process_running(&self, _process_name: &str) -> bool { + false + } + + fn service_running(&self, _service_name: &str) -> bool { + false + } + + fn registry_install_entries(&self) -> Vec { + self.registry.clone() + } +} + +fn normalize_path(path: &Path) -> String { + path.display() + .to_string() + .replace('/', "\\") + .to_ascii_lowercase() +} + +fn test_root(name: &str) -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + + std::env::temp_dir().join(format!("proxywarden-commands-{name}-{timestamp}")) +} + +fn cleanup(root: &Path) { + let _ = fs::remove_dir_all(root); +} + +fn discord_profile(target_id: &str) -> Profile { + Profile { + id: "discord".to_string(), + name: "Discord".to_string(), + enabled: true, + target_id: target_id.to_string(), + protocols: vec![Protocol::Tcp, Protocol::Udp], + items: vec![ProfileItem { + item_type: ProfileItemType::Process, + value: "Discord".to_string(), + recursive: false, + }], + } +} + +fn external_socks5_target() -> Target { + Target { + id: "home-gateway".to_string(), + name: "Домашний шлюз".to_string(), + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host: "192.168.50.111".to_string(), + port: 8080, + requires_component: None, + } +} + +fn local_singbox_target() -> Target { + Target { + id: "local-singbox".to_string(), + name: "Локальный sing-box".to_string(), + kind: TargetKind::Local, + protocol: ProxyProtocol::Socks5, + host: "127.0.0.1".to_string(), + port: 1080, + requires_component: Some(ComponentId::Singbox), + } +} + +fn proxyfier_running() -> ComponentStatus { + ComponentStatus { + id: ComponentId::Proxyfier, + name: "ProxiFyre".to_string(), + state: ComponentState::Running, + installed: true, + running: true, + version: Some("2.2.1".to_string()), + path: Some(r"C:\Tools\ProxiFyre".to_string()), + problems: Vec::new(), + actions: vec!["Restart".to_string()], + } +} + +fn singbox_missing() -> ComponentStatus { + ComponentStatus { + id: ComponentId::Singbox, + name: "Локальный sing-box".to_string(), + state: ComponentState::Missing, + installed: false, + running: false, + version: None, + path: None, + problems: vec!["Локальный sing-box не установлен".to_string()], + actions: vec!["Установить локальный sing-box".to_string()], + } +} diff --git a/src-tauri/tests/component_detection_tests.rs b/src-tauri/tests/component_detection_tests.rs new file mode 100644 index 0000000..4b03ce7 --- /dev/null +++ b/src-tauri/tests/component_detection_tests.rs @@ -0,0 +1,207 @@ +#[path = "../src/component_detection.rs"] +mod component_detection; +#[path = "../src/models.rs"] +mod models; + +use component_detection::{ + detect_proxyfier_install_with_host, detect_singbox_install_with_host, + proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost, + ProxyfierEngine, RegistryInstallEntry, +}; +use models::ComponentState; +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, +}; + +#[test] +fn detects_existing_proxifyre_from_registry_install_location() { + let host = MockHost::new() + .with_registry("ProxiFyre", r"C:\Tools\ProxiFyre") + .with_path(r"C:\Tools\ProxiFyre") + .with_service("ProxiFyreService"); + + let detected = detect_proxyfier_install_with_host(&host) + .expect("existing ProxiFyre install should be detected"); + + assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre); + assert_eq!(detected.install_dir, PathBuf::from(r"C:\Tools\ProxiFyre")); + assert_eq!( + detected.config_path, + Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")) + ); + assert!(detected.running); + + let component = proxyfier_component_from_detection(Some(&detected)); + assert_eq!(component.state, ComponentState::Running); + assert!(component.installed); + assert!(component.running); + assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string())); + assert!(component.problems.is_empty()); +} + +#[test] +fn ignores_plain_proxifier_install() { + let host = MockHost::new() + .with_registry("Proxifier", r"C:\Program Files\Proxifier") + .with_path(r"C:\Program Files\Proxifier") + .with_process("Proxifier.exe"); + + assert!(detect_proxyfier_install_with_host(&host).is_none()); +} + +#[test] +fn env_override_can_point_to_portable_proxifyre_install() { + let host = MockHost::new() + .with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"D:\Portable\ProxiFyre") + .with_path(r"D:\Portable\ProxiFyre\ProxiFyre.exe"); + + let detected = detect_proxyfier_install_with_host(&host) + .expect("env override should be checked before common paths"); + + assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre); + assert_eq!( + detected.executable_path, + PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe") + ); +} + +#[test] +fn missing_proxyfier_returns_install_action_status() { + let component = proxyfier_component_from_detection(None); + + assert_eq!(component.state, ComponentState::Missing); + assert!(!component.installed); + assert_eq!(component.actions, vec!["Установить ProxiFyre"]); +} + +#[test] +fn detects_running_local_singbox_from_default_install_root_and_service() { + let host = MockHost::new() + .with_path(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe") + .with_service("ProxyWardenSingBox"); + + let detected = + detect_singbox_install_with_host(&host).expect("existing sing-box should be detected"); + + assert_eq!( + detected.executable_path, + PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe") + ); + assert_eq!(detected.service_name, "ProxyWardenSingBox"); + assert!(detected.running); + + let component = singbox_component_from_detection(Some(&detected)); + assert_eq!(component.state, ComponentState::Running); + assert!(component.installed); + assert!(component.running); + assert_eq!( + component.path, + Some(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe".to_string()) + ); + assert!(component.problems.is_empty()); +} + +#[test] +fn detects_stopped_local_singbox_from_env_override() { + let host = MockHost::new() + .with_env("PROXYWARDEN_SINGBOX_ROOT", r"D:\Portable\sing-box") + .with_path(r"D:\Portable\sing-box\sing-box.exe"); + + let detected = detect_singbox_install_with_host(&host).expect("env override should be checked"); + let component = singbox_component_from_detection(Some(&detected)); + + assert_eq!( + detected.executable_path, + PathBuf::from(r"D:\Portable\sing-box\sing-box.exe") + ); + assert_eq!(component.state, ComponentState::Stopped); + assert!(component.installed); + assert!(!component.running); + assert!(component + .problems + .iter() + .any(|problem| problem.contains("остановлена"))); +} + +#[test] +fn missing_local_singbox_returns_optional_install_action_status() { + let component = singbox_component_from_detection(None); + + assert_eq!(component.state, ComponentState::Missing); + assert!(!component.installed); + assert!(!component.running); + assert_eq!(component.actions, vec!["Установить Local sing-box"]); + assert!(component.problems.is_empty()); +} + +#[derive(Default)] +struct MockHost { + env: HashMap, + paths: HashSet, + processes: HashSet, + services: HashSet, + registry: Vec, +} + +impl MockHost { + fn new() -> Self { + Self::default() + } + + fn with_env(mut self, name: &str, value: &str) -> Self { + self.env.insert(name.to_string(), value.to_string()); + self + } + + fn with_path(mut self, path: &str) -> Self { + self.paths.insert(normalize_path(path)); + self + } + + fn with_process(mut self, process: &str) -> Self { + self.processes.insert(process.to_ascii_lowercase()); + self + } + + fn with_service(mut self, service: &str) -> Self { + self.services.insert(service.to_ascii_lowercase()); + self + } + + fn with_registry(mut self, display_name: &str, install_location: &str) -> Self { + self.registry.push(RegistryInstallEntry { + display_name: display_name.to_string(), + install_location: Some(PathBuf::from(install_location)), + display_icon: None, + }); + self + } +} + +impl ProxyfierDetectionHost for MockHost { + fn env_var(&self, name: &str) -> Option { + self.env.get(name).cloned() + } + + fn path_exists(&self, path: &Path) -> bool { + self.paths + .contains(&normalize_path(&path.display().to_string())) + } + + fn process_running(&self, process_name: &str) -> bool { + self.processes.contains(&process_name.to_ascii_lowercase()) + } + + fn service_running(&self, service_name: &str) -> bool { + self.services.contains(&service_name.to_ascii_lowercase()) + } + + fn registry_install_entries(&self) -> Vec { + self.registry.clone() + } +} + +fn normalize_path(path: &str) -> String { + path.replace('/', "\\").to_ascii_lowercase() +} diff --git a/src-tauri/tests/domain_tests.rs b/src-tauri/tests/domain_tests.rs new file mode 100644 index 0000000..a06428f --- /dev/null +++ b/src-tauri/tests/domain_tests.rs @@ -0,0 +1,128 @@ +#[path = "../src/models.rs"] +mod models; +#[path = "../src/validation.rs"] +mod validation; + +use models::{ + ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, + TargetInput, TargetKind, +}; +use validation::{normalize_profile, normalize_target}; + +#[test] +fn normalizes_profile_source_items() { + let profile = normalize_profile(ProfileInput { + id: Some("Discord + Vesktop".to_string()), + name: " Discord + Vesktop ".to_string(), + enabled: true, + target_id: " home-gateway ".to_string(), + protocols: vec!["tcp".to_string(), "UDP".to_string(), "TCP".to_string()], + items: vec![ + ProfileItemInput { + item_type: "process".to_string(), + value: "Discord.exe".to_string(), + recursive: None, + }, + ProfileItemInput { + item_type: "folder".to_string(), + value: "%LOCALAPPDATA%\\Vesktop".to_string(), + recursive: Some(true), + }, + ProfileItemInput { + item_type: "exe".to_string(), + value: "C:\\Games\\Game\\game.exe".to_string(), + recursive: Some(true), + }, + ], + }) + .expect("profile should normalize"); + + assert_eq!(profile.id, "discord-vesktop"); + assert_eq!(profile.name, "Discord + Vesktop"); + assert_eq!(profile.target_id, "home-gateway"); + assert_eq!(profile.protocols, vec![Protocol::Tcp, Protocol::Udp]); + assert_eq!(profile.items[0].item_type, ProfileItemType::Process); + assert_eq!(profile.items[0].value, "Discord"); + assert!(!profile.items[0].recursive); + assert_eq!(profile.items[1].item_type, ProfileItemType::Folder); + assert!(profile.items[1].recursive); + assert_eq!(profile.items[2].item_type, ProfileItemType::Exe); + assert!(!profile.items[2].recursive); +} + +#[test] +fn rejects_unsupported_profile_protocols() { + let error = normalize_profile(ProfileInput { + id: None, + name: "Bad protocol".to_string(), + enabled: true, + target_id: "home-gateway".to_string(), + protocols: vec!["icmp".to_string()], + items: vec![ProfileItemInput { + item_type: "process".to_string(), + value: "Discord".to_string(), + recursive: None, + }], + }) + .expect_err("unsupported protocol should fail"); + + assert!(error.iter().any(|item| item.field == "protocols")); +} + +#[test] +fn normalizes_external_target_without_local_singbox() { + let target = normalize_target(TargetInput { + id: Some("Home Gateway".to_string()), + name: " Home Gateway ".to_string(), + kind: "external".to_string(), + protocol: "socks5".to_string(), + host: " 192.168.50.111 ".to_string(), + port: 8080, + requires_component: None, + }) + .expect("external target should normalize"); + + assert_eq!(target.id, "home-gateway"); + assert_eq!(target.kind, TargetKind::External); + assert_eq!(target.protocol, ProxyProtocol::Socks5); + assert_eq!(target.host, "192.168.50.111"); + assert_eq!(target.port, 8080); + assert_eq!(target.requires_component, None); +} + +#[test] +fn local_singbox_target_can_exist_before_component_is_installed() { + let target = normalize_target(TargetInput { + id: Some("local-singbox".to_string()), + name: "Local sing-box".to_string(), + kind: "local".to_string(), + protocol: "socks5".to_string(), + host: "127.0.0.1".to_string(), + port: 1080, + requires_component: Some("singbox".to_string()), + }) + .expect("local target definition should not require installed component"); + + assert_eq!(target.kind, TargetKind::Local); + assert_eq!(target.requires_component, Some(ComponentId::Singbox)); +} + +#[test] +fn rejects_malformed_target_fields() { + let error = normalize_target(TargetInput { + id: None, + name: "".to_string(), + kind: "external".to_string(), + protocol: "ftp".to_string(), + host: "".to_string(), + port: 70_000, + requires_component: Some("unknown".to_string()), + }) + .expect_err("invalid target should fail"); + + assert!(error.iter().any(|item| item.field == "name")); + assert!(error.iter().any(|item| item.field == "host")); + assert!(error.iter().any(|item| item.field == "port")); + assert!(error.iter().any(|item| item.field == "protocol")); + assert!(error.iter().any(|item| item.field == "requires_component")); +} diff --git a/src-tauri/tests/helper_tests.rs b/src-tauri/tests/helper_tests.rs new file mode 100644 index 0000000..36d1744 --- /dev/null +++ b/src-tauri/tests/helper_tests.rs @@ -0,0 +1,139 @@ +#[path = "../src/helper.rs"] +mod helper; +#[path = "../src/models.rs"] +mod models; + +use helper::{ + helper_action_requires_elevation, install_request, parse_helper_response, + proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput, + HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper, +}; +use models::ComponentId; +use serde_json::json; +use std::cell::RefCell; +use std::path::PathBuf; + +#[test] +fn structured_helper_serializes_request_and_parses_json_response() { + let runner = MockRunner { + output: HelperCommandOutput { + status_code: 0, + stdout: serde_json::to_string(&HelperResponse { + success: true, + action: HelperAction::ProxyfierApply, + changed: true, + message: "Applied".to_string(), + details: json!({ "serviceName": "ProxiFyreService" }), + }) + .expect("response json"), + stderr: String::new(), + }, + seen: RefCell::new(Vec::new()), + }; + let helper = StructuredHelper::new("proxywarden-helper.exe", runner); + + let response = helper + .execute(&proxifyre_apply_request( + r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json", + "ProxiFyreService", + )) + .expect("helper response"); + + assert!(response.success); + assert_eq!(response.action, HelperAction::ProxyfierApply); + assert_eq!(response.details["serviceName"], "ProxiFyreService"); +} + +#[test] +fn helper_runner_receives_json_stdin_and_elevation_flag() { + let runner = MockRunner { + output: HelperCommandOutput { + status_code: 0, + stdout: r#"{"success":true,"action":"service.restart","changed":true,"message":"Restarted","details":{}}"#.to_string(), + stderr: String::new(), + }, + seen: RefCell::new(Vec::new()), + }; + let helper = StructuredHelper::new("proxywarden-helper.exe", runner); + let request = service_request(ComponentId::Proxyfier, HelperAction::ServiceRestart); + + let _ = helper.execute(&request).expect("helper response"); + let seen = helper.runner().seen.borrow(); + let spec = seen.first().expect("runner should be called"); + let stdin: serde_json::Value = serde_json::from_str(&spec.stdin).expect("stdin json"); + + assert_eq!(spec.program, PathBuf::from("proxywarden-helper.exe")); + assert_eq!(spec.args, vec!["--json"]); + assert!(spec.requires_elevation); + assert_eq!(stdin["action"], "service.restart"); + assert_eq!(stdin["component"], "proxyfier"); +} + +#[test] +fn install_requests_are_explicit_component_actions() { + let control = install_request(ComponentId::ControlApp); + let proxyfier = install_request(ComponentId::Proxyfier); + let singbox = install_request(ComponentId::Singbox); + + assert_eq!(control.action, HelperAction::InstallControlApp); + assert_eq!(proxyfier.action, HelperAction::InstallProxyfier); + assert_eq!(singbox.action, HelperAction::InstallSingbox); + assert!(helper_action_requires_elevation(&proxyfier.action)); +} + +#[test] +fn apply_request_does_not_encode_installer_action() { + let request = proxifyre_apply_request( + r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json", + "ProxiFyreService", + ); + + assert_eq!(request.action, HelperAction::ProxyfierApply); + assert_eq!(request.component, Some(ComponentId::Proxyfier)); + assert_eq!( + request.payload["configPath"], + r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json" + ); +} + +#[test] +fn non_json_helper_stdout_is_rejected() { + let error = parse_helper_response("Proxyfier restarted successfully") + .expect_err("raw stdout should not be accepted"); + + assert_eq!(error.code, "helper_response_decode"); +} + +#[test] +fn failed_helper_exit_is_structured_error() { + let runner = MockRunner { + output: HelperCommandOutput { + status_code: 5, + stdout: String::new(), + stderr: "Access denied".to_string(), + }, + seen: RefCell::new(Vec::new()), + }; + let helper = StructuredHelper::new("proxywarden-helper.exe", runner); + let error = helper + .execute(&service_request( + ComponentId::Proxyfier, + HelperAction::ServiceRestart, + )) + .expect_err("failed exit should become helper error"); + + assert_eq!(error.code, "helper_exit"); + assert!(error.message.contains("Access denied")); +} + +struct MockRunner { + output: HelperCommandOutput, + seen: RefCell>, +} + +impl HelperCommandRunner for MockRunner { + fn run(&self, spec: &HelperCommandSpec) -> Result { + self.seen.borrow_mut().push(spec.clone()); + Ok(self.output.clone()) + } +} diff --git a/src-tauri/tests/proxifyre_adapter_tests.rs b/src-tauri/tests/proxifyre_adapter_tests.rs new file mode 100644 index 0000000..fe70682 --- /dev/null +++ b/src-tauri/tests/proxifyre_adapter_tests.rs @@ -0,0 +1,207 @@ +#[path = "../src/models.rs"] +mod models; +#[path = "../src/adapters/proxifyre.rs"] +mod proxifyre; +#[path = "../src/adapters/proxy_router.rs"] +mod proxy_router; + +use models::{ + ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol, + ProxyProtocol, Target, TargetKind, +}; +use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE}; +use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest}; + +#[test] +fn generates_proxifyre_config_for_discord_external_socks5_target() { + let adapter = ProxiFyreAdapter::default(); + let profiles = vec![discord_profile("home-gateway")]; + let targets = vec![external_socks5_target()]; + let components = vec![missing_singbox_component()]; + + let generated = adapter + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) + .expect("external socks5 target should not require sing-box"); + let config: ProxiFyreConfig = + serde_json::from_str(&generated.contents).expect("generated config json"); + + assert_eq!(generated.adapter_id, "proxifyre"); + assert_eq!(generated.output_file_name, PROXIFYRE_OUTPUT_FILE); + assert_eq!(generated.enabled_profiles, 1); + assert_eq!(generated.routed_apps, 1); + assert_eq!(config.log_level, "Info"); + assert!(config.bypass_lan); + assert_eq!(config.proxies.len(), 1); + assert_eq!(config.proxies[0].app_names, vec!["Discord"]); + assert_eq!( + config.proxies[0].socks5_proxy_endpoint, + "192.168.50.111:8080" + ); + assert_eq!(config.proxies[0].supported_protocols, vec!["TCP", "UDP"]); +} + +#[test] +fn skips_disabled_profiles_when_generating_proxifyre_config() { + let adapter = ProxiFyreAdapter::default(); + let mut disabled = discord_profile("home-gateway"); + disabled.enabled = false; + let profiles = vec![disabled]; + let targets = vec![external_socks5_target()]; + let components = Vec::new(); + + let generated = adapter + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) + .expect("disabled profiles should produce empty config"); + let config: ProxiFyreConfig = + serde_json::from_str(&generated.contents).expect("generated config json"); + + assert_eq!(generated.enabled_profiles, 0); + assert_eq!(generated.routed_apps, 0); + assert!(config.proxies.is_empty()); +} + +#[test] +fn includes_folder_paths_when_generating_proxifyre_config() { + let adapter = ProxiFyreAdapter::default(); + let mut profile = discord_profile("home-gateway"); + profile.items.push(ProfileItem { + item_type: ProfileItemType::Folder, + value: r"C:\Games\MyGame".to_string(), + recursive: true, + }); + let profiles = vec![profile]; + let targets = vec![external_socks5_target()]; + let components = Vec::new(); + + let generated = adapter + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) + .expect("folder paths should be accepted by ProxiFyre config generation"); + let config: ProxiFyreConfig = + serde_json::from_str(&generated.contents).expect("generated config json"); + + assert_eq!(generated.routed_apps, 2); + assert_eq!( + config.proxies[0].app_names, + vec!["Discord", r"C:\Games\MyGame"] + ); +} + +#[test] +fn blocks_local_singbox_target_when_required_component_is_missing() { + let adapter = ProxiFyreAdapter::default(); + let profiles = vec![discord_profile("local-singbox")]; + let targets = vec![local_singbox_target()]; + let components = Vec::new(); + + let error = adapter + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) + .expect_err("local sing-box target should require installed running sing-box"); + + assert_eq!(error.kind, ProxyRouterErrorKind::MissingRequiredComponent); +} + +#[test] +fn local_singbox_target_generates_when_required_component_is_running() { + let adapter = ProxiFyreAdapter::default(); + let profiles = vec![discord_profile("local-singbox")]; + let targets = vec![local_singbox_target()]; + let components = vec![running_singbox_component()]; + + let generated = adapter + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) + .expect("running sing-box should satisfy local target dependency"); + let config: ProxiFyreConfig = + serde_json::from_str(&generated.contents).expect("generated config json"); + + assert_eq!(config.proxies.len(), 1); + assert_eq!(config.proxies[0].socks5_proxy_endpoint, "127.0.0.1:1080"); +} + +#[test] +fn rejects_http_target_because_proxifyre_adapter_is_socks5_only() { + let adapter = ProxiFyreAdapter::default(); + let profiles = vec![discord_profile("office-http")]; + let targets = vec![Target { + id: "office-http".to_string(), + name: "Office HTTP".to_string(), + kind: TargetKind::External, + protocol: ProxyProtocol::Http, + host: "192.168.50.111".to_string(), + port: 3128, + requires_component: None, + }]; + let components = Vec::new(); + + let error = adapter + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) + .expect_err("ProxiFyre should reject HTTP targets"); + + assert_eq!(error.kind, ProxyRouterErrorKind::UnsupportedTargetProtocol); +} + +fn discord_profile(target_id: &str) -> Profile { + Profile { + id: "discord".to_string(), + name: "Discord".to_string(), + enabled: true, + target_id: target_id.to_string(), + protocols: vec![Protocol::Tcp, Protocol::Udp], + items: vec![ProfileItem { + item_type: ProfileItemType::Process, + value: "Discord".to_string(), + recursive: false, + }], + } +} + +fn external_socks5_target() -> Target { + Target { + id: "home-gateway".to_string(), + name: "Home Gateway".to_string(), + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host: "192.168.50.111".to_string(), + port: 8080, + requires_component: None, + } +} + +fn local_singbox_target() -> Target { + Target { + id: "local-singbox".to_string(), + name: "Local sing-box".to_string(), + kind: TargetKind::Local, + protocol: ProxyProtocol::Socks5, + host: "127.0.0.1".to_string(), + port: 1080, + requires_component: Some(ComponentId::Singbox), + } +} + +fn missing_singbox_component() -> ComponentStatus { + ComponentStatus { + id: ComponentId::Singbox, + name: "Local sing-box".to_string(), + state: ComponentState::Missing, + installed: false, + running: false, + version: None, + path: None, + problems: vec!["Local sing-box is not installed".to_string()], + actions: vec!["Install Local sing-box".to_string()], + } +} + +fn running_singbox_component() -> ComponentStatus { + ComponentStatus { + id: ComponentId::Singbox, + name: "Local sing-box".to_string(), + state: ComponentState::Running, + installed: true, + running: true, + version: Some("1.11.0".to_string()), + path: Some(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe".to_string()), + problems: Vec::new(), + actions: vec!["Restart".to_string(), "Stop".to_string()], + } +} diff --git a/src-tauri/tests/singbox_adapter_tests.rs b/src-tauri/tests/singbox_adapter_tests.rs new file mode 100644 index 0000000..5c7fcfb --- /dev/null +++ b/src-tauri/tests/singbox_adapter_tests.rs @@ -0,0 +1,294 @@ +#[path = "../src/models.rs"] +mod models; +#[path = "../src/adapters/proxifyre.rs"] +mod proxifyre; +#[path = "../src/adapters/proxy_router.rs"] +mod proxy_router; +#[path = "../src/adapters/singbox.rs"] +mod singbox; + +use models::{ + ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem, + ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, + TargetKind, +}; +use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig}; +use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest}; +use singbox::{ + SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError, + SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG, + SINGBOX_OUTPUT_FILE, +}; +use std::{ + cell::RefCell, + path::{Path, PathBuf}, +}; + +#[test] +fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplied() { + let adapter = SingBoxAdapter::default(); + let config = local_singbox_config("nl-1"); + let cache = subscription_cache(); + let checker = RecordingChecker::ok("configuration OK"); + let binary_path = Path::new(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe"); + + let generated = adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, Some(binary_path)), + &checker, + ) + .expect("selected outbound should generate config"); + let generated_config: serde_json::Value = + serde_json::from_str(&generated.contents).expect("generated sing-box json"); + + assert_eq!(generated.adapter_id, "singbox"); + assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE); + assert_eq!(generated.selected_server_tag, "nl-1"); + assert_eq!(generated.listen, "127.0.0.1"); + assert_eq!(generated.listen_port, 1080); + assert_eq!( + generated.check, + Some(SingBoxCheckResult { + checked: true, + success: true, + message: "configuration OK".to_string(), + }) + ); + assert_eq!(generated_config["log"]["level"], "info"); + assert_eq!(generated_config["inbounds"][0]["type"], "mixed"); + assert_eq!(generated_config["inbounds"][0]["listen"], "127.0.0.1"); + assert_eq!(generated_config["inbounds"][0]["listen_port"], 1080); + assert_eq!(generated_config["inbounds"][0]["set_system_proxy"], false); + assert_eq!(generated_config["outbounds"][0]["type"], "vless"); + assert_eq!( + generated_config["outbounds"][0]["tag"], + DEFAULT_VPN_OUTBOUND_TAG + ); + assert_eq!( + generated_config["outbounds"][0]["server"], + "nl.example.test" + ); + assert_eq!(generated_config["outbounds"][0]["packet_encoding"], "xudp"); + assert_eq!(generated_config["route"]["final"], DEFAULT_VPN_OUTBOUND_TAG); + let calls = checker.calls.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0.as_path(), binary_path); + assert!(calls[0].1.contains(r#""type": "mixed""#)); + assert!(calls[0].1.contains(r#""tag": "vpn""#)); +} + +#[test] +fn skips_singbox_check_when_binary_path_is_not_supplied() { + let adapter = SingBoxAdapter::default(); + let config = local_singbox_config("nl-1"); + let cache = subscription_cache(); + let checker = RecordingChecker::ok("should not run"); + + let generated = adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, None), + &checker, + ) + .expect("binary path is optional"); + + assert_eq!(generated.check, None); + assert!(checker.calls.borrow().is_empty()); +} + +#[test] +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; + let cache = subscription_cache(); + let checker = RecordingChecker::ok("should not run"); + + let error = adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, None), + &checker, + ) + .expect_err("missing selected server should block config"); + + assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedServer); + assert!(checker.calls.borrow().is_empty()); +} + +#[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 checker = RecordingChecker::ok("should not run"); + + let error = adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, None), + &checker, + ) + .expect_err("missing outbound should block config"); + + assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound); + assert!(error.message.contains("missing-server")); + assert!(checker.calls.borrow().is_empty()); +} + +#[test] +fn propagates_failed_singbox_check_as_structured_error() { + let adapter = SingBoxAdapter::default(); + let config = local_singbox_config("nl-1"); + let cache = subscription_cache(); + let checker = RecordingChecker::err("invalid config"); + + let error = adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, Some(Path::new("sing-box.exe"))), + &checker, + ) + .expect_err("failed sing-box check should block generated config"); + + assert_eq!(error.kind, SingBoxConfigErrorKind::CheckFailed); + assert!(error.message.contains("invalid config")); +} + +#[test] +fn external_proxifyre_apply_does_not_require_singbox_component() { + let adapter = ProxiFyreAdapter::default(); + let profiles = vec![discord_profile("home-gateway")]; + let targets = vec![external_socks5_target()]; + let components = vec![missing_singbox_component()]; + + let generated = adapter + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) + .expect("external SOCKS5 target should not require local sing-box"); + let config: ProxiFyreConfig = + serde_json::from_str(&generated.contents).expect("generated proxifyre json"); + + assert_eq!(config.proxies.len(), 1); + assert_eq!( + config.proxies[0].socks5_proxy_endpoint, + "192.168.50.111:8080" + ); +} + +struct RecordingChecker { + calls: RefCell>, + result: Result, +} + +impl RecordingChecker { + fn ok(message: &str) -> Self { + Self { + calls: RefCell::new(Vec::new()), + result: Ok(SingBoxCheckResult { + checked: true, + success: true, + message: message.to_string(), + }), + } + } + + fn err(message: &str) -> Self { + Self { + calls: RefCell::new(Vec::new()), + result: Err(SingBoxConfigError::new( + SingBoxConfigErrorKind::CheckFailed, + message, + )), + } + } +} + +impl SingBoxConfigChecker for RecordingChecker { + fn check_config( + &self, + binary_path: &Path, + config_json: &str, + ) -> Result { + self.calls + .borrow_mut() + .push((binary_path.to_path_buf(), config_json.to_string())); + self.result.clone() + } +} + +fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig { + LocalSingBoxConfig { + subscription_url: Some("https://sub.example.test/list".to_string()), + selected_server_tag: Some(selected_server_tag.to_string()), + listen_host: "127.0.0.1".to_string(), + listen_port: 1080, + service_name: "ProxyWardenSingBox".to_string(), + install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(), + updated_at: Some("2026-07-07T10:00:00Z".to_string()), + } +} + +fn subscription_cache() -> SubscriptionCache { + SubscriptionCache { + config: serde_json::json!({ + "outbounds": [ + { + "type": "vless", + "tag": "nl-1", + "server": "nl.example.test", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111" + }, + { + "type": "direct", + "tag": "direct" + } + ] + }), + servers: vec![SubscriptionServer { + tag: "nl-1".to_string(), + server_type: "vless".to_string(), + server: "nl.example.test".to_string(), + server_port: 443, + }], + user_info: serde_json::Map::new(), + fetched_at: "2026-07-07T10:00:00Z".to_string(), + } +} + +fn discord_profile(target_id: &str) -> Profile { + Profile { + id: "discord".to_string(), + name: "Discord".to_string(), + enabled: true, + target_id: target_id.to_string(), + protocols: vec![Protocol::Tcp, Protocol::Udp], + items: vec![ProfileItem { + item_type: ProfileItemType::Process, + value: "Discord".to_string(), + recursive: false, + }], + } +} + +fn external_socks5_target() -> Target { + Target { + id: "home-gateway".to_string(), + name: "Home Gateway".to_string(), + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host: "192.168.50.111".to_string(), + port: 8080, + requires_component: None, + } +} + +fn missing_singbox_component() -> ComponentStatus { + ComponentStatus { + id: ComponentId::Singbox, + name: "Local sing-box".to_string(), + state: ComponentState::Missing, + installed: false, + running: false, + version: None, + path: None, + problems: vec!["Local sing-box is not installed".to_string()], + actions: vec!["Install Local sing-box".to_string()], + } +} diff --git a/src-tauri/tests/singbox_command_tests.rs b/src-tauri/tests/singbox_command_tests.rs new file mode 100644 index 0000000..4a7eab3 --- /dev/null +++ b/src-tauri/tests/singbox_command_tests.rs @@ -0,0 +1,397 @@ +#[path = "../src/activity.rs"] +mod activity; +#[path = "../src/commands.rs"] +mod commands; +#[path = "../src/component_detection.rs"] +mod component_detection; +#[path = "../src/models.rs"] +mod models; +#[path = "../src/adapters/proxifyre.rs"] +mod proxifyre; +#[path = "../src/adapters/proxy_router.rs"] +mod proxy_router; +#[path = "../src/adapters/singbox.rs"] +mod singbox; +#[path = "../src/singbox_service.rs"] +mod singbox_service; +#[path = "../src/storage.rs"] +mod storage; +#[path = "../src/subscription.rs"] +mod subscription; +#[path = "../src/validation.rs"] +mod validation; + +use commands::{ + fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage, + generate_singbox_config_with_services, save_singbox_subscription_to_storage, + select_singbox_server_in_storage, Clock, SaveSingBoxSubscriptionInputDto, + SelectSingBoxServerInputDto, SubscriptionFetcher, +}; +use models::{ + ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache, + SubscriptionServer, TargetKind, +}; +use serde_json::{json, Map}; +use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use storage::JsonStorage; + +#[test] +fn saves_subscription_url_without_exposing_secret_query() { + let root = test_root("save-subscription"); + let storage = JsonStorage::new(root.clone()); + + let status = save_singbox_subscription_to_storage( + &storage, + SaveSingBoxSubscriptionInputDto { + subscription_url: " https://sub.example.test/path?token=secret ".to_string(), + }, + &FixedClock, + ) + .expect("subscription URL should be saved"); + let config = storage + .read_local_singbox_config() + .expect("read local sing-box config"); + + assert_eq!( + config.subscription_url, + Some("https://sub.example.test/path?token=secret".to_string()) + ); + assert!(status.config.has_subscription); + assert_eq!( + status.config.subscription_display_url, + Some("https://sub.example.test/...".to_string()) + ); + + cleanup(&root); +} + +#[test] +fn rejects_non_http_subscription_url() { + let root = test_root("invalid-subscription"); + let storage = JsonStorage::new(root.clone()); + + let error = save_singbox_subscription_to_storage( + &storage, + SaveSingBoxSubscriptionInputDto { + subscription_url: "file:///C:/sub.txt".to_string(), + }, + &FixedClock, + ) + .expect_err("non-http subscription URL should fail"); + + assert_eq!(error.code, "singbox_subscription_url_invalid"); + + cleanup(&root); +} + +#[test] +fn fetches_subscription_cache_and_selects_first_server() { + let root = test_root("fetch-subscription"); + let storage = JsonStorage::new(root.clone()); + save_singbox_subscription_to_storage( + &storage, + SaveSingBoxSubscriptionInputDto { + subscription_url: "https://sub.example.test/path?token=secret".to_string(), + }, + &FixedClock, + ) + .expect("save subscription URL"); + + let status = fetch_singbox_subscription_with_fetcher( + &storage, + &MockFetcher(sample_cache()), + &FixedClock, + ) + .expect("fetch subscription through mock"); + let config = storage + .read_local_singbox_config() + .expect("read local sing-box config"); + let cache = storage + .read_singbox_subscription_cache() + .expect("read cache") + .expect("cache should exist"); + let activity = storage.read_activity().expect("read activity"); + + assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string())); + assert_eq!(status.cache.expect("status cache").servers.len(), 2); + assert_eq!(config.selected_server_tag, Some("nl-1".to_string())); + assert_eq!(cache.servers.len(), 2); + assert_eq!(activity[0].level, ActivityLevel::Success); + assert_eq!(activity[0].title, "Подписка Local sing-box обновлена"); + + cleanup(&root); +} + +#[test] +fn selects_server_from_cached_subscription() { + let root = test_root("select-server"); + let storage = JsonStorage::new(root.clone()); + storage + .write_singbox_subscription_cache(&sample_cache()) + .expect("write cache"); + + let status = select_singbox_server_in_storage( + &storage, + SelectSingBoxServerInputDto { + tag: "de-1".to_string(), + server: None, + server_port: None, + }, + &FixedClock, + ) + .expect("server should be selected"); + let config = storage + .read_local_singbox_config() + .expect("read local sing-box config"); + + assert_eq!(status.config.selected_server_tag, Some("de-1".to_string())); + assert_eq!(config.selected_server_tag, Some("de-1".to_string())); + + cleanup(&root); +} + +#[test] +fn selects_server_by_endpoint_when_display_tag_is_sanitized() { + let root = test_root("select-server-sanitized-tag"); + let storage = JsonStorage::new(root.clone()); + storage + .write_singbox_subscription_cache(&sample_cache_with_flag_tag()) + .expect("write cache"); + + let status = select_singbox_server_in_storage( + &storage, + SelectSingBoxServerInputDto { + tag: "Умный".to_string(), + server: Some("media.example.test".to_string()), + server_port: Some(443), + }, + &FixedClock, + ) + .expect("server should be selected by endpoint fallback"); + let config = storage + .read_local_singbox_config() + .expect("read local sing-box config"); + + assert_eq!( + status.config.selected_server_tag, + Some("Умный 🇳🇱->🇷🇺".to_string()) + ); + assert_eq!(config.selected_server_tag, Some("Умный 🇳🇱->🇷🇺".to_string())); + + cleanup(&root); +} + +#[test] +fn generate_writes_config_and_local_singbox_target() { + let root = test_root("generate-config"); + let storage = JsonStorage::new(root.clone()); + storage + .write_local_singbox_config(&LocalSingBoxConfig { + subscription_url: Some("https://sub.example.test/path".to_string()), + selected_server_tag: Some("nl-1".to_string()), + ..LocalSingBoxConfig::default() + }) + .expect("write local sing-box config"); + storage + .write_singbox_subscription_cache(&sample_cache()) + .expect("write cache"); + + let response = generate_singbox_config_with_services( + &storage, + &SingBoxAdapter::default(), + &MockChecker, + &FixedClock, + Some(Path::new("sing-box.exe")), + ) + .expect("generate sing-box config"); + let generated = fs::read_to_string(&response.generated_config_path).expect("read generated"); + let targets = storage.read_targets().expect("read targets"); + let target = targets + .iter() + .find(|target| target.id == "local-singbox") + .expect("local sing-box target"); + let activity = storage.read_activity().expect("read activity"); + + assert!(response.success); + assert_eq!(response.adapter_id, "singbox"); + assert_eq!(response.selected_server_tag, "nl-1"); + assert!(response.check.expect("check result").success); + assert!(generated.contains("\"type\": \"mixed\"")); + assert!(generated.contains("\"tag\": \"vpn\"")); + assert_eq!(target.kind, TargetKind::Local); + assert_eq!(target.protocol, ProxyProtocol::Socks5); + assert_eq!(target.host, "127.0.0.1"); + assert_eq!(target.port, 1080); + assert_eq!(target.requires_component, Some(ComponentId::Singbox)); + assert_eq!(activity[0].title, "Конфиг Local sing-box создан"); + + cleanup(&root); +} + +#[test] +fn generate_requires_cached_subscription() { + let root = test_root("generate-missing-cache"); + let storage = JsonStorage::new(root.clone()); + + let error = generate_singbox_config_with_services( + &storage, + &SingBoxAdapter::default(), + &MockChecker, + &FixedClock, + None, + ) + .expect_err("missing cache should block generation"); + + assert_eq!(error.code, "singbox_subscription_cache_missing"); + + cleanup(&root); +} + +#[test] +fn forget_subscription_clears_url_selection_and_cache() { + let root = test_root("forget-subscription"); + let storage = JsonStorage::new(root.clone()); + storage + .write_local_singbox_config(&LocalSingBoxConfig { + subscription_url: Some("https://sub.example.test/path".to_string()), + selected_server_tag: Some("nl-1".to_string()), + ..LocalSingBoxConfig::default() + }) + .expect("write local sing-box config"); + storage + .write_singbox_subscription_cache(&sample_cache()) + .expect("write cache"); + + let status = + forget_singbox_subscription_in_storage(&storage, &FixedClock).expect("forget subscription"); + let config = storage + .read_local_singbox_config() + .expect("read local sing-box config"); + let cache = storage + .read_singbox_subscription_cache() + .expect("read cache"); + + assert!(!status.config.has_subscription); + assert_eq!(config.subscription_url, None); + assert_eq!(config.selected_server_tag, None); + assert_eq!(cache, None); + + cleanup(&root); +} + +struct MockFetcher(SubscriptionCache); + +impl SubscriptionFetcher for MockFetcher { + fn fetch_subscription( + &self, + url: &str, + ) -> Result { + assert_eq!(url, "https://sub.example.test/path?token=secret"); + Ok(self.0.clone()) + } +} + +struct MockChecker; + +impl SingBoxConfigChecker for MockChecker { + fn check_config( + &self, + binary_path: &Path, + config_json: &str, + ) -> Result { + assert_eq!(binary_path, Path::new("sing-box.exe")); + assert!(config_json.contains("\"final\": \"vpn\"")); + Ok(SingBoxCheckResult { + checked: true, + success: true, + message: "mock check passed".to_string(), + }) + } +} + +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> String { + "2026-07-07T00:00:00Z".to_string() + } +} + +fn sample_cache() -> SubscriptionCache { + SubscriptionCache { + config: json!({ + "outbounds": [ + { + "type": "vless", + "tag": "nl-1", + "server": "nl.example.test", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111" + }, + { + "type": "trojan", + "tag": "de-1", + "server": "de.example.test", + "server_port": 443, + "password": "secret" + } + ] + }), + servers: vec![ + SubscriptionServer { + tag: "nl-1".to_string(), + server_type: "vless".to_string(), + server: "nl.example.test".to_string(), + server_port: 443, + }, + SubscriptionServer { + tag: "de-1".to_string(), + server_type: "trojan".to_string(), + server: "de.example.test".to_string(), + server_port: 443, + }, + ], + user_info: Map::new(), + fetched_at: "2026-07-07T00:00:00Z".to_string(), + } +} + +fn sample_cache_with_flag_tag() -> SubscriptionCache { + SubscriptionCache { + config: json!({ + "outbounds": [ + { + "type": "vless", + "tag": "Умный 🇳🇱->🇷🇺", + "server": "media.example.test", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111" + } + ] + }), + servers: vec![SubscriptionServer { + tag: "Умный 🇳🇱->🇷🇺".to_string(), + server_type: "vless".to_string(), + server: "media.example.test".to_string(), + server_port: 443, + }], + user_info: Map::new(), + fetched_at: "2026-07-07T00:00:00Z".to_string(), + } +} + +fn test_root(name: &str) -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + + std::env::temp_dir().join(format!("proxywarden-singbox-commands-{name}-{timestamp}")) +} + +fn cleanup(root: &Path) { + let _ = fs::remove_dir_all(root); +} diff --git a/src-tauri/tests/singbox_service_tests.rs b/src-tauri/tests/singbox_service_tests.rs new file mode 100644 index 0000000..ca033a0 --- /dev/null +++ b/src-tauri/tests/singbox_service_tests.rs @@ -0,0 +1,128 @@ +#[path = "../src/component_detection.rs"] +mod component_detection; +#[path = "../src/models.rs"] +mod models; +#[path = "../src/singbox_service.rs"] +mod singbox_service; + +use component_detection::DetectedSingBox; +use singbox_service::{ + build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output, + service_control_script, SingBoxServiceAction, +}; +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::process::Command as ProcessCommand; + +#[test] +fn setup_status_reports_missing_items_when_singbox_is_absent() { + let status = build_singbox_setup_status(None); + + assert!(!status.ready); + assert_eq!(status.missing_count, 3); + assert_eq!(status.items[0].id, "sing-box-binary"); + assert!(status.items[0].details.contains("SagerNet/sing-box")); + assert_eq!(status.items[1].id, "winsw-wrapper"); + assert!(status.items[1].details.contains("winsw/winsw")); + assert_eq!(status.items[2].id, "windows-service"); +} + +#[test] +fn setup_status_reports_ready_when_binary_wrapper_and_service_exist() { + let detected = detected_singbox(true, true, true); + let status = build_singbox_setup_status(Some(&detected)); + + assert!(status.ready); + assert_eq!(status.missing_count, 0); + assert!(status.items.iter().all(|item| item.installed)); +} + +#[test] +fn parses_last_json_service_command_output_line() { + let output = br#" +noise +{"success":true,"code":"started","serviceName":"ProxyWardenSingBox","status":"Running","processId":42} +"#; + let parsed = parse_service_command_output(output).expect("service json should parse"); + + assert!(parsed.success); + assert_eq!(parsed.code, "started"); + assert_eq!(parsed.service_name, Some("ProxyWardenSingBox".to_string())); + assert_eq!(parsed.status, Some("Running".to_string())); + assert_eq!(parsed.process_id, Some(42)); +} + +#[test] +fn safe_install_dir_allows_only_proxywarden_singbox_folder() { + assert!( + ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box")).is_ok() + ); + assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err()); + assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err()); +} + +#[test] +fn service_control_script_targets_named_service_and_action() { + let script = service_control_script(SingBoxServiceAction::Start, "ProxyWardenSingBox", None, None); + + assert!(script.contains("$serviceName = 'ProxyWardenSingBox'")); + assert!(script.contains("$action = 'start'")); + assert!(script.contains("ConvertTo-Json -Compress")); +} + +#[test] +fn service_control_script_syncs_generated_config_before_start() { + let source = Path::new(r"C:\ProgramData\ProxyWarden\generated\sing-box-config.json"); + let target = Path::new(r"C:\Program Files\ProxyWarden\sing-box\config.json"); + let script = service_control_script( + SingBoxServiceAction::Start, + "ProxyWardenSingBox", + Some(source), + Some(target), + ); + + assert!(script.contains( + "$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'" + )); + assert!(script.contains( + "$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'" + )); + assert!(script.contains("Copy-Item -LiteralPath $configSource")); + assert!(script.contains("'config_sync_failed'")); +} + +#[test] +#[cfg(windows)] +fn install_singbox_script_parses_as_powershell() { + let script_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("scripts") + .join("install-singbox.ps1"); + let escaped_path = script_path.display().to_string().replace('\'', "''"); + let parser = format!( + "$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}" + ); + let output = ProcessCommand::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &parser]) + .output() + .expect("powershell parser should run"); + + assert!( + output.status.success(), + "install-singbox.ps1 should parse\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox { + DetectedSingBox { + install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"), + executable_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe"), + wrapper_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe"), + binary_exists, + wrapper_exists, + running, + service_name: "ProxyWardenSingBox".to_string(), + } +} diff --git a/src-tauri/tests/storage_tests.rs b/src-tauri/tests/storage_tests.rs new file mode 100644 index 0000000..d51be5f --- /dev/null +++ b/src-tauri/tests/storage_tests.rs @@ -0,0 +1,302 @@ +#[path = "../src/activity.rs"] +mod activity; +#[path = "../src/models.rs"] +mod models; +#[path = "../src/storage.rs"] +mod storage; + +use models::{ + ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, + Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, + SubscriptionServer, Target, TargetKind, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use storage::{backup_path, default_config_root, JsonStorage, StoragePaths}; + +#[test] +fn storage_defaults_to_programdata_root() { + let expected = PathBuf::from(r"C:\ProgramData\ProxyWarden"); + + assert_eq!(default_config_root(), expected); + assert_eq!(StoragePaths::default().root, expected); +} + +#[test] +fn roundtrips_profiles_targets_components_and_activity() { + let root = test_root("roundtrip"); + let storage = JsonStorage::new(root.clone()); + + let profiles = vec![sample_profile("discord")]; + let targets = vec![sample_target("home-gateway")]; + let components = vec![sample_component()]; + let activity = vec![sample_activity( + "created", + "2026-01-01T10:00:00Z", + ActivityLevel::Success, + )]; + + storage.write_profiles(&profiles).expect("write profiles"); + storage.write_targets(&targets).expect("write targets"); + storage + .write_components(&components) + .expect("write components"); + storage.write_activity(&activity).expect("write activity"); + + assert_eq!(storage.read_profiles().expect("read profiles"), profiles); + assert_eq!(storage.read_targets().expect("read targets"), targets); + assert_eq!( + storage.read_components().expect("read components"), + components + ); + assert_eq!(storage.read_activity().expect("read activity"), activity); + + cleanup(&root); +} + +#[test] +fn roundtrips_local_singbox_config_and_subscription_cache() { + let root = test_root("local-singbox"); + let storage = JsonStorage::new(root.clone()); + let config = LocalSingBoxConfig { + subscription_url: Some("https://sub.example.test/path?token=secret".to_string()), + selected_server_tag: Some("nl-1".to_string()), + listen_host: "127.0.0.1".to_string(), + listen_port: 1080, + service_name: "ProxyWardenSingBox".to_string(), + install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(), + updated_at: Some("2026-07-07T10:00:00Z".to_string()), + }; + let cache = sample_subscription_cache(); + + storage + .write_local_singbox_config(&config) + .expect("write local sing-box config"); + storage + .write_singbox_subscription_cache(&cache) + .expect("write subscription cache"); + + assert_eq!( + storage + .read_local_singbox_config() + .expect("read local sing-box config"), + config + ); + assert_eq!( + storage + .read_singbox_subscription_cache() + .expect("read subscription cache"), + Some(cache) + ); + assert_eq!( + config.subscription_display_url(), + Some("https://sub.example.test/...".to_string()) + ); + + cleanup(&root); +} + +#[test] +fn missing_local_singbox_config_defaults_to_optional_empty_state() { + let root = test_root("local-singbox-default"); + let storage = JsonStorage::new(root.clone()); + let config = storage + .read_local_singbox_config() + .expect("read default local sing-box config"); + + assert_eq!(config.subscription_url, None); + assert_eq!(config.selected_server_tag, None); + assert_eq!(config.listen_host, "127.0.0.1"); + assert_eq!(config.listen_port, 1080); + assert_eq!(config.service_name, "ProxyWardenSingBox"); + + cleanup(&root); +} + +#[test] +fn invalid_subscription_cache_falls_back_to_none() { + let root = test_root("invalid-subscription-cache"); + let storage = JsonStorage::new(root.clone()); + storage.ensure_dirs().expect("create storage dirs"); + fs::write( + &storage.paths().singbox_subscription_cache_file, + "{not valid json", + ) + .expect("write invalid cache"); + + assert_eq!( + storage + .read_singbox_subscription_cache() + .expect("invalid cache fallback"), + None + ); + + cleanup(&root); +} + +#[test] +fn invalid_json_falls_back_to_empty_collection() { + let root = test_root("invalid-json"); + let storage = JsonStorage::new(root.clone()); + storage.ensure_dirs().expect("create storage dirs"); + fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json"); + + assert_eq!( + storage.read_profiles().expect("invalid profiles fallback"), + Vec::::new() + ); + + cleanup(&root); +} + +#[test] +fn write_creates_backup_before_overwriting_source_file() { + let root = test_root("backup"); + let storage = JsonStorage::new(root.clone()); + let first = vec![sample_profile("first")]; + let second = vec![sample_profile("second")]; + + storage.write_profiles(&first).expect("first write"); + storage.write_profiles(&second).expect("second write"); + + let backup = backup_path(&storage.paths().profiles_file); + assert!(backup.exists(), "backup file should exist"); + + let backup_contents = fs::read_to_string(backup).expect("read backup"); + let backup_profiles: Vec = + serde_json::from_str(&backup_contents).expect("backup json"); + + assert_eq!(backup_profiles, first); + assert_eq!(storage.read_profiles().expect("current profiles"), second); + + cleanup(&root); +} + +#[test] +fn activity_entries_are_sorted_and_capped() { + let root = test_root("activity"); + let storage = JsonStorage::with_activity_limit(root.clone(), 2); + + storage + .append_activity(sample_activity( + "old", + "2026-01-01T10:00:00Z", + ActivityLevel::Info, + )) + .expect("append old"); + storage + .append_activity(sample_activity( + "new", + "2026-01-03T10:00:00Z", + ActivityLevel::Success, + )) + .expect("append new"); + storage + .append_activity(sample_activity( + "middle", + "2026-01-02T10:00:00Z", + ActivityLevel::Warning, + )) + .expect("append middle"); + + let entries = storage.read_activity().expect("read capped activity"); + + assert_eq!(entries.len(), 2); + assert_eq!( + entries + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + vec!["new", "middle"] + ); + + cleanup(&root); +} + +fn test_root(name: &str) -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + + std::env::temp_dir().join(format!("proxywarden-storage-{name}-{timestamp}")) +} + +fn cleanup(root: &Path) { + let _ = fs::remove_dir_all(root); +} + +fn sample_profile(id: &str) -> Profile { + Profile { + id: id.to_string(), + name: format!("Profile {id}"), + enabled: true, + target_id: "home-gateway".to_string(), + protocols: vec![Protocol::Tcp, Protocol::Udp], + items: vec![ProfileItem { + item_type: ProfileItemType::Process, + value: "Discord".to_string(), + recursive: false, + }], + } +} + +fn sample_target(id: &str) -> Target { + Target { + id: id.to_string(), + name: "Home Gateway".to_string(), + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host: "192.168.50.111".to_string(), + port: 8080, + requires_component: None, + } +} + +fn sample_component() -> ComponentStatus { + ComponentStatus { + id: ComponentId::Proxyfier, + name: "ProxiFyre".to_string(), + state: ComponentState::Missing, + installed: false, + running: false, + version: None, + path: None, + problems: vec!["ProxiFyre не установлен".to_string()], + actions: vec!["Установить ProxiFyre".to_string()], + } +} + +fn sample_subscription_cache() -> SubscriptionCache { + SubscriptionCache { + config: serde_json::json!({ + "outbounds": [ + { + "type": "vless", + "tag": "nl-1", + "server": "nl.example.test", + "server_port": 443 + } + ] + }), + servers: vec![SubscriptionServer { + tag: "nl-1".to_string(), + server_type: "vless".to_string(), + server: "nl.example.test".to_string(), + server_port: 443, + }], + user_info: serde_json::Map::new(), + fetched_at: "2026-07-07T10:00:00Z".to_string(), + } +} + +fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry { + ActivityEntry { + id: id.to_string(), + at: at.to_string(), + level, + title: format!("Activity {id}"), + message: "Storage test activity".to_string(), + } +} diff --git a/src-tauri/tests/subscription_tests.rs b/src-tauri/tests/subscription_tests.rs new file mode 100644 index 0000000..3ee69b5 --- /dev/null +++ b/src-tauri/tests/subscription_tests.rs @@ -0,0 +1,99 @@ +#[path = "../src/models.rs"] +mod models; +#[path = "../src/subscription.rs"] +mod subscription; + +use base64::{engine::general_purpose, Engine}; +use models::redact_subscription_url; +use subscription::{parse_subscription_body, parse_user_info}; + +#[test] +fn parses_singbox_json_config_servers() { + let parsed = parse_subscription_body( + r#"{ + "outbounds": [ + { "type": "direct", "tag": "direct" }, + { "type": "vless", "tag": "nl-1", "server": "nl.example.test", "server_port": 443 }, + { "type": "trojan", "tag": "de-1", "server": "de.example.test", "server_port": 8443 } + ] + }"#, + ) + .expect("json subscription should parse"); + + assert_eq!(parsed.servers.len(), 2); + assert_eq!(parsed.servers[0].tag, "nl-1"); + assert_eq!(parsed.servers[0].server_type, "vless"); + assert_eq!(parsed.servers[1].server_port, 8443); +} + +#[test] +fn parses_base64_vless_link_list() { + let link = sample_vless_link("nl-1"); + let encoded = general_purpose::STANDARD.encode(format!("{link}\n")); + + let parsed = parse_subscription_body(&encoded).expect("base64 vless list should parse"); + let outbound = &parsed.config["outbounds"][0]; + + assert_eq!(parsed.servers.len(), 1); + assert_eq!(parsed.servers[0].tag, "nl-1"); + assert_eq!(outbound["type"], "vless"); + assert_eq!(outbound["server"], "nl.example.test"); + assert_eq!(outbound["packet_encoding"], "xudp"); +} + +#[test] +fn rejects_body_without_supported_outbounds() { + let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#) + .expect_err("unsupported subscription should fail"); + + assert!(error.message.contains("No supported proxy outbounds found")); +} + +#[test] +fn rejects_vless_without_reality_parameters() { + let error = parse_subscription_body("vless://uuid@nl.example.test:443#nl-1") + .expect_err("missing reality params should fail"); + + assert!(error.message.contains("pbk and sid")); +} + +#[test] +fn parses_subscription_user_info_header() { + let user_info = parse_user_info(Some("upload=10; download=20; total=30; expire=bad")); + + assert_eq!(user_info["upload"], 10); + assert_eq!(user_info["download"], 20); + assert_eq!(user_info["total"], 30); + assert!(!user_info.contains_key("expire")); +} + +#[test] +fn rejects_invalid_or_non_http_subscription_url_before_network() { + let invalid = subscription::fetch_subscription("not a url") + .expect_err("invalid url should fail before request"); + let unsupported = subscription::fetch_subscription("file:///C:/subscription.txt") + .expect_err("non-http url should fail before request"); + + assert!(invalid.message.contains("Invalid subscription URL")); + assert!(unsupported.message.contains("http or https")); +} + +#[test] +fn redacts_subscription_url_for_display() { + assert_eq!( + redact_subscription_url("https://sub.example.test/path?token=secret"), + "https://sub.example.test/..." + ); + assert_eq!( + redact_subscription_url("vless://uuid@example.test"), + "vless://uuid@example.test/..." + ); +} + +fn sample_vless_link(tag: &str) -> String { + format!( + "vless://{}@nl.example.test:443?security=reality&sni=example.test&fp=chrome&pbk=public-key&sid=short-id&flow=xtls-rprx-vision#{}", + "11111111-1111-1111-1111-111111111111", + tag + ) +} diff --git a/src/api/tauriCommands.ts b/src/api/tauriCommands.ts new file mode 100644 index 0000000..ab2bf7b --- /dev/null +++ b/src/api/tauriCommands.ts @@ -0,0 +1,235 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { + ActivityEntry, + ComponentStatus, + LocalSingBoxConfig, + Profile, + ProfileInput, + SubscriptionCache, + SubscriptionServer, + Target, + TargetInput, +} from '../domain/types'; + +export interface CommandError { + code: string; + message: string; + details?: Array<{ + field: string; + message: string; + }>; +} + +export interface StatusResponse { + routeLine: string; + activeProfileCount: number; + routedAppCount: number; + activeTarget?: Target; + components: ComponentStatus[]; + recentActivity: ActivityEntry[]; + generatedConfigPath: string; +} + +export interface SavedStateResponse { + profiles: Profile[]; + targets: Target[]; + generatedConfigPath: string; +} + +export interface ProxiFyreSetupItem { + id: string; + name: string; + installed: boolean; + version?: string; + details: string; +} + +export interface ProxiFyreSetupStatus { + ready: boolean; + missingCount: number; + items: ProxiFyreSetupItem[]; +} + +export type SingBoxSetupItem = ProxiFyreSetupItem; + +export interface SingBoxSetupStatus { + ready: boolean; + missingCount: number; + items: SingBoxSetupItem[]; +} + +export interface LocalSingBoxStatusResponse { + config: LocalSingBoxConfig; + cache?: SubscriptionCache; + component: ComponentStatus; + generatedConfigPath: string; + lanListenHost?: string; +} + +export interface PingServerResponse { + tag: string; + server: string; + serverPort: number; + ok: boolean; + latency?: number; + error?: string; +} + +export interface GenerateSingBoxConfigResponse { + success: boolean; + message: string; + adapterId: string; + generatedConfigPath: string; + selectedServerTag: string; + listenHost: string; + listenPort: number; + check?: { + checked: boolean; + success: boolean; + message: string; + }; + 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 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 }); +} + +export function getComponents(): Promise { + return invoke('get_components'); +} + +export function getProxiFyreSetupStatus(): Promise { + return invoke('get_proxifyre_setup_status'); +} + +export function getSingBoxStatus(): Promise { + return invoke('get_singbox_status'); +} + +export function getSingBoxSetupStatus(): Promise { + return invoke('get_singbox_setup_status'); +} + +export function saveSingBoxSubscription(subscriptionUrl: string): Promise { + return invoke('save_singbox_subscription', { + input: { subscriptionUrl }, + }); +} + +export function fetchSingBoxSubscription(): Promise { + return invoke('fetch_singbox_subscription'); +} + +export function forgetSingBoxSubscription(): Promise { + return invoke('forget_singbox_subscription'); +} + +export function selectSingBoxServer(server: SubscriptionServer): Promise { + return invoke('select_singbox_server', { + input: { + tag: server.tag, + server: server.server, + serverPort: server.serverPort, + }, + }); +} + +export function pingSingBoxServer(tag: string): Promise { + return invoke('ping_singbox_server', { + input: { tag }, + }); +} + +export function pingAllSingBoxServers(): Promise { + return invoke('ping_all_singbox_servers'); +} + +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'); +} + +export function applyProfiles(): Promise { + return invoke('apply_profiles'); +} + +export function openConfigLocation(): Promise { + return invoke('open_config_location'); +} + +export function startProxiFyreService(): Promise { + return invoke('start_proxifyre_service'); +} + +export function stopProxiFyreService(): Promise { + return invoke('stop_proxifyre_service'); +} + +export function installProxiFyre(): Promise { + return invoke('install_proxifyre'); +} + +export function uninstallProxiFyre(): Promise { + return invoke('uninstall_proxifyre'); +} + +export function startSingBoxService(): Promise { + return invoke('start_singbox_service'); +} + +export function stopSingBoxService(): Promise { + return invoke('stop_singbox_service'); +} + +export function installSingBox(): Promise { + return invoke('install_singbox'); +} + +export function uninstallSingBox(): Promise { + return invoke('uninstall_singbox'); +} diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..ec45fa9 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,2162 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { open } from '@tauri-apps/plugin-dialog'; +import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, Trash2, Wand2 } from 'lucide-react'; +import { + applyProfiles, + fetchSingBoxSubscription, + forgetSingBoxSubscription, + generateSingBoxConfig, + getComponents, + getProxiFyreSetupStatus, + getSavedState, + getSingBoxSetupStatus, + getSingBoxStatus, + installProxiFyre, + installSingBox, + openConfigLocation, + pingAllSingBoxServers, + pingProxyTarget, + pingSingBoxServer, + saveProfile, + saveSingBoxSubscription, + saveTarget, + selectSingBoxServer, + startProxiFyreService, + startSingBoxService, + stopProxiFyreService, + stopSingBoxService, + uninstallProxiFyre, + uninstallSingBox, + type ApplyProfilesResponse, + type LocalSingBoxStatusResponse, + type PingServerResponse, + type ProxiFyreSetupStatus, + type SingBoxSetupStatus, +} from '../api/tauriCommands'; +import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; +import { Button, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui'; +import { getApplyReadiness } from './readiness'; +import { serviceControlState } from './viewModel'; + +type DraftItemType = Extract; +type ProxiFyreAction = 'start' | 'stop' | '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'; + +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[]; +} + +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[] = ['summary', 'proxifyre', 'proxy']; + +const fallbackComponents: ComponentStatus[] = [ + { + id: 'proxyfier', + name: 'ProxiFyre', + state: 'missing', + installed: false, + running: false, + problems: ['ProxiFyre не найден'], + actions: [], + }, + { + id: 'singbox', + name: 'Локальный sing-box', + state: 'missing', + installed: false, + running: false, + problems: [], + actions: [], + }, +]; + +export function App() { + 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 [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 [singBoxStatus, setSingBoxStatus] = useState(null); + const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); + const [subscriptionInput, setSubscriptionInput] = useState(''); + const [serverPings, setServerPings] = useState>({}); + const [proxyPing, setProxyPing] = useState(null); + const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false); + const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false); + const [generatedConfigPath, setGeneratedConfigPath] = useState(''); + const [logEntries, setLogEntries] = useState([]); + const [activeLogId, setActiveLogId] = useState(null); + const [isLogOpen, setIsLogOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isDetectingComponents, setIsDetectingComponents] = useState(true); + const [isApplying, setIsApplying] = useState(false); + const [isOpeningConfig, setIsOpeningConfig] = useState(false); + const [isProxyChecking, setIsProxyChecking] = useState(false); + const [serverPingTag, setServerPingTag] = 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 serviceVisualTimerRef = useRef(null); + const appsSectionRef = useRef(null); + + const proxyfier = useMemo( + () => components.find((component) => component.id === 'proxyfier'), + [components], + ); + const singbox = useMemo( + () => 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 currentSnapshot = useMemo( + () => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag), + [items, proxyInput, routeMode, selectedServerTag], + ); + const hasUnappliedChanges = appliedSnapshot + ? !sameConfigSnapshot(currentSnapshot, appliedSnapshot) + : false; + const systemSummary = systemSummaryState({ + isLoading, + isDetectingComponents, + proxyfier, + singbox, + routeMode, + singBoxStatus, + proxyPing, + hasUnappliedChanges, + }); + + useEffect(() => { + void refresh(); + }, []); + + useEffect(() => { + return () => { + if (serviceVisualTimerRef.current !== null) { + window.clearTimeout(serviceVisualTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!activeLogId) return undefined; + + const timer = window.setTimeout(() => { + setActiveLogId((current) => (current === activeLogId ? null : current)); + }, LOG_VISIBLE_MS); + + return () => window.clearTimeout(timer); + }, [activeLogId]); + + async function refresh() { + setIsLoading(true); + try { + const saved = await getSavedState(); + applySavedState(saved.profiles, saved.targets, saved.generatedConfigPath); + } catch { + showNotice({ + kind: 'info', + title: 'Режим предпросмотра', + text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.', + }); + } finally { + setIsLoading(false); + } + + void refreshComponents(); + } + + async function refreshComponents() { + setIsDetectingComponents(true); + try { + const [detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([ + getComponents(), + getProxiFyreSetupStatus(), + getSingBoxStatus(), + getSingBoxSetupStatus(), + ]); + setComponents(detectedComponents); + setSetupStatus(detectedSetupStatus); + setSingBoxStatus(detectedSingBoxStatus); + setSingBoxSetupStatus(detectedSingBoxSetupStatus); + setAppliedSnapshot((current) => { + if (!current || current.routeMode !== 'local-singbox' || current.selectedServerTag) return current; + return { + ...current, + selectedServerTag: normalizeServerTag(detectedSingBoxStatus.config.selectedServerTag), + }; + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Компоненты не проверены', + text: errorMessage(error), + }); + } finally { + setIsDetectingComponents(false); + } + } + + function applySavedState( + profiles: Profile[], + targets: Target[], + generatedPath: string, + singBoxStatusForSnapshot = singBoxStatus, + ) { + const activeProfiles = profiles.filter((profile) => profile.enabled); + 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 savedItems = itemsForProfiles(editableProfiles); + const savedRouteMode = + activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID + ? 'local-singbox' + : 'external'; + + setProxyInput(savedProxyInput); + setProxyPing(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, + )); + } + + function addItem(type: DraftItemType, rawValue: string) { + const value = normalizeItemValue(rawValue, type); + if (!value) { + showNotice({ + kind: 'error', + title: 'Нечего добавить', + text: emptyItemMessage(type), + }); + return false; + } + + if (items.some((item) => item.type === type && sameValue(item.value, value))) { + showNotice({ + kind: 'info', + title: 'Уже добавлено', + text: value, + }); + return false; + } + + setItems((current) => [ + ...current, + { + id: `${type}-${Date.now()}`, + type, + value, + }, + ]); + return true; + } + + function addProcess() { + if (addItem('process', processInput)) { + setProcessInput(''); + setIsProcessInputOpen(false); + } + } + + function removeItem(id: string) { + setItems((current) => current.filter((item) => item.id !== id)); + } + + function changeRouteMode(nextMode: RouteMode) { + setRouteMode(nextMode); + } + + function changeProxyInput(nextValue: string) { + setProxyInput(nextValue); + setProxyPing(null); + } + + function openAppsPanel() { + switchPanel('proxifyre'); + window.setTimeout(() => { + appsSectionRef.current?.scrollIntoView({ block: 'start' }); + }, 0); + } + + function switchPanel(nextPanel: PanelId) { + if (activePanel === nextPanel) return; + + const currentIndex = PANEL_ORDER.indexOf(activePanel); + const nextIndex = PANEL_ORDER.indexOf(nextPanel); + setTabTransitionDirection(nextIndex > currentIndex ? 'right' : 'left'); + setActivePanel(nextPanel); + } + + async function pickAndAddItem(type: Extract) { + setPickerAction(type); + try { + const selectedPath = await pickPath(type); + if (selectedPath) { + addItem(type, selectedPath); + } + } catch (error) { + showNotice({ + kind: 'error', + title: type === 'exe' ? 'EXE не выбран' : 'Папка не выбрана', + text: errorMessage(error), + }); + } finally { + setPickerAction(null); + } + } + + async function updateConfig() { + let parsedProxy: ParsedProxy | null = null; + try { + if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.'); + if (routeMode === 'external') { + parsedProxy = parseProxy(proxyInput); + } else if (!isSingBoxInstalled) { + throw new Error('Сначала установи Local sing-box.'); + } else if (!singBoxStatus?.config.selectedServerTag) { + throw new Error('Выбери сервер Local sing-box.'); + } + } catch (error) { + showNotice({ + kind: 'error', + title: 'Проверь данные', + text: errorMessage(error), + }); + return; + } + + 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), + }); + await Promise.all( + loadedProfiles + .filter((profile) => profile.enabled && profile.id !== profileId) + .map((profile) => saveProfile(profileInputFromProfile(profile, false))), + ); + + const result = await applyProfiles(); + const [saved, detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([ + getSavedState(), + getComponents(), + getProxiFyreSetupStatus(), + getSingBoxStatus(), + getSingBoxSetupStatus(), + ]); + + applySavedState(saved.profiles, saved.targets, result.generatedConfigPath); + setComponents(detectedComponents); + setSetupStatus(detectedSetupStatus); + setSingBoxStatus(detectedSingBoxStatus); + setSingBoxSetupStatus(detectedSingBoxSetupStatus); + showNotice(routeMode === 'local-singbox' ? noticeFromLocalApply(result, singBoxGeneratedPath) : noticeFromApply(result)); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Конфиг не обновлен', + text: errorMessage(error), + }); + } finally { + setIsApplying(false); + } + } + + 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 openConfig() { + setIsOpeningConfig(true); + try { + const openedPath = await openConfigLocation(); + showNotice({ + kind: 'info', + title: 'Конфиг открыт', + text: openedPath, + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Не удалось открыть конфиг', + text: errorMessage(error), + }); + } finally { + setIsOpeningConfig(false); + } + } + + async function setProxiFyreServiceRunning(shouldRun: boolean) { + const action = shouldRun ? 'start' : 'stop'; + setServiceAction(action); + setIsServiceMenuOpen(false); + startServiceVisual(); + try { + await nextFrame(); + const component = shouldRun + ? await startProxiFyreService() + : await stopProxiFyreService(); + + setComponents((current) => upsertComponent(current, component)); + showNotice({ + kind: 'success', + title: shouldRun ? 'Служба запущена' : 'Служба остановлена', + text: proxyfierDetails(component, false), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: shouldRun ? 'Служба не запущена' : 'Служба не остановлена', + text: errorMessage(error), + }); + } finally { + setServiceAction(null); + settleServiceVisual(); + } + } + + async function installProxiFyrePackage() { + setServiceAction('install'); + setIsServiceMenuOpen(false); + startServiceVisual(); + try { + await nextFrame(); + const component = await installProxiFyre(); + const detectedSetupStatus = await getProxiFyreSetupStatus(); + setComponents((current) => upsertComponent(current, component)); + setSetupStatus(detectedSetupStatus); + showNotice({ + kind: 'success', + title: 'ProxiFyre установлен', + text: proxyfierDetails(component, false), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'ProxiFyre не установлен', + text: errorMessage(error), + }); + } finally { + setServiceAction(null); + settleServiceVisual(); + } + } + + async function uninstallProxiFyrePackage() { + const confirmed = window.confirm( + 'Удалить ProxiFyre с компьютера? Будет удалена служба и папка установки ProxiFyre.', + ); + if (!confirmed) return; + + setServiceAction('uninstall'); + setIsServiceMenuOpen(false); + startServiceVisual(); + try { + await nextFrame(); + const component = await uninstallProxiFyre(); + const detectedSetupStatus = await getProxiFyreSetupStatus(); + setComponents((current) => upsertComponent(current, component)); + setSetupStatus(detectedSetupStatus); + showNotice({ + kind: 'success', + title: 'ProxiFyre удален', + text: 'Служба и папка установки ProxiFyre удалены.', + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'ProxiFyre не удален', + text: errorMessage(error), + }); + } finally { + setServiceAction(null); + settleServiceVisual(); + } + } + + async function refreshSingBoxState() { + const [detectedSingBoxStatus, detectedSingBoxSetupStatus, detectedComponents] = await Promise.all([ + getSingBoxStatus(), + getSingBoxSetupStatus(), + getComponents(), + ]); + setSingBoxStatus(detectedSingBoxStatus); + setSingBoxSetupStatus(detectedSingBoxSetupStatus); + setComponents(detectedComponents); + return detectedSingBoxStatus; + } + + async function setSingBoxServiceRunning(shouldRun: boolean) { + const action: SingBoxAction = shouldRun ? 'start' : 'stop'; + setSingBoxAction(action); + setIsSingBoxMenuOpen(false); + try { + await nextFrame(); + const component = shouldRun ? await startSingBoxService() : await stopSingBoxService(); + setComponents((current) => upsertComponent(current, component)); + await refreshSingBoxState(); + showNotice({ + kind: 'success', + title: shouldRun ? 'sing-box запущен' : 'sing-box остановлен', + text: componentDetails(component, false), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: shouldRun ? 'sing-box не запущен' : 'sing-box не остановлен', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function installSingBoxPackage() { + setSingBoxAction('install'); + setIsSingBoxMenuOpen(false); + try { + await nextFrame(); + const component = await installSingBox(); + setComponents((current) => upsertComponent(current, component)); + await refreshSingBoxState(); + showNotice({ + kind: 'success', + title: 'Local sing-box установлен', + text: componentDetails(component, false), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Local sing-box не установлен', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function uninstallSingBoxPackage() { + const confirmed = window.confirm( + 'Удалить Local sing-box с компьютера? Будет удалена служба и папка установки sing-box.', + ); + if (!confirmed) return; + + setSingBoxAction('uninstall'); + setIsSingBoxMenuOpen(false); + try { + await nextFrame(); + const component = await uninstallSingBox(); + setComponents((current) => upsertComponent(current, component)); + await refreshSingBoxState(); + showNotice({ + kind: 'success', + title: 'Local sing-box удален', + text: 'Служба и папка установки Local sing-box удалены.', + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Local sing-box не удален', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function syncSingBoxSubscription() { + const subscriptionUrl = subscriptionInput.trim(); + if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) { + showNotice({ + kind: 'error', + title: 'Ссылка не указана', + text: 'Вставь ссылку подписки Local sing-box.', + }); + return; + } + + setSingBoxAction('fetch'); + try { + if (subscriptionUrl) { + await saveSingBoxSubscription(subscriptionUrl); + } + const status = await fetchSingBoxSubscription(); + setSingBoxStatus(status); + setComponents((current) => upsertComponent(current, status.component)); + setSubscriptionInput(''); + setServerPings({}); + showNotice({ + kind: 'success', + title: 'Подписка обновлена', + text: `Серверов: ${status.cache?.servers.length ?? 0}`, + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Подписка не обновлена', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function forgetSingBoxSubscriptionData() { + setSingBoxAction('forget'); + setIsSingBoxMenuOpen(false); + try { + const status = await forgetSingBoxSubscription(); + setSingBoxStatus(status); + setComponents((current) => upsertComponent(current, status.component)); + setServerPings({}); + showNotice({ + kind: 'info', + title: 'Подписка очищена', + text: 'Ссылка, cache и выбранный сервер Local sing-box удалены.', + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Подписка не очищена', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function chooseSingBoxServer(server: SubscriptionServer) { + try { + const status = await selectSingBoxServer(server); + setSingBoxStatus(status); + setComponents((current) => upsertComponent(current, status.component)); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Сервер не выбран', + text: errorMessage(error), + }); + } + } + + async function pingSingBoxServers() { + setSingBoxAction('ping'); + try { + const results = await pingAllSingBoxServers(); + setServerPings(Object.fromEntries(results.map((result) => [result.tag, result]))); + showNotice({ + kind: 'info', + title: 'Ping завершен', + text: pingSummary(results), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Ping не выполнен', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function pingSingleSingBoxServer(server: SubscriptionServer) { + setServerPingTag(server.tag); + try { + const result = await pingSingBoxServer(server.tag); + setServerPings((current) => ({ + ...current, + [result.tag]: result, + })); + showNotice({ + kind: result.ok ? 'success' : 'error', + title: result.ok ? 'Сервер отвечает' : 'Сервер не ответил', + text: serverTooltip(server, result), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Ping не выполнен', + text: errorMessage(error), + }); + } finally { + setServerPingTag(null); + } + } + + async function pingExternalProxy() { + let parsed: ParsedProxy; + try { + parsed = parseProxy(proxyInput); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Прокси не проверен', + text: errorMessage(error), + }); + return; + } + + setIsProxyChecking(true); + try { + const result = await pingProxyTarget(parsed.host, parsed.port); + setProxyPing(result); + showNotice({ + kind: result.ok ? 'success' : 'error', + title: result.ok ? 'Внешний прокси отвечает' : 'Внешний прокси не ответил', + text: pingResultText(result), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Прокси не проверен', + text: errorMessage(error), + }); + } finally { + setIsProxyChecking(false); + } + } + + async function generateSingBoxNow() { + setSingBoxAction('generate'); + try { + const result = await generateSingBoxConfig(); + await refreshSingBoxState(); + showNotice({ + kind: 'success', + title: 'Конфиг sing-box создан', + text: result.generatedConfigPath, + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Конфиг sing-box не создан', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + function startServiceVisual() { + if (serviceVisualTimerRef.current !== null) { + window.clearTimeout(serviceVisualTimerRef.current); + serviceVisualTimerRef.current = null; + } + + setServiceVisualState('active'); + } + + function settleServiceVisual() { + if (serviceVisualTimerRef.current !== null) { + window.clearTimeout(serviceVisualTimerRef.current); + } + + 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 renderTabs() { + const tabs: Array<{ id: PanelId; label: string }> = [ + { id: 'summary', label: 'Сводка' }, + { id: 'proxifyre', label: 'ProxiFyre' }, + { id: 'proxy', label: 'VPN / Прокси' }, + ]; + + return ; + } + + function renderSummaryPanel() { + const appliedRouteMode = appliedSnapshot?.routeMode ?? routeMode; + const draftHasLocalServer = currentSnapshot.routeMode === 'local-singbox' && currentSnapshot.selectedServerTag; + + return ( +
+
+
+ +
+
+
+ Применено сейчас + {appliedSnapshot ? routeModeLabel(appliedRouteMode) : 'Состояние загружается'} +
+ + {hasUnappliedChanges ? 'Есть черновик' : 'Совпадает'} + +
+
+
+
ProxiFyre
+
{proxyfierTitle(proxyfier, isDetectingComponents)}
+
+
+
Маршрут
+
{snapshotRouteLine(appliedSnapshot, singBoxStatus)}
+
+
+
VPN сервер
+
{snapshotServerText(appliedSnapshot)}
+
+
+
Приложения
+
{snapshotItemsText(appliedSnapshot)}
+
+
+
Конфиг
+
{generatedConfigPath || 'Примененный config еще не создан.'}
+
+
+
+ +
+
+ Черновик интерфейса + {hasUnappliedChanges ? 'Есть реальные изменения' : 'Изменений нет'} +

+ {hasUnappliedChanges + ? 'Текущие настройки отличаются от примененного состояния. Если вернуть значения назад, предупреждение исчезнет.' + : 'То, что выбрано в интерфейсе, совпадает с примененной конфигурацией.'} +

+
+ {hasUnappliedChanges ? ( +
+
+
Будет маршрут
+
{snapshotRouteLine(currentSnapshot, singBoxStatus)}
+
+
+
VPN сервер
+
{draftHasLocalServer ? displayServerTag(currentSnapshot.selectedServerTag) : snapshotServerText(currentSnapshot)}
+
+
+
Приложения
+
{snapshotItemsText(currentSnapshot)}
+
+
+ ) : null} +
+ +
+ + +
+
+ ); + } + + function renderProxiFyreCard() { + const state = serviceControlState(proxyfier, isDetectingComponents); + const visualState = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : null; + const primaryAction = proxyfier?.installed + ? { + label: proxyfier.running ? 'Остановить' : 'Запустить', + onClick: () => void setProxiFyreServiceRunning(!proxyfier.running), + variant: proxyfier.running ? 'danger' as const : 'neutral' as const, + loading: serviceAction === 'start' || serviceAction === 'stop', + loadingLabel: serviceAction === 'start' ? 'Запускаю' : 'Останавливаю', + disabled: isDetectingComponents || Boolean(serviceAction), + } + : { + label: 'Установить', + onClick: () => void installProxiFyrePackage(), + variant: 'primary' as const, + loading: serviceAction === 'install', + loadingLabel: 'Устанавливаю', + disabled: isDetectingComponents || Boolean(serviceAction), + }; + + return ( + void uninstallProxiFyrePackage(), + }], + } : undefined} + /> + ); + } + + function renderProxiFyreSetupStrip() { + const stripItems = setupStatus?.items ?? proxifyreSetupPlaceholders(); + + return ( +
+ Состав +
+ {stripItems.map((item) => ( +
+
+ ))} +
+
+ ); + } + + function renderAppsSection() { + return ( +
+
+
+

Приложения

+ {items.length} +
+
+ {renderApplyActions('proxifyre', { showConfigPath: false })} +
+
+ + {isProcessInputOpen ? ( +
+ setProcessInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') addProcess(); + if (event.key === 'Escape') { + setProcessInput(''); + setIsProcessInputOpen(false); + } + }} + placeholder="Discord" + spellCheck={false} + autoFocus + /> + + +
+ ) : ( +
+
+ +
+ Новое приложение + Добавь процесс, EXE-файл или папку +
+
+
+ setIsProcessInputOpen(true)} + label="Добавить процесс" + icon={} + /> + void pickAndAddItem('exe')} + disabled={Boolean(pickerAction)} + loading={pickerAction === 'exe'} + label="Добавить EXE-файл" + icon={} + /> + void pickAndAddItem('folder')} + disabled={Boolean(pickerAction)} + loading={pickerAction === 'folder'} + label="Добавить папку" + icon={} + /> +
+
+ )} + +
+ {isLoading ? ( +
+ + +
+ ) : items.length ? ( + items.map((item) => ( +
+
+ +
+ {item.value} + {itemTypeLabel(item.type)} +
+
+ +
+ )) + ) : ( +
Список пуст. Добавь первое приложение сверху.
+ )} +
+
+ ); + } + + function renderApplyActions( + context: 'proxifyre' | 'proxy', + options: { showConfigPath?: boolean } = {}, + ) { + const showConfigPath = options.showConfigPath ?? true; + const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null; + const readiness = getApplyReadiness({ + routeMode, + appCount: items.length, + proxiFyreInstalled: Boolean(proxyfier?.installed), + singBoxInstalled: isSingBoxInstalled, + selectedServerTag: singBoxStatus?.config.selectedServerTag, + externalProxyValue: proxyInput, + externalProxyError, + busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction), + }); + const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents; + + return ( + <> + {showBlocker ? ( +
+ {readiness.title} + {readiness.text} +
+ ) : hasUnappliedChanges ? ( +
+ Изменения еще не применены в ProxiFyre + {applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))} +
+ ) : null} + +
+ + +
+ + {showConfigPath && generatedConfigPath ?

{generatedConfigPath}

: null} + + ); + } + + function renderProxiFyrePanel() { + return ( +
+ {renderProxiFyreCard()} + {renderProxiFyreSetupStrip()} + {renderAppsSection()} +
+ ); + } + + function renderExternalProxyControls() { + const proxyValidation = proxyInput.trim() ? safeProxyError(proxyInput) : null; + + return ( +
+ +
+
+ {proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'} + {proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'} +
+ +
+
+ ); + } + + function renderSingBoxCard() { + const state = serviceControlState(singbox, isDetectingComponents); + const primaryAction = singbox?.installed + ? { + label: singbox.running ? 'Остановить' : 'Запустить', + onClick: () => void setSingBoxServiceRunning(!singbox.running), + variant: singbox.running ? 'danger' as const : 'neutral' as const, + loading: singBoxAction === 'start' || singBoxAction === 'stop', + loadingLabel: singBoxAction === 'start' ? 'Запускаю' : 'Останавливаю', + disabled: isDetectingComponents || Boolean(singBoxAction), + } + : { + label: 'Установить', + onClick: () => void installSingBoxPackage(), + variant: 'primary' as const, + loading: singBoxAction === 'install', + loadingLabel: 'Устанавливаю', + disabled: isDetectingComponents || Boolean(singBoxAction), + }; + + return ( + void uninstallSingBoxPackage(), + }], + } : undefined} + inlineActions={( + <> + + setIsSingBoxInfoOpen((current) => !current)} + label="Подробности Local sing-box" + aria-expanded={isSingBoxInfoOpen} + title="Подробности" + icon={} + /> + + )} + > + {isSingBoxInfoOpen ? ( +
+
+ Локально + {localSingBoxAddress(singBoxStatus)} + LAN + {lanSingBoxAddress(singBoxStatus) ?? 'недоступен'} + Сервер + + {selectedServerTag + ? displayServerTag(selectedServerTag) + : 'не выбран'} + + Файл + {singbox?.path ?? 'не найден'} + Конфиг + {singBoxStatus?.generatedConfigPath ?? 'не создан'} +
+
+ + +
+
+ ) : null} + + {isSingBoxSetupOpen ? ( +
+ {singBoxSetupStatus ? ( + singBoxSetupStatus.items.map((item) => ( +
+
+ )) + ) : ( +
+
+ )} +
+ ) : null} + + {isSingBoxInstalled ? renderSingBoxWorkspace() : ( +
+ Local sing-box не установлен + Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут. +
+ )} +
+ ); + } + + function renderSingBoxWorkspace() { + return ( +
+
+ + setSubscriptionInput(event.target.value)} + placeholder={singBoxStatus?.config.subscriptionDisplayUrl ?? 'https://example.com/sub'} + spellCheck={false} + /> + + void forgetSingBoxSubscriptionData()} + disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription} + label="Очистить подписку Local sing-box" + title="Очистить" + icon={} + /> +
+ + {singBoxStatus?.cache?.servers.length ? ( +
+ {singBoxStatus.cache.servers.map((server) => { + const ping = serverPings[server.tag]; + const selected = server.tag === selectedServerTag; + return ( +
+ + void pingSingleSingBoxServer(server)} + disabled={Boolean(serverPingTag)} + loading={serverPingTag === server.tag} + label={`Проверить ${displayServerTag(server.tag)}`} + title="Ping" + icon={} + /> +
+ ); + })} +
+ ) : ( +
Подписка Local sing-box еще не загружена.
+ )} +
+ ); + } + + function renderProxyPanel() { + return ( +
+
+
+ Настройки VPN / Прокси +

Маршрут и проверка соединения

+
+ {routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'} +
+ +
+
+ + +
+ + {routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()} +
+ +
+ Маршрут + {routePreviewText(routeMode, proxyInput, singBoxStatus)} +
+ + {renderApplyActions('proxy')} +
+ ); + } + + function renderActivePanel() { + if (activePanel === 'proxifyre') return renderProxiFyrePanel(); + if (activePanel === 'proxy') return renderProxyPanel(); + return renderSummaryPanel(); + } + + return ( +
+
+
+
+

ProxyWarden

+ Proxy для приложений +
+ +
+ + {renderTabs()} +
+ {renderActivePanel()} +
+
+ + setIsLogOpen((current) => !current)} + formatTime={formatLogTime} + /> +
+ ); +} + +interface ParsedProxy { + protocol: 'socks5'; + host: string; + port: number; +} + +interface SummaryStateInput { + isLoading: boolean; + isDetectingComponents: boolean; + proxyfier: ComponentStatus | undefined; + singbox: ComponentStatus | undefined; + routeMode: RouteMode; + singBoxStatus: LocalSingBoxStatusResponse | null; + proxyPing: PingServerResponse | null; + hasUnappliedChanges: boolean; +} + +interface SummaryState { + tone: StatusTone; + title: string; + text: string; +} + +function configSnapshotFromUi( + routeMode: RouteMode, + proxyInput: string, + items: Array>, + selectedServerTag?: string, +): ConfigSnapshot { + return { + routeMode, + proxy: routeMode === 'external' ? normalizeProxySnapshot(proxyInput) : '', + selectedServerTag: routeMode === 'local-singbox' ? normalizeServerTag(selectedServerTag) : '', + items: normalizeSnapshotItems(items), + }; +} + +function normalizeProxySnapshot(value: string) { + try { + const parsed = parseProxy(value); + return `${parsed.protocol}://${parsed.host.trim().toLowerCase()}:${parsed.port}`; + } catch { + return value.trim().toLowerCase(); + } +} + +function normalizeServerTag(tag: string | undefined) { + return tag?.trim() ?? ''; +} + +function normalizeSnapshotItems(items: Array>): ConfigSnapshotItem[] { + return items + .map((item) => ({ + type: item.type, + value: normalizeItemValue(item.value, item.type).toLowerCase(), + })) + .filter((item) => item.value) + .sort((left, right) => `${left.type}:${left.value}`.localeCompare(`${right.type}:${right.value}`)); +} + +function sameConfigSnapshot(left: ConfigSnapshot, right: ConfigSnapshot) { + if (left.routeMode !== right.routeMode) return false; + if (left.proxy !== right.proxy) return false; + if (left.selectedServerTag !== right.selectedServerTag) return false; + if (left.items.length !== right.items.length) return false; + + return left.items.every((item, index) => { + const other = right.items[index]; + return item.type === other.type && item.value === other.value; + }); +} + +function routeModeLabel(routeMode: RouteMode) { + return routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'; +} + +function snapshotRouteLine(snapshot: ConfigSnapshot | null, status: LocalSingBoxStatusResponse | null) { + if (!snapshot) return 'Сохраненное состояние еще не загружено.'; + + if (snapshot.routeMode === 'local-singbox') { + const server = snapshot.selectedServerTag ? displayServerTag(snapshot.selectedServerTag) : 'сервер не выбран'; + return `Выбранные приложения -> ProxiFyre -> Local sing-box ${localSingBoxAddress(status)} -> ${server}`; + } + + return `Выбранные приложения -> ProxiFyre -> внешний прокси ${displaySnapshotProxy(snapshot.proxy)}`; +} + +function snapshotServerText(snapshot: ConfigSnapshot | null) { + if (!snapshot) return 'Сохраненное состояние еще не загружено.'; + if (snapshot.routeMode === 'external') return 'Не используется: применен внешний прокси.'; + return snapshot.selectedServerTag ? displayServerTag(snapshot.selectedServerTag) : 'Сервер не выбран.'; +} + +function snapshotItemsText(snapshot: ConfigSnapshot | null) { + if (!snapshot) return 'Сохраненное состояние еще не загружено.'; + return snapshot.items.length ? appCountText(snapshot.items.length) : 'Приложения не настроены.'; +} + +function displaySnapshotProxy(proxy: string) { + return proxy.replace(/^socks5:\/\//, '') || 'не указан'; +} + +function systemSummaryState(input: SummaryStateInput): SummaryState { + if (input.isLoading || input.isDetectingComponents) { + return { + tone: 'checking', + title: 'Проверяю', + text: 'Обновляю сохраненный маршрут, компоненты и локальный proxy path.', + }; + } + + if (!input.proxyfier?.installed) { + return { + tone: 'error', + title: 'Не настроено', + text: 'ProxiFyre не найден. Перейди в настройки ProxiFyre и установи компонент.', + }; + } + + if (!input.proxyfier.running) { + return { + tone: 'warning', + title: 'Требует внимания', + text: 'ProxiFyre установлен, но служба сейчас остановлена.', + }; + } + + if (input.routeMode === 'local-singbox') { + if (!input.singbox?.installed) { + return { + tone: 'warning', + title: 'Локальный прокси не готов', + text: 'Для локального маршрута нужно установить Local sing-box.', + }; + } + if (!input.singbox.running) { + return { + tone: 'warning', + title: 'Локальный прокси остановлен', + text: 'Local sing-box установлен, но служба не запущена.', + }; + } + if (!input.singBoxStatus?.config.selectedServerTag) { + return { + tone: 'warning', + title: 'Сервер не выбран', + text: 'Выбери сервер Local sing-box на вкладке VPN / Прокси.', + }; + } + } + + if (input.routeMode === 'external' && input.proxyPing && !input.proxyPing.ok) { + return { + tone: 'warning', + title: 'Прокси не ответил', + text: pingResultText(input.proxyPing), + }; + } + + if (input.hasUnappliedChanges) { + return { + tone: 'warning', + title: 'Есть непримененные изменения', + text: 'Настройки изменены, но ProxiFyre еще не обновлен.', + }; + } + + return { + tone: 'ok', + title: 'Работает', + text: 'Сохраненная конфигурация выглядит готовой к маршрутизации выбранных приложений.', + }; +} + +function routeEndpointLabel( + routeMode: RouteMode, + proxyInput: string, + status: LocalSingBoxStatusResponse | null, +) { + if (routeMode === 'local-singbox') { + const lan = lanSingBoxAddress(status); + return lan ? `${localSingBoxAddress(status)} · LAN: ${lan}` : localSingBoxAddress(status); + } + + try { + const parsed = parseProxy(proxyInput); + return formatHostPort(parsed.host, parsed.port); + } catch { + return proxyInput.trim() || 'не указан'; + } +} + +function appCountText(count: number) { + if (count === 1) return '1 приложение маршрутизируется через профиль.'; + if (count > 1 && count < 5) return `${count} приложения маршрутизируются через профиль.`; + return `${count} приложений маршрутизируются через профиль.`; +} + +function safeProxyError(value: string) { + try { + parseProxy(value); + return null; + } catch (error) { + return errorMessage(error); + } +} + +function pingTone(ping: PingServerResponse): StatusTone { + return ping.ok ? 'ok' : 'error'; +} + +function pingResultTitle(ping: PingServerResponse) { + return ping.ok ? 'Доступен' : 'Недоступен'; +} + +function pingResultText(ping: PingServerResponse) { + if (ping.ok) return `${ping.server}:${ping.serverPort} ответил за ${ping.latency ?? 0} ms.`; + return `${ping.server}:${ping.serverPort}: ${ping.error ?? 'нет ответа'}`; +} + +function routePreviewText( + routeMode: RouteMode, + proxyInput: string, + status: LocalSingBoxStatusResponse | null, +) { + if (routeMode === 'local-singbox') { + const server = status?.config.selectedServerTag + ? displayServerTag(status.config.selectedServerTag) + : 'сервер не выбран'; + return `Выбранные приложения -> ProxiFyre -> Local sing-box ${localSingBoxAddress(status)} -> ${server}`; + } + + return `Выбранные приложения -> ProxiFyre -> внешний прокси ${routeEndpointLabel('external', proxyInput, status)}`; +} + +function applyButtonLabel( + context: 'proxifyre' | 'proxy', + isApplying: boolean, + hasUnappliedChanges: boolean, + singBoxAction: SingBoxAction | null, +) { + if (isApplying) { + if (singBoxAction === 'start') return 'Запускаю sing-box...'; + if (singBoxAction === 'stop') return 'Перезапускаю sing-box...'; + return 'Применяю...'; + } + if (hasUnappliedChanges) return context === 'proxy' ? 'Применить маршрут' : 'Применить в ProxiFyre'; + return context === 'proxy' ? 'Обновить маршрут' : 'Обновить конфиг'; +} + +function parseProxy(rawValue: string): ParsedProxy { + const value = rawValue.trim(); + if (!value) throw new Error('Введи адрес прокси.'); + + const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`; + let parsed: URL; + try { + parsed = new URL(withProtocol); + } catch { + throw new Error('Формат: socks5://host:port или host:port.'); + } + + const protocol = parsed.protocol.replace(':', '').toLowerCase(); + if (protocol !== 'socks5') { + throw new Error('Сейчас поддерживается только SOCKS5.'); + } + if (parsed.username || parsed.password) { + throw new Error('Прокси с логином и паролем пока не поддерживаются.'); + } + + const host = parsed.hostname.replace(/^\[|\]$/g, ''); + const port = Number(parsed.port); + if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('Укажи хост и порт прокси.'); + } + + return { protocol: 'socks5', host, port }; +} + +function targetForUi(targets: Target[], profile: Profile | undefined) { + if (profile) return targets.find((target) => target.id === profile.targetId); + return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external'); +} + +function targetForExternalProxy(targets: Target[]) { + return targets.find((target) => target.id === MAIN_TARGET_ID) + ?? targets.find((target) => target.kind === 'external' && target.id !== LOCAL_SINGBOX_TARGET_ID); +} + +function itemsForProfiles(profiles: Profile[]): DraftItem[] { + const seen = new Set(); + const items: DraftItem[] = []; + + for (const profile of profiles) { + for (const item of profile.items) { + if (item.type !== 'process' && item.type !== 'folder' && item.type !== 'exe') continue; + + const key = `${item.type}:${item.value.trim().toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + items.push({ + id: `${item.type}-${items.length}-${item.value}`, + type: item.type, + value: item.value, + }); + } + } + + return items; +} + +function formatProxy(target: Target) { + return target.protocol === 'socks5' + ? `${target.host}:${target.port}` + : `${target.protocol}://${target.host}:${target.port}`; +} + +function normalizeItemValue(value: string, type: DraftItemType) { + const clean = value.trim().replace(/^"|"$/g, ''); + if (!clean) return ''; + if (type === 'folder' || type === 'exe') return clean; + + return clean + .split(/[\\/]/) + .pop() + ?.replace(/\.exe$/i, '') + .trim() ?? ''; +} + +async function pickPath(type: Extract) { + const selected = await open( + type === 'folder' + ? { + title: 'Выбери папку', + directory: true, + multiple: false, + } + : { + title: 'Выбери EXE-файл', + directory: false, + multiple: false, + filters: [{ name: 'EXE-файлы', extensions: ['exe'] }], + }, + ); + + if (Array.isArray(selected)) return selected[0] ?? null; + return selected; +} + +function nextFrame() { + return new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); +} + +function profileItemInput(item: DraftItem): ProfileItemInput { + return { + type: item.type, + value: item.value, + recursive: item.type === 'folder', + }; +} + +function emptyItemMessage(type: DraftItemType) { + if (type === 'process') return 'Введи имя процесса.'; + if (type === 'folder') return 'Введи путь к папке.'; + return 'Введи путь к EXE-файлу.'; +} + +function itemTypeLabel(type: DraftItemType) { + if (type === 'process') return 'процесс'; + if (type === 'folder') return 'папка'; + return 'EXE-файл'; +} + +function itemIcon(type: DraftItemType) { + if (type === 'process') return ; + if (type === 'folder') return ; + return ; +} + +function setupItemDetails(installed: boolean, version: string | undefined, details: string) { + if (!installed) return `Нужно установить. ${details}`; + if (version) return `${version}. ${details}`; + return details; +} + +function proxifyreSetupPlaceholders(): ProxiFyreSetupStatus['items'] { + return [ + { id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' }, + { id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' }, + { id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' }, + ]; +} + +function setupItemUserName(id: string, fallbackName: string) { + if (id === 'vc-runtime') return 'Среда запуска'; + if (id === 'packet-filter') return 'Сетевой драйвер'; + if (id === 'proxifyre') return 'Клиент ProxiFyre'; + return fallbackName; +} + +function setupItemShortStatus(item: ProxiFyreSetupStatus['items'][number]) { + if (item.details === 'Проверяю') return 'проверяю'; + if (!item.installed) return 'нужно установить'; + if (item.id === 'proxifyre') return item.version?.includes('не запущена') ? 'остановлен' : 'запущен'; + return 'готово'; +} + +function profileInputFromProfile(profile: Profile, enabled: boolean) { + return { + id: profile.id, + name: profile.name, + enabled, + targetId: profile.targetId, + protocols: profile.protocols, + items: profile.items.map((item) => ({ + type: item.type, + value: item.value, + recursive: item.recursive, + })), + }; +} + +function proxyfierTitle(component: ComponentStatus | undefined, checking: boolean) { + if (checking) return 'Проверяю ProxiFyre'; + if (!component) return 'ProxiFyre не проверен'; + if (component.running) return 'ProxiFyre найден и запущен'; + if (component.installed) return 'ProxiFyre найден'; + return 'ProxiFyre не найден'; +} + +function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) { + if (checking) return 'Ищу установленный клиент и состояние службы.'; + if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.'; + if (component.path) return component.path; + return component.problems[0] ?? 'Путь установки не найден.'; +} + +function singBoxTitle(component: ComponentStatus | undefined, checking: boolean) { + if (checking) return 'Проверяю Local sing-box'; + if (!component) return 'Local sing-box не проверен'; + if (component.running) return 'Local sing-box найден и запущен'; + if (component.installed) return 'Local sing-box найден'; + return 'Local sing-box не установлен'; +} + +function singBoxDetails( + component: ComponentStatus | undefined, + status: LocalSingBoxStatusResponse | null, + checking: boolean, +) { + if (checking) return 'Ищу sing-box, wrapper и службу.'; + if (component?.running && status) { + const lanAddress = lanSingBoxAddress(status); + return lanAddress + ? `Доступен локально: ${localSingBoxAddress(status)} · LAN: ${lanAddress}` + : `Доступен локально: ${localSingBoxAddress(status)}`; + } + if (component?.installed) return 'Служба остановлена. Запусти Local sing-box перед применением маршрута.'; + if (status?.config.hasSubscription) { + return status.config.subscriptionDisplayUrl ?? 'Подписка сохранена.'; + } + return component?.problems[0] ?? 'Установи компонент, чтобы подключить подписку и выбрать сервер.'; +} + +function componentDetails(component: ComponentStatus | undefined, checking: boolean) { + if (checking) return 'Проверяю состояние службы.'; + if (!component) return 'Компонент не проверен.'; + if (component.path) return component.path; + return component.problems[0] ?? 'Путь установки не найден.'; +} + +function noticeFromApply(result: ApplyProfilesResponse): Notice { + return { + kind: result.success ? 'success' : 'error', + title: result.success ? 'Конфиг обновлен' : 'Конфиг создан, но не применен', + text: result.message, + }; +} + +function noticeFromLocalApply(result: ApplyProfilesResponse, singBoxGeneratedPath: string): Notice { + return { + kind: result.success ? 'success' : 'error', + title: result.success ? 'Local sing-box применен' : 'Конфиг создан, но не применен', + text: singBoxGeneratedPath ? `${result.message} sing-box: ${singBoxGeneratedPath}` : result.message, + }; +} + +function applyStateText(routeMode: RouteMode, isSingBoxInstalled: boolean, isSingBoxRunning: boolean) { + if (routeMode === 'local-singbox' && isSingBoxInstalled && !isSingBoxRunning) { + return 'Local sing-box сейчас остановлен. При применении клиент сначала запустит службу, затем обновит ProxiFyre.'; + } + if (routeMode === 'local-singbox' && isSingBoxInstalled && isSingBoxRunning) { + return 'При применении клиент обновит конфиг, перезапустит Local sing-box и затем обновит ProxiFyre.'; + } + return 'Нажми «Применить в ProxiFyre». Если служба уже запущена и маршрут не обновился, перезапусти ProxiFyre.'; +} + +function upsertComponent(components: ComponentStatus[], component: ComponentStatus) { + const index = components.findIndex((current) => current.id === component.id); + if (index === -1) return [...components, component]; + + return [ + ...components.slice(0, index), + component, + ...components.slice(index + 1), + ]; +} + +function sameValue(left: string, right: string) { + return left.trim().toLowerCase() === right.trim().toLowerCase(); +} + +function formatLogTime(timestamp: number) { + return new Date(timestamp).toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function serverLabel(server: SubscriptionServer) { + return `${server.type} · ${server.server}:${server.serverPort}`; +} + +function serverTooltip(server: SubscriptionServer, ping: PingServerResponse | undefined) { + const details = serverLabel(server); + if (!ping) return details; + return ping.ok ? `${details} · ping ${ping.latency ?? 0} ms` : `${details} · ping fail`; +} + +function displayServerTag(tag: string) { + const withoutFlags = tag + .replace(/[\u{1f1e6}-\u{1f1ff}]/gu, '') + .replace(/\s*->\s*/g, ' -> ') + .replace(/\s*->\s*$/g, '') + .replace(/^\s*->\s*/g, '') + .replace(/\s{2,}/g, ' ') + .trim(); + + return withoutFlags || tag; +} + +function pingSummary(results: PingServerResponse[]) { + if (!results.length) return 'Серверов для проверки нет.'; + const ok = results.filter((result) => result.ok); + if (!ok.length) return `Не ответил ни один сервер из ${results.length}.`; + const best = ok.reduce((current, result) => { + if ((result.latency ?? Number.MAX_SAFE_INTEGER) < (current.latency ?? Number.MAX_SAFE_INTEGER)) return result; + return current; + }); + return `Ответили ${ok.length}/${results.length}; быстрее ${best.tag}: ${best.latency ?? 0} ms.`; +} + +function localSingBoxAddress(status: LocalSingBoxStatusResponse | null) { + if (!status) return 'не загружен'; + const host = status.config.listenHost.trim(); + const displayHost = host === '0.0.0.0' || host === '::' || !host ? '127.0.0.1' : host; + return formatHostPort(displayHost, status.config.listenPort); +} + +function lanSingBoxAddress(status: LocalSingBoxStatusResponse | null) { + if (!status) return null; + const host = status.config.listenHost.trim().toLowerCase(); + if (host === '127.0.0.1' || host === 'localhost' || host === '::1') return null; + const lanHost = host === '0.0.0.0' || host === '::' || !host + ? status.lanListenHost + : status.config.listenHost; + if (!lanHost) return null; + return formatHostPort(lanHost, status.config.listenPort); +} + +function formatHostPort(host: string, port: number) { + return host.includes(':') && !host.startsWith('[') ? `[${host}]:${port}` : `${host}:${port}`; +} + +function errorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error && typeof error === 'object' && 'message' in error) { + return String((error as { message: unknown }).message); + } + return 'Неизвестная ошибка.'; +} diff --git a/src/app/readiness.ts b/src/app/readiness.ts new file mode 100644 index 0000000..04b8068 --- /dev/null +++ b/src/app/readiness.ts @@ -0,0 +1,83 @@ +export type RouteMode = 'external' | 'local-singbox'; + +export interface ApplyReadinessInput { + routeMode: RouteMode; + appCount: number; + proxiFyreInstalled: boolean; + singBoxInstalled: boolean; + selectedServerTag?: string; + externalProxyValue: string; + externalProxyError?: string | null; + busy: boolean; +} + +export interface ApplyReadiness { + ready: boolean; + title?: string; + text?: string; +} + +export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness { + if (input.busy) { + return { + ready: false, + title: 'Операция уже выполняется', + text: 'Дождись завершения текущего действия перед повторным применением.', + }; + } + + if (!input.proxiFyreInstalled) { + return { + ready: false, + title: 'ProxiFyre не установлен', + text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.', + }; + } + + if (input.appCount < 1) { + return { + ready: false, + title: 'Нет приложений', + text: 'Добавь хотя бы один процесс, EXE-файл или папку.', + }; + } + + if (input.routeMode === 'external') { + if (!input.externalProxyValue.trim()) { + return { + ready: false, + title: 'Прокси не указан', + text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.', + }; + } + + if (input.externalProxyError) { + return { + ready: false, + title: 'Проверь формат прокси', + text: input.externalProxyError, + }; + } + } + + if (input.routeMode === 'local-singbox') { + if (!input.singBoxInstalled) { + return { + ready: false, + title: 'Local sing-box не установлен', + text: 'Установи Local sing-box, чтобы применить локальный маршрут.', + }; + } + + if (!input.selectedServerTag) { + return { + ready: false, + title: 'Сервер не выбран', + text: 'Выбери сервер Local sing-box перед применением маршрута.', + }; + } + } + + return { ready: true }; +} + diff --git a/src/app/viewModel.ts b/src/app/viewModel.ts new file mode 100644 index 0000000..2e337ac --- /dev/null +++ b/src/app/viewModel.ts @@ -0,0 +1,15 @@ +import type { ComponentStatus } from '../domain/types'; +import type { ServiceControlState } from '../ui'; + +export function serviceControlState( + component: ComponentStatus | undefined, + checking: boolean, +): ServiceControlState { + if (checking) return 'checking'; + if (!component) return 'missing'; + if (component.state === 'error') return 'error'; + if (component.running) return 'running'; + if (component.installed) return 'stopped'; + return 'missing'; +} + diff --git a/src/domain/types.ts b/src/domain/types.ts new file mode 100644 index 0000000..c0a446e --- /dev/null +++ b/src/domain/types.ts @@ -0,0 +1,101 @@ +export type Protocol = 'TCP' | 'UDP'; +export type ProfileItemType = 'process' | 'folder' | 'exe'; +export type TargetKind = 'local' | 'external'; +export type ProxyProtocol = 'socks5' | 'http'; +export type ComponentId = 'control-app' | 'proxyfier' | 'singbox'; +export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error'; +export type ActivityLevel = 'info' | 'warning' | 'error' | 'success'; + +export interface ProfileItemInput { + type: ProfileItemType | string; + value: string; + recursive?: boolean; +} + +export interface ProfileInput { + id?: string; + name: string; + enabled?: boolean; + targetId?: string; + protocols?: string[]; + items?: ProfileItemInput[]; +} + +export interface ProfileItem { + type: ProfileItemType; + value: string; + recursive: boolean; +} + +export interface Profile { + id: string; + name: string; + enabled: boolean; + targetId: string; + protocols: Protocol[]; + items: ProfileItem[]; +} + +export interface TargetInput { + id?: string; + name: string; + kind?: TargetKind | string; + protocol?: ProxyProtocol | string; + host: string; + port: number; + requiresComponent?: ComponentId | string; +} + +export interface Target { + id: string; + name: string; + kind: TargetKind; + protocol: ProxyProtocol; + host: string; + port: number; + requiresComponent?: ComponentId; +} + +export interface ComponentStatus { + id: ComponentId; + name: string; + state: ComponentState; + installed: boolean; + running: boolean; + version?: string; + path?: string; + problems: string[]; + actions: string[]; +} + +export interface LocalSingBoxConfig { + subscriptionDisplayUrl?: string; + hasSubscription: boolean; + selectedServerTag?: string; + listenHost: string; + listenPort: number; + serviceName: string; + installRoot: string; + updatedAt?: string; +} + +export interface SubscriptionServer { + tag: string; + type: string; + server: string; + serverPort: number; +} + +export interface SubscriptionCache { + servers: SubscriptionServer[]; + userInfo: Record; + fetchedAt: string; +} + +export interface ActivityEntry { + id: string; + at: string; + level: ActivityLevel; + title: string; + message: string; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..40916e5 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './app/App'; +import './styles/app.css'; + +createRoot(document.getElementById('root') as HTMLElement).render( + + + , +); + diff --git a/src/styles/app.css b/src/styles/app.css new file mode 100644 index 0000000..2c8308e --- /dev/null +++ b/src/styles/app.css @@ -0,0 +1,2668 @@ +:root { + --app-footer-height: 54px; + --app-header-row-height: 50px; + --app-tab-height: 46px; + --app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height)); + --motion-fast: 120ms; + --motion-standard: 180ms; + --motion-panel: 220ms; + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-standard: cubic-bezier(0.22, 0.72, 0.18, 1); + --surface-canvas: #101216; + --surface-panel: #131720; + --surface-raised: #151923; + --surface-control: #242a35; + --surface-inset: #0d1016; + --border-soft: #2b3342; + --border-strong: #343b49; + --focus-ring: #3b82f6; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + color: #e5e7eb; + background: #101216; + font-synthesis: none; + height: 100%; + overflow: hidden; + scrollbar-color: #4b5568 #101216; + scrollbar-gutter: stable; + scrollbar-width: thin; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} + +::-webkit-scrollbar-track { + background: #101216; +} + +::-webkit-scrollbar-thumb { + min-height: 36px; + border: 2px solid #101216; + border-radius: 999px; + background: #4b5568; +} + +::-webkit-scrollbar-thumb:hover { + background: #64748b; +} + +::-webkit-scrollbar-corner { + background: #101216; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + height: 100vh; + background: #101216; + overflow: hidden; +} + +button, +input { + font: inherit; +} + +button { + border: 0; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.56; +} + +.ui-button, +.ui-icon-button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--surface-control); + color: #eef2ff; + cursor: pointer; + font-weight: 750; + line-height: 1; + text-decoration: none; + transition: + background-color var(--motion-fast) var(--ease-out), + border-color var(--motion-fast) var(--ease-out), + color var(--motion-fast) var(--ease-out), + opacity var(--motion-fast) var(--ease-out), + transform var(--motion-fast) var(--ease-out); +} + +.ui-button:hover, +.ui-icon-button:hover { + background: #2d3543; +} + +.ui-button:active, +.ui-icon-button:active { + transform: scale(0.98); +} + +.ui-button:focus-visible, +.ui-icon-button:focus-visible, +.ui-tab:focus-visible, +.ui-action-menu-popover button:focus-visible { + outline: 0; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.72); +} + +.ui-button:disabled, +.ui-icon-button:disabled { + cursor: not-allowed; + opacity: 0.56; + transform: none; +} + +.ui-button--sm { + min-height: 32px; + padding: 6px 10px; + font-size: 13px; +} + +.ui-button--md { + min-height: 36px; + padding: 8px 12px; + font-size: 14px; +} + +.ui-button--lg { + min-height: 46px; + padding: 10px 14px; + font-size: 14px; +} + +.ui-button--primary { + border-color: #16a34a; + background: #22c55e; + color: #04130a; +} + +.ui-button--primary:hover { + background: #4ade80; +} + +.ui-button--add, +.ui-icon-button--add { + border-color: #2b3342; + background: #202633; + color: #dbeafe; +} + +.ui-button--add:hover, +.ui-icon-button--add:hover { + border-color: var(--focus-ring); + background: #182033; +} + +.ui-button--danger, +.ui-icon-button--danger { + color: #fecaca; +} + +.ui-button--danger:hover, +.ui-icon-button--danger:hover { + border-color: rgba(239, 68, 68, 0.52); + background: rgba(127, 29, 29, 0.34); +} + +.ui-icon-button { + width: 42px; + min-width: 42px; + min-height: 38px; + padding: 0; +} + +.ui-button-icon, +.ui-button-label { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.ui-button-spinner { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 999px; + animation: spin 0.75s linear infinite; +} + +.ui-tabs { + position: fixed; + top: var(--app-header-row-height); + right: 0; + left: 0; + z-index: 40; + display: flex; + align-items: stretch; + gap: 0; + min-height: var(--app-tab-height); + overflow-x: auto; + border-top: 1px solid #242a35; + border-bottom: 1px solid var(--border-soft); + background: #181b22; + scrollbar-width: none; + margin: 0; + padding: 0 18px; +} + +.ui-tabs::-webkit-scrollbar { + display: none; +} + +.ui-tab { + appearance: none; + position: relative; + flex: 1 1 0; + min-width: 0; + min-height: var(--app-tab-height); + border: 0; + border-radius: 0; + background: transparent; + color: #9aa8bd; + cursor: pointer; + font-weight: 750; + margin: 0; + padding: 10px 12px 12px; + transition: + background-color var(--motion-fast) var(--ease-out), + color var(--motion-fast) var(--ease-out); + white-space: nowrap; +} + +.ui-tab:hover { + background: #1c212b; + color: #eef2ff; +} + +.ui-tab.is-active { + color: #eff6ff; +} + +.ui-tab.is-active::after { + position: absolute; + right: 12px; + bottom: 0; + left: 12px; + height: 3px; + border-radius: 999px 999px 0 0; + background: var(--focus-ring); + box-shadow: 0 -6px 18px rgba(59, 130, 246, 0.34); + content: ""; +} + +.ui-status-pill { + display: inline-flex; + align-items: center; + width: fit-content; + border: 1px solid var(--border-soft); + border-radius: 999px; + background: #1a202b; + color: #cbd5e1; + padding: 5px 9px; + font-size: 12px; + font-weight: 750; +} + +.ui-status-pill--ok { + border-color: rgba(34, 197, 94, 0.38); + color: #86efac; +} + +.ui-status-pill--warning { + border-color: rgba(245, 158, 11, 0.46); + color: #fcd34d; +} + +.ui-status-pill--error { + border-color: rgba(239, 68, 68, 0.46); + color: #fecaca; +} + +.ui-status-pill--checking { + border-color: rgba(59, 130, 246, 0.46); + color: #bfdbfe; +} + +.ui-field { + display: grid; + gap: 7px; +} + +.ui-field-label { + color: #8d99ae; +} + +.ui-field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: stretch; +} + +.ui-field input { + min-height: 42px; + width: 100%; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--surface-inset); + color: #f8fafc; + outline: none; + padding: 9px 11px; +} + +.ui-field input:focus { + border-color: var(--focus-ring); + box-shadow: 0 0 0 1px var(--focus-ring); +} + +.ui-field-help { + color: #8d99ae; + font-size: 12px; +} + +.ui-field-help.is-error { + color: #fcd34d; +} + +.ui-action-menu { + position: relative; + display: flex; +} + +.ui-action-menu-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 8; + min-width: 172px; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: #171c26; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.38); + padding: 5px; + transform-origin: top right; + animation: ui-popover-in var(--motion-standard) var(--ease-out); +} + +.ui-action-menu-popover button { + width: 100%; + min-height: 34px; + border: 0; + border-radius: 3px; + background: transparent; + color: #e5e7eb; + cursor: pointer; + font: inherit; + padding: 7px 9px; + text-align: left; +} + +.ui-action-menu-popover button:hover { + background: #242a35; +} + +.ui-action-menu-popover button.is-danger { + color: #fecaca; +} + +.ui-action-menu-popover button.is-danger:hover { + background: rgba(127, 29, 29, 0.42); +} + +.ui-service-row { + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: auto minmax(220px, 1fr) auto; + align-items: center; + gap: 12px; + justify-content: stretch; + min-height: 56px; + overflow: visible; + border: 1px solid var(--border-soft); + border-radius: 4px; + background: var(--surface-raised); + padding: 12px; +} + +.ui-service-row > :not(.ui-service-border-glow) { + position: relative; + z-index: 1; +} + +.ui-service-border-glow { + position: absolute; + display: none; + z-index: 0; + inset: 0; + overflow: hidden; + border-radius: inherit; + opacity: 0; + contain: layout paint; + pointer-events: none; + transition: opacity var(--motion-panel) var(--ease-out); +} + +.ui-service-row--checking .ui-service-border-glow, +.ui-service-row--working .ui-service-border-glow { + display: block; + opacity: 0.78; +} + +.ui-service-row--settling .ui-service-border-glow { + display: block; + opacity: 0; + transition-duration: 0.7s; +} + +.ui-service-border-glow-segment { + position: absolute; + display: block; + background: #93c5fd; + box-shadow: + 0 0 8px rgba(96, 165, 250, 0.95), + 0 0 16px rgba(34, 197, 94, 0.36); + opacity: 0; +} + +.ui-service-border-glow-segment.top, +.ui-service-border-glow-segment.bottom { + width: 108px; + height: 2px; + background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); +} + +.ui-service-border-glow-segment.right, +.ui-service-border-glow-segment.left { + width: 2px; + height: 64px; + background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); +} + +.ui-service-border-glow-segment.top { + top: 0; + animation: finder-border-top 1.6s linear infinite; +} + +.ui-service-border-glow-segment.right { + right: 0; + animation: finder-border-right 1.6s linear infinite; +} + +.ui-service-border-glow-segment.bottom { + bottom: 0; + animation: finder-border-bottom 1.6s linear infinite; +} + +.ui-service-border-glow-segment.left { + left: 0; + animation: finder-border-left 1.6s linear infinite; +} + +.ui-service-dot { + flex: 0 0 auto; + width: 11px; + height: 11px; + border-radius: 999px; + background: #f59e0b; + box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12); +} + +.ui-service-row--running .ui-service-dot, +.ui-service-row--installed .ui-service-dot { + background: #22c55e; + box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12); +} + +.ui-service-row--stopped .ui-service-dot, +.ui-service-row--missing .ui-service-dot { + background: #f59e0b; +} + +.ui-service-row--error .ui-service-dot { + background: #ef4444; + box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12); +} + +.ui-service-row--checking .ui-service-dot { + border: 2px solid var(--focus-ring); + border-top-color: transparent; + background: transparent; + box-shadow: none; + animation: spin 0.75s linear infinite; +} + +.ui-service-text { + min-width: 0; +} + +.ui-service-text > strong, +.ui-service-text > span { + display: block; + overflow-wrap: anywhere; +} + +.ui-service-text > span { + color: #8d99ae; +} + +.ui-service-inline-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.ui-service-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.ui-service-row > .setup-details, +.ui-service-row > .singbox-info-popover, +.ui-service-row > .singbox-workspace, +.ui-service-row > .singbox-install-note { + grid-column: 1 / -1; +} + +.log-raw-detail { + color: #7d8aa0; + margin-top: 3px; +} + +@keyframes ui-popover-in { + from { + opacity: 0; + transform: scale(0.97); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +.simple-shell { + display: block; + height: 100vh; + overflow: hidden; + background: #101216; + padding: var(--app-header-height) 0 var(--app-footer-height); +} + +.simple-panel { + display: grid; + align-content: start; + min-height: 0; + height: calc(100vh - var(--app-header-height) - var(--app-footer-height)); + width: 100%; + border: 0; + border-radius: 0; + background: #101216; + box-shadow: none; + overflow-x: hidden; + overflow-y: scroll; + scrollbar-gutter: stable; + padding: 14px 18px 20px; +} + +.simple-header, +.section-head, +.add-toolbar, +.process-add-line, +.app-row, +.route-switch, +.singbox-info-actions, +.panel-tabs, +.server-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.simple-header { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 40; + min-height: var(--app-header-row-height); + margin: 0; + background: #181b22; + padding: 7px 14px; +} + +.simple-header small, +.simple-field span, +.app-row-main > div > span, +.config-path, +.finder-card span { + color: #8d99ae; +} + +.simple-header h1, +.section-head h2 { + margin: 0; + letter-spacing: 0; +} + +.simple-header h1 { + margin-top: 0; + font-size: 18px; + line-height: 1.1; + font-weight: 650; +} + +.simple-header .ghost-button { + min-height: 32px; + padding: 6px 10px; + font-size: 13px; +} + +.section-head h2 { + font-size: 16px; +} + +.panel-tabs { + position: fixed; + top: var(--app-header-row-height); + right: 0; + left: 0; + z-index: 40; + display: flex; + align-items: stretch; + gap: 0; + min-height: var(--app-tab-height); + overflow-x: auto; + border-top: 1px solid #242a35; + border-bottom: 1px solid #2b3342; + background: #181b22; + scrollbar-width: none; + margin: 0; + padding: 0 18px; +} + +.panel-tabs::-webkit-scrollbar { + display: none; +} + +.panel-tabs button { + appearance: none; + position: relative; + flex: 1 1 0; + min-width: 0; + min-height: var(--app-tab-height); + border: 0; + border-radius: 0; + background: transparent; + color: #9aa8bd; + font-weight: 750; + cursor: pointer; + margin: 0; + padding: 10px 12px 12px; + white-space: nowrap; +} + +.panel-tabs button:hover { + background: #1c212b; + color: #eef2ff; +} + +.panel-tabs button.active { + background: #181b22; + color: #eff6ff; + box-shadow: none; +} + +.panel-tabs button.active::after { + position: absolute; + right: 12px; + bottom: 0; + left: 12px; + height: 3px; + border-radius: 999px 999px 0 0; + background: #3b82f6; + box-shadow: 0 -6px 18px rgba(59, 130, 246, 0.34); + content: ""; +} + +.tab-panel-frame { + min-width: 0; + will-change: opacity, transform; +} + +.tab-panel-frame.swipe-right { + animation: panel-swipe-right 0.24s cubic-bezier(0.22, 0.72, 0.18, 1); +} + +.tab-panel-frame.swipe-left { + animation: panel-swipe-left 0.24s cubic-bezier(0.22, 0.72, 0.18, 1); +} + +.tab-panel { + display: grid; + gap: 10px; +} + +.panel-section-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + min-height: 48px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #131720; + padding: 11px 12px; +} + +.panel-section-head span { + display: block; + color: #8d99ae; + font-size: 12px; +} + +.panel-section-head h2 { + margin: 2px 0 0; + color: #eef2ff; + font-size: 17px; + letter-spacing: 0; +} + +.panel-section-head > strong { + min-width: 0; + color: #bfdbfe; + font-size: 13px; + overflow-wrap: anywhere; + text-align: right; +} + +.summary-panel { + gap: 12px; +} + +.summary-hero { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + border: 1px solid #2b3342; + border-radius: 4px; + background: #151923; + padding: 14px; +} + +.summary-hero.ok { + border-color: rgba(34, 197, 94, 0.44); +} + +.summary-hero.warning { + border-color: rgba(245, 158, 11, 0.48); +} + +.summary-hero.error { + border-color: rgba(239, 68, 68, 0.52); +} + +.summary-hero.checking { + border-color: rgba(59, 130, 246, 0.54); +} + +.summary-status-dot { + width: 14px; + height: 14px; + border-radius: 999px; + background: #64748b; + margin-top: 5px; +} + +.summary-status-dot.ok { + background: #22c55e; + box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12); +} + +.summary-status-dot.warning { + background: #f59e0b; + box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12); +} + +.summary-status-dot.error { + background: #ef4444; + box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12); +} + +.summary-status-dot.checking { + border: 2px solid #3b82f6; + border-top-color: transparent; + background: transparent; + animation: spin 0.75s linear infinite; +} + +.summary-hero span, +.summary-card-label { + color: #8d99ae; + font-size: 12px; + font-weight: 700; +} + +.summary-hero strong { + display: block; + color: #f8fafc; + font-size: 22px; + line-height: 1.15; + margin-top: 2px; +} + +.summary-hero p { + color: #b6c2d4; + margin: 5px 0 0; + overflow-wrap: anywhere; +} + +.summary-readout, +.summary-draft { + border: 1px solid #2b3342; + border-radius: 4px; + background: #131720; + padding: 13px 14px; +} + +.summary-readout-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; +} + +.summary-readout-head span, +.summary-draft span { + display: block; + color: #8d99ae; + font-size: 12px; + font-weight: 700; +} + +.summary-readout-head strong, +.summary-draft strong { + display: block; + color: #f8fafc; + font-size: 18px; + line-height: 1.2; + margin-top: 2px; +} + +.summary-pill { + flex: 0 0 auto; + border: 1px solid #2b3342; + border-radius: 999px; + background: #1a202b; + padding: 5px 9px; +} + +.summary-pill.ok { + border-color: rgba(34, 197, 94, 0.38); + color: #86efac; +} + +.summary-pill.warning { + border-color: rgba(245, 158, 11, 0.46); + color: #fcd34d; +} + +.summary-lines { + display: grid; + gap: 0; + margin: 12px 0 0; +} + +.summary-lines.compact { + margin-top: 10px; +} + +.summary-lines div { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + gap: 12px; + border-top: 1px solid #242b38; + padding: 9px 0; +} + +.summary-lines dt, +.summary-lines dd { + margin: 0; +} + +.summary-lines dt { + color: #8d99ae; + font-size: 12px; + font-weight: 750; +} + +.summary-lines dd { + color: #dbe4f0; + overflow-wrap: anywhere; +} + +.summary-draft { + display: grid; + gap: 8px; +} + +.summary-draft.ok { + border-color: rgba(34, 197, 94, 0.34); +} + +.summary-draft.warning { + border-color: rgba(245, 158, 11, 0.48); + background: rgba(120, 53, 15, 0.16); +} + +.summary-draft p { + color: #b6c2d4; + margin: 5px 0 0; + overflow-wrap: anywhere; +} + +.summary-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.summary-actions button { + min-height: 34px; + border: 1px solid #343b49; + border-radius: 4px; + background: #242a35; + color: #eef2ff; + padding: 7px 10px; + cursor: pointer; +} + +.summary-actions button:hover { + background: #2d3543; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 9px; +} + +.summary-card { + display: grid; + gap: 5px; + align-content: start; + min-height: 104px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #131720; + color: #dbeafe; + padding: 11px 12px; + text-align: left; +} + +button.summary-card { + cursor: pointer; +} + +button.summary-card:hover { + border-color: #3b82f6; + background: #172033; +} + +.summary-card.ok { + border-color: rgba(34, 197, 94, 0.36); +} + +.summary-card.warning { + border-color: rgba(245, 158, 11, 0.42); +} + +.summary-card.error { + border-color: rgba(239, 68, 68, 0.42); +} + +.summary-card.checking { + border-color: rgba(59, 130, 246, 0.42); +} + +.summary-card strong, +.summary-card span { + overflow-wrap: anywhere; +} + +.summary-card strong { + color: #f8fafc; + font-size: 15px; + line-height: 1.25; +} + +.summary-card > span:not(.summary-card-label) { + color: #9aa8bd; + font-size: 13px; +} + +.summary-card.readout { + cursor: default; +} + +.ghost-button, +.add-toolbar button, +.process-add-line button, +.app-row button, +.open-config-button, +.service-button, +.service-menu-button, +.subscription-line button, +.route-switch button, +.singbox-info-actions button, +.server-row { + min-height: 36px; + border: 1px solid #343b49; + border-radius: 4px; + background: #242a35; + color: #eef2ff; + padding: 8px 12px; + cursor: pointer; +} + +.ghost-button:hover, +.add-toolbar button:hover, +.process-add-line button:hover, +.app-row button:hover, +.open-config-button:hover, +.service-button:hover, +.service-menu-button:hover, +.subscription-line button:hover, +.route-switch button:hover, +.singbox-info-actions button:hover, +.server-row:hover { + background: #2d3543; +} + +.finder-card { + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: auto minmax(220px, 1fr) auto; + align-items: center; + gap: 12px; + justify-content: stretch; + min-height: 56px; + overflow: visible; + border: 1px solid #2b3342; + border-radius: 4px; + background: #151923; + padding: 12px; +} + +.singbox-card { + margin-top: 10px; +} + +.finder-card .finder-border-glow { + position: absolute; + display: none; + z-index: 0; + inset: 0; + overflow: hidden; + border-radius: inherit; + opacity: 0; + contain: layout paint; + pointer-events: none; + transition: opacity 0.22s ease; +} + +.finder-card > :not(.finder-border-glow) { + position: relative; + z-index: 1; +} + +.finder-card.checking .finder-border-glow, +.finder-card.working .finder-border-glow { + display: block; + opacity: 1; +} + +.finder-card.checking .finder-border-glow { + opacity: 0.78; +} + +.finder-card.settling .finder-border-glow { + display: block; + opacity: 0; + transition-duration: 0.7s; +} + +.finder-border-glow-segment { + position: absolute; + display: block; + background: #93c5fd; + box-shadow: + 0 0 8px rgba(96, 165, 250, 0.95), + 0 0 16px rgba(34, 197, 94, 0.36); + opacity: 0; +} + +.finder-border-glow-segment.top, +.finder-border-glow-segment.bottom { + width: 108px; + height: 2px; + background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); +} + +.finder-border-glow-segment.right, +.finder-border-glow-segment.left { + width: 2px; + height: 64px; + background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); +} + +.finder-border-glow-segment.top { + top: 0; + animation: finder-border-top 1.6s linear infinite; +} + +.finder-border-glow-segment.right { + right: 0; + animation: finder-border-right 1.6s linear infinite; +} + +.finder-border-glow-segment.bottom { + bottom: 0; + animation: finder-border-bottom 1.6s linear infinite; +} + +.finder-border-glow-segment.left { + left: 0; + animation: finder-border-left 1.6s linear infinite; +} + +.finder-text, +.app-row > div, +.app-row-main > div { + min-width: 0; +} + +.finder-text > strong, +.finder-text > span, +.app-row-main strong, +.app-row-main > div > span { + display: block; + overflow-wrap: anywhere; +} + +.finder-text > strong { + max-width: 520px; +} + +.finder-text > span { + max-width: 760px; +} + +.finder-card .subscription-icon, +.finder-card .server-icon { + display: grid; +} + +.status-light { + flex: 0 0 auto; + width: 11px; + height: 11px; + border-radius: 999px; + background: #ef4444; +} + +.finder-card.found .status-light { + background: #22c55e; + box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12); +} + +.finder-card.missing .status-light { + background: #f59e0b; + box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12); +} + +.finder-card.checking .status-light { + border: 2px solid #3b82f6; + border-top-color: transparent; + background: transparent; + box-shadow: none; + animation: spin 0.75s linear infinite; +} + +.setup-toggle { + display: inline-flex; + gap: 7px; + align-items: center; + width: fit-content; + min-height: 0; + border: 0; + background: transparent; + color: #93c5fd; + padding: 4px 0 0; + text-align: left; + cursor: pointer; +} + +.setup-toggle:hover { + color: #bfdbfe; +} + +.setup-toggle .ui-button-label > span { + display: inline-block; + border: 1px solid #343b49; + border-radius: 4px; + background: #1b202b; + color: #cbd5e1; + padding: 1px 6px; + font-size: 11px; + font-weight: 700; +} + +.setup-strip { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: center; + min-height: 42px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #111720; + padding: 7px 10px; +} + +.setup-strip-title { + color: #8d99ae; + font-size: 12px; + font-weight: 800; +} + +.setup-strip-items { + display: flex; + gap: 7px; + min-width: 0; + overflow-x: auto; + scrollbar-width: thin; +} + +.setup-strip-item { + display: inline-flex; + flex: 0 0 auto; + gap: 7px; + align-items: center; + min-height: 28px; + border: 1px solid #2b3342; + border-radius: 999px; + background: #151b25; + color: #dbeafe; + padding: 5px 9px; +} + +.setup-strip-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #f59e0b; + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.11); +} + +.setup-strip-item.installed .setup-strip-dot { + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.11); +} + +.setup-strip-item strong { + font-size: 12px; + white-space: nowrap; +} + +.setup-strip-item span:not(.setup-strip-dot) { + color: #9aa8bd; + font-size: 12px; + white-space: nowrap; +} + +.setup-details { + display: grid; + grid-column: 2 / -1; + gap: 6px; + border-top: 1px solid #2b3342; + margin-top: 2px; + padding-top: 10px; +} + +.setup-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 9px; + align-items: start; + min-height: 34px; + border: 1px solid #263040; + border-radius: 4px; + background: #111720; + padding: 8px 9px; +} + +.setup-state-dot { + width: 9px; + height: 9px; + border-radius: 999px; + background: #f59e0b; + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.12); + margin-top: 5px; +} + +.setup-item.installed .setup-state-dot { + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12); +} + +.setup-item strong, +.setup-item span { + display: block; + overflow-wrap: anywhere; +} + +.setup-item span { + color: #9aa8bd; + font-size: 13px; +} + +.service-actions { + position: relative; + display: flex; + gap: 6px; + align-items: center; +} + +.service-button { + min-width: 102px; + white-space: nowrap; +} + +.service-button.stop { + color: #fecaca; +} + +.service-button.install { + border-color: #166534; + background: #14532d; + color: #dcfce7; + font-weight: 700; +} + +.service-button.install:hover { + background: #166534; +} + +.service-menu { + position: relative; + display: flex; +} + +.service-menu-button { + display: grid; + place-items: center; + width: 42px; + padding: 0; +} + +.service-menu-button svg { + display: block; +} + +.service-menu-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 6; + min-width: 172px; + border: 1px solid #343b49; + border-radius: 4px; + background: #171c26; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.38); + padding: 5px; +} + +.service-menu-popover button { + width: 100%; + min-height: 34px; + border: 0; + border-radius: 3px; + background: transparent; + color: #fecaca; + text-align: left; + padding: 7px 9px; + cursor: pointer; +} + +.service-menu-popover button:hover { + background: rgba(127, 29, 29, 0.42); +} + +.route-panel { + display: grid; + gap: 8px; + margin-top: 10px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #151923; + padding: 10px; +} + +.external-proxy-card { + display: grid; + gap: 9px; +} + +.proxy-check-line { + display: grid; + grid-template-columns: minmax(0, 1fr) 132px; + gap: 8px; + align-items: stretch; +} + +.proxy-check-state, +.route-preview { + display: grid; + gap: 3px; + min-height: 46px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #111720; + padding: 9px 11px; +} + +.proxy-check-state.ok { + border-color: rgba(34, 197, 94, 0.42); +} + +.proxy-check-state.warning { + border-color: rgba(245, 158, 11, 0.42); +} + +.proxy-check-state.error { + border-color: rgba(239, 68, 68, 0.42); +} + +.proxy-check-state strong, +.route-preview strong { + color: #eef2ff; + overflow-wrap: anywhere; +} + +.proxy-check-state span, +.route-preview span { + color: #8d99ae; + overflow-wrap: anywhere; +} + +.route-preview { + margin-top: 2px; +} + +.simple-field { + display: grid; + gap: 7px; + margin: 14px 0; +} + +.route-proxy-field { + margin: 0; +} + +.simple-field input, +.process-add-line input, +.subscription-line input { + min-height: 42px; + width: 100%; + border: 1px solid #343b49; + border-radius: 4px; + background: #0d1016; + color: #f8fafc; + outline: none; + padding: 9px 11px; +} + +.simple-field input:focus, +.process-add-line input:focus, +.subscription-line input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 1px #3b82f6; +} + +.singbox-workspace { + display: grid; + grid-column: 1 / -1; + gap: 8px; + border-top: 1px solid #2b3342; + margin-top: 2px; + padding-top: 10px; +} + +.singbox-inline-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.info-toggle { + display: grid; + place-items: center; + width: 28px; + height: 24px; + border: 1px solid #263040; + border-radius: 4px; + background: #111720; + color: #bfdbfe; + cursor: pointer; +} + +.info-toggle:hover { + background: #1d2735; +} + +.info-toggle svg { + display: block; +} + +.singbox-info-popover { + position: relative; + z-index: 8; + display: grid; + grid-column: 1 / -1; + gap: 10px; + width: 100%; + border: 1px solid #343b49; + border-radius: 4px; + background: #111720; + box-shadow: none; + padding: 12px; +} + +.singbox-info-grid { + display: grid; + grid-template-columns: 86px minmax(0, 1fr); + gap: 7px 10px; + align-items: start; +} + +.singbox-info-grid span { + color: #8d99ae; + font-size: 12px; +} + +.singbox-info-grid strong { + min-width: 0; + color: #dbeafe; + font-size: 12px; + overflow-wrap: anywhere; +} + +.singbox-info-actions { + justify-content: flex-start; + gap: 7px; +} + +.singbox-info-actions button { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 32px; + color: #dbeafe; + font-weight: 700; +} + +.singbox-info-actions button svg { + display: block; +} + +.route-switch { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.route-switch button { + min-height: 38px; + width: 100%; + color: #cbd5e1; + font-weight: 700; +} + +.route-switch button.active { + border-color: #2563eb; + background: #1d4ed8; + color: #eff6ff; +} + +.route-switch .ui-button--primary { + border-color: #2563eb; + background: #1d4ed8; + color: #eff6ff; +} + +.subscription-line { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 112px 42px; + gap: 7px; + align-items: center; +} + +.subscription-icon, +.server-icon { + display: grid; + place-items: center; + color: #bfdbfe; +} + +.subscription-icon { + width: 38px; + height: 42px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #0d1016; +} + +.subscription-line button { + min-height: 42px; + white-space: nowrap; +} + +.subscription-line .icon-command { + display: grid; + place-items: center; + width: 42px; + padding: 0; + color: #fecaca; +} + +.server-list { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 7px; +} + +.server-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 34px; + align-items: center; + justify-content: stretch; + gap: 7px; + width: 100%; + min-width: 0; + min-height: 38px; + text-align: left; + padding: 7px 9px; +} + +.server-row.selected { + border-color: #2563eb; + background: #17213a; +} + +.server-row.ok { + border-color: rgba(34, 197, 94, 0.42); +} + +.server-row.error { + border-color: rgba(239, 68, 68, 0.42); +} + +.server-select-button, +.server-ping-button { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0; +} + +.server-select-button { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + text-align: left; +} + +.server-ping-button { + display: grid; + place-items: center; + width: 30px; + height: 28px; + border: 1px solid #263040; + border-radius: 4px; + color: #bfdbfe; +} + +.server-ping-button:hover { + background: #1d2735; +} + +.server-select-dot { + width: 10px; + height: 10px; + border: 1px solid #64748b; + border-radius: 999px; + background: #0d1016; +} + +.server-row.selected .server-select-dot { + border-color: #93c5fd; + background: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.16); +} + +.server-row strong { + min-width: 0; + overflow: hidden; + color: #dbeafe; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.server-ping-badge { + flex: 0 0 auto; + border: 1px solid #263040; + border-radius: 4px; + color: #bfdbfe; + padding: 2px 5px; + font-size: 11px; + font-weight: 800; +} + +.singbox-install-note { + display: grid; + grid-column: 1 / -1; + gap: 3px; + border-top: 1px solid #2b3342; + color: #9aa8bd; + margin-top: 2px; + padding-top: 10px; +} + +.singbox-install-note strong { + color: #e5e7eb; +} + +.apply-state { + display: grid; + gap: 3px; + margin-top: 14px; + border: 1px solid rgba(245, 158, 11, 0.48); + border-radius: 4px; + background: rgba(120, 53, 15, 0.2); + color: #fcd34d; + padding: 10px 12px; +} + +.apply-state span { + color: #d6b875; + overflow-wrap: anywhere; +} + +.apps-section { + display: grid; + gap: 9px; + margin-top: 8px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #10141c; + padding: 12px; +} + +.apps-header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(340px, 520px); + gap: 12px; + align-items: center; + min-height: 48px; +} + +.apps-title { + min-height: 48px; + align-items: center; + justify-content: flex-start; +} + +.apps-config-actions { + display: grid; + gap: 8px; + align-items: center; +} + +.apps-config-actions .apply-state { + margin: 0; +} + +.apps-config-actions .command-row { + grid-template-columns: minmax(0, 1fr) 118px; + margin-top: 0; +} + +.apps-config-actions .apply-button, +.apps-config-actions .open-config-button { + height: 48px; + min-height: 48px; +} + +.apps-config-actions .config-path { + margin: 0; + color: #8d99ae; +} + +.section-head span { + min-width: 28px; + height: 30px; + border: 1px solid #343b49; + border-radius: 4px; + background: #1b202b; + color: #dbeafe; + padding: 0 9px; + text-align: center; + font-size: 12px; + font-weight: 700; + line-height: 28px; +} + +.add-toolbar { + justify-content: flex-end; + padding: 0; +} + +.add-tile { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + width: 44px; + height: 38px; + border-color: #2b3342; + background: #202633; + color: #dbeafe; + line-height: 0; + padding: 0; +} + +.add-tile svg { + width: 20px; + height: 20px; + display: block; + flex: 0 0 auto; + transform: translate(0.5px, 0.5px); +} + +.add-tile:hover { + border-color: #3b82f6; + background: #182033; +} + +.add-tile[data-tooltip]::before, +.add-tile[data-tooltip]::after { + position: absolute; + left: 50%; + z-index: 30; + pointer-events: none; + opacity: 0; + transition: opacity 90ms ease, transform 120ms ease; + transition-delay: 55ms; +} + +.add-tile[data-tooltip]::before { + bottom: calc(100% + 3px); + width: 8px; + height: 8px; + background: #0d1118; + border-right: 1px solid #334155; + border-bottom: 1px solid #334155; + content: ""; + transform: translate(-50%, 2px) rotate(45deg); +} + +.add-tile[data-tooltip]::after { + bottom: calc(100% + 8px); + border: 1px solid #334155; + border-radius: 4px; + background: #0d1118; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34); + color: #e5e7eb; + content: attr(data-tooltip); + font-size: 12px; + font-weight: 750; + line-height: 1; + padding: 7px 8px; + transform: translate(-50%, 4px); + white-space: nowrap; +} + +.add-tile[data-tooltip]:hover::before, +.add-tile[data-tooltip]:hover::after, +.add-tile[data-tooltip]:focus-visible::before, +.add-tile[data-tooltip]:focus-visible::after { + opacity: 1; +} + +.add-tile[data-tooltip]:hover::before, +.add-tile[data-tooltip]:focus-visible::before { + transform: translate(-50%, 0) rotate(45deg); +} + +.add-tile[data-tooltip]:hover::after, +.add-tile[data-tooltip]:focus-visible::after { + transform: translate(-50%, 0); +} + +.add-tile[aria-pressed="true"] { + border-color: #3b82f6; + background: #1e3a8a; +} + +.tile-loading { + color: #dbeafe; + font-weight: 800; + animation: pulse 1s ease-in-out infinite; +} + +.app-add-skeleton { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + min-height: 58px; + border: 1px dashed #334155; + border-radius: 4px; + background: + linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.08), transparent), + #121722; + background-size: 220% 100%; + color: #94a3b8; + padding: 9px 12px; +} + +.app-add-main { + display: flex; + gap: 10px; + align-items: center; + min-width: 0; +} + +.app-add-main strong, +.app-add-main span { + display: block; +} + +.app-add-main strong { + color: #cbd5e1; +} + +.app-add-main span { + color: #7d8aa0; + font-size: 13px; +} + +.process-add-line { + display: grid; + grid-template-columns: minmax(0, 1fr) 72px 88px; +} + +.process-add-line button { + border-color: #166534; + background: #14532d; + font-weight: 700; +} + +.process-add-line .process-cancel-button { + border-color: #343b49; + background: #242a35; + color: #cbd5e1; + font-weight: 600; +} + +.process-add-line button:hover { + background: #166534; +} + +.process-add-line .process-cancel-button:hover { + background: #2d3543; +} + +.app-list { + display: grid; + gap: 6px; + margin-top: 1px; +} + +.app-row-main { + display: flex; + align-items: center; + gap: 10px; +} + +.app-row .item-icon, +.app-add-main .item-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + align-self: center; + width: 34px; + height: 34px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #0d1016; + color: #dbeafe; + line-height: 0; +} + +.app-add-main .app-add-icon { + color: #64748b; +} + +.app-row .item-icon svg, +.app-add-main .item-icon svg { + width: 20px; + height: 20px; + display: block; + flex: 0 0 auto; +} + +.app-row, +.empty-state { + border: 1px solid #2b3342; + border-radius: 4px; + background: #131720; + padding: 10px 12px; +} + +.app-row button { + color: #fecaca; +} + +.empty-state { + color: #8d99ae; + min-height: 48px; +} + +.list-skeleton { + display: grid; + gap: 8px; +} + +.list-skeleton span { + min-height: 56px; + border: 1px solid #2b3342; + border-radius: 4px; + background: + linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.12), transparent), + #131720; + background-size: 220% 100%; + animation: shimmer 1.15s linear infinite; +} + +.command-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 132px; + gap: 8px; + margin-top: 14px; +} + +.command-row .ui-button, +.proxy-check-line .ui-button, +.subscription-line .ui-button { + width: 100%; +} + +.apps-config-actions .ui-button { + height: 48px; + min-height: 48px; +} + +.apply-button { + min-height: 46px; + width: 100%; + border: 1px solid #16a34a; + border-radius: 4px; + background: #22c55e; + color: #04130a; + font-weight: 800; + cursor: pointer; +} + +.apply-button:hover { + background: #4ade80; +} + +.open-config-button { + min-height: 46px; + width: 100%; +} + +.config-path { + margin: 10px 0 0; + font-size: 12px; + overflow-wrap: anywhere; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.log-dock { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 40; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-height: var(--app-footer-height); + border-top: 1px solid #2b3342; + background: #151923; + box-shadow: none; + margin: 0; + padding: 8px 14px; +} + +.log-dock.error { + border-top-color: rgba(239, 68, 68, 0.58); +} + +.log-dock.success { + border-top-color: rgba(34, 197, 94, 0.58); +} + +.log-dock.info { + border-top-color: rgba(59, 130, 246, 0.58); +} + +.log-current { + display: flex; + min-width: 0; + gap: 10px; + align-items: baseline; + padding: 0 8px; + transition: opacity 180ms ease, transform 180ms ease; +} + +.log-current.hidden { + color: #8d99ae; + opacity: 0.72; +} + +.log-current.visible { + opacity: 1; +} + +.log-current strong, +.log-current span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.log-current strong { + flex: 0 1 auto; + min-width: 0; +} + +.log-current span { + min-width: 0; +} + +.log-muted { + color: #8d99ae; +} + +.log-toggle { + display: inline-flex; + gap: 8px; + align-items: center; + min-height: 32px; + border: 1px solid #343b49; + border-radius: 4px; + background: #242a35; + color: #eef2ff; + padding: 6px 10px; + cursor: pointer; +} + +.log-toggle:hover { + background: #2d3543; +} + +.log-toggle .log-count { + min-width: 22px; + border-radius: 4px; + background: #1b202b; + color: #dbeafe; + padding: 2px 6px; + text-align: center; + font-size: 12px; + font-weight: 700; +} + +.log-history { + position: absolute; + right: 0; + bottom: 100%; + left: 0; + display: grid; + grid-column: 1 / -1; + gap: 6px; + max-height: 230px; + overflow: auto; + border: 1px solid #2b3342; + border-bottom: 0; + background: #151923; + padding: 7px 14px; +} + +.log-history-row { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 10px; + align-items: start; + border: 1px solid #2b3342; + border-radius: 4px; + background: #0d1016; + padding: 8px; +} + +.log-history-row.error { + border-color: rgba(239, 68, 68, 0.42); +} + +.log-history-row.success { + border-color: rgba(34, 197, 94, 0.36); +} + +.log-history-row.info { + border-color: rgba(59, 130, 246, 0.36); +} + +.log-history-row time { + color: #8d99ae; + font-size: 12px; +} + +.log-history-row strong, +.log-history-row span { + display: block; + overflow-wrap: anywhere; +} + +.log-history-row span { + color: #b6c2d4; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes pulse { + 50% { + opacity: 0.45; + } +} + +@keyframes finder-border-top { + 0% { + left: -116px; + opacity: 0; + } + + 4%, + 23% { + opacity: 1; + } + + 25%, + 100% { + left: calc(100% + 8px); + opacity: 0; + } +} + +@keyframes finder-border-right { + 0%, + 25% { + top: -72px; + opacity: 0; + } + + 29%, + 48% { + opacity: 1; + } + + 50%, + 100% { + top: calc(100% + 8px); + opacity: 0; + } +} + +@keyframes finder-border-bottom { + 0%, + 50% { + right: -116px; + opacity: 0; + } + + 54%, + 73% { + opacity: 1; + } + + 75%, + 100% { + right: calc(100% + 8px); + opacity: 0; + } +} + +@keyframes finder-border-left { + 0%, + 75% { + bottom: -72px; + opacity: 0; + } + + 79%, + 98% { + opacity: 1; + } + + 100% { + bottom: calc(100% + 8px); + opacity: 0; + } +} + +@keyframes shimmer { + from { + background-position: 220% 0; + } + + to { + background-position: -220% 0; + } +} + +@keyframes panel-swipe-right { + from { + opacity: 0; + transform: translateX(34px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes panel-swipe-left { + from { + opacity: 0; + transform: translateX(-34px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .tab-panel-frame, + .tab-panel, + .finder-card .finder-border-glow, + .finder-border-glow-segment, + .status-light, + .summary-status-dot.checking, + .list-skeleton span, + .tile-loading { + animation: none; + transition: none; + } +} + +@media (max-width: 680px) { + .simple-shell { + padding: var(--app-header-height) 0 var(--app-footer-height); + } + + .simple-panel { + height: calc(100vh - var(--app-header-height) - var(--app-footer-height)); + padding: 12px 12px 16px; + } + + .app-row { + align-items: stretch; + flex-direction: column; + } + + .simple-header { + align-items: center; + flex-direction: row; + gap: 10px; + margin: 0; + padding: 7px 10px; + } + + .simple-header h1 { + font-size: 16px; + } + + .simple-header .ghost-button { + min-height: 30px; + padding: 5px 8px; + font-size: 12px; + } + + .panel-tabs { + gap: 1px; + padding: 0 10px; + } + + .ui-tabs { + gap: 1px; + padding: 0 10px; + } + + .panel-tabs button { + flex: 1 0 110px; + min-height: var(--app-tab-height); + padding: 8px 10px 9px; + font-size: 13px; + } + + .ui-tab { + flex: 1 0 110px; + min-height: var(--app-tab-height); + padding: 8px 10px 9px; + font-size: 13px; + } + + .panel-section-head { + align-items: stretch; + flex-direction: column; + } + + .panel-section-head > strong { + text-align: left; + } + + .summary-grid { + grid-template-columns: 1fr; + } + + .summary-readout-head { + align-items: stretch; + flex-direction: column; + } + + .summary-pill { + width: fit-content; + } + + .summary-lines div { + grid-template-columns: 1fr; + gap: 4px; + } + + .summary-actions { + display: grid; + grid-template-columns: 1fr; + } + + .setup-strip { + grid-template-columns: 1fr; + align-items: stretch; + } + + .apps-header { + grid-template-columns: 1fr; + min-height: 0; + } + + .apps-title { + min-height: 34px; + } + + .apps-config-actions .command-row { + grid-template-columns: 1fr; + } + + .app-add-skeleton { + grid-template-columns: 1fr; + } + + .proxy-check-line { + grid-template-columns: 1fr; + } + + .add-toolbar { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .add-tile { + width: 100%; + } + + .process-add-line { + grid-template-columns: 1fr; + } + + .subscription-line { + grid-template-columns: 38px minmax(0, 1fr) 42px; + } + + .subscription-line input { + grid-column: 2 / 4; + } + + .subscription-line button:not(.icon-command) { + grid-column: 1 / 3; + } + + .subscription-line .icon-command { + grid-column: 3; + width: 42px; + } + + .finder-card { + grid-template-columns: auto minmax(0, 1fr); + } + + .ui-service-row { + grid-template-columns: auto minmax(0, 1fr); + } + + .service-actions { + grid-column: 1 / -1; + } + + .ui-service-actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + width: 100%; + } + + .ui-service-actions > .ui-button { + width: 100%; + } + + .ui-service-actions > .ui-button:only-child { + grid-column: 1 / -1; + } + + .setup-strip-items { + flex-wrap: wrap; + overflow: visible; + } + + .setup-details { + grid-column: 1 / -1; + } + + .service-button { + flex: 1; + } + + .singbox-info-popover { + width: auto; + } + + .singbox-info-grid { + grid-template-columns: 70px minmax(0, 1fr); + } + + .server-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .command-row { + grid-template-columns: 1fr; + } + + .log-dock { + position: fixed; + right: 0; + bottom: 0; + left: 0; + grid-template-columns: minmax(0, 1fr) auto; + margin: 0; + padding: 7px 10px; + } + + .log-current { + align-items: baseline; + flex-direction: row; + gap: 8px; + padding: 0 4px; + } + + .log-current strong { + white-space: nowrap; + } + + .log-current span:not(.log-muted) { + display: none; + } + + .log-history { + max-height: 220px; + padding: 7px 10px; + } + + .log-history-row { + grid-template-columns: 1fr; + } +} diff --git a/src/ui/ActionMenu.tsx b/src/ui/ActionMenu.tsx new file mode 100644 index 0000000..eab534a --- /dev/null +++ b/src/ui/ActionMenu.tsx @@ -0,0 +1,54 @@ +import { MoreHorizontal } from 'lucide-react'; +import { IconButton } from './IconButton'; + +export interface ActionMenuItem { + label: string; + onClick: () => void; + danger?: boolean; + disabled?: boolean; +} + +export interface ActionMenuProps { + open: boolean; + onOpenChange: (open: boolean) => void; + label: string; + items: ActionMenuItem[]; + disabled?: boolean; +} + +export function ActionMenu({ + open, + onOpenChange, + label, + items, + disabled, +}: ActionMenuProps) { + return ( +
+ } + onClick={() => onOpenChange(!open)} + disabled={disabled} + aria-expanded={open} + /> + {open ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : null} +
+ ); +} + diff --git a/src/ui/Button.tsx b/src/ui/Button.tsx new file mode 100644 index 0000000..10c7311 --- /dev/null +++ b/src/ui/Button.tsx @@ -0,0 +1,49 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger'; +export type ButtonSize = 'sm' | 'md' | 'lg'; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + loading?: boolean; + loadingLabel?: string; + leftIcon?: ReactNode; + rightIcon?: ReactNode; +} + +export function Button({ + variant = 'neutral', + size = 'md', + loading = false, + loadingLabel, + leftIcon, + rightIcon, + className, + children, + disabled, + ...props +}: ButtonProps) { + const classes = [ + 'ui-button', + `ui-button--${variant}`, + `ui-button--${size}`, + loading ? 'is-loading' : '', + className ?? '', + ].filter(Boolean).join(' '); + + return ( + + ); +} + diff --git a/src/ui/Field.tsx b/src/ui/Field.tsx new file mode 100644 index 0000000..e2b669a --- /dev/null +++ b/src/ui/Field.tsx @@ -0,0 +1,38 @@ +import type { InputHTMLAttributes, ReactNode } from 'react'; + +export interface FieldProps extends InputHTMLAttributes { + label: string; + error?: string | null; + hint?: string; + action?: ReactNode; +} + +export function Field({ + label, + error, + hint, + action, + className, + id, + ...props +}: FieldProps) { + const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`; + const helpId = `${inputId}-help`; + + return ( + + ); +} + diff --git a/src/ui/IconButton.tsx b/src/ui/IconButton.tsx new file mode 100644 index 0000000..bd553b7 --- /dev/null +++ b/src/ui/IconButton.tsx @@ -0,0 +1,42 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +export type IconButtonVariant = 'neutral' | 'add' | 'danger'; + +export interface IconButtonProps extends ButtonHTMLAttributes { + label: string; + icon: ReactNode; + variant?: IconButtonVariant; + loading?: boolean; +} + +export function IconButton({ + label, + icon, + variant = 'neutral', + loading = false, + className, + disabled, + title, + ...props +}: IconButtonProps) { + const classes = [ + 'ui-icon-button', + `ui-icon-button--${variant}`, + loading ? 'is-loading' : '', + className ?? '', + ].filter(Boolean).join(' '); + + return ( + + ); +} + diff --git a/src/ui/LogDock.tsx b/src/ui/LogDock.tsx new file mode 100644 index 0000000..176796b --- /dev/null +++ b/src/ui/LogDock.tsx @@ -0,0 +1,82 @@ +import { Button } from './Button'; + +export interface LogDockEntry { + id: string; + kind: 'success' | 'error' | 'info'; + title: string; + text: string; + at: number; +} + +export interface LogDockProps { + entries: LogDockEntry[]; + activeEntry: LogDockEntry | null; + open: boolean; + onToggle: () => void; + formatTime: (timestamp: number) => string; +} + +function isNativePreviewError(entry: LogDockEntry | null) { + if (!entry) return false; + return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading'); +} + +function displayEntry(entry: LogDockEntry | null) { + if (!entry) return null; + if (!isNativePreviewError(entry)) return entry; + return { + ...entry, + title: 'Desktop-команды недоступны', + text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.', + }; +} + +export function LogDock({ + entries, + activeEntry, + open, + onToggle, + formatTime, +}: LogDockProps) { + const current = displayEntry(activeEntry); + + return ( +
+
+ {current ? ( + <> + {current.title} + {current.text} + + ) : ( + Журнал событий + )} +
+ + {open ? ( +
+ {entries.length ? entries.map((entry) => { + const friendly = displayEntry(entry); + return ( +
+ +
+ {friendly?.title ?? entry.title} + {friendly?.text ?? entry.text} + {isNativePreviewError(entry) ? Детали: {entry.text} : null} +
+
+ ); + }) : ( +
+ + Событий пока нет. +
+ )} +
+ ) : null} +
+ ); +} diff --git a/src/ui/ServiceControlRow.tsx b/src/ui/ServiceControlRow.tsx new file mode 100644 index 0000000..b2c3bc8 --- /dev/null +++ b/src/ui/ServiceControlRow.tsx @@ -0,0 +1,93 @@ +import type { ReactNode } from 'react'; +import { Button, type ButtonVariant } from './Button'; +import { ActionMenu, type ActionMenuItem } from './ActionMenu'; + +export type ServiceControlState = 'checking' | 'missing' | 'installed' | 'running' | 'stopped' | 'error'; + +export interface ServicePrimaryAction { + label: string; + onClick: () => void; + variant?: ButtonVariant; + loading?: boolean; + loadingLabel?: string; + disabled?: boolean; +} + +export interface ServiceControlRowProps { + state: ServiceControlState; + title: string; + detail: string; + primaryAction?: ServicePrimaryAction; + menu?: { + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; + items: ActionMenuItem[]; + disabled?: boolean; + }; + visualState?: 'working' | 'settling' | null; + className?: string; + inlineActions?: ReactNode; + children?: ReactNode; +} + +export function ServiceControlRow({ + state, + title, + detail, + primaryAction, + menu, + visualState, + className, + inlineActions, + children, +}: ServiceControlRowProps) { + const classes = [ + 'ui-service-row', + `ui-service-row--${state}`, + visualState ? `ui-service-row--${visualState}` : '', + className ?? '', + ].filter(Boolean).join(' '); + + return ( +
+
+ ); +} + diff --git a/src/ui/StatusPill.tsx b/src/ui/StatusPill.tsx new file mode 100644 index 0000000..3107f50 --- /dev/null +++ b/src/ui/StatusPill.tsx @@ -0,0 +1,11 @@ +export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted'; + +export interface StatusPillProps { + tone?: StatusPillTone; + children: string; +} + +export function StatusPill({ tone = 'muted', children }: StatusPillProps) { + return {children}; +} + diff --git a/src/ui/Tabs.tsx b/src/ui/Tabs.tsx new file mode 100644 index 0000000..1632bd5 --- /dev/null +++ b/src/ui/Tabs.tsx @@ -0,0 +1,81 @@ +import { useRef, type KeyboardEvent } from 'react'; + +export interface TabItem { + id: T; + label: string; +} + +export interface TabsProps { + items: Array>; + activeId: T; + onChange: (id: T) => void; + ariaLabel: string; +} + +export function Tabs({ + items, + activeId, + onChange, + ariaLabel, +}: TabsProps) { + const refs = useRef>([]); + + function moveFocus(currentId: T, direction: 1 | -1) { + const currentIndex = items.findIndex((item) => item.id === currentId); + const nextIndex = (currentIndex + direction + items.length) % items.length; + const next = items[nextIndex]; + if (!next) return; + onChange(next.id); + window.requestAnimationFrame(() => refs.current[nextIndex]?.focus()); + } + + function handleKeyDown(event: KeyboardEvent, id: T) { + if (event.key === 'ArrowRight') { + event.preventDefault(); + moveFocus(id, 1); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + moveFocus(id, -1); + } else if (event.key === 'Home') { + event.preventDefault(); + const first = items[0]; + if (!first) return; + onChange(first.id); + window.requestAnimationFrame(() => refs.current[0]?.focus()); + } else if (event.key === 'End') { + event.preventDefault(); + const last = items[items.length - 1]; + if (!last) return; + onChange(last.id); + window.requestAnimationFrame(() => refs.current[items.length - 1]?.focus()); + } + } + + return ( +
+ {items.map((item, index) => { + const active = item.id === activeId; + return ( + + ); + })} +
+ ); +} + diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 0000000..3c1edc2 --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,17 @@ +export { ActionMenu } from './ActionMenu'; +export type { ActionMenuItem } from './ActionMenu'; +export { Button } from './Button'; +export type { ButtonProps, ButtonSize, ButtonVariant } from './Button'; +export { Field } from './Field'; +export type { FieldProps } from './Field'; +export { IconButton } from './IconButton'; +export type { IconButtonProps, IconButtonVariant } from './IconButton'; +export { LogDock } from './LogDock'; +export type { LogDockEntry, LogDockProps } from './LogDock'; +export { ServiceControlRow } from './ServiceControlRow'; +export type { ServiceControlRowProps, ServiceControlState } from './ServiceControlRow'; +export { StatusPill } from './StatusPill'; +export type { StatusPillProps, StatusPillTone } from './StatusPill'; +export { Tabs } from './Tabs'; +export type { TabItem, TabsProps } from './Tabs'; + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dde57bf --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [] +} + diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b1fcac5 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +const host = process.env.TAURI_DEV_HOST || '127.0.0.1'; + +export default defineConfig({ + plugins: [react()], + clearScreen: false, + server: { + host: host || false, + port: 5173, + strictPort: true, + watch: { + ignored: ['**/src-tauri/**'], + }, + }, +});