Compare commits

..

13 Commits

71 changed files with 5875 additions and 1361 deletions

61
.agent/README.md Normal file
View File

@@ -0,0 +1,61 @@
# ProxyWarden Agent Kit
Этот каталог содержит инструкции для кодовых агентов, которые работают с ProxyWarden.
Главный файл — `AGENTS.md` в корне репозитория. Он задает инварианты и общие правила. Файлы в `.agent/skills` описывают конкретные режимы работы: backend, UI, security, Windows services, subscriptions/routing, testing/release и понятные отчеты.
## Как использовать
1. Прочитать корневой `AGENTS.md`.
2. Выбрать skill под задачу.
3. Перед изменением проверить релевантные чек-листы из `.agent/checklists`.
4. После изменения выполнить минимальные проверки.
5. Для любого нетривиального ответа использовать `communication-reporting`: коротко, по файлам, с проверками и рисками.
6. В финальном отчете явно указать, что было и не было проверено.
## Как агент должен писать ответы
По умолчанию агент пишет не техническую простыню, а короткий отчет:
```text
Коротко
Что изменилось по файлам
Важные места
Проверено
Не проверено
Риски
```
Для 2+ файлов желательно использовать таблицу `Файл / Что изменилось / Зачем`. В ответе должны быть конкретные пути файлов и человеческая причина изменения. Не надо пересказывать каждую строку diff, если пользователь не попросил.
Подробные правила лежат в `.agent/skills/communication-reporting/SKILL.md`, чек-лист — в `.agent/checklists/communication.md`. Да, это нужно отдельно прописывать, иначе агент опять напишет роман о своем внутреннем мире и двух переименованных переменных.
## Skill index
- `repository-orientation` — вход в проект, карта файлов, где искать source of truth.
- `rust-tauri-backend` — Tauri commands, Rust models, validation, storage, adapters.
- `react-typescript-ui` — React UI, typed invoke facade, readiness, components.
- `security-hardening` — CSP, секреты, elevated boundary, storage corruption, SSRF.
- `windows-services-powershell` — scripts, UAC, services, ProxiFyre/sing-box operations.
- `subscriptions-routing` — external SOCKS5, sing-box subscriptions, config generation, ping.
- `testing-ci-release` — build/test matrix, CI recommendations, release hygiene.
- `communication-reporting` — короткие планы, понятные сводки по файлам, отчеты без текстовой каши.
## Communication defaults
Перед длинным ответом или отчетом использовать:
- `.agent/skills/communication-reporting/SKILL.md`
- `.agent/checklists/communication.md`
- `.agent/checklists/explanation-quality.md`
- `.agent/templates/change-report.md` или `.agent/templates/user-facing-summary.md`
- `.agent/templates/file-impact-map.md`, если надо заранее показать, какие файлы будут затронуты
Главная идея: сначала короткая сводка, потом таблица файлов, потом проверки и риски. Не наоборот, потому что пользователь не обязан добывать смысл киркой.
## Что не является целью
- Перевод проекта в SaaS/gateway/server.
- Добавление облачного backend.
- Замена ProxiFyre без отдельной архитектурной задачи.
- Коммерциализация, telemetry-first подход или рекламная шелуха, этот вид пластика уже и так в океане.

View File

@@ -0,0 +1,30 @@
# Change Safety Checklist
Use this before and after non-trivial changes.
## Scope
- [ ] I identified whether this touches backend, UI, security, Windows service, subscription/routing, testing/release.
- [ ] I read the matching skill file.
- [ ] I avoided unrelated rewrites.
- [ ] I did not introduce a second source of truth.
## Tauri boundary
- [ ] New/changed Rust command has matching TypeScript wrapper.
- [ ] DTOs are synchronized between Rust and TypeScript.
- [ ] Error shape is structured and actionable.
- [ ] Blocking work is not run on async runtime thread.
## UX
- [ ] User-visible actions are explicit.
- [ ] Disabled states have reasons.
- [ ] Pending changes are visible before apply.
- [ ] Secrets are redacted.
## Validation
- [ ] Relevant frontend build/test was run or explicitly not run with reason.
- [ ] Relevant Rust fmt/clippy/test was run or explicitly not run with reason.
- [ ] Windows-specific behavior was not claimed unless tested on Windows.

View File

@@ -0,0 +1,37 @@
# Communication Checklist
Используй перед финальным ответом по любой нетривиальной задаче.
## Структура
- [ ] Ответ начинается с `Коротко` или с такой же короткой сводки на 2-4 пункта.
- [ ] Измененные файлы или зоны проекта перечислены в начале ответа, а не спрятаны в конце.
- [ ] Для каждого важного файла понятно: что изменилось и зачем.
- [ ] Важные изменения поведения, безопасности или состояния отделены от мелких деталей.
- [ ] Проверки разделены на `Проверено` и `Не проверено`.
- [ ] Риски написаны явно.
## Понятность
- [ ] Нет плотных абзацев длиннее 4-5 строк.
- [ ] Нет терминов и аббревиатур без пользы или краткого объяснения.
- [ ] Нет полных логов, если они не нужны для вывода.
- [ ] Нет пересказа каждой строки diff, если пользователь не просил.
- [ ] Нет мутных фраз вроде `улучшена архитектура` без объяснения, что стало проще, безопаснее или понятнее.
## Честность
- [ ] Windows/service/elevation поведение не названо проверенным, если оно не тестировалось на Windows.
- [ ] У пропущенных проверок есть простая причина.
- [ ] Ответ не говорит `готово`, если важные проверки пропущены.
## Быстрая самопроверка
Перед отправкой ответ должен отвечать на вопросы:
1. Что изменилось или найдено?
2. В каких файлах?
3. Зачем это нужно?
4. Что реально проверено?
5. Что не проверено?
6. Где остался риск?

View File

@@ -0,0 +1,41 @@
# Checklist: Explanation Quality
Используй перед финальным ответом или PR summary.
## Обязательное
- [ ] В начале есть короткий итог на 24 пункта.
- [ ] Есть список файлов или таблица `файл / что / зачем`.
- [ ] Термины объяснены простыми словами, если они важны.
- [ ] Нет длинных полотен без заголовков.
- [ ] Нет пересказа каждой строки diff.
- [ ] Указано, что проверено.
- [ ] Указано, что не проверено.
- [ ] Риски написаны прямо, без «должно работать».
## Хороший формат
```md
## Коротко
- ...
## Файлы
| Файл | Что | Зачем |
|---|---|---|
## Проверки
- Выполнено: ...
- Не выполнено: ...
## Риски
- ...
```
## Плохие признаки
- Один огромный абзац.
- Много терминов без пользы.
- «Исправлена логика» без указания файла и эффекта.
- «Проверено» без команды или способа проверки.
- «Не проверял Windows, но всё готово».
- Список из 25 пунктов одинаковой важности.

View File

@@ -0,0 +1,30 @@
# Release Checklist
## Build
- [ ] `npm ci`
- [ ] `npm run build`
- [ ] `cargo fmt --all -- --check`
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
- [ ] `cargo test --all-targets`
- [ ] `npm run tauri -- build`
## Windows smoke
- [ ] Fresh Windows VM smoke test.
- [ ] ProxiFyre install plan and real install.
- [ ] sing-box install plan and real install.
- [ ] External SOCKS5 route works.
- [ ] Local sing-box subscription route works.
- [ ] Start/stop/restart service controls work.
- [ ] Uninstall does not delete unmanaged paths.
## Security/release hygiene
- [ ] CSP enabled.
- [ ] Capabilities minimal.
- [ ] No raw secrets in repo/logs.
- [ ] Subscription redaction checked.
- [ ] Generated config writes are safe.
- [ ] Artifact version is correct.
- [ ] Large assets reviewed/compressed if practical.

View File

@@ -0,0 +1,36 @@
# Security Checklist
## Secrets
- [ ] Subscription URLs are redacted through parser-based logic.
- [ ] Proxy credentials are not logged.
- [ ] Outbound configs with secrets are not printed in diagnostics.
- [ ] Error messages do not include tokens/passwords/userinfo.
## Tauri/webview
- [ ] CSP is enabled.
- [ ] No broad shell permissions added.
- [ ] No direct command execution from UI input.
- [ ] No `dangerouslySetInnerHTML` or equivalent unsafe HTML rendering without sanitization.
## Network fetch
- [ ] Subscription fetch has timeout.
- [ ] URL scheme is restricted.
- [ ] Local/private/link-local/metadata address behavior is explicit.
- [ ] Redirect behavior does not bypass blocked address checks.
## Filesystem
- [ ] Critical writes are atomic where practical.
- [ ] Corrupt config handling does not silently discard user state.
- [ ] Recursive delete has strict path/marker checks.
- [ ] Temp elevated scripts use unpredictable names and safe directory/ACL when practical.
## Windows services
- [ ] Managed service is verified by name and PathName/metadata.
- [ ] Fuzzy candidates are not automatically controlled.
- [ ] UAC cancellation has clear error.
- [ ] Plan-only remains side-effect-free.

24
.agent/checklists/ui.md Normal file
View File

@@ -0,0 +1,24 @@
# UI Checklist
## Structure
- [ ] No direct `invoke(...)` outside `src/api/tauriCommands.ts`.
- [ ] New reusable UI uses `src/ui/*` components or extends them.
- [ ] New business/display logic is not buried in JSX if it can be tested separately.
- [ ] `App.tsx` was not made worse without justification.
## Accessibility and behavior
- [ ] Buttons have accessible names.
- [ ] Toggle state uses `aria-pressed` or equivalent.
- [ ] Tabs/popovers preserve keyboard and screen-reader behavior.
- [ ] Reduced motion preference is respected where animation is added.
- [ ] Errors are visible and readable.
## ProxyWarden-specific
- [ ] External SOCKS5 route does not require sing-box.
- [ ] Local sing-box route requires selected server and readiness.
- [ ] Route chain matches actual backend state.
- [ ] Apply readiness gives a clear reason.
- [ ] Summary remains read-only.

View File

@@ -0,0 +1,248 @@
# Skill: Communication Reporting
## Когда использовать
Используй этот skill в каждом ответе пользователю после анализа, правки кода, ревью, аудита, планирования рефакторинга или подготовки PR. Особенно если задача затрагивает несколько файлов, backend/frontend boundary, security, Windows services или UI.
## Цель
Писать так, чтобы человек с опытом разработки быстро понял суть без чтения технической простыни. Не упрощать до детского сада, но объяснять по-человечески: что поменялось, где, зачем, как проверить, где риск.
Пользователь не обязан продираться через внутренний монолог агента и каталог аббревиатур. У него есть жизнь, возможно даже вне репозитория, страшно представить.
## Базовый формат ответа
Для нетривиальных изменений используй такую структуру:
```text
Коротко
- 2-4 пункта: главный результат, важный риск, что проверить.
Что изменилось по файлам
| Файл | Что изменилось | Зачем |
|---|---|---|
| src/... | Кратко | Человеческая причина |
Важные места
- 3-6 конкретных мест: файл + функция/секция + смысл.
Как проверить
- Команды или ручные шаги.
Что не проверено
- Честно и коротко.
Риски
- Только реальные риски, не философия.
```
Если изменение маленькое, можно сократить до:
```text
Коротко: ...
Файлы:
- `path`: что и зачем.
Проверка: ...
```
## Правила ясности
- Сначала вывод, потом детали.
- Не писать длиннее, чем нужно для решения задачи.
- Не перечислять каждую строку diff. Указывать только смысловые изменения.
- Всегда называть конкретные файлы.
- Для сложных мест указывать функцию, модуль или секцию, если это помогает найти код.
- Если используешь термин, рядом дать короткое человеческое объяснение.
- Не использовать аббревиатуры без расшифровки при первом упоминании, кроме очевидных: UI, JSON, URL, API.
- Не писать «улучшена архитектура» без объяснения, что именно стало проще или безопаснее.
- Не писать «всё готово», если часть проверок не запускалась.
- Не скрывать ошибки окружения. Если `cargo` или Windows недоступны, так и сказать.
## Как объяснять технические изменения
Плохо:
```text
Refactored orchestration layer and extracted imperative use-case side effects into composable boundaries.
```
Хорошо:
```text
Вынес запуск service-команд из большого `commands.rs` в отдельный модуль. Теперь Tauri command только принимает запрос и возвращает ошибку, а вся Windows-логика лежит отдельно. Так проще тестировать и меньше шанс сломать соседние команды.
```
Плохо:
```text
Added CSP hardening.
```
Хорошо:
```text
Включил CSP в `src-tauri/tauri.conf.json`. Это ограничивает, какие скрипты/ресурсы может загрузить webview, и снижает ущерб, если в UI когда-нибудь появится XSS.
```
## Уровни детализации
По умолчанию — средний уровень:
- достаточно конкретно, чтобы разработчик понял diff;
- без пересказа каждой строки;
- без внутренних рассуждений агента;
- без длинной теории.
Если пользователь просит глубже, добавь раздел:
```text
Детальнее
- ...
```
Если пользователь просит совсем кратко, оставь только:
```text
Коротко
Файлы
Проверка
```
## Таблица файлов
Для 2+ файлов почти всегда используй таблицу:
| Файл | Тип изменения | Смысл |
|---|---|---|
| `src/api/tauriCommands.ts` | API boundary | Добавлен typed wrapper для новой Tauri command |
| `src-tauri/src/commands.rs` | Backend | Добавлена команда, которая вызывает уже существующую service-логику |
Правила:
- Не вставлять огромные таблицы на 30 строк. Группировать мелкие файлы.
- В колонке `Смысл` писать человеческую причину, не только «обновлено».
- Если файл опасный, отметить это: `security-sensitive`, `Windows/elevation`, `storage`, `routing`.
## Как писать про риски
Риск должен быть конкретным:
Плохо:
```text
Есть некоторые риски.
```
Хорошо:
```text
Риск: я не запускал real Windows service flow, поэтому install/start/stop надо проверить на Windows 10/11 с UAC.
```
Плохо:
```text
Может быть несовместимость.
```
Хорошо:
```text
Риск: если у пользователя уже стоит чужая служба с похожим именем ProxiFyre, fuzzy detection может показать ее кандидатом. Управлять ей нельзя без проверки `PathName`.
```
## Как писать про проверки
Разделяй выполненное и невыполненное:
```text
Проверено
- `npm run build` — прошел.
- Markdown-файлы открываются, битых путей не нашел.
Не проверено
- `cargo test` — не запускал, в среде нет Rust toolchain.
- Windows service flow — не проверял, нужна Windows-машина с UAC.
```
Не объединять это в мутное «тесты частично пройдены». Машины и люди заслуживают хотя бы грамм конкретики.
## Запрещенный стиль
Не писать:
- огромные абзацы без заголовков;
- «магия», «оптимизировано», «улучшено» без конкретики;
- внутренний дневник действий агента;
- цепочки мыслей;
- список всех строк diff;
- рекламный тон;
- уверенные заявления о непроверенных Windows/elevation сценариях;
- «как вы и просили, я с радостью...» — репозиторий от этого лучше не станет.
## Мини-шаблоны
### Для bugfix
```text
Коротко
- Исправил ...
- Основной риск был ...
- Проверка: ...
Что изменилось по файлам
| Файл | Что изменилось | Зачем |
|---|---|---|
Важные места
- `file`: ...
Проверено
- ...
Не проверено
- ...
```
### Для ревью без правок
```text
Коротко
- Самая важная проблема: ...
- Второй приоритет: ...
- Быстрый выигрыш: ...
Что я смотрел
- ...
Проблемы по важности
1. Critical/High: ...
2. Medium: ...
3. Low: ...
Что бы я сделал первым
- ...
```
### Для плана изменений
```text
Коротко
- Цель: ...
- Затронет: ...
- Не трогаем: ...
План по файлам
| Файл/зона | Что сделать | Почему |
|---|---|---|
Порядок работ
1. ...
2. ...
3. ...
Проверка
- ...
```

