Compare commits
8 Commits
1.0.0
...
9fd0a8c0b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fd0a8c0b9 | |||
| 1bb795a532 | |||
| db0c1dede9 | |||
| 4d859dc0a6 | |||
| a2581187da | |||
| 2e80f7b8eb | |||
| f6b722b22a | |||
| 42b85cc8fa |
61
.agent/README.md
Normal file
61
.agent/README.md
Normal 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 подход или рекламная шелуха, этот вид пластика уже и так в океане.
|
||||
30
.agent/checklists/change-safety.md
Normal file
30
.agent/checklists/change-safety.md
Normal 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.
|
||||
37
.agent/checklists/communication.md
Normal file
37
.agent/checklists/communication.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Communication Checklist
|
||||
|
||||
Используй перед финальным ответом по любой нетривиальной задаче.
|
||||
|
||||
## Структура
|
||||
|
||||
- [ ] Ответ начинается с `Коротко` или с такой же короткой сводки на 2-4 пункта.
|
||||
- [ ] Измененные файлы или зоны проекта перечислены в начале ответа, а не спрятаны в конце.
|
||||
- [ ] Для каждого важного файла понятно: что изменилось и зачем.
|
||||
- [ ] Важные изменения поведения, безопасности или состояния отделены от мелких деталей.
|
||||
- [ ] Проверки разделены на `Проверено` и `Не проверено`.
|
||||
- [ ] Риски написаны явно.
|
||||
|
||||
## Понятность
|
||||
|
||||
- [ ] Нет плотных абзацев длиннее 4-5 строк.
|
||||
- [ ] Нет терминов и аббревиатур без пользы или краткого объяснения.
|
||||
- [ ] Нет полных логов, если они не нужны для вывода.
|
||||
- [ ] Нет пересказа каждой строки diff, если пользователь не просил.
|
||||
- [ ] Нет мутных фраз вроде `улучшена архитектура` без объяснения, что стало проще, безопаснее или понятнее.
|
||||
|
||||
## Честность
|
||||
|
||||
- [ ] Windows/service/elevation поведение не названо проверенным, если оно не тестировалось на Windows.
|
||||
- [ ] У пропущенных проверок есть простая причина.
|
||||
- [ ] Ответ не говорит `готово`, если важные проверки пропущены.
|
||||
|
||||
## Быстрая самопроверка
|
||||
|
||||
Перед отправкой ответ должен отвечать на вопросы:
|
||||
|
||||
1. Что изменилось или найдено?
|
||||
2. В каких файлах?
|
||||
3. Зачем это нужно?
|
||||
4. Что реально проверено?
|
||||
5. Что не проверено?
|
||||
6. Где остался риск?
|
||||
41
.agent/checklists/explanation-quality.md
Normal file
41
.agent/checklists/explanation-quality.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Checklist: Explanation Quality
|
||||
|
||||
Используй перед финальным ответом или PR summary.
|
||||
|
||||
## Обязательное
|
||||
|
||||
- [ ] В начале есть короткий итог на 2–4 пункта.
|
||||
- [ ] Есть список файлов или таблица `файл / что / зачем`.
|
||||
- [ ] Термины объяснены простыми словами, если они важны.
|
||||
- [ ] Нет длинных полотен без заголовков.
|
||||
- [ ] Нет пересказа каждой строки diff.
|
||||
- [ ] Указано, что проверено.
|
||||
- [ ] Указано, что не проверено.
|
||||
- [ ] Риски написаны прямо, без «должно работать».
|
||||
|
||||
## Хороший формат
|
||||
|
||||
```md
|
||||
## Коротко
|
||||
- ...
|
||||
|
||||
## Файлы
|
||||
| Файл | Что | Зачем |
|
||||
|---|---|---|
|
||||
|
||||
## Проверки
|
||||
- Выполнено: ...
|
||||
- Не выполнено: ...
|
||||
|
||||
## Риски
|
||||
- ...
|
||||
```
|
||||
|
||||
## Плохие признаки
|
||||
|
||||
- Один огромный абзац.
|
||||
- Много терминов без пользы.
|
||||
- «Исправлена логика» без указания файла и эффекта.
|
||||
- «Проверено» без команды или способа проверки.
|
||||
- «Не проверял Windows, но всё готово».
|
||||
- Список из 25 пунктов одинаковой важности.
|
||||
30
.agent/checklists/release.md
Normal file
30
.agent/checklists/release.md
Normal 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.
|
||||
36
.agent/checklists/security.md
Normal file
36
.agent/checklists/security.md
Normal 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
24
.agent/checklists/ui.md
Normal 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.
|
||||
248
.agent/skills/communication-reporting/SKILL.md
Normal file
248
.agent/skills/communication-reporting/SKILL.md
Normal 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. ...
|
||||
|
||||
Проверка
|
||||
- ...
|
||||
```
|
||||
93
.agent/skills/react-typescript-ui/SKILL.md
Normal file
93
.agent/skills/react-typescript-ui/SKILL.md
Normal 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`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
78
.agent/skills/repository-orientation/SKILL.md
Normal file
78
.agent/skills/repository-orientation/SKILL.md
Normal 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`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
106
.agent/skills/rust-tauri-backend/SKILL.md
Normal file
106
.agent/skills/rust-tauri-backend/SKILL.md
Normal 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`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
138
.agent/skills/security-hardening/SKILL.md
Normal file
138
.agent/skills/security-hardening/SKILL.md
Normal 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`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
87
.agent/skills/subscriptions-routing/SKILL.md
Normal file
87
.agent/skills/subscriptions-routing/SKILL.md
Normal 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`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
104
.agent/skills/testing-ci-release/SKILL.md
Normal file
104
.agent/skills/testing-ci-release/SKILL.md
Normal 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`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
94
.agent/skills/windows-services-powershell/SKILL.md
Normal file
94
.agent/skills/windows-services-powershell/SKILL.md
Normal 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`.
|
||||
|
||||
Минимум для нетривиальной задачи:
|
||||
|
||||
- короткая сводка;
|
||||
- таблица файлов `Файл / Что изменилось / Зачем`;
|
||||
- важные места без пересказа каждой строки;
|
||||
- что проверено;
|
||||
- что не проверено;
|
||||
- конкретные риски.
|
||||
29
.agent/templates/change-report.md
Normal file
29
.agent/templates/change-report.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Change Report
|
||||
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Важные детали
|
||||
|
||||
-
|
||||
|
||||
## Проверки
|
||||
|
||||
- ✅/⚠️ `command` — результат простыми словами.
|
||||
|
||||
## Не проверено
|
||||
|
||||
-
|
||||
|
||||
## Риски / что потом
|
||||
|
||||
-
|
||||
30
.agent/templates/concise-change-summary.md
Normal file
30
.agent/templates/concise-change-summary.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Concise Change Summary Template
|
||||
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Что изменилось по файлам
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/to/file` | | |
|
||||
|
||||
## Важные места
|
||||
|
||||
- `path/to/file`, функция/секция:
|
||||
- `path/to/file`, функция/секция:
|
||||
|
||||
## Проверено
|
||||
|
||||
-
|
||||
|
||||
## Не проверено
|
||||
|
||||
-
|
||||
|
||||
## Риски
|
||||
|
||||
-
|
||||
20
.agent/templates/file-impact-map.md
Normal file
20
.agent/templates/file-impact-map.md
Normal 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 |
|
||||
|
||||
## Что не трогаем
|
||||
|
||||
-
|
||||
|
||||
## Как проверить после изменений
|
||||
|
||||
-
|
||||
27
.agent/templates/investigation-report.md
Normal file
27
.agent/templates/investigation-report.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Investigation Report
|
||||
|
||||
## Коротко
|
||||
|
||||
- Главный вывод:
|
||||
- Где проблема:
|
||||
- Что делать первым:
|
||||
|
||||
## Что смотрел
|
||||
|
||||
| Файл / зона | Зачем смотрел | Вывод |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Находки
|
||||
|
||||
| Приоритет | Где | Что не так | Как исправить |
|
||||
|---|---|---|---|
|
||||
| Критично / Важно / Можно потом / Косметика | `path/file` | | |
|
||||
|
||||
## Проверки
|
||||
|
||||
-
|
||||
|
||||
## Ограничения анализа
|
||||
|
||||
-
|
||||
32
.agent/templates/pr-description.md
Normal file
32
.agent/templates/pr-description.md
Normal 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
|
||||
|
||||
## Не проверено / риски
|
||||
|
||||
-
|
||||
27
.agent/templates/user-facing-summary.md
Normal file
27
.agent/templates/user-facing-summary.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# User-facing Summary
|
||||
|
||||
## Коротко
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Что изменилось простыми словами
|
||||
|
||||
-
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что изменилось | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Что важно знать
|
||||
|
||||
-
|
||||
|
||||
## Проверки и риски
|
||||
|
||||
- ✅ Проверено:
|
||||
- ⚠️ Не проверено:
|
||||
- Риск:
|
||||
23
.agent/templates/work-plan.md
Normal file
23
.agent/templates/work-plan.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Work Plan
|
||||
|
||||
## Коротко
|
||||
|
||||
Сделаю так:
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Какие файлы, вероятно, затрону
|
||||
|
||||
| Файл / зона | Что планируется | Зачем |
|
||||
|---|---|---|
|
||||
| `path/file` | | |
|
||||
|
||||
## Что проверю
|
||||
|
||||
-
|
||||
|
||||
## Что может остаться непроверенным
|
||||
|
||||
-
|
||||
32
.cursor/rules/proxywarden.mdc
Normal file
32
.cursor/rules/proxywarden.mdc
Normal 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
26
.github/copilot-instructions.md
vendored
Normal 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
60
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
windows-baseline:
|
||||
name: Windows baseline
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend tests
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Check Rust formatting
|
||||
working-directory: src-tauri
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run Rust lints
|
||||
working-directory: src-tauri
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: src-tauri
|
||||
run: cargo test --all-targets
|
||||
|
||||
- name: Check Tauri environment
|
||||
run: npm run tauri -- info
|
||||
|
||||
- name: Plan control app installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-control-app.ps1 -PlanOnly
|
||||
|
||||
- name: Plan ProxiFyre installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||
|
||||
- name: Plan sing-box installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-singbox.ps1 -PlanOnly
|
||||
249
AGENTS.md
249
AGENTS.md
@@ -1,72 +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.
|
||||
- ProxiFyre - обязательный слой для per-app routing; Local sing-box - необязательный runtime.
|
||||
- Внешний SOCKS5 flow должен работать без установленного Local sing-box.
|
||||
- Profile apply не должен скрыто устанавливать, удалять, запускать или чинить компоненты. Install/start/stop/uninstall - только явные действия пользователя.
|
||||
- Source of truth - JSON под `C:\ProgramData\ProxyWarden\config` и `state`.
|
||||
- `C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json` и `sing-box-config.json` - derived artifacts, их можно пересоздать.
|
||||
- Subscription URL и другие секреты нельзя показывать полностью в UI, diagnostics или логах.
|
||||
- Summary panel должен оставаться read-only: без apply/install/start/stop/delete/input/subscription mutations.
|
||||
Проект не должен превращаться в коммерческий SaaS, Node gateway, VPN-провайдер, proxy server или облачный control plane. Это локальная Windows-утилита для себя и друзей.
|
||||
|
||||
Цель: надежно и понятно конфигурировать маршрутизацию выбранных приложений через внешний SOCKS5 proxy или через локальный sing-box, не ломая системную сеть и не пряча опасные действия за безобидными кнопками.
|
||||
|
||||
## Архитектурные инварианты
|
||||
|
||||
- Control App, ProxiFyre и Local sing-box — разные компоненты. Не смешивать их ответственность.
|
||||
- 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.
|
||||
- Не писать 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 не готов.
|
||||
- Не оставлять dev-серверы (`npm run dev`, `npm run tauri -- dev`, preview-серверы) запущенными после проверки. Если сервер был поднят агентом, остановить его перед финальным ответом.
|
||||
### Backend
|
||||
|
||||
## Проверка
|
||||
- Не добавлять новую 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
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Rust/backend:
|
||||
Для Rust/backend изменений:
|
||||
|
||||
```powershell
|
||||
cd D:\repos\ProxyWarden\src-tauri
|
||||
cargo test
|
||||
cd src-tauri
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
Tauri/toolchain:
|
||||
Для Tauri/toolchain:
|
||||
|
||||
```powershell
|
||||
npm run tauri -- info
|
||||
@@ -74,7 +200,7 @@ npm run tauri -- dev
|
||||
npm run tauri -- build
|
||||
```
|
||||
|
||||
Installer boundaries:
|
||||
Для installer boundaries:
|
||||
|
||||
```powershell
|
||||
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||
@@ -82,10 +208,25 @@ Installer boundaries:
|
||||
& .\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. Эта фраза и так слишком много навредила миру.
|
||||
|
||||
60
docs/agent/ARCHITECTURE-NOTES.md
Normal file
60
docs/agent/ARCHITECTURE-NOTES.md
Normal 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
456
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxywarden",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxywarden",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
@@ -21,7 +21,8 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^7.0.0"
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1499,6 +1500,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1547,6 +1566,131 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
||||
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.7",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.41",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
|
||||
@@ -1594,6 +1738,16 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001800",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
||||
@@ -1615,6 +1769,33 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1647,6 +1828,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.385",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
|
||||
@@ -1654,6 +1845,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
@@ -1706,6 +1904,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1782,6 +2000,13 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -1801,6 +2026,16 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1837,6 +2072,23 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1978,6 +2230,13 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1988,6 +2247,54 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal/node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -2005,6 +2312,36 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2125,6 +2462,119 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.4.1",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"pathe": "^2.0.3",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.7",
|
||||
"@vitest/mocker": "3.2.7",
|
||||
"@vitest/pretty-format": "^3.2.7",
|
||||
"@vitest/runner": "3.2.7",
|
||||
"@vitest/snapshot": "3.2.7",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"std-env": "^3.9.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tinypool": "^1.1.1",
|
||||
"tinyrainbow": "^2.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||
"vite-node": "3.2.4",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/debug": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxywarden",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -24,6 +25,7 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.2.4",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -386,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 {
|
||||
if ($SkipBuild) {
|
||||
Write-Host ""
|
||||
@@ -402,22 +421,50 @@ function Invoke-ReleaseBuild {
|
||||
Write-Host "Skipping Rust tests because -SkipTests was provided."
|
||||
}
|
||||
|
||||
Clear-ReleaseBundleOutput
|
||||
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
|
||||
}
|
||||
|
||||
function Get-ArtifactVersionPattern {
|
||||
param([string]$TargetVersion)
|
||||
|
||||
"(^|[^0-9A-Za-z])$([regex]::Escape($TargetVersion))([^0-9A-Za-z]|$)"
|
||||
}
|
||||
|
||||
function Copy-ReleaseArtifacts {
|
||||
param([string]$ReleaseDir)
|
||||
param(
|
||||
[string]$ReleaseDir,
|
||||
[string]$TargetVersion
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
||||
throw "Tauri bundle output was not found: $BundleRoot"
|
||||
}
|
||||
|
||||
$artifactDir = Join-Path $ReleaseDir "artifacts"
|
||||
$files = Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") }
|
||||
$allFiles = @(Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
||||
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) {
|
||||
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 = @()
|
||||
@@ -587,7 +634,7 @@ try {
|
||||
Invoke-ReleaseBuild
|
||||
|
||||
$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-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts
|
||||
|
||||
|
||||
5
src-tauri/Cargo.lock
generated
5
src-tauri/Cargo.lock
generated
@@ -2314,9 +2314,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxywarden"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2324,6 +2325,8 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"url",
|
||||
"uuid",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxywarden"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
description = "Standalone Windows desktop proxy management app for ProxyWarden."
|
||||
authors = ["ProxyWarden"]
|
||||
edition = "2021"
|
||||
@@ -19,4 +19,9 @@ serde_json = "1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
base64 = "0.22"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] }
|
||||
percent-encoding = "2"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
@@ -7,11 +6,6 @@ use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker,
|
||||
SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest,
|
||||
@@ -15,24 +12,14 @@ use crate::component_detection::{
|
||||
proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier,
|
||||
DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost,
|
||||
};
|
||||
use crate::elevated_scripts;
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
||||
};
|
||||
use crate::process::command_no_window;
|
||||
#[cfg(test)]
|
||||
use crate::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
||||
#[cfg(test)]
|
||||
use crate::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker,
|
||||
SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest,
|
||||
};
|
||||
use crate::safe_fs;
|
||||
use crate::singbox_service::{
|
||||
build_singbox_setup_status, ensure_safe_singbox_install_dir,
|
||||
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
|
||||
@@ -225,6 +212,8 @@ pub struct LocalSingBoxStatusResponse {
|
||||
pub component: ComponentStatusDto,
|
||||
pub generated_config_path: String,
|
||||
pub lan_listen_host: Option<String>,
|
||||
#[cfg(debug_assertions)]
|
||||
pub subscription_identity: SubscriptionRequestIdentityDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -258,6 +247,21 @@ pub struct SubscriptionServerDto {
|
||||
pub server_port: u16,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionRequestIdentityDto {
|
||||
pub headers: Vec<SubscriptionRequestHeaderDto>,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionRequestHeaderDto {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveSingBoxSubscriptionInputDto {
|
||||
@@ -491,6 +495,7 @@ pub trait SubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
|
||||
}
|
||||
|
||||
@@ -500,11 +505,27 @@ impl SubscriptionFetcher for SystemSubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
subscription::fetch_subscription(url)
|
||||
subscription::fetch_subscription_with_identity(url, identity)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto {
|
||||
let identity = subscription::SubscriptionFetchIdentity::default();
|
||||
let headers = identity
|
||||
.request_headers_without_device_hwid()
|
||||
.into_iter()
|
||||
.map(|(name, value)| SubscriptionRequestHeaderDto {
|
||||
name: name.to_string(),
|
||||
value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
SubscriptionRequestIdentityDto { headers }
|
||||
}
|
||||
|
||||
pub trait Clock {
|
||||
fn now(&self) -> String;
|
||||
}
|
||||
@@ -699,54 +720,71 @@ pub fn select_singbox_server(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ping_singbox_server(
|
||||
pub async fn ping_singbox_server(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
input: PingSingBoxServerInputDto,
|
||||
) -> Result<PingServerResponse, CommandError> {
|
||||
ping_singbox_server_in_storage(&state.storage(), input)
|
||||
let storage = state.storage();
|
||||
tauri::async_runtime::spawn_blocking(move || ping_singbox_server_in_storage(&storage, input))
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ping_all_singbox_servers(
|
||||
pub async fn ping_all_singbox_servers(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
) -> Result<Vec<PingServerResponse>, CommandError> {
|
||||
ping_all_singbox_servers_in_storage(&state.storage())
|
||||
let storage = state.storage();
|
||||
tauri::async_runtime::spawn_blocking(move || ping_all_singbox_servers_in_storage(&storage))
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ping_proxy_target(
|
||||
pub async fn ping_proxy_target(
|
||||
input: PingProxyTargetInputDto,
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
ping_proxy_target_endpoint(input)
|
||||
tauri::async_runtime::spawn_blocking(move || ping_proxy_target_endpoint(input))
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn generate_singbox_config(
|
||||
pub async fn generate_singbox_config(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
) -> Result<GenerateSingBoxConfigResponse, CommandError> {
|
||||
let storage = state.storage();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let detected = detect_singbox_install();
|
||||
let binary_path = detected
|
||||
.as_ref()
|
||||
.map(|detected| detected.executable_path.as_path());
|
||||
generate_singbox_config_with_services(
|
||||
&state.storage(),
|
||||
&storage,
|
||||
&SingBoxAdapter::default(),
|
||||
&SingBoxCommandChecker,
|
||||
&SystemClock,
|
||||
binary_path,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn apply_profiles(
|
||||
pub async fn apply_profiles(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||
let storage = state.storage();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let adapter = ProxiFyreAdapter::default();
|
||||
let helper = DetectedProxyApplyHelper::system();
|
||||
let clock = SystemClock;
|
||||
|
||||
apply_profiles_with_services(&storage, &adapter, &helper, &clock)
|
||||
})
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1325,10 +1363,29 @@ pub fn apply_profiles_with_services(
|
||||
adapter: &impl ProxyRouterAdapter,
|
||||
helper: &impl ProxyApplyHelper,
|
||||
clock: &impl Clock,
|
||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||
apply_profiles_with_services_and_detection(
|
||||
storage,
|
||||
adapter,
|
||||
helper,
|
||||
clock,
|
||||
detect_proxyfier_install(),
|
||||
detect_singbox_install(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_profiles_with_services_and_detection(
|
||||
storage: &JsonStorage,
|
||||
adapter: &impl ProxyRouterAdapter,
|
||||
helper: &impl ProxyApplyHelper,
|
||||
clock: &impl Clock,
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
let components = components_or_defaults(storage)?;
|
||||
let components =
|
||||
components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?;
|
||||
let generated =
|
||||
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
|
||||
Ok(generated) => generated,
|
||||
@@ -1398,6 +1455,8 @@ fn read_singbox_status_with_detection(
|
||||
.display()
|
||||
.to_string(),
|
||||
lan_listen_host: local_lan_ipv4(),
|
||||
#[cfg(debug_assertions)]
|
||||
subscription_identity: subscription_request_identity_for_display(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1411,6 +1470,7 @@ pub fn save_singbox_subscription_to_storage(
|
||||
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.subscription_url = Some(subscription_url);
|
||||
ensure_device_hwid(&mut config);
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
@@ -1438,8 +1498,18 @@ pub fn fetch_singbox_subscription_with_fetcher(
|
||||
)
|
||||
})?;
|
||||
|
||||
let device_hwid_created = ensure_device_hwid(&mut config);
|
||||
if device_hwid_created {
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
}
|
||||
|
||||
let identity =
|
||||
subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref());
|
||||
let cache = fetcher
|
||||
.fetch_subscription(&subscription_url)
|
||||
.fetch_subscription(&subscription_url, &identity)
|
||||
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
|
||||
let selected_tag = config
|
||||
.selected_server_tag
|
||||
@@ -1689,6 +1759,19 @@ fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_device_hwid(config: &mut LocalSingBoxConfig) -> bool {
|
||||
if config
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
config.device_hwid = Some(uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase());
|
||||
true
|
||||
}
|
||||
|
||||
fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse {
|
||||
ping_endpoint(&server.tag, &server.server, server.server_port)
|
||||
}
|
||||
@@ -2129,11 +2212,7 @@ fn write_elevated_singbox_service_script(
|
||||
config_source: Option<&Path>,
|
||||
config_target: Option<&Path>,
|
||||
) -> Result<PathBuf, CommandError> {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
let script_path = env::temp_dir().join(format!("proxywarden-singbox-service-{nonce}.ps1"));
|
||||
let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service");
|
||||
let script =
|
||||
elevated_singbox_service_script(action, service_name, config_source, config_target);
|
||||
|
||||
@@ -2310,10 +2389,6 @@ fn run_elevated_singbox_package_script(
|
||||
installer_args: Vec<String>,
|
||||
artifact_dir: &Path,
|
||||
) -> Result<(), CommandError> {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
fs::create_dir_all(artifact_dir).map_err(|error| {
|
||||
CommandError::new(
|
||||
action.error_code(),
|
||||
@@ -2324,18 +2399,12 @@ fn run_elevated_singbox_package_script(
|
||||
)
|
||||
})?;
|
||||
|
||||
let installer_path = artifact_dir.join(format!(
|
||||
"proxywarden-singbox-{}-{nonce}.ps1",
|
||||
action.file_label()
|
||||
));
|
||||
let runner_path = artifact_dir.join(format!(
|
||||
"proxywarden-singbox-{}-{nonce}.runner.ps1",
|
||||
action.file_label()
|
||||
));
|
||||
let result_path = artifact_dir.join(format!(
|
||||
"proxywarden-singbox-{}-{nonce}.log",
|
||||
action.file_label()
|
||||
));
|
||||
let prefix = format!("proxywarden-singbox-{}", action.file_label());
|
||||
let installer_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
|
||||
let runner_path =
|
||||
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.runner"), "ps1");
|
||||
let result_path =
|
||||
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
|
||||
|
||||
write_powershell_script(&installer_path, installer_body).map_err(|error| {
|
||||
CommandError::new(
|
||||
@@ -2415,7 +2484,7 @@ try {{
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn singbox_installer_runner_script(
|
||||
pub fn singbox_installer_runner_script(
|
||||
installer_path: &Path,
|
||||
result_path: &Path,
|
||||
installer_args: &[String],
|
||||
@@ -2534,11 +2603,23 @@ fn elevated_singbox_service_failed_message(
|
||||
}
|
||||
|
||||
fn components_or_defaults(storage: &JsonStorage) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||
components_or_defaults_with_detection(
|
||||
storage,
|
||||
detect_proxyfier_install(),
|
||||
detect_singbox_install(),
|
||||
)
|
||||
}
|
||||
|
||||
fn components_or_defaults_with_detection(
|
||||
storage: &JsonStorage,
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||
let components = storage.read_components().map_err(storage_error)?;
|
||||
Ok(resolve_component_statuses(
|
||||
components,
|
||||
detect_proxyfier_install(),
|
||||
detect_singbox_install(),
|
||||
detected_proxyfier,
|
||||
detected_singbox,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2659,10 +2740,7 @@ fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> ResolvedAppDt
|
||||
}
|
||||
|
||||
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(storage_error)?;
|
||||
}
|
||||
fs::write(path, contents).map_err(storage_error)
|
||||
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn open_file_or_select(path: &Path) -> Result<(), CommandError> {
|
||||
@@ -2965,11 +3043,7 @@ fn write_elevated_service_script(
|
||||
action: ServiceControlAction,
|
||||
service_names: &[String],
|
||||
) -> Result<PathBuf, CommandError> {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
let script_path = env::temp_dir().join(format!("proxywarden-proxifyre-service-{nonce}.ps1"));
|
||||
let script_path = elevated_scripts::temp_script_path("proxywarden-proxifyre-service");
|
||||
let script = elevated_service_script(action, service_names);
|
||||
|
||||
write_powershell_script(&script_path, &script).map_err(|error| {
|
||||
@@ -3329,10 +3403,6 @@ fn run_elevated_package_script(
|
||||
body: String,
|
||||
artifact_dir: &Path,
|
||||
) -> Result<(), CommandError> {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
fs::create_dir_all(artifact_dir).map_err(|error| {
|
||||
CommandError::new(
|
||||
action.error_code(),
|
||||
@@ -3342,14 +3412,10 @@ fn run_elevated_package_script(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let script_path = artifact_dir.join(format!(
|
||||
"proxywarden-proxifyre-{}-{nonce}.ps1",
|
||||
action.file_label()
|
||||
));
|
||||
let result_path = artifact_dir.join(format!(
|
||||
"proxywarden-proxifyre-{}-{nonce}.log",
|
||||
action.file_label()
|
||||
));
|
||||
let prefix = format!("proxywarden-proxifyre-{}", action.file_label());
|
||||
let script_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
|
||||
let result_path =
|
||||
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
|
||||
let script = wrap_elevated_package_script(&body, &result_path);
|
||||
|
||||
write_powershell_script(&script_path, &script).map_err(|error| {
|
||||
@@ -3417,7 +3483,7 @@ try {{
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String {
|
||||
pub fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String {
|
||||
let mut script = String::new();
|
||||
script.push_str("$ErrorActionPreference = 'Stop'\n");
|
||||
script.push_str(&format!(
|
||||
@@ -3504,7 +3570,7 @@ fn powershell_output_message(output: &Output, fallback: &str) -> String {
|
||||
fallback.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
pub fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
let mut script = String::new();
|
||||
script.push_str(&format!(
|
||||
"$targetDir = '{}'\n",
|
||||
@@ -3541,8 +3607,126 @@ pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
return 'x86'
|
||||
}
|
||||
|
||||
function Invoke-Download([string]$uri, [string]$path) {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $path -Headers @{ 'User-Agent' = 'proxywarden' }
|
||||
function Get-SafeUriForLog([string]$uri) {
|
||||
try {
|
||||
$parsed = [Uri]$uri
|
||||
$port = if ($parsed.IsDefaultPort) { '' } else { ":$($parsed.Port)" }
|
||||
return "$($parsed.Scheme)://$($parsed.Host)$port$($parsed.AbsolutePath)"
|
||||
} catch {
|
||||
return '<invalid-url>'
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-ReleaseApi([string]$uri, [string]$label) {
|
||||
$safeUri = Get-SafeUriForLog $uri
|
||||
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/vnd.github+json' }
|
||||
$lastError = $null
|
||||
|
||||
foreach ($attempt in 1..3) {
|
||||
try {
|
||||
return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 -MaximumRedirection 10
|
||||
} catch {
|
||||
$lastError = $_.Exception.Message
|
||||
if ($attempt -lt 3) {
|
||||
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw "Не удалось получить metadata для $label ($safeUri): $lastError"
|
||||
}
|
||||
|
||||
function Complete-Download([string]$partialPath, [string]$path, [string]$label) {
|
||||
if (-not (Test-Path -LiteralPath $partialPath)) {
|
||||
throw "${label}: файл не был создан."
|
||||
}
|
||||
|
||||
$item = Get-Item -LiteralPath $partialPath
|
||||
if ($item.Length -le 0) {
|
||||
throw "${label}: скачанный файл пустой."
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $partialPath -Destination $path -Force
|
||||
}
|
||||
|
||||
function Invoke-WebClientDownload([string]$uri, [string]$partialPath) {
|
||||
$client = New-Object System.Net.WebClient
|
||||
try {
|
||||
$client.Headers.Add('User-Agent', 'proxywarden')
|
||||
$client.Headers.Add('Accept', 'application/octet-stream,*/*')
|
||||
$client.DownloadFile($uri, $partialPath)
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-CurlDownload([string]$uri, [string]$partialPath) {
|
||||
$curl = Get-Command 'curl.exe' -ErrorAction SilentlyContinue
|
||||
if ($null -eq $curl) {
|
||||
throw 'curl.exe не найден.'
|
||||
}
|
||||
|
||||
$curlOutput = & $curl.Source --silent --show-error --fail --location --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 180 --user-agent 'proxywarden' --output $partialPath --url $uri 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$curlMessage = ($curlOutput | Out-String).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($curlMessage)) {
|
||||
throw "curl.exe завершился с кодом $LASTEXITCODE."
|
||||
}
|
||||
|
||||
throw "curl.exe завершился с кодом ${LASTEXITCODE}: $curlMessage"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Download([string]$uri, [string]$path, [string]$label) {
|
||||
$safeUri = Get-SafeUriForLog $uri
|
||||
$partialPath = "$path.part"
|
||||
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/octet-stream,*/*' }
|
||||
$webRequestError = $null
|
||||
$webClientError = $null
|
||||
$curlError = $null
|
||||
|
||||
foreach ($attempt in 1..3) {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $partialPath -Headers $headers -TimeoutSec 180 -MaximumRedirection 10
|
||||
Complete-Download $partialPath $path $label
|
||||
return
|
||||
} catch {
|
||||
$webRequestError = $_.Exception.Message
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
if ($attempt -lt 3) {
|
||||
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
Invoke-WebClientDownload $uri $partialPath
|
||||
Complete-Download $partialPath $path $label
|
||||
return
|
||||
} catch {
|
||||
$webClientError = $_.Exception.Message
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
Invoke-CurlDownload $uri $partialPath
|
||||
Complete-Download $partialPath $path $label
|
||||
return
|
||||
} catch {
|
||||
$curlError = $_.Exception.Message
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$errors = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($webRequestError)) { $errors += "Invoke-WebRequest: $webRequestError" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($webClientError)) { $errors += "WebClient: $webClientError" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($curlError)) { $errors += "curl.exe: $curlError" }
|
||||
$details = if ($errors.Count -gt 0) { $errors -join ' | ' } else { 'неизвестная ошибка' }
|
||||
|
||||
throw "Не удалось скачать $label ($safeUri): $details"
|
||||
}
|
||||
|
||||
function Select-Asset($assets, [string]$pattern, [string]$label) {
|
||||
@@ -3606,7 +3790,7 @@ pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
if (-not (Test-VcRuntime $arch)) {
|
||||
$vcRedistPath = Join-Path $workDir 'vc_redist.exe'
|
||||
$vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url }
|
||||
Invoke-Download $vcRedistUrl $vcRedistPath
|
||||
Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'
|
||||
$vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden
|
||||
if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) {
|
||||
throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)."
|
||||
@@ -3614,12 +3798,12 @@ pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
}
|
||||
|
||||
if (-not (Test-WindowsPacketFilter)) {
|
||||
$ndisRelease = Invoke-RestMethod -Uri $ndisapiReleaseApi -Headers @{ 'User-Agent' = 'proxywarden' }
|
||||
$ndisRelease = Invoke-ReleaseApi $ndisapiReleaseApi 'Windows Packet Filter'
|
||||
$ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' }
|
||||
$ndisAsset = Select-Asset $ndisRelease.assets $ndisPattern 'Windows Packet Filter'
|
||||
$ndisPath = Join-Path $workDir $ndisAsset.name
|
||||
$ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log'
|
||||
Invoke-Download $ndisAsset.browser_download_url $ndisPath
|
||||
Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'
|
||||
Verify-AssetHash $ndisPath $ndisAsset
|
||||
$ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden
|
||||
if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) {
|
||||
@@ -3628,11 +3812,11 @@ pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
$proxifyreRelease = Invoke-RestMethod -Uri $proxifyreReleaseApi -Headers @{ 'User-Agent' = 'proxywarden' }
|
||||
$proxifyreRelease = Invoke-ReleaseApi $proxifyreReleaseApi 'ProxiFyre'
|
||||
$proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' }
|
||||
$proxifyreAsset = Select-Asset $proxifyreRelease.assets $proxifyrePattern 'ProxiFyre'
|
||||
$proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name
|
||||
Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath
|
||||
Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'
|
||||
Verify-AssetHash $proxifyreZipPath $proxifyreAsset
|
||||
|
||||
Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force
|
||||
@@ -3769,46 +3953,17 @@ fn apply_to_detected_proxyfier(
|
||||
return staged_apply_result(request);
|
||||
};
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err(
|
||||
|error| {
|
||||
CommandError::new(
|
||||
"proxyfier_apply_failed",
|
||||
format!(
|
||||
"Не удалось создать папку конфига ProxiFyre '{}': {error}",
|
||||
parent.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if config_path.exists() {
|
||||
let backup_path = config_path.with_file_name(format!(
|
||||
"{}.bak",
|
||||
config_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("app-config.json")
|
||||
));
|
||||
fs::copy(config_path, backup_path).map_err(|error| {
|
||||
CommandError::new(
|
||||
"proxyfier_apply_failed",
|
||||
format!(
|
||||
"Не удалось создать backup текущего конфига ProxiFyre '{}': {error}",
|
||||
"Не удалось безопасно записать конфиг ProxiFyre '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
fs::write(config_path, request.config_contents).map_err(|error| {
|
||||
CommandError::new(
|
||||
"proxyfier_apply_failed",
|
||||
format!(
|
||||
"Не удалось записать конфиг ProxiFyre '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
|
||||
19
src-tauri/src/elevated_scripts.rs
Normal file
19
src-tauri/src/elevated_scripts.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn temp_script_path(prefix: &str) -> PathBuf {
|
||||
env::temp_dir().join(unique_file_name(prefix, "ps1"))
|
||||
}
|
||||
|
||||
pub fn artifact_path(artifact_dir: &Path, prefix: &str, extension: &str) -> PathBuf {
|
||||
artifact_dir.join(unique_file_name(prefix, extension))
|
||||
}
|
||||
|
||||
fn unique_file_name(prefix: &str, extension: &str) -> String {
|
||||
let extension = extension.trim_start_matches('.');
|
||||
format!(
|
||||
"{prefix}-{}.{}",
|
||||
uuid::Uuid::new_v4().hyphenated(),
|
||||
extension
|
||||
)
|
||||
}
|
||||
@@ -1,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() {
|
||||
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!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
}
|
||||
|
||||
@@ -1,75 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod activity;
|
||||
mod commands;
|
||||
mod component_detection;
|
||||
mod models;
|
||||
mod process;
|
||||
mod singbox_service;
|
||||
mod storage;
|
||||
mod subscription;
|
||||
mod validation;
|
||||
|
||||
mod adapters {
|
||||
pub mod proxifyre;
|
||||
pub mod proxy_router;
|
||||
pub mod singbox;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod proxifyre {
|
||||
pub use crate::adapters::proxifyre::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod proxy_router {
|
||||
pub use crate::adapters::proxy_router::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod singbox {
|
||||
pub use crate::adapters::singbox::*;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::get_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!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
proxywarden_lib::run();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use percent_encoding::percent_decode_str;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
|
||||
@@ -138,6 +141,8 @@ pub struct LocalSingBoxConfig {
|
||||
#[serde(default)]
|
||||
pub subscription_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_hwid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_tag: Option<String>,
|
||||
#[serde(default = "default_local_singbox_listen_host")]
|
||||
pub listen_host: String,
|
||||
@@ -157,12 +162,19 @@ impl LocalSingBoxConfig {
|
||||
.as_deref()
|
||||
.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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
subscription_url: None,
|
||||
device_hwid: None,
|
||||
selected_server_tag: None,
|
||||
listen_host: default_local_singbox_listen_host(),
|
||||
listen_port: default_local_singbox_listen_port(),
|
||||
@@ -183,6 +195,36 @@ pub struct SubscriptionCache {
|
||||
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)]
|
||||
pub struct SubscriptionServer {
|
||||
pub tag: String,
|
||||
@@ -252,22 +294,27 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
match trimmed.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
let host = rest
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("subscription");
|
||||
format!("{scheme}://{host}/...")
|
||||
}
|
||||
None => {
|
||||
let visible = trimmed.chars().take(18).collect::<String>();
|
||||
if trimmed.chars().count() <= 18 {
|
||||
"***".to_string()
|
||||
let Ok(parsed) = Url::parse(trimmed) else {
|
||||
return "***".to_string();
|
||||
};
|
||||
|
||||
let host = parsed.host_str().unwrap_or("subscription");
|
||||
let host = if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
format!("{visible}...")
|
||||
}
|
||||
}
|
||||
host.to_string()
|
||||
};
|
||||
let port = parsed
|
||||
.port()
|
||||
.map(|port| format!(":{port}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!("{}://{}{}/...", parsed.scheme(), host, port)
|
||||
}
|
||||
|
||||
pub fn decode_percent_encoded_utf8(value: &str) -> String {
|
||||
percent_decode_str(value)
|
||||
.decode_utf8()
|
||||
.map(|decoded| decoded.into_owned())
|
||||
.unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
53
src-tauri/src/safe_fs.rs
Normal file
53
src-tauri/src/safe_fs.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, "bak")
|
||||
}
|
||||
|
||||
pub fn corrupt_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(
|
||||
path,
|
||||
&format!("corrupt.{}", uuid::Uuid::new_v4().hyphenated()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn temp_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, &format!("tmp.{}", uuid::Uuid::new_v4().hyphenated()))
|
||||
}
|
||||
|
||||
pub fn write_with_backup(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let temp_path = temp_path(path);
|
||||
fs::write(&temp_path, contents)?;
|
||||
|
||||
let backup_path = backup_path(path);
|
||||
if path.exists() {
|
||||
fs::copy(path, &backup_path)?;
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
match fs::rename(&temp_path, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
if !path.exists() && backup_path.exists() {
|
||||
let _ = fs::copy(&backup_path, path);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("proxywarden-file");
|
||||
|
||||
path.with_file_name(format!("{file_name}.{suffix}"))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
||||
use crate::models::{
|
||||
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
|
||||
};
|
||||
use crate::safe_fs;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{self, ErrorKind};
|
||||
@@ -96,7 +97,10 @@ impl JsonStorage {
|
||||
}
|
||||
|
||||
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<()> {
|
||||
@@ -104,7 +108,12 @@ impl JsonStorage {
|
||||
}
|
||||
|
||||
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<()> {
|
||||
@@ -136,10 +145,9 @@ impl JsonStorage {
|
||||
T: DeserializeOwned + Default,
|
||||
{
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => Ok(T::default()),
|
||||
},
|
||||
Ok(contents) => {
|
||||
parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error))
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
@@ -159,7 +167,9 @@ impl JsonStorage {
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => Ok(serde_json::from_str(&contents).ok()),
|
||||
Ok(contents) => parse_json(path, &contents)
|
||||
.map(Some)
|
||||
.or_else(|error| recover_corrupt_json(path, error).map(Some)),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
@@ -173,40 +183,72 @@ impl Default for JsonStorage {
|
||||
}
|
||||
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, "bak")
|
||||
}
|
||||
|
||||
fn temp_path(path: &Path) -> PathBuf {
|
||||
sibling_with_suffix(path, "tmp")
|
||||
}
|
||||
|
||||
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("storage.json");
|
||||
|
||||
path.with_file_name(format!("{file_name}.{suffix}"))
|
||||
safe_fs::backup_path(path)
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
safe_fs::write_with_backup(path, contents)
|
||||
}
|
||||
|
||||
let temp_path = temp_path(path);
|
||||
fs::write(&temp_path, contents)?;
|
||||
|
||||
if path.exists() {
|
||||
fs::copy(path, backup_path(path))?;
|
||||
fs::remove_file(path)?;
|
||||
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()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
match fs::rename(&temp_path, path) {
|
||||
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()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn move_corrupt_file(path: &Path, corrupt_path: &Path) -> io::Result<()> {
|
||||
match fs::rename(path, corrupt_path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
Err(error)
|
||||
Err(rename_error) => {
|
||||
fs::copy(path, corrupt_path)?;
|
||||
fs::remove_file(path)?;
|
||||
if !corrupt_path.exists() {
|
||||
return Err(rename_error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use crate::models::{SubscriptionCache, SubscriptionServer};
|
||||
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use reqwest::redirect;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
const DEFAULT_APP_NAME: &str = "ProxyWarden";
|
||||
const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionError {
|
||||
@@ -33,6 +39,72 @@ pub struct ParsedSubscription {
|
||||
pub servers: Vec<SubscriptionServer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct SubscriptionFetchPolicy {
|
||||
pub allow_unsafe_local_urls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionFetchIdentity {
|
||||
pub device_hwid: Option<String>,
|
||||
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> {
|
||||
let config = match serde_json::from_str::<Value>(body) {
|
||||
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> {
|
||||
let parsed_url =
|
||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription URL must use http or https",
|
||||
));
|
||||
fetch_subscription_with_identity(url, &SubscriptionFetchIdentity::default())
|
||||
}
|
||||
|
||||
let response = reqwest::blocking::Client::new()
|
||||
.get(parsed_url)
|
||||
.header("user-agent", "singbox")
|
||||
.header("x-device-os", std::env::consts::OS)
|
||||
.header("x-device-model", "proxywarden")
|
||||
pub fn fetch_subscription_with_identity(
|
||||
url: &str,
|
||||
identity: &SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
fetch_subscription_with_identity_and_policy(url, identity, SubscriptionFetchPolicy::default())
|
||||
}
|
||||
|
||||
pub fn fetch_subscription_with_identity_and_policy(
|
||||
url: &str,
|
||||
identity: &SubscriptionFetchIdentity,
|
||||
policy: SubscriptionFetchPolicy,
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
let parsed_url =
|
||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||
validate_subscription_fetch_url(&parsed_url, policy)?;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if let Some(device_hwid) = identity
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request.header("x-hwid", device_hwid);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||
|
||||
@@ -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> {
|
||||
let decoded = maybe_decode_base64(body);
|
||||
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 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 server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||
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())
|
||||
}
|
||||
|
||||
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, ¤t_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 {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyWarden",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"identifier": "ru.dokops.proxywarden.windows",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -13,16 +13,16 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "ProxyWarden",
|
||||
"width": 820,
|
||||
"width": 920,
|
||||
"height": 760,
|
||||
"minWidth": 820,
|
||||
"maxWidth": 820,
|
||||
"minWidth": 760,
|
||||
"maxWidth": 1200,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
#[path = "../src/activity.rs"]
|
||||
mod activity;
|
||||
#[path = "../src/commands.rs"]
|
||||
mod commands;
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
#[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 proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::commands::{
|
||||
self, apply_profiles_with_services, apply_profiles_with_services_and_detection, 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,
|
||||
};
|
||||
use models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
use proxywarden_lib::models::{
|
||||
self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType,
|
||||
Protocol, ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
@@ -44,7 +21,6 @@ use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command as ProcessCommand;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::JsonStorage;
|
||||
|
||||
#[test]
|
||||
fn save_commands_normalize_and_persist_profile_and_target() {
|
||||
@@ -275,6 +251,35 @@ fn proxifyre_install_script_parses_as_powershell() {
|
||||
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]
|
||||
fn singbox_runner_preserves_installer_args_with_spaces() {
|
||||
let script = commands::singbox_installer_runner_script(
|
||||
@@ -354,11 +359,13 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() {
|
||||
.expect("write targets");
|
||||
write_json(&storage.paths().components_file, &[singbox_missing()]);
|
||||
|
||||
let error = apply_profiles_with_services(
|
||||
let error = apply_profiles_with_services_and_detection(
|
||||
&storage,
|
||||
&ProxiFyreAdapter::default(),
|
||||
&MockApplyHelper,
|
||||
&FixedClock,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("missing sing-box should block local target apply");
|
||||
let activity = storage.read_activity().expect("read blocked activity");
|
||||
@@ -422,11 +429,14 @@ fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||
|
||||
let applied =
|
||||
fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config");
|
||||
let backup =
|
||||
fs::read_to_string(install_dir.join("app-config.json.bak")).expect("read backup config");
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.changed);
|
||||
assert_eq!(result.action, "proxifyre.apply-detected-config");
|
||||
assert_eq!(applied, r#"{"proxies":[]}"#);
|
||||
assert_eq!(backup, "{}");
|
||||
assert!(install_dir.join("app-config.json.bak").exists());
|
||||
|
||||
cleanup(&root);
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
|
||||
use component_detection::{
|
||||
use proxywarden_lib::component_detection::{
|
||||
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
||||
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
|
||||
ProxyfierEngine, RegistryInstallEntry,
|
||||
};
|
||||
use models::ComponentState;
|
||||
use proxywarden_lib::models::ComponentState;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/validation.rs"]
|
||||
mod validation;
|
||||
|
||||
use models::{
|
||||
use proxywarden_lib::models::{
|
||||
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||
TargetInput, TargetKind,
|
||||
};
|
||||
use validation::{normalize_profile, normalize_target};
|
||||
use proxywarden_lib::validation::{normalize_profile, normalize_target};
|
||||
|
||||
#[test]
|
||||
fn normalizes_profile_source_items() {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
#[path = "../src/helper.rs"]
|
||||
mod helper;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
|
||||
use helper::{
|
||||
use proxywarden_lib::helper::{
|
||||
helper_action_requires_elevation, install_request, parse_helper_response,
|
||||
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
|
||||
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
|
||||
};
|
||||
use models::ComponentId;
|
||||
use proxywarden_lib::models::ComponentId;
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
|
||||
use models::{
|
||||
use proxywarden_lib::adapters::proxifyre::{
|
||||
ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE,
|
||||
};
|
||||
use proxywarden_lib::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest,
|
||||
};
|
||||
use proxywarden_lib::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
|
||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
|
||||
|
||||
#[test]
|
||||
fn generates_proxifyre_config_for_discord_external_socks5_target() {
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
#[path = "../src/adapters/singbox.rs"]
|
||||
mod singbox;
|
||||
|
||||
use models::{
|
||||
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use singbox::{
|
||||
use proxywarden_lib::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||
use proxywarden_lib::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use proxywarden_lib::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
||||
SINGBOX_OUTPUT_FILE,
|
||||
};
|
||||
use proxywarden_lib::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
path::{Path, PathBuf},
|
||||
@@ -217,6 +206,7 @@ impl SingBoxConfigChecker for RecordingChecker {
|
||||
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
||||
LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
device_hwid: None,
|
||||
selected_server_tag: Some(selected_server_tag.to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
|
||||
@@ -1,44 +1,22 @@
|
||||
#[path = "../src/activity.rs"]
|
||||
mod activity;
|
||||
#[path = "../src/commands.rs"]
|
||||
mod commands;
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
#[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 proxywarden_lib::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
};
|
||||
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,
|
||||
SubscriptionServer, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use proxywarden_lib::subscription;
|
||||
use serde_json::{json, Map};
|
||||
use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::JsonStorage;
|
||||
|
||||
#[test]
|
||||
fn saves_subscription_url_without_exposing_secret_query() {
|
||||
@@ -61,6 +39,7 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
||||
config.subscription_url,
|
||||
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_eq!(
|
||||
status.config.subscription_display_url,
|
||||
@@ -70,6 +49,39 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
||||
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]
|
||||
fn rejects_non_http_subscription_url() {
|
||||
let root = test_root("invalid-subscription");
|
||||
@@ -127,6 +139,64 @@ fn fetches_subscription_cache_and_selects_first_server() {
|
||||
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]
|
||||
fn selects_server_from_cached_subscription() {
|
||||
let root = test_root("select-server");
|
||||
@@ -193,6 +263,7 @@ fn generate_writes_config_and_local_singbox_target() {
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
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()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
@@ -256,9 +327,11 @@ fn generate_requires_cached_subscription() {
|
||||
fn forget_subscription_clears_url_selection_and_cache() {
|
||||
let root = test_root("forget-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let existing_hwid = "C34C14C9-94BC-4918-B053-A249BC117A91".to_string();
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
device_hwid: Some(existing_hwid.clone()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
@@ -278,6 +351,7 @@ fn forget_subscription_clears_url_selection_and_cache() {
|
||||
|
||||
assert!(!status.config.has_subscription);
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.device_hwid, Some(existing_hwid));
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(cache, None);
|
||||
|
||||
@@ -290,12 +364,59 @@ impl SubscriptionFetcher for MockFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
_identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||
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;
|
||||
|
||||
impl SingBoxConfigChecker for MockChecker {
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/process.rs"]
|
||||
mod process;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
|
||||
use component_detection::DetectedSingBox;
|
||||
use singbox_service::{
|
||||
use proxywarden_lib::component_detection::DetectedSingBox;
|
||||
use proxywarden_lib::singbox_service::{
|
||||
build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
|
||||
service_control_script, SingBoxServiceAction,
|
||||
};
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
#[path = "../src/activity.rs"]
|
||||
mod activity;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/storage.rs"]
|
||||
mod storage;
|
||||
|
||||
use models::{
|
||||
use proxywarden_lib::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, Target, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||
|
||||
#[test]
|
||||
fn storage_defaults_to_programdata_root() {
|
||||
@@ -59,6 +52,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let config = LocalSingBoxConfig {
|
||||
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()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
@@ -91,7 +85,6 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
config.subscription_display_url(),
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
@@ -104,6 +97,7 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||
.expect("read default local sing-box config");
|
||||
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.device_hwid, None);
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(config.listen_host, "127.0.0.1");
|
||||
assert_eq!(config.listen_port, 1080);
|
||||
@@ -113,7 +107,59 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||
}
|
||||
|
||||
#[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 storage = JsonStorage::new(root.clone());
|
||||
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");
|
||||
|
||||
assert_eq!(
|
||||
storage
|
||||
let error = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("invalid cache fallback"),
|
||||
None
|
||||
);
|
||||
.expect_err("invalid cache should not silently fallback");
|
||||
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(!storage.paths().singbox_subscription_cache_file.exists());
|
||||
assert!(has_corrupt_sibling(
|
||||
&storage.paths().singbox_subscription_cache_file
|
||||
));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_falls_back_to_empty_collection() {
|
||||
let root = test_root("invalid-json");
|
||||
fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() {
|
||||
let root = test_root("invalid-json-no-backup");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
fs::create_dir_all(&storage.paths().config_dir).expect("create config dir");
|
||||
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
|
||||
|
||||
let error = storage
|
||||
.read_profiles()
|
||||
.expect_err("invalid profiles should not silently fallback");
|
||||
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(!storage.paths().profiles_file.exists());
|
||||
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_recovers_from_valid_backup() {
|
||||
let root = test_root("invalid-json-valid-backup");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let backup_profiles = vec![sample_profile("backup")];
|
||||
let current_profiles = vec![sample_profile("current")];
|
||||
|
||||
storage
|
||||
.write_profiles(&backup_profiles)
|
||||
.expect("write first profiles");
|
||||
storage
|
||||
.write_profiles(¤t_profiles)
|
||||
.expect("write second profiles");
|
||||
fs::write(&storage.paths().profiles_file, "{not valid json").expect("corrupt live json");
|
||||
|
||||
let recovered = storage
|
||||
.read_profiles()
|
||||
.expect("invalid profiles should recover from valid backup");
|
||||
|
||||
assert_eq!(recovered, backup_profiles);
|
||||
assert_eq!(
|
||||
storage.read_profiles().expect("invalid profiles fallback"),
|
||||
Vec::<Profile>::new()
|
||||
storage.read_profiles().expect("restored live profiles"),
|
||||
backup_profiles
|
||||
);
|
||||
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -233,6 +314,26 @@ fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
|
||||
fs::write(path, contents).expect("write json");
|
||||
}
|
||||
|
||||
fn has_corrupt_sibling(path: &Path) -> bool {
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let prefix = format!("{file_name}.corrupt.");
|
||||
|
||||
fs::read_dir(parent)
|
||||
.expect("read sibling dir")
|
||||
.filter_map(Result::ok)
|
||||
.any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with(&prefix))
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_profile(id: &str) -> Profile {
|
||||
Profile {
|
||||
id: id.to_string(),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use models::redact_subscription_url;
|
||||
use subscription::{parse_subscription_body, parse_user_info};
|
||||
use proxywarden_lib::models::redact_subscription_url;
|
||||
use proxywarden_lib::subscription::{
|
||||
self, parse_subscription_body, parse_user_info, SubscriptionFetchIdentity,
|
||||
SubscriptionFetchPolicy,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn parses_singbox_json_config_servers() {
|
||||
@@ -41,6 +42,19 @@ fn parses_base64_vless_link_list() {
|
||||
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]
|
||||
fn rejects_body_without_supported_outbounds() {
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn redacts_subscription_url_for_display() {
|
||||
assert_eq!(
|
||||
@@ -86,7 +177,13 @@ fn redacts_subscription_url_for_display() {
|
||||
);
|
||||
assert_eq!(
|
||||
redact_subscription_url("vless://uuid@example.test"),
|
||||
"vless://uuid@example.test/..."
|
||||
"vless://example.test/..."
|
||||
);
|
||||
assert_eq!(
|
||||
redact_subscription_url(
|
||||
"https://user:password@sub.example.test:8443/path?token=secret#frag"
|
||||
),
|
||||
"https://sub.example.test:8443/..."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,16 @@ export interface LocalSingBoxStatusResponse {
|
||||
component: ComponentStatus;
|
||||
generatedConfigPath: string;
|
||||
lanListenHost?: string;
|
||||
subscriptionIdentity?: SubscriptionRequestIdentity;
|
||||
}
|
||||
|
||||
export interface SubscriptionRequestIdentity {
|
||||
headers: SubscriptionRequestHeader[];
|
||||
}
|
||||
|
||||
export interface SubscriptionRequestHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PingServerResponse {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from '../api/tauriCommands';
|
||||
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
|
||||
import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
|
||||
import { parseProxy, type ParsedProxy } from './lib/parseProxy';
|
||||
import { getApplyReadiness } from './readiness';
|
||||
import { serviceControlState } from './viewModel';
|
||||
|
||||
@@ -142,6 +143,7 @@ const MAIN_PROFILE_ID = 'main-profile';
|
||||
const LOCAL_SINGBOX_TARGET_ID = 'local-singbox';
|
||||
const LOG_VISIBLE_MS = 6500;
|
||||
const PANEL_ORDER: PanelId[] = ['proxifyre', 'summary', 'proxy'];
|
||||
const SHOW_DEV_SUBSCRIPTION_IDENTITY = import.meta.env.DEV;
|
||||
const proxyWardenToggleOnImage = new URL('../assets/proxywarden-toggle-on.png', import.meta.url).href;
|
||||
const proxyWardenToggleOffImage = new URL('../assets/proxywarden-toggle-off.png', import.meta.url).href;
|
||||
|
||||
@@ -1381,7 +1383,13 @@ export function App() {
|
||||
inlineActions={(
|
||||
<DetailsPopover
|
||||
className="setup-summary"
|
||||
details={singBoxDetailLines(singbox, singBoxStatus, singBoxSetupStatus, selectedServerTag)}
|
||||
details={singBoxDetailLines(
|
||||
singbox,
|
||||
singBoxStatus,
|
||||
singBoxSetupStatus,
|
||||
selectedServerTag,
|
||||
SHOW_DEV_SUBSCRIPTION_IDENTITY,
|
||||
)}
|
||||
popoverLabel="Состав Local sing-box"
|
||||
aria-label={`Подробности Local sing-box: ${setupSummary}`}
|
||||
>
|
||||
@@ -1413,6 +1421,7 @@ export function App() {
|
||||
loading={singBoxAction === 'fetch'}
|
||||
loadingLabel="Загружаю"
|
||||
variant="neutral"
|
||||
disabled={Boolean(singBoxAction)}
|
||||
>
|
||||
{subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
|
||||
</Button>
|
||||
@@ -1633,12 +1642,6 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
interface ParsedProxy {
|
||||
protocol: 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface SummaryStateInput {
|
||||
isLoading: boolean;
|
||||
isDetectingComponents: boolean;
|
||||
@@ -2329,35 +2332,6 @@ function changesApplyButtonLabel(
|
||||
return 'Применить изменения';
|
||||
}
|
||||
|
||||
function parseProxy(rawValue: string): ParsedProxy {
|
||||
const value = rawValue.trim();
|
||||
if (!value) throw new Error('Введи адрес прокси.');
|
||||
|
||||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(withProtocol);
|
||||
} catch {
|
||||
throw new Error('Формат: socks5://host:port или host:port.');
|
||||
}
|
||||
|
||||
const protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||||
if (protocol !== 'socks5') {
|
||||
throw new Error('Сейчас поддерживается только SOCKS5.');
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
|
||||
}
|
||||
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||
const port = Number(parsed.port);
|
||||
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('Укажи хост и порт прокси.');
|
||||
}
|
||||
|
||||
return { protocol: 'socks5', host, port };
|
||||
}
|
||||
|
||||
function routeProxyCheckTarget(
|
||||
routeMode: RouteMode,
|
||||
proxyInput: string,
|
||||
@@ -2551,6 +2525,7 @@ function singBoxDetailLines(
|
||||
status: LocalSingBoxStatusResponse | null,
|
||||
setupStatus: SingBoxSetupStatus | null,
|
||||
selectedServerTag: string | undefined,
|
||||
showDevSubscriptionIdentity = false,
|
||||
) {
|
||||
const setupDetails = setupStatus
|
||||
? setupStatus.items
|
||||
@@ -2558,7 +2533,7 @@ function singBoxDetailLines(
|
||||
.join('; ')
|
||||
: 'состав не проверен';
|
||||
|
||||
return [
|
||||
const details = [
|
||||
`Локально: ${localSingBoxAddress(status)}`,
|
||||
`LAN: ${lanSingBoxAddress(status) ?? 'недоступен'}`,
|
||||
`Сервер: ${selectedServerTag ? displayServerTag(selectedServerTag) : 'не выбран'}`,
|
||||
@@ -2567,6 +2542,30 @@ function singBoxDetailLines(
|
||||
`Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`,
|
||||
`Состав: ${setupDetails}`,
|
||||
];
|
||||
|
||||
if (showDevSubscriptionIdentity) {
|
||||
details.push(...subscriptionIdentityDetailLines(status));
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
function subscriptionIdentityDetailLines(status: LocalSingBoxStatusResponse | null) {
|
||||
const headers = status?.subscriptionIdentity?.headers
|
||||
?.filter((header) => header.name.trim().toLowerCase() !== 'x-hwid') ?? [];
|
||||
|
||||
if (!headers.length) return ['Dev headers подписки: недоступны'];
|
||||
|
||||
const details = [
|
||||
'Dev headers подписки (без HWID):',
|
||||
...headers.map((header) => `${header.name}: ${header.value || 'пусто'}`),
|
||||
];
|
||||
const sendsLegacyOsVersion = headers.some((header) => header.name.trim().toLowerCase() === 'x-ver-os');
|
||||
if (!sendsLegacyOsVersion) {
|
||||
details.push('X-Ver-OS: не отправится, версия ОС не определена');
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
function componentDetails(component: ComponentStatus | undefined, checking: boolean) {
|
||||
|
||||
43
src/app/lib/parseProxy.test.ts
Normal file
43
src/app/lib/parseProxy.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseProxy } from './parseProxy';
|
||||
|
||||
describe('parseProxy', () => {
|
||||
it('parses host and port without explicit protocol', () => {
|
||||
expect(parseProxy('proxy.example.test:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: 'proxy.example.test',
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses socks5 URLs', () => {
|
||||
expect(parseProxy('socks5://127.0.0.1:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: '127.0.0.1',
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses bracketed IPv6 hosts', () => {
|
||||
expect(parseProxy('socks5://[::1]:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: '::1',
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported schemes', () => {
|
||||
expect(() => parseProxy('http://proxy.example.test:8080')).toThrow('SOCKS5');
|
||||
});
|
||||
|
||||
it('rejects missing or invalid ports', () => {
|
||||
expect(() => parseProxy('proxy.example.test')).toThrow('хост и порт');
|
||||
expect(() => parseProxy('proxy.example.test:70000')).toThrow('Формат');
|
||||
});
|
||||
|
||||
it('rejects userinfo credentials', () => {
|
||||
expect(() => parseProxy('socks5://user:password@proxy.example.test:1080')).toThrow(
|
||||
'логином и паролем',
|
||||
);
|
||||
});
|
||||
});
|
||||
34
src/app/lib/parseProxy.ts
Normal file
34
src/app/lib/parseProxy.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface ParsedProxy {
|
||||
protocol: 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function parseProxy(rawValue: string): ParsedProxy {
|
||||
const value = rawValue.trim();
|
||||
if (!value) throw new Error('Введи адрес прокси.');
|
||||
|
||||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(withProtocol);
|
||||
} catch {
|
||||
throw new Error('Формат: socks5://host:port или host:port.');
|
||||
}
|
||||
|
||||
const protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||||
if (protocol !== 'socks5') {
|
||||
throw new Error('Сейчас поддерживается только SOCKS5.');
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
|
||||
}
|
||||
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||
const port = Number(parsed.port);
|
||||
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('Укажи хост и порт прокси.');
|
||||
}
|
||||
|
||||
return { protocol: 'socks5', host, port };
|
||||
}
|
||||
@@ -1896,7 +1896,7 @@ button.summary-card:hover {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscription-line .icon-command {
|
||||
.subscription-line .ui-icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
@@ -3153,11 +3153,11 @@ button.summary-card:hover {
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
|
||||
.subscription-line button:not(.icon-command) {
|
||||
.subscription-line button:not(.ui-icon-button) {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.subscription-line .icon-command {
|
||||
.subscription-line .ui-icon-button {
|
||||
grid-column: 3;
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user