View File

@@ -0,0 +1,93 @@
# Skill: React / TypeScript UI
## Когда использовать
Используй этот skill при изменениях в `src`, UI, state management, typed Tauri wrappers, readiness logic, route visualization, forms, logs, buttons, popovers, service panels.
## Главная цель
UI должен ясно объяснять, что будет сделано с сетью пользователя, не прятать опасные действия и не превращаться в панель управления космической станцией ради одной прокси-кнопки.
## Инварианты
- `src/api/tauriCommands.ts` — единственное место для `invoke(...)`.
- `src/domain/types.ts` должен отражать backend DTO.
- Apply-readiness logic живет в `src/app/readiness.ts`.
- Presentational UI должен переиспользовать `src/ui/*`.
- Summary panel read-only.
- Install/start/stop/uninstall/apply actions must be explicit.
- Secrets must be redacted.
## App.tsx rule
`src/app/App.tsx` уже слишком большой. Новую логику не добавлять туда, если можно вынести:
```text
src/app/hooks/useStartupSnapshot.ts
src/app/hooks/useApplyFlow.ts
src/app/hooks/useServiceControl.ts
src/app/hooks/useSubscription.ts
src/app/lib/parseProxy.ts
src/app/lib/snapshots.ts
src/app/components/RouteChain.tsx
src/app/components/ChangesDock.tsx
src/app/components/ProxyPanel.tsx
src/app/components/SingBoxWorkspace.tsx
```
Если изменение маленькое и локальное, допустимо править `App.tsx`, но не расширять его архитектурную роль.
## UI behavior rules
- Disabled button must explain why.
- Loading/busy state must prevent double submit.
- Errors must be human-readable and actionable.
- Long paths, service names and tags must not break layout.
- Do not use native `title` as main tooltip UX. Use existing popover/tooltip pattern.
- Use `aria-*` for tabs, toggle buttons, popovers, service controls.
- Honor reduced motion where relevant.
## Proxy/routing UI
When editing route UI:
- External SOCKS5 mode must not require sing-box.
- Local sing-box mode must require selected server and sing-box readiness.
- App list must clearly distinguish process, folder, exe/path if those are different target kinds.
- Route chain should reflect actual backend target/profile state.
- Pending changes should be visible before apply.
## Tests/checks
Minimum:
```powershell
npm run build
```
Recommended for extracted pure logic:
- Unit tests for proxy parsing.
- Unit tests for readiness states.
- Unit tests for snapshot diff/change dock model.
- UI smoke checks for desktop and narrow layout.
## Do not
- Do not call backend commands from random components.
- Do not store secrets in React state longer than needed if display value can be redacted.
- Do not duplicate Rust validation as the only validation. Frontend validation is UX, backend validation is authority.
- Do not add a visual state that implies a service is running unless backend confirmed it.
## Как отчитываться
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.

View File

@@ -0,0 +1,78 @@
# Skill: Repository Orientation
## Когда использовать
Используй этот skill в начале любой нетривиальной задачи по ProxyWarden: аудит, рефакторинг, bugfix, изменение UI, изменение Windows service flow, изменение subscription/routing.
## Цель
Быстро понять, где находится нужная логика, какие инварианты нельзя нарушать и какие проверки нужны перед финальным ответом.
## Карта проекта
```text
src/api/tauriCommands.ts typed frontend API boundary
src/domain/types.ts TypeScript DTO/domain mirror
src/app/App.tsx current UI orchestration, large file
src/app/readiness.ts apply readiness/gating
src/app/viewModel.ts view helpers
src/ui/* reusable UI components
src/styles/app.css global/component CSS
src-tauri/src/models.rs Rust domain models
src-tauri/src/validation.rs input normalization/validation
src-tauri/src/storage.rs JSON config/state storage
src-tauri/src/activity.rs activity log
src-tauri/src/subscription.rs subscription fetch/parse
src-tauri/src/component_detection.rs component status detection
src-tauri/src/adapters/* ProxiFyre/sing-box/proxy router adapters
src-tauri/src/commands.rs Tauri command layer, currently too large
src-tauri/tests/* Rust tests
scripts/*.ps1 Windows install/control scripts
```
## Source of truth
- Persistent app config/state: `C:\ProgramData\ProxyWarden\config` and `state`.
- Generated artifacts: `C:\ProgramData\ProxyWarden\generated`.
- Frontend state is not source of truth. It should represent backend state and pending UI edits.
- Component detection/runtime status should come from backend, not guessed in UI.
## First-pass procedure
1. Identify whether the task is backend, UI, security, Windows service, subscription/routing, or testing/release.
2. Read the matching skill file.
3. Inspect the relevant source files listed above.
4. Determine whether the change crosses the Tauri boundary. If yes, update both Rust DTO/command and TypeScript wrapper/types.
5. Determine whether the change touches secrets, service control, generated configs, process execution, filesystem deletion, or network fetch. If yes, apply security checklist.
6. Prefer small, isolated changes over broad rewrites.
## Do not
- Do not treat `App.tsx` or `commands.rs` as the correct permanent architecture just because they currently contain lots of logic.
- Do not introduce a second storage system.
- Do not move service/install logic into frontend.
- Do not claim Windows service/elevation behavior is verified unless it was actually tested on Windows.
## Output expectations
For code changes, final report should include:
- Changed files.
- User-visible behavior changes.
- Internal behavior changes.
- Tests/checks run.
- Known unverified areas.
## Как отчитываться
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.

View File

@@ -0,0 +1,106 @@
# Skill: Rust / Tauri Backend
## Когда использовать
Используй этот skill при изменениях в `src-tauri`, Tauri commands, Rust models, validation, storage, adapters, component detection, subscription fetching, config generation или service orchestration.
## Главная цель
Держать backend надежным, типизированным и безопасным. Backend управляет системой пользователя, поэтому «ну вроде работает» здесь примерно как инструкция по посадке самолета, написанная на салфетке.
## Инварианты
- Tauri command layer должен быть thin boundary, а не склад всей бизнес-логики.
- Долгие/blocking операции не должны выполняться на async runtime thread.
- Все ошибки, которые видит UI, должны быть structured and actionable.
- Validation должна происходить на backend даже если UI уже проверяет input.
- Generated config writes должны быть atomic where practical.
- Secrets never logged or fully displayed.
## Preferred command pattern
Для тяжелых операций:
```rust
#[tauri::command]
pub async fn some_command(input: SomeInput) -> Result<SomeOutput, CommandError> {
tauri::async_runtime::spawn_blocking(move || some_command_impl(input))
.await
.map_err(background_task_error)?
}
```
Implementation function должна быть тестируемой без Tauri runtime, если возможно.
## DTO boundary
При добавлении или изменении command:
1. Rust input/output DTO.
2. TypeScript DTO in `src/domain/types.ts`.
3. Wrapper in `src/api/tauriCommands.ts`.
4. UI usage.
5. Tests for pure logic.
Не использовать `serde_json::Value` как permanent API, если структура известна.
## Error handling
- Не использовать `unwrap()`/`expect()` в production path.
- Возвращать `CommandError { code, message, details }`.
- Для validation использовать список проблем, а не первую попавшуюся ошибку.
- Internal error text не должен раскрывать secrets.
- Если операция partial, вернуть partial state/result where possible.
## Storage
При изменении `storage.rs`:
- Keep tmp + bak write pattern.
- Не превращать invalid JSON в default молча. Prefer corruption backup/restore path.
- Для critical writes использовать same-directory temp file and rename.
- Не хранить derived artifacts как source of truth.
- Не хранить raw subscription/proxy secrets в activity log.
## Refactoring guidance
`src-tauri/src/commands.rs` слишком большой. Новую логику по возможности выносить:
```text
commands/dto.rs
commands/status.rs
commands/profiles.rs
commands/targets.rs
commands/components.rs
commands/subscription.rs
commands/proxifyre.rs
commands/singbox.rs
services/elevated.rs
services/powershell.rs
services/proxifyre_service.rs
services/singbox_service.rs
```
При рефакторинге сохранять external command names, чтобы UI не ломался без причины.
## Validation checklist
- `cargo fmt --all -- --check`
- `cargo clippy --all-targets --all-features -- -D warnings`
- `cargo test --all-targets`
- Relevant Windows/manual check if touching service/install/elevation.
Если `cargo` недоступен в среде, честно написать, что backend проверен только статически. Не изображать компилятор, у него и так тяжелая жизнь.
## Как отчитываться
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.

View File

@@ -0,0 +1,138 @@
# Skill: Security Hardening
## Когда использовать
Используй этот skill при изменениях, связанных с Tauri security, CSP, secrets, subscription fetching, process execution, PowerShell, temporary files, install/uninstall, file writes, generated configs, service control, logs, diagnostics.
## Threat model
ProxyWarden — desktop app that can influence network routing and run elevated Windows operations. Главные риски:
- leaking proxy/subscription credentials;
- unsafe local/network fetches;
- unsafe generated elevated PowerShell scripts;
- unscoped process execution;
- corrupting generated/service configs;
- deleting wrong directories;
- stale component status causing wrong actions;
- XSS/webview compromise amplified by privileged backend commands.
## Non-negotiables
- `tauri.conf.json` must not use `"csp": null` as final state.
- Do not add broad Tauri shell permissions.
- Do not execute user-controlled strings as commands.
- Do not log full subscription URLs, proxy passwords, userinfo, access tokens, or outbound configs with credentials.
- Do not recursively delete directories based only on fuzzy name matching.
- Do not treat fuzzy-detected services as managed without verification.
## CSP guidance
Prefer a restrictive CSP such as:
```json
"security": {
"csp": "default-src 'self'; img-src 'self' asset: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
}
```
Tighten further when possible. If inline styles are removed, remove `'unsafe-inline'`.
## Secret redaction
For URLs use a parser, not string splitting. Redacted display should include only:
- scheme;
- host;
- port if useful;
- generic path marker if necessary.
Never show:
- username;
- password;
- query string;
- fragment;
- subscription token path;
- full proxy credentials.
Bad:
```text
https://user:password@example.com/...
```
Good:
```text
https://example.com/...
```
## Subscription fetch hardening
- Add connect/read timeout.
- Accept only `http` and `https` unless explicitly designed otherwise.
- Consider blocking loopback/private/link-local/multicast/metadata addresses by default.
- Add explicit allow-local option only if needed.
- Do not follow redirects into blocked address ranges without re-check.
- Avoid storing remote body in logs.
## Temp/elevated script hardening
Runtime-generated elevated scripts must:
- use unpredictable names, preferably UUID/random;
- be written to a safe controlled directory where possible;
- set restrictive ACL when practical;
- be generated from static templates with escaped parameters;
- avoid including secrets in command line args;
- be cleaned up best-effort;
- fail closed if path validation fails.
Timestamp-only temp names are not enough.
## Atomic writes
For config files used by services:
1. Write to temp file in same directory.
2. Validate temp file if validator exists.
3. Backup current file.
4. Rename temp to final.
5. On failure, preserve backup and return actionable error.
## Safe delete checklist
Before recursive delete:
- Is path absolute?
- Is it under expected managed root?
- Does it contain ProxyWarden marker metadata?
- Does service PathName point inside this directory?
- Is it not drive root, user profile root, Desktop, ProgramData root, Windows directory, temp root?
- Is user action explicit?
If answer is unclear, do not delete.
## Final report expectations
When touching security-sensitive code, report:
- what threat was addressed;
- what was hardened;
- what remains unverified;
- whether any secrets could appear in logs/UI;
- whether Windows elevated path was tested.
## Как отчитываться
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.

View File

@@ -0,0 +1,87 @@
# Skill: Subscriptions and Routing
## Когда использовать
Используй этот skill при изменениях в external SOCKS5 flow, Local sing-box flow, subscription fetching/parsing, server selection, ping/check route, ProxiFyre config generation или sing-box config generation.
## Mental model
Supported route shapes:
```text
selected Windows apps -> ProxiFyre -> external SOCKS5 proxy
```
```text
selected Windows apps -> ProxiFyre -> 127.0.0.1:1080 Local sing-box -> selected subscription server
```
ProxiFyre is the per-app router. Sing-box is optional local outbound runtime.
## Invariants
- External SOCKS5 must work without Local sing-box.
- Local sing-box route requires installed/configured/running sing-box and selected server.
- UI route chain must match generated backend config.
- Subscription URL is secret.
- Server identity should not rely only on non-unique human tag forever.
- Generated config should be validated before apply/start where possible.
## Subscription rules
When changing subscription parsing:
- Preserve JSON outbound support.
- Do not claim support for link formats that parser does not implement.
- If adding VMess/Trojan/Shadowsocks link parsing, add tests for each.
- Keep unsupported outbound types visible as unsupported, not silently dropped if this affects user expectation.
- Redact subscription URL and outbound secrets in logs/UI.
## Ping/check rules
- Network checks should have timeout.
- Checks should be cancel-safe where possible.
- Do not make route check mutate config/service state.
- Do not store external IP probe result as secret, but avoid over-logging.
- Make it clear if check verifies local sing-box only, external proxy only, or full route.
## Config generation
For ProxiFyre:
- Respect selected app targets.
- Deduplicate carefully, preferably case-insensitive where Windows semantics apply.
- Validate target kind semantics: process name vs exe path vs folder.
- Write config atomically.
For sing-box:
- Validate selected server/outbound exists.
- Avoid duplicate tag ambiguity by introducing stable id if needed.
- Run `sing-box check` when binary is available.
- Avoid writing secrets to temp files outside safe app directories.
## Tests to add for changes
- External route without sing-box.
- Local route with selected server.
- Missing ProxiFyre blocks apply.
- Missing sing-box blocks local route apply.
- Duplicate server tags.
- Redacted subscription display.
- Unsupported subscription formats.
- Timeout/fetch failure.
- Config generation produces expected route chain.
## Как отчитываться
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.

View File

@@ -0,0 +1,104 @@
# Skill: Testing, CI and Release
## Когда использовать
Используй этот skill при добавлении CI, release scripts, build fixes, test changes, dependency updates, packaging changes или перед финальным отчетом по крупной задаче.
## Minimal local checks
Frontend:
```powershell
npm ci
npm run build
```
Rust:
```powershell
cd src-tauri
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
```
Tauri:
```powershell
npm run tauri -- info
npm run tauri -- build
```
PowerShell plan-only:
```powershell
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
```
## CI recommendation
Add GitHub Actions with at least:
- frontend build on Windows and Ubuntu if practical;
- Rust fmt/clippy/test;
- PowerShell syntax/plan-only smoke on Windows;
- Tauri build on Windows for release branches/tags;
- artifact upload only for trusted release workflow.
## Dependency updates
When changing dependencies:
- Update lockfiles.
- Check Tauri v2 compatibility.
- Avoid adding large UI/runtime dependencies for tiny tasks.
- Avoid adding shell/process libraries that bypass existing backend boundaries.
- Note why dependency is needed.
## Release hygiene
Before release:
- Verify app version in `package.json` and Tauri config if applicable.
- Verify icons/assets size.
- Verify CSP and capabilities.
- Verify no raw secrets/test URLs in repo.
- Verify installer scripts with `-PlanOnly`.
- Verify clean install on Windows VM.
- Verify external SOCKS5 flow.
- Verify local sing-box subscription flow.
- Verify uninstall/safe cleanup behavior.
## Final report format
```text
Changed:
- ...
Verified:
- npm run build
- cargo test
Not verified:
- Windows elevated install/uninstall, because ...
Risks:
- ...
```
Do not write “all tests pass” unless all listed relevant tests actually ran. Humanity has enough fictional dashboards.
## Как отчитываться
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.

View File

@@ -0,0 +1,94 @@
# Skill: Windows Services / PowerShell / Elevation
## Когда использовать
Используй этот skill при изменениях в `scripts/*.ps1`, ProxiFyre install/start/stop/uninstall, sing-box service control, UAC/admin checks, helper/elevation boundary, component detection.
## Цель
Сохранять service/install operations явными, безопасными и проверяемыми. Пользователь должен понимать, что приложение собирается менять в системе. Компьютер пользователя — не песочница для творческих экспериментов агента, как ни печально.
## Инварианты
- Install/start/stop/uninstall are explicit user actions.
- `apply` must not silently install/uninstall/start/stop components unless that behavior is clearly designed and surfaced.
- `-PlanOnly` scripts must be side-effect-free.
- PowerShell output intended for UI/backend must be structured JSON.
- Service detection must distinguish managed service from fuzzy candidate.
- Never relax safe-path checks to make uninstall easier.
## Script rules
PowerShell scripts should:
- use `Set-StrictMode -Version Latest` where practical;
- set `$ErrorActionPreference = 'Stop'`;
- return structured JSON for plan/status paths;
- avoid localized text parsing for control flow;
- avoid writing secrets to host output;
- have clear exit codes;
- support `-PlanOnly` for dry-run/status checks;
- avoid downloading/executing arbitrary remote scripts.
## Elevation rules
When launching elevated PowerShell:
- keep command fixed and parameters escaped;
- avoid user-controlled script text;
- avoid predictable temp script names;
- do not pass secrets via command line;
- verify script path before launch;
- clean up temp artifacts best-effort;
- return clear error if user cancels UAC.
## Service detection
Preferred approach:
1. Search known managed service names first.
2. Read service `PathName` through WMI/CIM.
3. Verify binary path and managed install metadata.
4. Only then mark as managed/controllable.
5. Fuzzy matches should be shown as candidates, not automatically controlled.
## Testing
Pure logic can be tested cross-platform with mocks.
Real verification requires Windows:
```powershell
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
npm run tauri -- dev
```
For real service tests:
- Windows 10/11.
- Admin/UAC path.
- Fresh machine or VM snapshot.
- Existing ProxiFyre/sing-box absent.
- Existing fuzzy ProxiFyre-like service present, if testing safety.
## Do not
- Do not claim actual service operations were tested unless they were run on Windows.
- Do not parse human-localized `sc.exe` output if structured WMI/CIM data is available.
- Do not delete paths from fuzzy discovery alone.
- Do not make scripts silently modify firewall/proxy/system settings outside their stated purpose.
## Как отчитываться
Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`.
Минимум для нетривиальной задачи:
- короткая сводка;
- таблица файлов `Файл / Что изменилось / Зачем`;
- важные места без пересказа каждой строки;
- что проверено;
- что не проверено;
- конкретные риски.

View File

@@ -0,0 +1,29 @@
# Change Report
## Коротко
-
-
-
## Файлы
| Файл | Что изменилось | Зачем |
|---|---|---|
| `path/file` | | |
## Важные детали
-
## Проверки
- ✅/⚠️ `command` — результат простыми словами.
## Не проверено
-
## Риски / что потом
-

View File

@@ -0,0 +1,30 @@
# Concise Change Summary Template
## Коротко
-
-
-
## Что изменилось по файлам
| Файл | Что изменилось | Зачем |
|---|---|---|
| `path/to/file` | | |
## Важные места
- `path/to/file`, функция/секция:
- `path/to/file`, функция/секция:
## Проверено
-
## Не проверено
-
## Риски
-

View File

@@ -0,0 +1,20 @@
# File Impact Map Template
Используй для плана или ревью, когда нужно заранее показать, какие файлы будут затронуты.
| Зона | Файлы | Что будет сделано | Почему это нужно | Риск |
|---|---|---|---|---|
| Frontend API | `src/api/tauriCommands.ts` | | | Low/Medium/High |
| Frontend UI | `src/app/...` | | | Low/Medium/High |
| Backend command | `src-tauri/src/...` | | | Low/Medium/High |
| Storage/config | `src-tauri/src/storage.rs` | | | Low/Medium/High |
| Windows/elevation | `scripts/*.ps1` | | | Low/Medium/High |
| Docs/agent | `.agent/...` | | | Low/Medium/High |
## Что не трогаем
-
## Как проверить после изменений
-

View File

@@ -0,0 +1,27 @@
# Investigation Report
## Коротко
- Главный вывод:
- Где проблема:
- Что делать первым:
## Что смотрел
| Файл / зона | Зачем смотрел | Вывод |
|---|---|---|
| `path/file` | | |
## Находки
| Приоритет | Где | Что не так | Как исправить |
|---|---|---|---|
| Критично / Важно / Можно потом / Косметика | `path/file` | | |
## Проверки
-
## Ограничения анализа
-

View File

@@ -0,0 +1,32 @@
## Коротко
-
-
-
## Файлы / зоны
| Файл / зона | Что изменилось | Зачем |
|---|---|---|
| `path/file` | | |
## Пользовательское поведение
- Что пользователь увидит:
- Что не должно измениться:
## Технические детали
-
## Проверки
- [ ] `npm run build`
- [ ] `cargo fmt --all -- --check`
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
- [ ] `cargo test --all-targets`
- [ ] Windows manual smoke, если затронуты service/elevation/install/routing
## Не проверено / риски
-

View File

@@ -0,0 +1,27 @@
# User-facing Summary
## Коротко
-
-
-
## Что изменилось простыми словами
-
## Файлы
| Файл | Что изменилось | Зачем |
|---|---|---|
| `path/file` | | |
## Что важно знать
-
## Проверки и риски
- ✅ Проверено:
- ⚠️ Не проверено:
- Риск:

View File

@@ -0,0 +1,23 @@
# Work Plan
## Коротко
Сделаю так:
1.
2.
3.
## Какие файлы, вероятно, затрону
| Файл / зона | Что планируется | Зачем |
|---|---|---|
| `path/file` | | |
## Что проверю
-
## Что может остаться непроверенным
-

View File

@@ -0,0 +1,32 @@
---
description: ProxyWarden repository rules for Cursor agents
alwaysApply: true
---
# ProxyWarden Cursor Rules
Read `AGENTS.md` before editing. Use `.agent/skills/*/SKILL.md` for task-specific guidance.
## Core rules
- Keep ProxyWarden a standalone Windows desktop utility: Tauri 2 + React/TypeScript + Rust.
- Preserve separation between Control App, ProxiFyre and Local sing-box.
- External SOCKS5 route must work without sing-box.
- UI must call backend through `src/api/tauriCommands.ts`, not direct random `invoke(...)` calls.
- Backend must validate all inputs even if UI validates them.
- Do not leak subscription URLs, proxy credentials or outbound secrets.
- Keep install/start/stop/uninstall explicit.
- Do not relax safe deletion or elevated script rules.
- Prefer shrinking `src-tauri/src/commands.rs` and `src/app/App.tsx` over adding more logic there.
- Run relevant checks and state unverified Windows/elevation behavior honestly.
## Communication rules
Before non-trivial answers, follow `.agent/skills/communication-reporting/SKILL.md` and `.agent/checklists/communication.md`.
- Write final answers in short, structured Russian unless the user asks otherwise.
- Start with `Коротко` for nontrivial work.
- For 2+ files, use a table with `Файл / Что изменилось / Зачем`.
- Do not dump every diff line. Mention important functions/sections only.
- Clearly split `Проверено` and `Не проверено`.
- Do not claim Windows/UAC/service checks were done unless they actually ran.

26
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,26 @@
# Copilot Instructions for ProxyWarden
Read `AGENTS.md` first. Follow the repo invariants there.
## Key reminders
- This is a standalone Windows Tauri 2 + React/TypeScript + Rust app.
- Do not turn it into a SaaS, gateway, server or cloud control plane.
- Do not call `invoke(...)` outside `src/api/tauriCommands.ts`.
- Do not make Local sing-box required for external SOCKS5 routing.
- Do not hide install/start/stop/uninstall behind apply.
- Do not log or display full subscription URLs, proxy credentials or outbound secrets.
- Keep Tauri capabilities minimal and CSP enabled.
- Treat `commands.rs` and `App.tsx` as large legacy orchestration files that should shrink over time.
- For security-sensitive changes, read `.agent/skills/security-hardening/SKILL.md`.
- For Windows service/elevation changes, read `.agent/skills/windows-services-powershell/SKILL.md`.
## Reporting style
Before non-trivial answers, follow `.agent/skills/communication-reporting/SKILL.md` and `.agent/checklists/communication.md`.
- Start with `Коротко`: 2-4 main points.
- For code changes, include a `Файл / Что изменилось / Зачем` table.
- Explain only important behavior, safety, UX and risk points. Do not retell every line.
- Split checks into `Проверено` and `Не проверено`.
- State unverified Windows/UAC/service behavior honestly.

60
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
jobs:
windows-baseline:
name: Windows baseline
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Set up Rust toolchain
run: rustup show
- name: Install frontend dependencies
run: npm ci
- name: Run frontend tests
run: npm test -- --run
- name: Build frontend
run: npm run build
- name: Check Rust formatting
working-directory: src-tauri
run: cargo fmt --all -- --check
- name: Run Rust lints
working-directory: src-tauri
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run Rust tests
working-directory: src-tauri
run: cargo test --all-targets
- name: Check Tauri environment
run: npm run tauri -- info
- name: Plan control app installer
shell: pwsh
run: .\scripts\install-control-app.ps1 -PlanOnly
- name: Plan ProxiFyre installer
shell: pwsh
run: .\scripts\install-proxyfier.ps1 -PlanOnly
- name: Plan sing-box installer
shell: pwsh
run: .\scripts\install-singbox.ps1 -PlanOnly

248
AGENTS.md
View File

@@ -1,71 +1,198 @@
# Инструкции для агентов # AGENTS.md
## Контекст проекта ## Назначение
ProxyWarden - standalone Windows desktop client в корне репозитория. Это Tauri 2 + React/TypeScript UI + Rust backend для маршрутизации выбранных Windows-приложений через внешний SOCKS5-прокси или опциональный Local sing-box. ProxyWarden standalone Windows desktop-приложение для удобного per-app proxy routing. Стек: Tauri 2, Rust backend, React/TypeScript frontend, Vite, PowerShell installer/control scripts. Приложение управляет выбранными Windows-приложениями через ProxiFyre и, опционально, через локальный sing-box runtime.
Не возвращать старую идею `APP_MODE=windows` и не подключать Windows-клиент к отдельному Node gateway/server. Текущий рабочий путь - `src`, `src-tauri`, `scripts` в корне репозитория. Этот файл — главный контракт для кодового агента. Любой агент, который меняет репозиторий, обязан соблюдать эти правила. Да, даже если ему очень хочется «быстренько поправить одну кнопочку» и случайно переписать половину сетевого стека. Особенно тогда.
## Основные инварианты ## Продуктовая рамка
- Три компонента должны оставаться разделенными: Control App, ProxiFyre, Local sing-box. Проект не должен превращаться в коммерческий SaaS, Node gateway, VPN-провайдер, proxy server или облачный control plane. Это локальная Windows-утилита для себя и друзей.
- ProxiFyre - обязательный слой для per-app routing; Local sing-box - необязательный runtime.
- Внешний SOCKS5 flow должен работать без установленного Local sing-box. Цель: надежно и понятно конфигурировать маршрутизацию выбранных приложений через внешний SOCKS5 proxy или через локальный 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 или логах. - Control App, ProxiFyre и Local sing-box — разные компоненты. Не смешивать их ответственность.
- Summary panel должен оставаться read-only: без apply/install/start/stop/delete/input/subscription mutations. - ProxiFyre — обязательный слой для per-app routing.
- Local sing-box — optional runtime. Внешний SOCKS5 flow обязан работать без sing-box.
- React UI не пишет generated config напрямую. UI вызывает typed Tauri commands.
- `src/api/tauriCommands.ts` — единственная TypeScript-обертка над `invoke(...)`.
- Rust backend отвечает за storage, validation, config generation, component detection, service/install orchestration и structured errors.
- `C:\ProgramData\ProxyWarden\config` и `C:\ProgramData\ProxyWarden\state` — source of truth.
- `C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json` и `sing-box-config.json` — derived artifacts. Их можно пересоздавать.
- Install/start/stop/uninstall — только явные действия пользователя. `apply` не должен скрыто устанавливать, удалять или «чинить» компоненты.
- Subscription URL, credentials, proxy passwords и userinfo нельзя выводить полностью в UI, logs, diagnostics, crash text или activity.
- Summary panel должен оставаться read-only: без install/start/stop/apply/delete/input/subscription mutations.
- Любые elevated операции должны быть максимально явными и проверяемыми.
## Основная структура
```text
src/
api/tauriCommands.ts # typed invoke facade
app/App.tsx # текущая UI orchestration зона, слишком крупная
app/readiness.ts # apply gating logic
app/viewModel.ts # display/view helpers
domain/types.ts # TypeScript DTO mirror
ui/* # reusable presentational components
styles/app.css # основной CSS
src-tauri/
tauri.conf.json # Tauri config, security, window config
capabilities/default.json # Tauri permissions/capabilities
src/models.rs # Rust domain models/defaults
src/validation.rs # normalization/validation
src/storage.rs # JSON storage, tmp/bak writes
src/activity.rs # activity log
src/subscription.rs # subscription fetch/parse
src/component_detection.rs # ProxiFyre/sing-box detection
src/singbox_service.rs # sing-box Windows service logic
src/process.rs # process/system helpers
src/helper.rs # helper/elevation boundary
src/adapters/* # ProxiFyre/sing-box/proxy-router adapters
src/commands.rs # Tauri command handlers; currently too large
tests/* # Rust integration/domain tests
scripts/
install-control-app.ps1
install-proxyfier.ps1
install-singbox.ps1
prepare-release.ps1
```
## Агентские skill-модули
Подробные инструкции лежат в `.agent/skills`:
- `.agent/skills/repository-orientation/SKILL.md` — как быстро понять репозиторий.
- `.agent/skills/rust-tauri-backend/SKILL.md` — Rust/Tauri backend changes.
- `.agent/skills/react-typescript-ui/SKILL.md` — frontend/UI changes.
- `.agent/skills/security-hardening/SKILL.md` — CSP, секреты, temp files, storage, SSRF, elevated boundary.
- `.agent/skills/windows-services-powershell/SKILL.md` — Windows service/install/PowerShell изменения.
- `.agent/skills/subscriptions-routing/SKILL.md` — subscription, sing-box, ProxiFyre routing.
- `.agent/skills/testing-ci-release/SKILL.md` — проверки, CI, release hygiene.
- `.agent/skills/communication-reporting/SKILL.md` — короткие понятные планы, ревью и отчеты с таблицами файлов.
Перед сложным изменением прочитать релевантный skill. Перед любым нетривиальным ответом владельцу проекта — прочитать `communication-reporting`. Да, инструкция про то, как не писать кашу, теперь тоже инструкция. Так мы и живем.
## Стиль общения агента
Пользователь — разработчик, но ему не нужен роман о каждом `match`, `useState` и переименованном импорте. Писать надо как для человека, которому нужно быстро принять решение: что изменилось, где изменилось, зачем и что проверить.
Перед любым нетривиальным ответом прочитать `.agent/skills/communication-reporting/SKILL.md` и перед финальным сообщением пройти `.agent/checklists/communication.md`.
### Обязательные правила
- Сначала результат, потом детали.
- Короткие абзацы, списки и таблицы вместо полотна текста.
- Для нетривиальных изменений использовать таблицу `Файл / Что изменилось / Зачем`.
- Не объяснять каждую строку. Объяснять важные места, решения, риски и поведение.
- Технические термины использовать только когда они помогают. Сложный термин объяснять одной простой фразой.
- Проверки делить на выполненные, не выполненные и требующие Windows/manual check.
- Для ревью группировать находки по приоритетам: `Критично`, `Важно`, `Можно потом`, `Косметика`.
- Не писать корпоративный туман вроде «улучшена архитектура» без указания, что именно стало проще, безопаснее или понятнее.
- Не заявлять “всё проверено”, если Rust tests, Windows service flow, Tauri build или UAC сценарии не запускались.
### Минимальный формат финального ответа
```md
## Коротко
- 1-3 главных результата.
## Файлы
| Файл | Что изменилось | Зачем |
|---|---|---|
| `path/file` | простое описание | практическая причина |
## Проверки
| Проверка | Статус | Комментарий |
|---|---|---|
| `command` | выполнено / не выполнено | почему |
## Риски
- Что осталось проверить или почему риска нет.
```
Если задача маленькая, формат можно сжать до нескольких строк. Если задача security/service/storage/routing-sensitive, детали обязательны, потому что «ну вроде работает» — это не инженерный метод, а жанр народного фольклора.
## Структура
- `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. ### Backend
- Не писать 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`.
- Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально.
- Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов.
## Проверка - Не добавлять новую Tauri command без typed wrapper в `src/api/tauriCommands.ts` и соответствующего TypeScript DTO в `src/domain/types.ts`, если command используется UI.
- Не возвращать raw strings для сложных ошибок. Использовать structured error DTO: `code`, `message`, `details`.
- Тяжелые или блокирующие операции должны быть `async` command + `tauri::async_runtime::spawn_blocking`.
- Не вызывать network/process/service/file-heavy logic прямо из async runtime thread.
- Не использовать `unwrap()`/`expect()` в production path, кроме очевидно невозможных bootstrap cases с комментарием.
- Не писать generated configs неатомарно. Использовать temp + backup + rename where practical.
- Не расширять `commands.rs` без необходимости. Для новой логики предпочитать отдельные модули и thin command wrapper.
Минимум для frontend/UI: ### Frontend
- Не увеличивать `App.tsx`, если можно вынести hook/helper/component.
- Не вызывать `invoke(...)` напрямую вне `src/api/tauriCommands.ts`.
- Не дублировать apply-readiness проверки в JSX. Расширять `src/app/readiness.ts`.
- Для UI использовать существующие компоненты из `src/ui`.
- Apply/start/install/delete buttons должны иметь disabled state и понятную причину.
- Не показывать secrets. Для subscription/proxy URL использовать redacted display values.
- UI должен оставаться compact Windows utility, а не SaaS dashboard с иллюзией корпоративной важности.
### Security
- Не отключать CSP. Если CSP мешает, исправлять source policy, а не ставить `csp: null`.
- Не добавлять Tauri shell permissions без жесткого scope и отдельного обоснования.
- Не запускать произвольные команды из UI input.
- Runtime-generated elevated scripts должны использовать непредсказуемые имена, safe directory/ACL и cleanup best-effort.
- Удаление директорий допускается только после safe-path/marker/service-path checks.
- Subscription fetch должен иметь timeout и защиту от очевидно опасных/local metadata адресов либо explicit allow-mode.
### Windows/service boundary
- `-PlanOnly` у PowerShell scripts должен оставаться side-effect-free и возвращать structured JSON.
- Install/start/stop/uninstall должны быть явными user actions.
- Fuzzy-detected service не считать managed service без проверки `PathName`/metadata.
- В Linux/macOS CI не пытаться «проверить» Windows service operations как реальные. Тестировать pure logic/mocks.
## Известный технический долг
- `src-tauri/src/commands.rs` слишком большой. Главная цель рефакторинга: разрезать на модули по use-case.
- `src/app/App.tsx` слишком большой. Главная цель frontend-рефакторинга: hooks/components/view-model helpers.
- `tauri.conf.json` сейчас требует security review, особенно CSP и window resize settings.
- JSON storage молча возвращает default при invalid JSON. Нужен corruption recovery через `.bak` и user-visible warning.
- ProxiFyre config apply должен стать atomic.
- Subscription URL redaction должен исключать userinfo/password.
- Link subscription parser сейчас ориентирован на VLESS; не обещать больше, чем реально поддерживается.
- Ping/select по server tag может ломаться при duplicate tags. Нужен stable server id.
## Минимальная проверка перед ответом
Для docs-only изменений достаточно проверить структуру файлов и отсутствие очевидных Markdown/JSON ошибок.
Для frontend изменений:
```powershell ```powershell
npm ci
npm run build npm run build
``` ```
Rust/backend: Для Rust/backend изменений:
```powershell ```powershell
cd D:\repos\ProxyWarden\src-tauri cd src-tauri
cargo test cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
``` ```
Tauri/toolchain: Для Tauri/toolchain:
```powershell ```powershell
npm run tauri -- info npm run tauri -- info
@@ -73,7 +200,7 @@ npm run tauri -- dev
npm run tauri -- build npm run tauri -- build
``` ```
Installer boundaries: Для installer boundaries:
```powershell ```powershell
& .\scripts\install-control-app.ps1 -PlanOnly & .\scripts\install-control-app.ps1 -PlanOnly
@@ -81,10 +208,25 @@ Installer boundaries:
& .\scripts\install-singbox.ps1 -PlanOnly & .\scripts\install-singbox.ps1 -PlanOnly
``` ```
Для UI-изменений проверять browser-preview на desktop и narrow viewport. Browser-preview не доказывает native Tauri commands или elevated service lane. Не оставлять dev/preview/Tauri dev servers запущенными после проверки.
## Известные риски ## Формат отчета агента
- Реальные 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 в корне. - `.agent/templates/work-plan.md` — короткий план перед работой.
- `.agent/templates/change-report.md` — отчет после изменения кода.
- `.agent/templates/investigation-report.md` — аудит, расследование, разбор проблемы.
- `.agent/templates/user-facing-summary.md` — краткая сводка для владельца проекта.
- `.agent/templates/pr-description.md` — описание PR.
Каждый нетривиальный ответ должен отвечать на вопросы:
1. Что поменялось или найдено?
2. В каких файлах?
3. Зачем это нужно?
4. Что проверено?
5. Что не проверено?
6. Где остался риск?
Не писать «всё готово», если Windows/elevated/service flow не проверялся на Windows. Эта фраза и так слишком много навредила миру.

View File

@@ -0,0 +1,60 @@
# ProxyWarden Architecture Notes for Agents
## Current design
ProxyWarden is a local Windows control app. It does not proxy traffic by itself. It orchestrates:
1. ProxiFyre for per-app routing.
2. Optional local sing-box for subscription-based outbound routing.
3. External SOCKS5 target for direct proxy routing.
## Route modes
External SOCKS5:
```text
selected Windows apps -> ProxiFyre -> external SOCKS5
```
Local sing-box:
```text
selected Windows apps -> ProxiFyre -> 127.0.0.1:1080 -> sing-box selected outbound
```
## Main risks
- Large orchestration files: `commands.rs` and `App.tsx`.
- Security-sensitive elevated operations.
- Secrets in subscription/proxy config.
- Non-atomic writes to generated configs.
- Stale component status.
- Duplicate sing-box server tags.
- UI/business logic entanglement.
## Desired direction
Backend:
- Thin Tauri command handlers.
- Use-case modules.
- Testable pure functions.
- Structured errors.
- Atomic writes and hardened temp/elevation handling.
Frontend:
- Smaller `App.tsx`.
- Extracted hooks/components.
- Typed API boundary.
- Testable readiness/snapshot/proxy parsing logic.
- Clear UX for pending changes and service state.
Security:
- CSP enabled.
- Minimal Tauri capabilities.
- No broad shell permission.
- Redacted secrets.
- Safe deletion.
- Explicit Windows service control.

456
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "proxywarden", "name": "proxywarden",
"version": "0.1.0", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxywarden", "name": "proxywarden",
"version": "0.1.0", "version": "1.0.2",
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
@@ -21,7 +21,8 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vite": "^7.0.0" "vite": "^7.0.0",
"vitest": "^3.2.4"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -1499,6 +1500,24 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1547,6 +1566,131 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.7",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.41", "version": "2.10.41",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
@@ -1594,6 +1738,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001800", "version": "1.0.30001800",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
@@ -1615,6 +1769,33 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1647,6 +1828,16 @@
} }
} }
}, },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.385", "version": "1.5.385",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
@@ -1654,6 +1845,13 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.1", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -1706,6 +1904,26 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1782,6 +2000,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1801,6 +2026,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1837,6 +2072,23 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1978,6 +2230,13 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1988,6 +2247,54 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2005,6 +2312,36 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2125,6 +2462,119 @@
} }
} }
}, },
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.7",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "proxywarden", "name": "proxywarden",
"version": "0.1.0", "version": "1.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Standalone Windows desktop proxy management app for ProxyWarden.", "description": "Standalone Windows desktop proxy management app for ProxyWarden.",
@@ -8,6 +8,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest",
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
@@ -24,6 +25,7 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.2.4",
"vite": "^7.0.0" "vite": "^7.0.0"
} }
} }

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]

View File

@@ -353,13 +353,12 @@ function New-ReleaseDirectory {
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion" $releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
if ((Test-Path -LiteralPath $releaseDir) -and $Force) { if (Test-Path -LiteralPath $releaseDir) {
if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) { if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) {
throw "Refusing to remove release directory outside OutputRoot: $releaseDir" throw "Refusing to remove release directory outside OutputRoot: $releaseDir"
} }
Write-Host "Replacing existing release directory: $releaseDir"
Remove-Item -LiteralPath $releaseDir -Recurse -Force Remove-Item -LiteralPath $releaseDir -Recurse -Force
} elseif (Test-Path -LiteralPath $releaseDir) {
throw "Release directory already exists: $releaseDir. Use -Force to replace it."
} }
New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null
@@ -387,6 +386,25 @@ function Invoke-NativeCommand {
} }
} }
function Clear-ReleaseBundleOutput {
if (-not (Test-Path -LiteralPath $BundleRoot)) {
return
}
$targetRoot = Get-FullPath -Path (Join-Path $RepoRoot "src-tauri\target")
$bundleFull = Get-FullPath -Path $BundleRoot
if (
$bundleFull.Equals($targetRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
-not (Test-IsSubPath -Parent $targetRoot -Child $bundleFull)
) {
throw "Refusing to remove bundle directory outside src-tauri target: $bundleFull"
}
Write-Host ""
Write-Host "Cleaning stale Tauri bundle output: $bundleFull"
Remove-Item -LiteralPath $bundleFull -Recurse -Force
}
function Invoke-ReleaseBuild { function Invoke-ReleaseBuild {
if ($SkipBuild) { if ($SkipBuild) {
Write-Host "" Write-Host ""
@@ -403,26 +421,50 @@ function Invoke-ReleaseBuild {
Write-Host "Skipping Rust tests because -SkipTests was provided." Write-Host "Skipping Rust tests because -SkipTests was provided."
} }
Clear-ReleaseBundleOutput
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build") Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
} }
function Copy-ReleaseArtifacts { function Get-ArtifactVersionPattern {
param([string]$ReleaseDir) param([string]$TargetVersion)
if ($SkipBuild) { "(^|[^0-9A-Za-z])$([regex]::Escape($TargetVersion))([^0-9A-Za-z]|$)"
return @() }
}
function Copy-ReleaseArtifacts {
param(
[string]$ReleaseDir,
[string]$TargetVersion
)
if (-not (Test-Path -LiteralPath $BundleRoot)) { if (-not (Test-Path -LiteralPath $BundleRoot)) {
throw "Tauri bundle output was not found: $BundleRoot" throw "Tauri bundle output was not found: $BundleRoot"
} }
$artifactDir = Join-Path $ReleaseDir "artifacts" $artifactDir = Join-Path $ReleaseDir "artifacts"
$files = Get-ChildItem -LiteralPath $BundleRoot -Recurse -File | $allFiles = @(Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") } Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") } |
Sort-Object FullName)
if ($allFiles.Count -eq 0) {
throw "No release artifacts were found under $BundleRoot."
}
$versionPattern = Get-ArtifactVersionPattern -TargetVersion $TargetVersion
$files = @($allFiles | Where-Object { $_.Name -match $versionPattern })
$ignoredFiles = @($allFiles | Where-Object { $_.Name -notmatch $versionPattern })
if ($files.Count -eq 0) { if ($files.Count -eq 0) {
throw "No release artifacts were found under $BundleRoot." $found = ($allFiles | ForEach-Object { Get-RelativePath -BasePath $BundleRoot -Path $_.FullName }) -join ", "
throw "No release artifacts for version $TargetVersion were found under $BundleRoot. Found artifacts: $found"
}
if ($ignoredFiles.Count -gt 0) {
Write-Host ""
Write-Host "Ignoring bundle artifacts that do not match version ${TargetVersion}:"
foreach ($ignored in $ignoredFiles) {
Write-Host (" - " + (Get-RelativePath -BasePath $BundleRoot -Path $ignored.FullName))
}
} }
$copied = @() $copied = @()
@@ -592,7 +634,7 @@ try {
Invoke-ReleaseBuild Invoke-ReleaseBuild
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion $releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir) $artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir -TargetVersion $targetVersion)
Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts

5
src-tauri/Cargo.lock generated
View File

@@ -2314,9 +2314,10 @@ dependencies = [
[[package]] [[package]]
name = "proxywarden" name = "proxywarden"
version = "0.1.0" version = "1.0.2"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"percent-encoding",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
@@ -2324,6 +2325,8 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"url", "url",
"uuid",
"winreg",
] ]
[[package]] [[package]]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "proxywarden" name = "proxywarden"
version = "0.1.0" version = "1.0.2"
description = "Standalone Windows desktop proxy management app for ProxyWarden." description = "Standalone Windows desktop proxy management app for ProxyWarden."
authors = ["ProxyWarden"] authors = ["ProxyWarden"]
edition = "2021" edition = "2021"
@@ -18,5 +18,10 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
base64 = "0.22" base64 = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] }
percent-encoding = "2"
url = "2" url = "2"
uuid = { version = "1", features = ["v4"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.55"

View File

@@ -1,4 +1,3 @@
#[cfg(not(test))]
use crate::adapters::proxy_router::{ use crate::adapters::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest, ProxyRouterRequest,
@@ -7,11 +6,6 @@ use crate::models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
ProxyProtocol, Target, ProxyProtocol, Target,
}; };
#[cfg(test)]
use crate::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre"; pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";

View File

@@ -1,10 +1,10 @@
use crate::models::{LocalSingBoxConfig, SubscriptionCache}; use crate::models::{LocalSingBoxConfig, SubscriptionCache};
use crate::process::command_no_window;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::{ use std::{
env, fs, env, fs,
path::Path, path::Path,
process::Command,
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
@@ -215,7 +215,7 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
) )
})?; })?;
let output = Command::new(binary_path) let output = command_no_window(binary_path)
.arg("check") .arg("check")
.arg("-c") .arg("-c")
.arg(&config_path) .arg(&config_path)

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,11 @@ use crate::models::{
ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
}; };
use crate::process::command_no_window;
use serde::Deserialize; use serde::Deserialize;
use std::{ use std::{
env, env,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command,
}; };
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -460,7 +460,7 @@ fn same_path(left: &Path, right: &Path) -> bool {
} }
fn powershell_bool(script: &str) -> bool { fn powershell_bool(script: &str) -> bool {
Command::new("powershell") command_no_window("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script]) .args(["-NoProfile", "-NonInteractive", "-Command", script])
.output() .output()
.ok() .ok()
@@ -492,7 +492,7 @@ $items |
ConvertTo-Json -Compress ConvertTo-Json -Compress
"#; "#;
let Ok(output) = Command::new("powershell") let Ok(output) = command_no_window("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script]) .args(["-NoProfile", "-NonInteractive", "-Command", script])
.output() .output()
else { else {

View File

@@ -0,0 +1,19 @@
use std::env;
use std::path::{Path, PathBuf};
pub fn temp_script_path(prefix: &str) -> PathBuf {
env::temp_dir().join(unique_file_name(prefix, "ps1"))
}
pub fn artifact_path(artifact_dir: &Path, prefix: &str, extension: &str) -> PathBuf {
artifact_dir.join(unique_file_name(prefix, extension))
}
fn unique_file_name(prefix: &str, extension: &str) -> String {
let extension = extension.trim_start_matches('.');
format!(
"{prefix}-{}.{}",
uuid::Uuid::new_v4().hyphenated(),
extension
)
}

View File

@@ -1,5 +1,61 @@
pub mod activity;
pub mod commands;
pub mod component_detection;
pub mod elevated_scripts;
pub mod helper;
pub mod models;
pub mod process;
pub mod safe_fs;
pub mod singbox_service;
pub mod storage;
pub mod subscription;
pub mod validation;
pub mod adapters {
pub mod proxifyre;
pub mod proxy_router;
pub mod singbox;
}
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(commands::CommandState::default())
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::get_admin_status,
commands::restart_as_admin,
commands::get_startup_snapshot,
commands::get_profiles,
commands::get_saved_state,
commands::save_profile,
commands::get_targets,
commands::save_target,
commands::get_components,
commands::get_proxifyre_setup_status,
commands::get_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!()) .run(tauri::generate_context!())
.expect("не удалось запустить клиент ProxyWarden"); .expect("не удалось запустить клиент ProxyWarden");
} }

View File

@@ -1,71 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![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() { fn main() {
tauri::Builder::default() proxywarden_lib::run();
.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");
} }

View File

@@ -1,4 +1,7 @@
use percent_encoding::percent_decode_str;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1"; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
@@ -138,6 +141,8 @@ pub struct LocalSingBoxConfig {
#[serde(default)] #[serde(default)]
pub subscription_url: Option<String>, pub subscription_url: Option<String>,
#[serde(default)] #[serde(default)]
pub device_hwid: Option<String>,
#[serde(default)]
pub selected_server_tag: Option<String>, pub selected_server_tag: Option<String>,
#[serde(default = "default_local_singbox_listen_host")] #[serde(default = "default_local_singbox_listen_host")]
pub listen_host: String, pub listen_host: String,
@@ -157,12 +162,19 @@ impl LocalSingBoxConfig {
.as_deref() .as_deref()
.map(redact_subscription_url) .map(redact_subscription_url)
} }
pub fn normalize_percent_encoded_tags(&mut self) {
if let Some(selected_server_tag) = self.selected_server_tag.as_mut() {
*selected_server_tag = decode_percent_encoded_utf8(selected_server_tag);
}
}
} }
impl Default for LocalSingBoxConfig { impl Default for LocalSingBoxConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
subscription_url: None, subscription_url: None,
device_hwid: None,
selected_server_tag: None, selected_server_tag: None,
listen_host: default_local_singbox_listen_host(), listen_host: default_local_singbox_listen_host(),
listen_port: default_local_singbox_listen_port(), listen_port: default_local_singbox_listen_port(),
@@ -183,6 +195,36 @@ pub struct SubscriptionCache {
pub fetched_at: String, pub fetched_at: String,
} }
impl SubscriptionCache {
pub fn normalize_percent_encoded_tags(&mut self) {
for server in &mut self.servers {
server.tag = decode_percent_encoded_utf8(&server.tag);
}
let Some(outbounds) = self
.config
.get_mut("outbounds")
.and_then(Value::as_array_mut)
else {
return;
};
for outbound in outbounds {
let Some(decoded_tag) = outbound
.get("tag")
.and_then(Value::as_str)
.map(decode_percent_encoded_utf8)
else {
continue;
};
if let Some(object) = outbound.as_object_mut() {
object.insert("tag".to_string(), Value::String(decoded_tag));
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscriptionServer { pub struct SubscriptionServer {
pub tag: String, pub tag: String,
@@ -252,22 +294,27 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
return String::new(); return String::new();
} }
match trimmed.split_once("://") { let Ok(parsed) = Url::parse(trimmed) else {
Some((scheme, rest)) => { return "***".to_string();
let host = rest };
.split(['/', '?', '#'])
.next() let host = parsed.host_str().unwrap_or("subscription");
.filter(|value| !value.is_empty()) let host = if host.contains(':') && !host.starts_with('[') {
.unwrap_or("subscription"); format!("[{host}]")
format!("{scheme}://{host}/...") } else {
} host.to_string()
None => { };
let visible = trimmed.chars().take(18).collect::<String>(); let port = parsed
if trimmed.chars().count() <= 18 { .port()
"***".to_string() .map(|port| format!(":{port}"))
} else { .unwrap_or_default();
format!("{visible}...")
} format!("{}://{}{}/...", parsed.scheme(), host, port)
} }
}
pub fn decode_percent_encoded_utf8(value: &str) -> String {
percent_decode_str(value)
.decode_utf8()
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| value.to_string())
} }

18
src-tauri/src/process.rs Normal file
View File

@@ -0,0 +1,18 @@
use std::{ffi::OsStr, process::Command};
pub fn command_no_window(program: impl AsRef<OsStr>) -> Command {
let mut command = Command::new(program);
hide_console_window(&mut command);
command
}
#[cfg(windows)]
fn hide_console_window(command: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn hide_console_window(_command: &mut Command) {}

53
src-tauri/src/safe_fs.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub fn backup_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "bak")
}
pub fn corrupt_path(path: &Path) -> PathBuf {
sibling_with_suffix(
path,
&format!("corrupt.{}", uuid::Uuid::new_v4().hyphenated()),
)
}
pub fn temp_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, &format!("tmp.{}", uuid::Uuid::new_v4().hyphenated()))
}
pub fn write_with_backup(path: &Path, contents: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = temp_path(path);
fs::write(&temp_path, contents)?;
let backup_path = backup_path(path);
if path.exists() {
fs::copy(path, &backup_path)?;
fs::remove_file(path)?;
}
match fs::rename(&temp_path, path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp_path);
if !path.exists() && backup_path.exists() {
let _ = fs::copy(&backup_path, path);
}
Err(error)
}
}
}
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("proxywarden-file");
path.with_file_name(format!("{file_name}.{suffix}"))
}

View File

@@ -2,6 +2,7 @@ use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
use crate::models::{ use crate::models::{
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target, ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
}; };
use crate::safe_fs;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use std::fs; use std::fs;
use std::io::{self, ErrorKind}; use std::io::{self, ErrorKind};
@@ -96,7 +97,10 @@ impl JsonStorage {
} }
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> { pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
self.read_json_or_default(&self.paths.local_singbox_file) let mut config: LocalSingBoxConfig =
self.read_json_or_default(&self.paths.local_singbox_file)?;
config.normalize_percent_encoded_tags();
Ok(config)
} }
pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> { pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> {
@@ -104,7 +108,12 @@ impl JsonStorage {
} }
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> { pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
self.read_optional_json(&self.paths.singbox_subscription_cache_file) let mut cache = self
.read_optional_json::<SubscriptionCache>(&self.paths.singbox_subscription_cache_file)?;
if let Some(cache) = cache.as_mut() {
cache.normalize_percent_encoded_tags();
}
Ok(cache)
} }
pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> { pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> {
@@ -136,10 +145,9 @@ impl JsonStorage {
T: DeserializeOwned + Default, T: DeserializeOwned + Default,
{ {
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) { Ok(contents) => {
Ok(value) => Ok(value), parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error))
Err(_) => Ok(T::default()), }
},
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()), Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(error), Err(error) => Err(error),
} }
@@ -159,7 +167,9 @@ impl JsonStorage {
T: DeserializeOwned, T: DeserializeOwned,
{ {
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(contents) => Ok(serde_json::from_str(&contents).ok()), Ok(contents) => parse_json(path, &contents)
.map(Some)
.or_else(|error| recover_corrupt_json(path, error).map(Some)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error), Err(error) => Err(error),
} }
@@ -173,40 +183,72 @@ impl Default for JsonStorage {
} }
pub fn backup_path(path: &Path) -> PathBuf { pub fn backup_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "bak") safe_fs::backup_path(path)
}
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<()> { fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() { safe_fs::write_with_backup(path, contents)
fs::create_dir_all(parent)?; }
fn parse_json<T>(path: &Path, contents: &str) -> io::Result<T>
where
T: DeserializeOwned,
{
serde_json::from_str(contents).map_err(|error| {
io::Error::new(
ErrorKind::InvalidData,
format!("Invalid JSON in '{}': {error}", path.display()),
)
})
}
fn recover_corrupt_json<T>(path: &Path, parse_error: io::Error) -> io::Result<T>
where
T: DeserializeOwned,
{
let corrupt_path = safe_fs::corrupt_path(path);
move_corrupt_file(path, &corrupt_path)?;
let backup_path = backup_path(path);
if backup_path.exists() {
let backup_contents = fs::read_to_string(&backup_path)?;
match parse_json(&backup_path, &backup_contents) {
Ok(value) => {
fs::copy(&backup_path, path)?;
Ok(value)
}
Err(backup_error) => Err(io::Error::new(
ErrorKind::InvalidData,
format!(
"Invalid JSON in '{}'; corrupt file moved to '{}'; backup '{}' could not be restored: {backup_error}; original error: {parse_error}",
path.display(),
corrupt_path.display(),
backup_path.display()
),
)),
}
} else {
Err(io::Error::new(
ErrorKind::InvalidData,
format!(
"Invalid JSON in '{}'; corrupt file moved to '{}'; no valid backup available: {parse_error}",
path.display(),
corrupt_path.display()
),
))
} }
}
let temp_path = temp_path(path); fn move_corrupt_file(path: &Path, corrupt_path: &Path) -> io::Result<()> {
fs::write(&temp_path, contents)?; match fs::rename(path, corrupt_path) {
if path.exists() {
fs::copy(path, backup_path(path))?;
fs::remove_file(path)?;
}
match fs::rename(&temp_path, path) {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(error) => { Err(rename_error) => {
let _ = fs::remove_file(&temp_path); fs::copy(path, corrupt_path)?;
Err(error) fs::remove_file(path)?;
if !corrupt_path.exists() {
return Err(rename_error);
}
Ok(())
} }
} }
} }

View File

@@ -1,10 +1,16 @@
use crate::models::{SubscriptionCache, SubscriptionServer}; use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
use base64::{engine::general_purpose, Engine}; use base64::{engine::general_purpose, Engine};
use reqwest::redirect;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use std::net::{IpAddr, Ipv6Addr};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use url::Url; use url::Url;
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]; const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
const DEFAULT_APP_NAME: &str = "ProxyWarden";
const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionError { pub struct SubscriptionError {
@@ -33,6 +39,72 @@ pub struct ParsedSubscription {
pub servers: Vec<SubscriptionServer>, pub servers: Vec<SubscriptionServer>,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SubscriptionFetchPolicy {
pub allow_unsafe_local_urls: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionFetchIdentity {
pub device_hwid: Option<String>,
pub app_name: String,
pub user_agent: String,
pub device_os: String,
pub device_os_version: Option<String>,
pub device_model: String,
}
impl SubscriptionFetchIdentity {
pub fn with_device_hwid(device_hwid: Option<&str>) -> Self {
Self {
device_hwid: device_hwid
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
..Self::default()
}
}
pub fn request_headers_without_device_hwid(&self) -> Vec<(&'static str, String)> {
let mut headers = vec![
("User-Agent", self.user_agent.clone()),
("X-App-Name", self.app_name.clone()),
("X-Device-OS", self.device_os.clone()),
("X-Device-Model", self.device_model.clone()),
];
if let Some(device_os_version) = self
.device_os_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let header_value = sanitize_header_value(device_os_version);
if !header_value.is_empty() {
headers.push(("X-Device-OS-Version", header_value.clone()));
headers.push(("X-Ver-OS", header_value));
}
}
headers
}
}
impl Default for SubscriptionFetchIdentity {
fn default() -> Self {
let device_os = std::env::consts::OS.to_string();
Self {
device_hwid: None,
app_name: DEFAULT_APP_NAME.to_string(),
user_agent: format!("{DEFAULT_APP_NAME}/{device_os}"),
device_os,
device_os_version: detect_device_os_version(),
device_model: DEFAULT_APP_NAME.to_string(),
}
}
}
pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> { pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
let config = match serde_json::from_str::<Value>(body) { let config = match serde_json::from_str::<Value>(body) {
Ok(value) => value, Ok(value) => value,
@@ -66,19 +138,56 @@ pub fn parse_user_info(header_value: Option<&str>) -> Map<String, Value> {
} }
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> { pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
fetch_subscription_with_identity(url, &SubscriptionFetchIdentity::default())
}
pub fn fetch_subscription_with_identity(
url: &str,
identity: &SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, SubscriptionError> {
fetch_subscription_with_identity_and_policy(url, identity, SubscriptionFetchPolicy::default())
}
pub fn fetch_subscription_with_identity_and_policy(
url: &str,
identity: &SubscriptionFetchIdentity,
policy: SubscriptionFetchPolicy,
) -> Result<SubscriptionCache, SubscriptionError> {
let parsed_url = let parsed_url =
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?; Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
if !matches!(parsed_url.scheme(), "http" | "https") { validate_subscription_fetch_url(&parsed_url, policy)?;
return Err(SubscriptionError::new(
"Subscription URL must use http or https", let redirect_policy = redirect::Policy::custom(move |attempt| {
)); if validate_subscription_fetch_url(attempt.url(), policy).is_ok() {
attempt.follow()
} else {
attempt.stop()
}
});
let client = reqwest::blocking::Client::builder()
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
.redirect(redirect_policy)
.build()
.map_err(|error| {
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
})?;
let mut request = client.get(parsed_url);
for (name, value) in identity.request_headers_without_device_hwid() {
request = request.header(name, value);
} }
let response = reqwest::blocking::Client::new() if let Some(device_hwid) = identity
.get(parsed_url) .device_hwid
.header("user-agent", "singbox") .as_deref()
.header("x-device-os", std::env::consts::OS) .map(str::trim)
.header("x-device-model", "proxywarden") .filter(|value| !value.is_empty())
{
request = request.header("x-hwid", device_hwid);
}
let response = request
.send() .send()
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?; .map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
@@ -109,6 +218,69 @@ pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionEr
}) })
} }
fn validate_subscription_fetch_url(
parsed_url: &Url,
policy: SubscriptionFetchPolicy,
) -> Result<(), SubscriptionError> {
if !matches!(parsed_url.scheme(), "http" | "https") {
return Err(SubscriptionError::new(
"Subscription URL must use http or https",
));
}
if !policy.allow_unsafe_local_urls && is_unsafe_subscription_host(parsed_url) {
return Err(SubscriptionError::new(
"Subscription URL host is local, private, link-local, multicast, or metadata-only",
));
}
Ok(())
}
fn is_unsafe_subscription_host(parsed_url: &Url) -> bool {
let Some(host) = parsed_url.host_str() else {
return true;
};
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
if matches!(host.as_str(), "localhost" | "metadata.google.internal")
|| host.ends_with(".localhost")
{
return true;
}
host.parse::<IpAddr>().is_ok_and(is_unsafe_ip)
}
fn is_unsafe_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_multicast()
|| ip.is_broadcast()
|| ip.is_unspecified()
|| ip.octets() == [169, 254, 169, 254]
}
IpAddr::V6(ip) => {
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| is_unique_local_ipv6(ip)
|| is_unicast_link_local_ipv6(ip)
}
}
}
fn is_unique_local_ipv6(ip: Ipv6Addr) -> bool {
(ip.segments()[0] & 0xfe00) == 0xfc00
}
fn is_unicast_link_local_ipv6(ip: Ipv6Addr) -> bool {
(ip.segments()[0] & 0xffc0) == 0xfe80
}
fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> { fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
let decoded = maybe_decode_base64(body); let decoded = maybe_decode_base64(body);
let links = decoded let links = decoded
@@ -137,7 +309,10 @@ fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
} }
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?; let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?;
let tag = parsed.fragment().unwrap_or("vless-out").to_string(); let tag = parsed
.fragment()
.map(decode_percent_encoded_utf8)
.unwrap_or_else(|| "vless-out".to_string());
let uuid = parsed.username().trim().to_string(); let uuid = parsed.username().trim().to_string();
let server = parsed.host_str().map(str::to_string).unwrap_or_default(); let server = parsed.host_str().map(str::to_string).unwrap_or_default();
let server_port = parsed.port_or_known_default().unwrap_or(443); let server_port = parsed.port_or_known_default().unwrap_or(443);
@@ -260,6 +435,86 @@ fn query_value(url: &Url, key: &str) -> Option<String> {
.map(|(_, value)| value.into_owned()) .map(|(_, value)| value.into_owned())
} }
fn sanitize_header_value(value: &str) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_graphic() || *ch == ' ')
.collect::<String>()
}
fn detect_device_os_version() -> Option<String> {
#[cfg(windows)]
{
windows_device_os_version()
}
#[cfg(not(windows))]
{
None
}
}
#[cfg(windows)]
fn windows_device_os_version() -> Option<String> {
use winreg::{enums::HKEY_LOCAL_MACHINE, RegKey};
let current_version = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion")
.ok()?;
let product_name = current_version
.get_value::<String, _>("ProductName")
.ok()
.map(|value| normalize_windows_product_name(&value, &current_version))
.filter(|value| !value.trim().is_empty());
let display_version = current_version
.get_value::<String, _>("DisplayVersion")
.ok()
.or_else(|| current_version.get_value::<String, _>("ReleaseId").ok())
.filter(|value| !value.trim().is_empty());
let build = current_version
.get_value::<String, _>("CurrentBuildNumber")
.ok()
.or_else(|| current_version.get_value::<String, _>("CurrentBuild").ok())
.filter(|value| !value.trim().is_empty());
let ubr = current_version.get_value::<u32, _>("UBR").ok();
let build = match (build, ubr) {
(Some(build), Some(ubr)) => Some(format!("{build}.{ubr}")),
(build, _) => build,
};
let mut parts = Vec::new();
if let Some(product_name) = product_name {
parts.push(product_name);
}
if let Some(display_version) = display_version {
parts.push(display_version);
}
if let Some(build) = build {
parts.push(format!("build {build}"));
}
let version = parts.join(" | ");
(!version.is_empty()).then_some(version)
}
#[cfg(windows)]
fn normalize_windows_product_name(value: &str, current_version: &winreg::RegKey) -> String {
let trimmed = value.trim();
let build_number = current_version
.get_value::<String, _>("CurrentBuildNumber")
.ok()
.or_else(|| current_version.get_value::<String, _>("CurrentBuild").ok())
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or_default();
if build_number >= 22000 && trimmed.starts_with("Windows 10") {
return trimmed.replacen("Windows 10", "Windows 11", 1);
}
trimmed.to_string()
}
fn now_timestamp() -> String { fn now_timestamp() -> String {
let seconds = SystemTime::now() let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyWarden", "productName": "ProxyWarden",
"version": "0.1.0", "version": "1.0.2",
"identifier": "ru.dokops.proxywarden.windows", "identifier": "ru.dokops.proxywarden.windows",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -13,15 +13,16 @@
"windows": [ "windows": [
{ {
"title": "ProxyWarden", "title": "ProxyWarden",
"width": 1120, "width": 920,
"height": 760, "height": 760,
"minWidth": 760, "minWidth": 760,
"maxWidth": 1200,
"minHeight": 560, "minHeight": 560,
"resizable": true "resizable": true
} }
], ],
"security": { "security": {
"csp": null "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'"
} }
}, },
"bundle": { "bundle": {

View File

@@ -1,40 +1,19 @@
#[path = "../src/activity.rs"] use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
mod activity; use proxywarden_lib::commands::{
#[path = "../src/commands.rs"] self, apply_profiles_with_services, apply_profiles_with_services_and_detection, build_status,
mod commands; read_saved_state_with_proxifyre_config, resolve_component_statuses, resolve_preview,
#[path = "../src/component_detection.rs"] save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
mod component_detection; HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
#[path = "../src/models.rs"] TargetInputDto,
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, read_saved_state_with_proxifyre_config,
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::{ use proxywarden_lib::component_detection::{
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry, DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
}; };
use models::{ use proxywarden_lib::models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol, self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType,
ProxyProtocol, Target, TargetKind, Protocol, ProxyProtocol, Target, TargetKind,
}; };
use proxifyre::ProxiFyreAdapter; use proxywarden_lib::storage::JsonStorage;
use std::collections::HashSet; use std::collections::HashSet;
use std::fs; use std::fs;
use std::net::TcpListener; use std::net::TcpListener;
@@ -42,7 +21,6 @@ use std::path::{Path, PathBuf};
#[cfg(windows)] #[cfg(windows)]
use std::process::Command as ProcessCommand; use std::process::Command as ProcessCommand;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use storage::JsonStorage;
#[test] #[test]
fn save_commands_normalize_and_persist_profile_and_target() { fn save_commands_normalize_and_persist_profile_and_target() {
@@ -222,17 +200,21 @@ fn ping_proxy_target_reports_open_tcp_endpoint() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener"); let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
let port = listener.local_addr().expect("read local addr").port(); let port = listener.local_addr().expect("read local addr").port();
let result = commands::ping_proxy_target_endpoint(commands::PingProxyTargetInputDto { let result = commands::ping_proxy_target_endpoint_with_probes(
host: "127.0.0.1".to_string(), commands::PingProxyTargetInputDto {
port, host: "127.0.0.1".to_string(),
}) port,
},
&[],
)
.expect("ping should return response"); .expect("ping should return response");
assert_eq!(result.tag, "external-proxy"); assert_eq!(result.tag, "route-proxy");
assert_eq!(result.server, "127.0.0.1"); assert_eq!(result.server, "127.0.0.1");
assert_eq!(result.server_port, port); assert_eq!(result.server_port, port);
assert!(result.ok); assert!(result.ok);
assert!(result.latency.is_some()); assert!(result.latency.is_some());
assert!(result.probes.is_empty());
} }
#[test] #[test]
@@ -269,6 +251,35 @@ fn proxifyre_install_script_parses_as_powershell() {
cleanup(&root); cleanup(&root);
} }
#[test]
fn proxifyre_install_script_uses_resilient_download_helpers() {
let root = test_root("proxifyre-install-script-downloads");
let script = commands::install_proxifyre_script(&root.join("proxifyre-app-config.json"));
assert!(script.contains("function Get-SafeUriForLog([string]$uri)"));
assert!(script.contains("function Invoke-ReleaseApi([string]$uri, [string]$label)"));
assert!(
script.contains("function Invoke-Download([string]$uri, [string]$path, [string]$label)")
);
assert!(script.contains("foreach ($attempt in 1..3)"));
assert!(script.contains("Invoke-WebClientDownload $uri $partialPath"));
assert!(script.contains("Invoke-CurlDownload $uri $partialPath"));
assert!(script.contains("--user-agent 'proxywarden' --output $partialPath --url $uri"));
assert!(script.contains("Move-Item -LiteralPath $partialPath -Destination $path -Force"));
assert!(script
.contains("Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'"));
assert!(script.contains("Invoke-ReleaseApi $ndisapiReleaseApi 'Windows Packet Filter'"));
assert!(script.contains(
"Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'"
));
assert!(script.contains("Invoke-ReleaseApi $proxifyreReleaseApi 'ProxiFyre'"));
assert!(script.contains(
"Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'"
));
cleanup(&root);
}
#[test] #[test]
fn singbox_runner_preserves_installer_args_with_spaces() { fn singbox_runner_preserves_installer_args_with_spaces() {
let script = commands::singbox_installer_runner_script( let script = commands::singbox_installer_runner_script(
@@ -348,11 +359,13 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() {
.expect("write targets"); .expect("write targets");
write_json(&storage.paths().components_file, &[singbox_missing()]); write_json(&storage.paths().components_file, &[singbox_missing()]);
let error = apply_profiles_with_services( let error = apply_profiles_with_services_and_detection(
&storage, &storage,
&ProxiFyreAdapter::default(), &ProxiFyreAdapter::default(),
&MockApplyHelper, &MockApplyHelper,
&FixedClock, &FixedClock,
None,
None,
) )
.expect_err("missing sing-box should block local target apply"); .expect_err("missing sing-box should block local target apply");
let activity = storage.read_activity().expect("read blocked activity"); let activity = storage.read_activity().expect("read blocked activity");
@@ -416,11 +429,14 @@ fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
let applied = let applied =
fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config"); fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config");
let backup =
fs::read_to_string(install_dir.join("app-config.json.bak")).expect("read backup config");
assert!(result.success); assert!(result.success);
assert!(result.changed); assert!(result.changed);
assert_eq!(result.action, "proxifyre.apply-detected-config"); assert_eq!(result.action, "proxifyre.apply-detected-config");
assert_eq!(applied, r#"{"proxies":[]}"#); assert_eq!(applied, r#"{"proxies":[]}"#);
assert_eq!(backup, "{}");
assert!(install_dir.join("app-config.json.bak").exists()); assert!(install_dir.join("app-config.json.bak").exists());
cleanup(&root); cleanup(&root);

View File

@@ -1,14 +1,9 @@
#[path = "../src/component_detection.rs"] use proxywarden_lib::component_detection::{
mod component_detection;
#[path = "../src/models.rs"]
mod models;
use component_detection::{
detect_proxyfier_install_with_host, detect_singbox_install_with_host, detect_proxyfier_install_with_host, detect_singbox_install_with_host,
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost, proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
ProxyfierEngine, RegistryInstallEntry, ProxyfierEngine, RegistryInstallEntry,
}; };
use models::ComponentState; use proxywarden_lib::models::ComponentState;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
path::{Path, PathBuf}, path::{Path, PathBuf},

View File

@@ -1,13 +1,8 @@
#[path = "../src/models.rs"] use proxywarden_lib::models::{
mod models;
#[path = "../src/validation.rs"]
mod validation;
use models::{
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
TargetInput, TargetKind, TargetInput, TargetKind,
}; };
use validation::{normalize_profile, normalize_target}; use proxywarden_lib::validation::{normalize_profile, normalize_target};
#[test] #[test]
fn normalizes_profile_source_items() { fn normalizes_profile_source_items() {

View File

@@ -1,14 +1,9 @@
#[path = "../src/helper.rs"] use proxywarden_lib::helper::{
mod helper;
#[path = "../src/models.rs"]
mod models;
use helper::{
helper_action_requires_elevation, install_request, parse_helper_response, helper_action_requires_elevation, install_request, parse_helper_response,
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput, proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper, HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
}; };
use models::ComponentId; use proxywarden_lib::models::ComponentId;
use serde_json::json; use serde_json::json;
use std::cell::RefCell; use std::cell::RefCell;
use std::path::PathBuf; use std::path::PathBuf;

View File

@@ -1,16 +1,13 @@
#[path = "../src/models.rs"] use proxywarden_lib::adapters::proxifyre::{
mod models; ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE,
#[path = "../src/adapters/proxifyre.rs"] };
mod proxifyre; use proxywarden_lib::adapters::proxy_router::{
#[path = "../src/adapters/proxy_router.rs"] ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest,
mod proxy_router; };
use proxywarden_lib::models::{
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind, ProxyProtocol, Target, TargetKind,
}; };
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
#[test] #[test]
fn generates_proxifyre_config_for_discord_external_socks5_target() { fn generates_proxifyre_config_for_discord_external_socks5_target() {

View File

@@ -1,24 +1,15 @@
#[path = "../src/models.rs"] use proxywarden_lib::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
mod models; use proxywarden_lib::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
#[path = "../src/adapters/proxifyre.rs"] use proxywarden_lib::adapters::singbox::{
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, SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG, SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
SINGBOX_OUTPUT_FILE, SINGBOX_OUTPUT_FILE,
}; };
use proxywarden_lib::models::{
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
TargetKind,
};
use std::{ use std::{
cell::RefCell, cell::RefCell,
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -215,6 +206,7 @@ impl SingBoxConfigChecker for RecordingChecker {
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig { fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
LocalSingBoxConfig { LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/list".to_string()), subscription_url: Some("https://sub.example.test/list".to_string()),
device_hwid: None,
selected_server_tag: Some(selected_server_tag.to_string()), selected_server_tag: Some(selected_server_tag.to_string()),
listen_host: "127.0.0.1".to_string(), listen_host: "127.0.0.1".to_string(),
listen_port: 1080, listen_port: 1080,

View File

@@ -1,42 +1,22 @@
#[path = "../src/activity.rs"] use proxywarden_lib::adapters::singbox::{
mod activity; SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
#[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::{ use proxywarden_lib::commands::{
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
generate_singbox_config_with_services, read_singbox_status,
save_singbox_subscription_to_storage, select_singbox_server_in_storage, Clock,
SaveSingBoxSubscriptionInputDto, SelectSingBoxServerInputDto, SubscriptionFetcher,
};
use proxywarden_lib::models::{
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache, ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
SubscriptionServer, TargetKind, SubscriptionServer, TargetKind,
}; };
use proxywarden_lib::storage::JsonStorage;
use proxywarden_lib::subscription;
use serde_json::{json, Map}; use serde_json::{json, Map};
use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use storage::JsonStorage;
#[test] #[test]
fn saves_subscription_url_without_exposing_secret_query() { fn saves_subscription_url_without_exposing_secret_query() {
@@ -59,6 +39,7 @@ fn saves_subscription_url_without_exposing_secret_query() {
config.subscription_url, config.subscription_url,
Some("https://sub.example.test/path?token=secret".to_string()) Some("https://sub.example.test/path?token=secret".to_string())
); );
assert_valid_generated_hwid(config.device_hwid.as_deref());
assert!(status.config.has_subscription); assert!(status.config.has_subscription);
assert_eq!( assert_eq!(
status.config.subscription_display_url, status.config.subscription_display_url,
@@ -68,6 +49,39 @@ fn saves_subscription_url_without_exposing_secret_query() {
cleanup(&root); cleanup(&root);
} }
#[cfg(debug_assertions)]
#[test]
fn singbox_status_exposes_dev_subscription_headers_without_hwid() {
let root = test_root("dev-subscription-identity");
let storage = JsonStorage::new(root.clone());
let status = read_singbox_status(&storage).expect("read sing-box status");
let headers = status
.subscription_identity
.headers
.iter()
.map(|header| (header.name.clone(), header.value.clone()))
.collect::<Vec<_>>();
let expected_headers = subscription::SubscriptionFetchIdentity::default()
.request_headers_without_device_hwid()
.into_iter()
.map(|(name, value)| (name.to_string(), value))
.collect::<Vec<_>>();
assert_eq!(headers, expected_headers);
assert!(headers
.iter()
.all(|(name, _)| !name.eq_ignore_ascii_case("x-hwid")));
let serialized = serde_json::to_value(&status).expect("serialize status");
assert!(serialized.get("subscriptionIdentity").is_some());
assert!(!serialized
.to_string()
.to_ascii_lowercase()
.contains("x-hwid"));
cleanup(&root);
}
#[test] #[test]
fn rejects_non_http_subscription_url() { fn rejects_non_http_subscription_url() {
let root = test_root("invalid-subscription"); let root = test_root("invalid-subscription");
@@ -125,6 +139,64 @@ fn fetches_subscription_cache_and_selects_first_server() {
cleanup(&root); cleanup(&root);
} }
#[test]
fn fetches_subscription_with_saved_device_hwid() {
let root = test_root("fetch-subscription-hwid");
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 and generated HWID");
let expected_hwid = storage
.read_local_singbox_config()
.expect("read local sing-box config")
.device_hwid
.expect("generated HWID");
fetch_singbox_subscription_with_fetcher(
&storage,
&HwidAssertingFetcher {
cache: sample_cache(),
expected_hwid,
},
&FixedClock,
)
.expect("fetch subscription through mock");
cleanup(&root);
}
#[test]
fn fetch_generates_device_hwid_for_existing_subscription_without_one() {
let root = test_root("fetch-generates-hwid");
let storage = JsonStorage::new(root.clone());
storage
.write_local_singbox_config(&LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
device_hwid: None,
..LocalSingBoxConfig::default()
})
.expect("write local sing-box config");
fetch_singbox_subscription_with_fetcher(
&storage,
&GeneratedHwidAssertingFetcher(sample_cache()),
&FixedClock,
)
.expect("fetch subscription through mock");
let config = storage
.read_local_singbox_config()
.expect("read local sing-box config");
assert_valid_generated_hwid(config.device_hwid.as_deref());
cleanup(&root);
}
#[test] #[test]
fn selects_server_from_cached_subscription() { fn selects_server_from_cached_subscription() {
let root = test_root("select-server"); let root = test_root("select-server");
@@ -191,6 +263,7 @@ fn generate_writes_config_and_local_singbox_target() {
storage storage
.write_local_singbox_config(&LocalSingBoxConfig { .write_local_singbox_config(&LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path".to_string()), subscription_url: Some("https://sub.example.test/path".to_string()),
device_hwid: Some("C34C14C9-94BC-4918-B053-A249BC117A91".to_string()),
selected_server_tag: Some("nl-1".to_string()), selected_server_tag: Some("nl-1".to_string()),
..LocalSingBoxConfig::default() ..LocalSingBoxConfig::default()
}) })
@@ -254,9 +327,11 @@ fn generate_requires_cached_subscription() {
fn forget_subscription_clears_url_selection_and_cache() { fn forget_subscription_clears_url_selection_and_cache() {
let root = test_root("forget-subscription"); let root = test_root("forget-subscription");
let storage = JsonStorage::new(root.clone()); let storage = JsonStorage::new(root.clone());
let existing_hwid = "C34C14C9-94BC-4918-B053-A249BC117A91".to_string();
storage storage
.write_local_singbox_config(&LocalSingBoxConfig { .write_local_singbox_config(&LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path".to_string()), subscription_url: Some("https://sub.example.test/path".to_string()),
device_hwid: Some(existing_hwid.clone()),
selected_server_tag: Some("nl-1".to_string()), selected_server_tag: Some("nl-1".to_string()),
..LocalSingBoxConfig::default() ..LocalSingBoxConfig::default()
}) })
@@ -276,6 +351,7 @@ fn forget_subscription_clears_url_selection_and_cache() {
assert!(!status.config.has_subscription); assert!(!status.config.has_subscription);
assert_eq!(config.subscription_url, None); assert_eq!(config.subscription_url, None);
assert_eq!(config.device_hwid, Some(existing_hwid));
assert_eq!(config.selected_server_tag, None); assert_eq!(config.selected_server_tag, None);
assert_eq!(cache, None); assert_eq!(cache, None);
@@ -288,12 +364,59 @@ impl SubscriptionFetcher for MockFetcher {
fn fetch_subscription( fn fetch_subscription(
&self, &self,
url: &str, url: &str,
_identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError> { ) -> Result<SubscriptionCache, subscription::SubscriptionError> {
assert_eq!(url, "https://sub.example.test/path?token=secret"); assert_eq!(url, "https://sub.example.test/path?token=secret");
Ok(self.0.clone()) Ok(self.0.clone())
} }
} }
struct HwidAssertingFetcher {
cache: SubscriptionCache,
expected_hwid: String,
}
impl SubscriptionFetcher for HwidAssertingFetcher {
fn fetch_subscription(
&self,
url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
assert_eq!(url, "https://sub.example.test/path?token=secret");
assert_eq!(
identity.device_hwid.as_deref(),
Some(self.expected_hwid.as_str())
);
assert_eq!(identity.app_name, "ProxyWarden");
assert!(identity.user_agent.starts_with("ProxyWarden/"));
assert_eq!(identity.device_os, std::env::consts::OS);
assert_eq!(identity.device_model, "ProxyWarden");
Ok(self.cache.clone())
}
}
struct GeneratedHwidAssertingFetcher(SubscriptionCache);
impl SubscriptionFetcher for GeneratedHwidAssertingFetcher {
fn fetch_subscription(
&self,
url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
assert_eq!(url, "https://sub.example.test/path?token=secret");
assert_valid_generated_hwid(identity.device_hwid.as_deref());
assert_eq!(identity.app_name, "ProxyWarden");
assert!(identity.user_agent.starts_with("ProxyWarden/"));
Ok(self.0.clone())
}
}
fn assert_valid_generated_hwid(value: Option<&str>) {
let value = value.expect("generated HWID");
uuid::Uuid::parse_str(value).expect("HWID should be a UUID");
assert_eq!(value, value.to_ascii_uppercase());
}
struct MockChecker; struct MockChecker;
impl SingBoxConfigChecker for MockChecker { impl SingBoxConfigChecker for MockChecker {

View File

@@ -1,12 +1,5 @@
#[path = "../src/component_detection.rs"] use proxywarden_lib::component_detection::DetectedSingBox;
mod component_detection; use proxywarden_lib::singbox_service::{
#[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, build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
service_control_script, SingBoxServiceAction, service_control_script, SingBoxServiceAction,
}; };
@@ -55,7 +48,8 @@ noise
#[test] #[test]
fn safe_install_dir_allows_only_proxywarden_singbox_folder() { fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
assert!( assert!(
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box")).is_ok() 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:\Windows")).is_err());
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err()); assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err());
@@ -63,7 +57,12 @@ fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
#[test] #[test]
fn service_control_script_targets_named_service_and_action() { fn service_control_script_targets_named_service_and_action() {
let script = service_control_script(SingBoxServiceAction::Start, "ProxyWardenSingBox", None, None); let script = service_control_script(
SingBoxServiceAction::Start,
"ProxyWardenSingBox",
None,
None,
);
assert!(script.contains("$serviceName = 'ProxyWardenSingBox'")); assert!(script.contains("$serviceName = 'ProxyWardenSingBox'"));
assert!(script.contains("$action = 'start'")); assert!(script.contains("$action = 'start'"));
@@ -84,9 +83,9 @@ fn service_control_script_syncs_generated_config_before_start() {
assert!(script.contains( assert!(script.contains(
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'" "$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
)); ));
assert!(script.contains( assert!(
"$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'" script.contains("$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'")
)); );
assert!(script.contains("Copy-Item -LiteralPath $configSource")); assert!(script.contains("Copy-Item -LiteralPath $configSource"));
assert!(script.contains("'config_sync_failed'")); assert!(script.contains("'config_sync_failed'"));
} }
@@ -119,7 +118,9 @@ fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) ->
DetectedSingBox { DetectedSingBox {
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"), 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"), 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"), wrapper_path: PathBuf::from(
r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe",
),
binary_exists, binary_exists,
wrapper_exists, wrapper_exists,
running, running,

View File

@@ -1,19 +1,12 @@
#[path = "../src/activity.rs"] use proxywarden_lib::models::{
mod activity;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/storage.rs"]
mod storage;
use models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
SubscriptionServer, Target, TargetKind, SubscriptionServer, Target, TargetKind,
}; };
use proxywarden_lib::storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
#[test] #[test]
fn storage_defaults_to_programdata_root() { fn storage_defaults_to_programdata_root() {
@@ -59,6 +52,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
let storage = JsonStorage::new(root.clone()); let storage = JsonStorage::new(root.clone());
let config = LocalSingBoxConfig { let config = LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()), subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
device_hwid: Some("hwid-abcdef1234".to_string()),
selected_server_tag: Some("nl-1".to_string()), selected_server_tag: Some("nl-1".to_string()),
listen_host: "127.0.0.1".to_string(), listen_host: "127.0.0.1".to_string(),
listen_port: 1080, listen_port: 1080,
@@ -91,7 +85,6 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
config.subscription_display_url(), config.subscription_display_url(),
Some("https://sub.example.test/...".to_string()) Some("https://sub.example.test/...".to_string())
); );
cleanup(&root); cleanup(&root);
} }
@@ -104,6 +97,7 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
.expect("read default local sing-box config"); .expect("read default local sing-box config");
assert_eq!(config.subscription_url, None); assert_eq!(config.subscription_url, None);
assert_eq!(config.device_hwid, None);
assert_eq!(config.selected_server_tag, None); assert_eq!(config.selected_server_tag, None);
assert_eq!(config.listen_host, "127.0.0.1"); assert_eq!(config.listen_host, "127.0.0.1");
assert_eq!(config.listen_port, 1080); assert_eq!(config.listen_port, 1080);
@@ -113,7 +107,59 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
} }
#[test] #[test]
fn invalid_subscription_cache_falls_back_to_none() { fn reads_percent_encoded_singbox_tags_as_utf8() {
let root = test_root("local-singbox-percent-tags");
let storage = JsonStorage::new(root.clone());
let encoded_tag =
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA";
let decoded_tag = "Умный 🇳🇱->🇷🇺";
storage
.write_local_singbox_config(&LocalSingBoxConfig {
selected_server_tag: Some(encoded_tag.to_string()),
..LocalSingBoxConfig::default()
})
.expect("write local sing-box config");
storage
.write_singbox_subscription_cache(&SubscriptionCache {
config: serde_json::json!({
"outbounds": [
{
"type": "vless",
"tag": encoded_tag,
"server": "nl.example.test",
"server_port": 443
}
]
}),
servers: vec![SubscriptionServer {
tag: encoded_tag.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(),
})
.expect("write subscription cache");
let config = storage
.read_local_singbox_config()
.expect("read local sing-box config");
let cache = storage
.read_singbox_subscription_cache()
.expect("read subscription cache")
.expect("subscription cache");
assert_eq!(config.selected_server_tag, Some(decoded_tag.to_string()));
assert_eq!(cache.servers[0].tag, decoded_tag);
assert_eq!(cache.config["outbounds"][0]["tag"], decoded_tag);
cleanup(&root);
}
#[test]
fn invalid_subscription_cache_without_backup_returns_error_and_moves_corrupt_file() {
let root = test_root("invalid-subscription-cache"); let root = test_root("invalid-subscription-cache");
let storage = JsonStorage::new(root.clone()); let storage = JsonStorage::new(root.clone());
fs::create_dir_all(&storage.paths().state_dir).expect("create state dir"); fs::create_dir_all(&storage.paths().state_dir).expect("create state dir");
@@ -123,27 +169,62 @@ fn invalid_subscription_cache_falls_back_to_none() {
) )
.expect("write invalid cache"); .expect("write invalid cache");
assert_eq!( let error = storage
storage .read_singbox_subscription_cache()
.read_singbox_subscription_cache() .expect_err("invalid cache should not silently fallback");
.expect("invalid cache fallback"),
None assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
); assert!(!storage.paths().singbox_subscription_cache_file.exists());
assert!(has_corrupt_sibling(
&storage.paths().singbox_subscription_cache_file
));
cleanup(&root); cleanup(&root);
} }
#[test] #[test]
fn invalid_json_falls_back_to_empty_collection() { fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() {
let root = test_root("invalid-json"); let root = test_root("invalid-json-no-backup");
let storage = JsonStorage::new(root.clone()); let storage = JsonStorage::new(root.clone());
fs::create_dir_all(&storage.paths().config_dir).expect("create config dir"); fs::create_dir_all(&storage.paths().config_dir).expect("create config dir");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json"); fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
let error = storage
.read_profiles()
.expect_err("invalid profiles should not silently fallback");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(!storage.paths().profiles_file.exists());
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
cleanup(&root);
}
#[test]
fn invalid_json_recovers_from_valid_backup() {
let root = test_root("invalid-json-valid-backup");
let storage = JsonStorage::new(root.clone());
let backup_profiles = vec![sample_profile("backup")];
let current_profiles = vec![sample_profile("current")];
storage
.write_profiles(&backup_profiles)
.expect("write first profiles");
storage
.write_profiles(&current_profiles)
.expect("write second profiles");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("corrupt live json");
let recovered = storage
.read_profiles()
.expect("invalid profiles should recover from valid backup");
assert_eq!(recovered, backup_profiles);
assert_eq!( assert_eq!(
storage.read_profiles().expect("invalid profiles fallback"), storage.read_profiles().expect("restored live profiles"),
Vec::<Profile>::new() backup_profiles
); );
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
cleanup(&root); cleanup(&root);
} }
@@ -233,6 +314,26 @@ fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
fs::write(path, contents).expect("write json"); fs::write(path, contents).expect("write json");
} }
fn has_corrupt_sibling(path: &Path) -> bool {
let Some(parent) = path.parent() else {
return false;
};
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
};
let prefix = format!("{file_name}.corrupt.");
fs::read_dir(parent)
.expect("read sibling dir")
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(&prefix))
})
}
fn sample_profile(id: &str) -> Profile { fn sample_profile(id: &str) -> Profile {
Profile { Profile {
id: id.to_string(), id: id.to_string(),

View File

@@ -1,11 +1,12 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/subscription.rs"]
mod subscription;
use base64::{engine::general_purpose, Engine}; use base64::{engine::general_purpose, Engine};
use models::redact_subscription_url; use proxywarden_lib::models::redact_subscription_url;
use subscription::{parse_subscription_body, parse_user_info}; use proxywarden_lib::subscription::{
self, parse_subscription_body, parse_user_info, SubscriptionFetchIdentity,
SubscriptionFetchPolicy,
};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;
#[test] #[test]
fn parses_singbox_json_config_servers() { fn parses_singbox_json_config_servers() {
@@ -41,6 +42,19 @@ fn parses_base64_vless_link_list() {
assert_eq!(outbound["packet_encoding"], "xudp"); assert_eq!(outbound["packet_encoding"], "xudp");
} }
#[test]
fn decodes_percent_encoded_vless_fragment_tag() {
let link = sample_vless_link(
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA",
);
let parsed = parse_subscription_body(&link).expect("vless link should parse");
let outbound = &parsed.config["outbounds"][0];
assert_eq!(parsed.servers[0].tag, "Умный 🇳🇱->🇷🇺");
assert_eq!(outbound["tag"], "Умный 🇳🇱->🇷🇺");
}
#[test] #[test]
fn rejects_body_without_supported_outbounds() { fn rejects_body_without_supported_outbounds() {
let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#) let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)
@@ -78,6 +92,83 @@ fn rejects_invalid_or_non_http_subscription_url_before_network() {
assert!(unsupported.message.contains("http or https")); assert!(unsupported.message.contains("http or https"));
} }
#[test]
fn rejects_unsafe_local_subscription_urls_before_network() {
for url in [
"http://127.0.0.1:9/subscription",
"http://localhost/subscription",
"http://169.254.169.254/latest/meta-data",
"http://192.168.0.1/subscription",
"http://[::1]/subscription",
] {
let error = subscription::fetch_subscription(url)
.expect_err("unsafe local URL should fail before request");
assert!(
error.message.contains("local, private"),
"unexpected error for {url}: {}",
error.message
);
}
}
#[test]
fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
let url = format!("http://{}/subscription", listener.local_addr().unwrap());
let request_thread = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept test request");
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("set read timeout");
let mut request = Vec::new();
let mut buffer = [0_u8; 512];
loop {
let bytes_read = stream.read(&mut buffer).expect("read request");
if bytes_read == 0 {
break;
}
request.extend_from_slice(&buffer[..bytes_read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let body = r#"{"outbounds":[{"type":"vless","tag":"nl-1","server":"nl.example.test","server_port":443}]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("write response");
String::from_utf8_lossy(&request).to_ascii_lowercase()
});
let mut identity = SubscriptionFetchIdentity::with_device_hwid(Some("hwid-abc123"));
identity.device_os_version = Some("Windows 11 Pro | 25H2 | build 26200.8655".to_string());
let cache = subscription::fetch_subscription_with_identity_and_policy(
&url,
&identity,
SubscriptionFetchPolicy {
allow_unsafe_local_urls: true,
},
)
.expect("fetch subscription through local test server");
let request = request_thread.join().expect("request thread");
assert_eq!(cache.servers[0].tag, "nl-1");
assert!(request.contains("x-hwid: hwid-abc123"));
assert!(request.contains("user-agent: proxywarden/"));
assert!(request.contains("x-app-name: proxywarden"));
assert!(request.contains("x-device-os:"));
assert!(request.contains("x-device-os-version: windows 11 pro | 25h2 | build 26200.8655"));
assert!(request.contains("x-ver-os: windows 11 pro | 25h2 | build 26200.8655"));
assert!(request.contains("x-device-model: proxywarden"));
}
#[test] #[test]
fn redacts_subscription_url_for_display() { fn redacts_subscription_url_for_display() {
assert_eq!( assert_eq!(
@@ -86,7 +177,13 @@ fn redacts_subscription_url_for_display() {
); );
assert_eq!( assert_eq!(
redact_subscription_url("vless://uuid@example.test"), redact_subscription_url("vless://uuid@example.test"),
"vless://uuid@example.test/..." "vless://example.test/..."
);
assert_eq!(
redact_subscription_url(
"https://user:password@sub.example.test:8443/path?token=secret#frag"
),
"https://sub.example.test:8443/..."
); );
} }

View File

@@ -30,12 +30,28 @@ export interface StatusResponse {
generatedConfigPath: string; generatedConfigPath: string;
} }
export interface AdminStatusResponse {
isWindows: boolean;
isElevated: boolean;
canRestartElevated: boolean;
message: string;
}
export interface SavedStateResponse { export interface SavedStateResponse {
profiles: Profile[]; profiles: Profile[];
targets: Target[]; targets: Target[];
generatedConfigPath: string; generatedConfigPath: string;
} }
export interface StartupSnapshotResponse {
adminStatus: AdminStatusResponse;
savedState: SavedStateResponse;
components: ComponentStatus[];
proxifyreSetupStatus: ProxiFyreSetupStatus;
singboxStatus: LocalSingBoxStatusResponse;
singboxSetupStatus: SingBoxSetupStatus;
}
export interface ProxiFyreSetupItem { export interface ProxiFyreSetupItem {
id: string; id: string;
name: string; name: string;
@@ -64,6 +80,16 @@ export interface LocalSingBoxStatusResponse {
component: ComponentStatus; component: ComponentStatus;
generatedConfigPath: string; generatedConfigPath: string;
lanListenHost?: string; lanListenHost?: string;
subscriptionIdentity?: SubscriptionRequestIdentity;
}
export interface SubscriptionRequestIdentity {
headers: SubscriptionRequestHeader[];
}
export interface SubscriptionRequestHeader {
name: string;
value: string;
} }
export interface PingServerResponse { export interface PingServerResponse {
@@ -75,6 +101,27 @@ export interface PingServerResponse {
error?: string; error?: string;
} }
export interface ProxyProbeResponse {
id: string;
name: string;
url: string;
ok: boolean;
status?: number;
latency?: number;
ip?: string;
error?: string;
}
export interface ProxyTargetCheckResponse {
tag: string;
server: string;
serverPort: number;
ok: boolean;
latency?: number;
error?: string;
probes: ProxyProbeResponse[];
}
export interface GenerateSingBoxConfigResponse { export interface GenerateSingBoxConfigResponse {
success: boolean; success: boolean;
message: string; message: string;
@@ -114,6 +161,18 @@ export function getStatus(): Promise<StatusResponse> {
return invoke<StatusResponse>('get_status'); return invoke<StatusResponse>('get_status');
} }
export function getAdminStatus(): Promise<AdminStatusResponse> {
return invoke<AdminStatusResponse>('get_admin_status');
}
export function restartAsAdmin(): Promise<void> {
return invoke<void>('restart_as_admin');
}
export function getStartupSnapshot(): Promise<StartupSnapshotResponse> {
return invoke<StartupSnapshotResponse>('get_startup_snapshot');
}
export function getSavedState(): Promise<SavedStateResponse> { export function getSavedState(): Promise<SavedStateResponse> {
return invoke<SavedStateResponse>('get_saved_state'); return invoke<SavedStateResponse>('get_saved_state');
} }
@@ -184,8 +243,8 @@ export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
return invoke<PingServerResponse[]>('ping_all_singbox_servers'); return invoke<PingServerResponse[]>('ping_all_singbox_servers');
} }
export function pingProxyTarget(host: string, port: number): Promise<PingServerResponse> { export function pingProxyTarget(host: string, port: number): Promise<ProxyTargetCheckResponse> {
return invoke<PingServerResponse>('ping_proxy_target', { return invoke<ProxyTargetCheckResponse>('ping_proxy_target', {
input: { host, port }, input: { host, port },
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { parseProxy } from './parseProxy';
describe('parseProxy', () => {
it('parses host and port without explicit protocol', () => {
expect(parseProxy('proxy.example.test:1080')).toEqual({
protocol: 'socks5',
host: 'proxy.example.test',
port: 1080,
});
});
it('parses socks5 URLs', () => {
expect(parseProxy('socks5://127.0.0.1:1080')).toEqual({
protocol: 'socks5',
host: '127.0.0.1',
port: 1080,
});
});
it('parses bracketed IPv6 hosts', () => {
expect(parseProxy('socks5://[::1]:1080')).toEqual({
protocol: 'socks5',
host: '::1',
port: 1080,
});
});
it('rejects unsupported schemes', () => {
expect(() => parseProxy('http://proxy.example.test:8080')).toThrow('SOCKS5');
});
it('rejects missing or invalid ports', () => {
expect(() => parseProxy('proxy.example.test')).toThrow('хост и порт');
expect(() => parseProxy('proxy.example.test:70000')).toThrow('Формат');
});
it('rejects userinfo credentials', () => {
expect(() => parseProxy('socks5://user:password@proxy.example.test:1080')).toThrow(
'логином и паролем',
);
});
});

34
src/app/lib/parseProxy.ts Normal file
View File

@@ -0,0 +1,34 @@
export interface ParsedProxy {
protocol: 'socks5';
host: string;
port: number;
}
export function parseProxy(rawValue: string): ParsedProxy {
const value = rawValue.trim();
if (!value) throw new Error('Введи адрес прокси.');
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
let parsed: URL;
try {
parsed = new URL(withProtocol);
} catch {
throw new Error('Формат: socks5://host:port или host:port.');
}
const protocol = parsed.protocol.replace(':', '').toLowerCase();
if (protocol !== 'socks5') {
throw new Error('Сейчас поддерживается только SOCKS5.');
}
if (parsed.username || parsed.password) {
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
}
const host = parsed.hostname.replace(/^\[|\]$/g, '');
const port = Number(parsed.port);
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Укажи хост и порт прокси.');
}
return { protocol: 'socks5', host, port };
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,13 @@
import { MoreHorizontal } from 'lucide-react'; import { MoreHorizontal } from 'lucide-react';
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from 'react';
import { createPortal } from 'react-dom';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
export interface ActionMenuItem { export interface ActionMenuItem {
@@ -16,6 +25,17 @@ export interface ActionMenuProps {
disabled?: boolean; disabled?: boolean;
} }
interface ActionMenuPosition {
top: number;
left: number;
width: number;
placement: 'top' | 'bottom';
}
const MENU_WIDTH = 190;
const VIEWPORT_MARGIN = 12;
const MENU_OFFSET = 6;
export function ActionMenu({ export function ActionMenu({
open, open,
onOpenChange, onOpenChange,
@@ -23,30 +43,137 @@ export function ActionMenu({
items, items,
disabled, disabled,
}: ActionMenuProps) { }: ActionMenuProps) {
const menuId = useId();
const triggerRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<ActionMenuPosition>({
top: 0,
left: 0,
width: MENU_WIDTH,
placement: 'bottom',
});
useEffect(() => {
if (disabled && open) onOpenChange(false);
}, [disabled, onOpenChange, open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = Math.min(MENU_WIDTH, Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2));
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
const left = Math.max(
VIEWPORT_MARGIN,
Math.min(rect.right - width, window.innerWidth - width - VIEWPORT_MARGIN),
);
let top = rect.bottom + MENU_OFFSET;
let placement: ActionMenuPosition['placement'] = 'bottom';
if (
popoverHeight
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
&& rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
) {
top = rect.top - popoverHeight - MENU_OFFSET;
placement = 'top';
}
const maxTop = popoverHeight
? window.innerHeight - popoverHeight - VIEWPORT_MARGIN
: window.innerHeight - VIEWPORT_MARGIN;
setPosition({
top: Math.max(VIEWPORT_MARGIN, Math.min(top, maxTop)),
left,
width,
placement,
});
};
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
onOpenChange(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
onOpenChange(false);
};
document.addEventListener('pointerdown', closeOnOutsidePointer);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('pointerdown', closeOnOutsidePointer);
document.removeEventListener('keydown', closeOnEscape);
};
}, [onOpenChange, open]);
const popoverStyle = {
top: position.top,
left: position.left,
width: position.width,
} as CSSProperties;
return ( return (
<div className="ui-action-menu"> <div className="ui-action-menu" ref={triggerRef}>
<IconButton <IconButton
label={label} label={label}
icon={<MoreHorizontal size={20} strokeWidth={2} />} icon={<MoreHorizontal size={20} strokeWidth={2} />}
onClick={() => onOpenChange(!open)} onClick={() => onOpenChange(!open)}
disabled={disabled} disabled={disabled}
aria-controls={open ? menuId : undefined}
aria-expanded={open} aria-expanded={open}
aria-haspopup="menu"
/> />
{open ? ( {open && typeof document !== 'undefined' ? createPortal(
<div className="ui-action-menu-popover" role="menu"> <div
className="ui-action-menu-popover"
data-placement={position.placement}
id={menuId}
ref={popoverRef}
role="menu"
style={popoverStyle}
>
{items.map((item) => ( {items.map((item) => (
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={item.danger ? 'is-danger' : ''} className={item.danger ? 'is-danger' : ''}
onClick={item.onClick} onClick={() => {
onOpenChange(false);
item.onClick();
}}
disabled={item.disabled} disabled={item.disabled}
key={item.label} key={item.label}
> >
{item.label} {item.label}
</button> </button>
))} ))}
</div> </div>,
document.body,
) : null} ) : null}
</div> </div>
); );

16
src/ui/BusyRing.tsx Normal file
View File

@@ -0,0 +1,16 @@
export interface BusyRingProps {
className?: string;
}
export function BusyRing({ className }: BusyRingProps) {
const classes = ['ui-busy-ring', className ?? ''].filter(Boolean).join(' ');
return (
<span className={classes} aria-hidden="true">
<span className="ui-busy-ring-segment top" />
<span className="ui-busy-ring-segment right" />
<span className="ui-busy-ring-segment bottom" />
<span className="ui-busy-ring-segment left" />
</span>
);
}

View File

@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { BusyRing } from './BusyRing';
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger'; export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg'; export type ButtonSize = 'sm' | 'md' | 'lg';
@@ -37,8 +38,10 @@ export function Button({
{...props} {...props}
className={classes} className={classes}
disabled={disabled || loading} disabled={disabled || loading}
aria-busy={loading || undefined}
> >
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? ( {loading ? <BusyRing /> : null}
{!loading && leftIcon ? (
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span> <span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
) : null} ) : null}
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span> <span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>

View File

@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { BusyRing } from './BusyRing';
export type IconButtonVariant = 'neutral' | 'add' | 'danger'; export type IconButtonVariant = 'neutral' | 'add' | 'danger';
@@ -36,8 +37,10 @@ export function IconButton({
aria-label={label} aria-label={label}
data-tooltip={tooltipText} data-tooltip={tooltipText}
disabled={disabled || loading} disabled={disabled || loading}
aria-busy={loading || undefined}
> >
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon} {loading ? <BusyRing /> : null}
{icon}
</button> </button>
); );
} }

View File

@@ -59,9 +59,11 @@ export function ServiceControlRow({
</span> </span>
<span className="ui-service-dot" aria-hidden="true" /> <span className="ui-service-dot" aria-hidden="true" />
<div className="ui-service-text"> <div className="ui-service-text">
<strong>{title}</strong> <div className="ui-service-title-line">
<strong>{title}</strong>
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
</div>
<span>{detail}</span> <span>{detail}</span>
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
</div> </div>
<div className="ui-service-actions"> <div className="ui-service-actions">
{primaryAction ? ( {primaryAction ? (

View File

@@ -1,3 +1,5 @@
import { BusyRing } from './BusyRing';
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted'; export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
export interface StatusPillProps { export interface StatusPillProps {
@@ -6,6 +8,14 @@ export interface StatusPillProps {
} }
export function StatusPill({ tone = 'muted', children }: StatusPillProps) { export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
return <span className={`ui-status-pill ui-status-pill--${tone}`}>{children}</span>; return (
<span
className={`ui-status-pill ui-status-pill--${tone}`}
aria-busy={tone === 'checking' || undefined}
>
{tone === 'checking' ? <BusyRing /> : null}
{children}
</span>
);
} }

View File

@@ -1,5 +1,7 @@
export { ActionMenu } from './ActionMenu'; export { ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu'; export type { ActionMenuItem } from './ActionMenu';
export { BusyRing } from './BusyRing';
export type { BusyRingProps } from './BusyRing';
export { Button } from './Button'; export { Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button'; export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
export { DetailsPopover } from './DetailsPopover'; export { DetailsPopover } from './DetailsPopover';

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />