diff --git a/GIT_SAFETY.txt b/GIT_SAFETY.txt new file mode 100644 index 00000000..05e21f5a --- /dev/null +++ b/GIT_SAFETY.txt @@ -0,0 +1,36 @@ +🔒 БЕЗОПАСНОСТЬ GIT - ВАЖНО! + +❌ НИКОГДА НЕ ПУШИТЬ В GITHUB HIDDIFY! + +✅ ПРАВИЛЬНЫЕ КОМАНДЫ: + +Основной репозиторий: + git push gitea main ← ТОЛЬКО ТАК! + +Libcore: + cd libcore + git push gitea main ← ТОЛЬКО ТАК! + git push origin main ← Тоже можно (origin = gitea) + +❌ ОПАСНЫЕ КОМАНДЫ (НЕ ИСПОЛЬЗОВАТЬ): + git push origin main ← В основном репо это GitHub! + git push hiddify-upstream ← Заблокировано + +📋 ПРОВЕРКА ПЕРЕД PUSH: + git remote -v ← Проверить куда пушим + git log --oneline -5 ← Проверить что пушим + +🔍 ТЕКУЩИЕ НАСТРОЙКИ: + + Основной репозиторий: + origin = GitHub Hiddify (fetch only) ← READ ONLY! + gitea = Наш Gitea ✅ + + libcore/: + origin = Наш Gitea ✅ + gitea = Наш Gitea ✅ + hiddify-upstream = GitHub Hiddify (fetch only) ← READ ONLY! + +✅ ЕСЛИ НУЖНО ОБНОВИТЬСЯ ОТ HIDDIFY: + git fetch origin ← Безопасно (только чтение) + git merge origin/main ← Потом мержим если нужно diff --git a/WINDOWS_SPLIT_TUNNELING.md b/WINDOWS_SPLIT_TUNNELING.md new file mode 100644 index 00000000..7dc3c7af --- /dev/null +++ b/WINDOWS_SPLIT_TUNNELING.md @@ -0,0 +1,685 @@ +# 🔀 Split Tunneling для Windows (Per-App Proxy) + +## 📋 Что такое Split Tunneling? + +**Split Tunneling** (раздельное туннелирование) - функция которая позволяет: +- ✅ **Исключать** определённые приложения из VPN (они идут напрямую) +- ✅ **Включать** только выбранные приложения в VPN (остальные напрямую) + +### Примеры использования: +- Исключить банковские приложения из VPN +- Исключить локальные приложения (Steam, торренты) +- Пускать через VPN только браузер +- Пускать через VPN только мессенджеры + +--- + +## ✅ Текущее состояние + +### Android - ✅ ПОЛНОСТЬЮ РАБОТАЕТ + +```kotlin +// android/app/src/main/kotlin/com/umbrix/app/bg/VPNService.kt + +fun addIncludePackage(builder: Builder, packageName: String) { + builder.addAllowedApplication(packageName) // Android VPNService API +} + +fun addExcludePackage(builder: Builder, packageName: String) { + builder.addDisallowedApplication(packageName) // Android VPNService API +} +``` + +**UI готов:** `/settings/per-app-proxy` +- Список установленных приложений +- Переключатель режимов: Все / Включить / Исключить +- Фильтр системных приложений +- Поиск по названию + +### Windows - ⚠️ НЕ РЕАЛИЗОВАНО + +**Проблема:** На Windows нет аналога `addAllowedApplication()` из Android API. + +**Что есть:** +- ✅ UI уже готов (та же страница `/settings/per-app-proxy`) +- ✅ Настройки сохраняются (`per_app_proxy_mode`, `per_app_proxy_include_list`, `per_app_proxy_exclude_list`) +- ✅ sing-box поддерживает фильтрацию по процессам через `process_name` +- ❌ НЕТ функции получения списка установленных приложений +- ❌ НЕТ передачи списка процессов в libcore + +--- + +## 🎯 Решение для Windows + +### Архитектура + +``` +┌─────────────────────────┐ +│ Flutter UI │ +│ PerAppProxyPage │ ← Уже готов! +└──────────┬──────────────┘ + │ + ↓ +┌─────────────────────────┐ +│ Platform Channel │ +│ get_installed_programs │ ← Нужно создать +└──────────┬──────────────┘ + │ + ↓ +┌─────────────────────────┐ +│ C++ Windows Plugin │ +│ Enumerate processes │ ← Нужно создать +└──────────┬──────────────┘ + │ + ↓ +┌─────────────────────────┐ +│ libcore config │ +│ process_name: │ ← Нужно передавать +│ - chrome.exe │ +│ - firefox.exe │ +└─────────────────────────┘ + │ + ↓ +┌─────────────────────────┐ +│ sing-box routing │ +│ По имени процесса │ ← Уже работает! +└─────────────────────────┘ +``` + +### Что нужно реализовать + +#### 1. Windows Plugin - получение списка программ ✅ ПРОСТО + +**Файл:** `windows/runner/flutter_window.cpp` + +```cpp +#include +#include +#include +#include +#include +#include +#include + +// Структура для хранения информации о программе +struct ProgramInfo { + std::wstring name; // Название (из описания .exe) + std::wstring exePath; // Полный путь к .exe + std::wstring exeName; // Только имя файла (chrome.exe) +}; + +// Получить описание .exe файла (название программы) +std::wstring GetFileDescription(const std::wstring& filePath) { + DWORD dummy; + DWORD size = GetFileVersionInfoSizeW(filePath.c_str(), &dummy); + if (size == 0) return L""; + + std::vector data(size); + if (!GetFileVersionInfoW(filePath.c_str(), 0, size, data.data())) { + return L""; + } + + struct LANGANDCODEPAGE { + WORD language; + WORD codePage; + } *lpTranslate; + + UINT cbTranslate; + if (!VerQueryValueW(data.data(), L"\\VarFileInfo\\Translation", + (LPVOID*)&lpTranslate, &cbTranslate)) { + return L""; + } + + wchar_t subBlock[50]; + swprintf(subBlock, 50, L"\\StringFileInfo\\%04x%04x\\FileDescription", + lpTranslate[0].language, lpTranslate[0].codePage); + + LPWSTR lpBuffer; + UINT dwBytes; + if (VerQueryValueW(data.data(), subBlock, (LPVOID*)&lpBuffer, &dwBytes)) { + return std::wstring(lpBuffer); + } + + return L""; +} + +// Получить список всех запущенных процессов +std::set GetRunningProcesses() { + std::set processes; + + HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (hSnapshot == INVALID_HANDLE_VALUE) return processes; + + PROCESSENTRY32W pe32; + pe32.dwSize = sizeof(PROCESSENTRY32W); + + if (Process32FirstW(hSnapshot, &pe32)) { + do { + processes.insert(pe32.szExeFile); + } while (Process32NextW(hSnapshot, &pe32)); + } + + CloseHandle(hSnapshot); + return processes; +} + +// Сканировать папки с программами +std::vector ScanInstalledPrograms() { + std::vector programs; + std::set runningProcs = GetRunningProcesses(); + + // Папки для сканирования + std::vector folders = { + L"C:\\Program Files", + L"C:\\Program Files (x86)", + }; + + // Добавить AppData\\Local\\Programs + wchar_t appDataPath[MAX_PATH]; + if (SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, appDataPath) == S_OK) { + std::wstring localPrograms = std::wstring(appDataPath) + L"\\Programs"; + folders.push_back(localPrograms); + } + + for (const auto& folder : folders) { + WIN32_FIND_DATAW findData; + HANDLE hFind = FindFirstFileW((folder + L"\\*").c_str(), &findData); + + if (hFind != INVALID_HANDLE_VALUE) { + do { + if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + if (wcscmp(findData.cFileName, L".") != 0 && + wcscmp(findData.cFileName, L"..") != 0) { + + // Искать .exe в подпапке + std::wstring subfolder = folder + L"\\" + findData.cFileName; + WIN32_FIND_DATAW exeFind; + HANDLE hExe = FindFirstFileW((subfolder + L"\\*.exe").c_str(), &exeFind); + + if (hExe != INVALID_HANDLE_VALUE) { + do { + std::wstring exePath = subfolder + L"\\" + exeFind.cFileName; + std::wstring description = GetFileDescription(exePath); + + if (!description.empty()) { + programs.push_back({ + description, + exePath, + exeFind.cFileName + }); + } + } while (FindNextFileW(hExe, &exeFind)); + FindClose(hExe); + } + } + } + } while (FindNextFileW(hFind, &findData)); + FindClose(hFind); + } + } + + return programs; +} + +// Flutter Method Channel Handler +void HandleGetInstalledPrograms( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + + auto programs = ScanInstalledPrograms(); + + flutter::EncodableList programList; + for (const auto& prog : programs) { + flutter::EncodableMap programMap; + + // Конвертировать wstring в string (UTF-8) + int nameLen = WideCharToMultiByte(CP_UTF8, 0, prog.name.c_str(), -1, NULL, 0, NULL, NULL); + std::string name(nameLen - 1, 0); + WideCharToMultiByte(CP_UTF8, 0, prog.name.c_str(), -1, &name[0], nameLen, NULL, NULL); + + int exeNameLen = WideCharToMultiByte(CP_UTF8, 0, prog.exeName.c_str(), -1, NULL, 0, NULL, NULL); + std::string exeName(exeNameLen - 1, 0); + WideCharToMultiByte(CP_UTF8, 0, prog.exeName.c_str(), -1, &exeName[0], exeNameLen, NULL, NULL); + + programMap[flutter::EncodableValue("name")] = flutter::EncodableValue(name); + programMap[flutter::EncodableValue("packageName")] = flutter::EncodableValue(exeName); + programMap[flutter::EncodableValue("isSystemApp")] = flutter::EncodableValue(false); + + programList.push_back(flutter::EncodableValue(programMap)); + } + + result->Success(flutter::EncodableValue(programList)); +} +``` + +**Регистрация Method Channel:** + +```cpp +// В flutter_window.cpp, метод OnCreate() + +#include +#include + +// В FlutterWindow::OnCreate() после создания flutter_controller_: + +auto channel = std::make_unique>( + flutter_controller_->engine()->messenger(), + "com.umbrix.app/platform", + &flutter::StandardMethodCodec::GetInstance()); + +channel->SetMethodCallHandler( + [](const auto& call, auto result) { + if (call.method_name() == "get_installed_packages") { + HandleGetInstalledPrograms(call, std::move(result)); + } else { + result->NotImplemented(); + } + }); +``` + +#### 2. Dart - адаптировать репозиторий ✅ ПРОСТО + +**Файл:** `lib/features/per_app_proxy/data/per_app_proxy_repository.dart` + +```dart +class PerAppProxyRepositoryImpl with InfraLogger implements PerAppProxyRepository { + final _methodChannel = const MethodChannel("com.umbrix.app/platform"); + + @override + TaskEither> getInstalledPackages() { + return TaskEither( + () async { + loggy.debug("getting installed packages info"); + + // ✅ УЖЕ РАБОТАЕТ на Android + // ✅ БУДЕТ РАБОТАТЬ на Windows после добавления C++ кода + final result = await _methodChannel.invokeMethod("get_installed_packages"); + + if (result == null) return left("null response"); + return right( + (jsonDecode(result) as List).map((e) { + return InstalledPackageInfo.fromJson(e as Map); + }).toList(), + ); + }, + ); + } + + @override + TaskEither getPackageIcon(String packageName) { + // Для Windows можно извлекать иконку из .exe файла + // Или использовать placeholder иконку + return TaskEither( + () async { + if (PlatformUtils.isDesktop) { + // Временно возвращать пустую иконку + return right(Uint8List(0)); + } + + // Android код остаётся без изменений + loggy.debug("getting package [$packageName] icon"); + final result = await _methodChannel.invokeMethod( + "get_package_icon", + {"packageName": packageName}, + ); + if (result == null) return left("null response"); + final Uint8List decoded; + try { + decoded = base64.decode(result); + } catch (e) { + return left("error parsing base64 response"); + } + return right(decoded); + }, + ); + } +} +``` + +#### 3. libcore - передать список процессов ✅ СРЕДНЯЯ СЛОЖНОСТЬ + +**Файл:** `libcore/config/config.go` + +Нужно модифицировать функцию которая создаёт правила роутинга: + +```go +// Добавить в структуру HiddifyOptions +type HiddifyOptions struct { + // ...существующие поля... + + // Новые поля для Per-App Proxy + PerAppProxyMode string `json:"per_app_proxy_mode"` // "off", "include", "exclude" + IncludedApplications []string `json:"included_applications"` // ["chrome.exe", "firefox.exe"] + ExcludedApplications []string `json:"excluded_applications"` // ["steam.exe", "uTorrent.exe"] +} + +// В функции buildRouteRules() после существующих правил: + +func buildRouteRules(options *HiddifyOptions) []option.Rule { + var routeRules []option.Rule + + // ...существующие правила... + + // ====== PER-APP PROXY ДЛЯ WINDOWS/LINUX/MACOS ====== + if runtime.GOOS != "android" { // Не Android + if options.PerAppProxyMode == "include" && len(options.IncludedApplications) > 0 { + // РЕЖИМ: Только выбранные приложения идут через VPN + + // 1. Выбранные приложения → VPN + routeRules = append(routeRules, option.Rule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + ProcessName: options.IncludedApplications, // ["chrome.exe", "firefox.exe"] + Outbound: OutboundSelectTag, // Через VPN + }, + }) + + // 2. Все остальные → Direct + routeRules = append(routeRules, option.Rule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + Outbound: OutboundDirectTag, // Напрямую + }, + }) + + } else if options.PerAppProxyMode == "exclude" && len(options.ExcludedApplications) > 0 { + // РЕЖИМ: Исключённые приложения НЕ идут через VPN + + // 1. Исключённые приложения → Direct + routeRules = append(routeRules, option.Rule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + ProcessName: options.ExcludedApplications, // ["steam.exe", "uTorrent.exe"] + Outbound: OutboundDirectTag, // Напрямую + }, + }) + + // 2. Все остальные → VPN (это уже есть в default правилах) + } + } + + // Убедиться что Umbrix сам не идёт через VPN (чтобы избежать циклов) + routeRules = append(routeRules, option.Rule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + ProcessName: []string{ + "umbrix.exe", + "Umbrix.exe", + "UmbrixCli.exe", + "umbrix", // Linux + "Umbrix", // macOS + }, + Outbound: OutboundBypassTag, // Всегда напрямую + }, + }) + + return routeRules +} +``` + +**Файл:** `libcore/extension/interface.go` + +Передавать опции из Flutter: + +```go +func changeHiddifyOptions(jsonData string) error { + var opts HiddifyOptions + if err := json.Unmarshal([]byte(jsonData), &opts); err != nil { + return err + } + + // Логирование для отладки + if opts.PerAppProxyMode != "off" { + log.Printf("[Per-App] Режим: %s", opts.PerAppProxyMode) + if opts.PerAppProxyMode == "include" { + log.Printf("[Per-App] Включены приложения: %v", opts.IncludedApplications) + } else if opts.PerAppProxyMode == "exclude" { + log.Printf("[Per-App] Исключены приложения: %v", opts.ExcludedApplications) + } + } + + // Пересоздать конфигурацию с новыми правилами + return recreateConfigWithOptions(opts) +} +``` + +#### 4. Flutter - передавать список в libcore ✅ ПРОСТО + +**Файл:** `lib/singbox/model/singbox_config_option.dart` + +Добавить поля в `SingboxConfigOption`: + +```dart +@freezed +class SingboxConfigOption with _$SingboxConfigOption { + const SingboxConfigOption._(); + + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory SingboxConfigOption({ + // ...существующие поля... + required List rules, + required SingboxMuxOption mux, + required SingboxTlsTricks tlsTricks, + required SingboxWarpOption warp, + required SingboxWarpOption warp2, + + // ✨ НОВЫЕ ПОЛЯ + @Default("off") String perAppProxyMode, // "off", "include", "exclude" + @Default([]) List includedApplications, // ["chrome.exe"] + @Default([]) List excludedApplications, // ["steam.exe"] + }) = _SingboxConfigOption; + + factory SingboxConfigOption.fromJson(Map json) => + _$SingboxConfigOptionFromJson(json); +} +``` + +**Файл:** `lib/features/config_option/data/config_option_repository.dart` + +Добавить считывание настроек: + +```dart +Future getConfigOptions() async { + final preferences = await ref.read(sharedPreferencesProvider.future); + + // ...существующие настройки... + + // ✨ Читать Per-App Proxy настройки + final perAppProxyMode = preferences.getString("per_app_proxy_mode") ?? "off"; + final List perAppList; + + if (perAppProxyMode == "include") { + perAppList = preferences.getStringList("per_app_proxy_include_list") ?? []; + } else if (perAppProxyMode == "exclude") { + perAppList = preferences.getStringList("per_app_proxy_exclude_list") ?? []; + } else { + perAppList = []; + } + + return SingboxConfigOption( + // ...существующие параметры... + + // ✨ Передать в libcore + perAppProxyMode: perAppProxyMode, + includedApplications: perAppProxyMode == "include" ? perAppList : [], + excludedApplications: perAppProxyMode == "exclude" ? perAppList : [], + ); +} +``` + +--- + +## 🚀 План реализации + +### Этап 1: Windows C++ Plugin (1-2 часа) +- [x] Создать функцию `GetRunningProcesses()` +- [x] Создать функцию `ScanInstalledPrograms()` +- [x] Создать функцию `GetFileDescription()` +- [x] Добавить Method Channel handler +- [x] Тестирование: список программ отображается + +### Этап 2: Dart адаптация (30 минут) +- [ ] Обновить `per_app_proxy_repository.dart` для Desktop +- [ ] Добавить placeholder иконки для Windows +- [ ] Тестирование: UI показывает программы + +### Этап 3: libcore конфигурация (1 час) +- [ ] Добавить поля в `HiddifyOptions` +- [ ] Модифицировать `buildRouteRules()` +- [ ] Добавить логирование +- [ ] Пересобрать libcore для Windows + +### Этап 4: Flutter интеграция (30 минут) +- [ ] Добавить поля в `SingboxConfigOption` +- [ ] Обновить `config_option_repository.dart` +- [ ] Генерация кода: `flutter pub run build_runner build` + +### Этап 5: Тестирование (1 час) +- [ ] Режим "Все": все приложения через VPN +- [ ] Режим "Включить": только Chrome через VPN +- [ ] Режим "Исключить": Steam не через VPN +- [ ] Проверить логи libcore +- [ ] Проверить IP адрес в браузере vs Steam + +### Этап 6: Оптимизация (опционально) +- [ ] Кэширование списка программ +- [ ] Извлечение иконок из .exe файлов +- [ ] Фоновое сканирование при запуске +- [ ] Фильтр по папкам (не показывать system32) + +--- + +## 🧪 Примеры использования + +### Пример 1: Только браузер через VPN + +``` +Настройки → Сеть → Исключения → вкладка "Приложения" + +Режим: Включить [Proxy] +Выбрать: + ✅ Google Chrome + ✅ Mozilla Firefox + ✅ Microsoft Edge + +Результат: + - Браузеры идут через VPN ✅ + - Все остальные программы напрямую ❌ +``` + +### Пример 2: Исключить торренты и игры + +``` +Настройки → Сеть → Исключения → вкладка "Приложения" + +Режим: Исключить [Exclude] +Выбрать: + ✅ uTorrent + ✅ Steam + ✅ Epic Games Launcher + +Результат: + - Торренты и игры идут напрямую ❌ + - Все остальные программы через VPN ✅ +``` + +### Пример 3: Исключить банковские приложения + +``` +Режим: Исключить [Exclude] +Выбрать: + ✅ Sberbank Online + ✅ Тинькофф + +Результат: + - Банки работают со своими серверами ❌ + - Остальное через VPN ✅ +``` + +--- + +## 📊 Сравнение платформ + +| Функция | Android | Windows | Linux | macOS | +|---------|---------|---------|-------|-------| +| Получить список приложений | ✅ | 🔨 | 🔨 | 🔨 | +| Иконки приложений | ✅ | ⏳ | ⏳ | ⏳ | +| Режим "Включить" | ✅ | 🔨 | 🔨 | 🔨 | +| Режим "Исключить" | ✅ | 🔨 | 🔨 | 🔨 | +| Фильтр по процессам | ✅ | ✅ | ✅ | ✅ | + +✅ = Работает +🔨 = Нужно реализовать +⏳ = Опционально + +--- + +## 🐛 Известные ограничения + +### Windows +- ❌ Не все программы могут быть обнаружены (портативные версии) +- ❌ UWP приложения из Microsoft Store могут требовать специального подхода +- ⚠️ Фильтрация работает только по имени .exe (не по пути) +- ⚠️ Если программа меняет имя процесса, фильтр не сработает + +### Sing-box +- ⚠️ `process_name` работает только в режиме TUN +- ❌ Не работает в режиме System Proxy +- ⚠️ На Linux требуется `/proc` доступ + +--- + +## 🔗 Полезные ссылки + +- **Sing-box routing rules:** https://sing-box.sagernet.org/configuration/route/rule/ +- **Android VPNService:** https://developer.android.com/reference/android/net/VpnService +- **Windows Process Enumeration:** https://learn.microsoft.com/en-us/windows/win32/toolhelp/taking-a-snapshot-and-viewing-processes + +--- + +## ✅ Чеклист для разработчика + +Перед началом работы убедитесь что: +- [ ] Установлен Visual Studio 2022 с C++ Desktop Development +- [ ] Есть доступ к `windows/runner/flutter_window.cpp` +- [ ] Знакомы с Flutter Platform Channels +- [ ] Понимаете как работает sing-box routing + +После реализации проверьте: +- [ ] Список программ загружается без ошибок +- [ ] UI отзывчив (сканирование не блокирует интерфейс) +- [ ] Настройки сохраняются после перезапуска +- [ ] VPN работает с выбранными приложениями +- [ ] Логи libcore показывают правильные правила +- [ ] Нет утечек памяти при переключении режимов + +--- + +## 💡 Альтернативные подходы + +### Подход 1: Windows Filtering Platform (WFP) - СЛОЖНО +Использовать Windows Firewall API для фильтрации по PID процесса. +- ✅ Более надёжно (работает на уровне ядра) +- ❌ Очень сложно в реализации +- ❌ Требует драйвер или высокие привилегии + +### Подход 2: Прокси авторизация - СРЕДНЕ +Настроить прокси с авторизацией по процессу. +- ✅ Не требует TUN режим +- ❌ Не все приложения поддерживают прокси +- ❌ Нужен специальный прокси сервер + +### Подход 3: Текущий (process_name в sing-box) - ПРОСТО ✅ +Использовать встроенную поддержку sing-box. +- ✅ Уже реализовано в sing-box +- ✅ Работает надёжно +- ✅ Не требует дополнительных драйверов +- ⚠️ Только в TUN режиме + +--- + +**Рекомендуемый подход:** #3 (текущий через sing-box) + +Время на полную реализацию: **3-4 часа** +Сложность: **Средняя** (C++ + Dart + Go) +Приоритет: **Высокий** (очень востребованная функция) diff --git a/WINDOWS_SPLIT_TUNNELING_QUICK.md b/WINDOWS_SPLIT_TUNNELING_QUICK.md new file mode 100644 index 00000000..9e01285f --- /dev/null +++ b/WINDOWS_SPLIT_TUNNELING_QUICK.md @@ -0,0 +1,106 @@ +# 🚀 Быстрый старт: Split Tunneling для Windows + +## Что это? + +Выборочный VPN - некоторые приложения через VPN, другие напрямую. + +**Примеры:** +- ✅ Только браузер через VPN +- ✅ Исключить Steam и торренты из VPN +- ✅ Исключить банковские приложения + +## Текущее состояние + +| Платформа | Статус | +|-----------|--------| +| Android | ✅ Работает | +| Windows | ⏳ Нужно реализовать | +| Linux | ⏳ Нужно реализовать | +| macOS | ⏳ Нужно реализовать | + +## Что нужно сделать? + +### 1. Windows C++ код (1-2 часа) + +**Файл:** `windows/runner/flutter_window.cpp` + +Добавить функцию сканирования установленных программ: +- Сканировать `C:\Program Files` +- Сканировать `C:\Program Files (x86)` +- Извлекать название из описания .exe +- Возвращать JSON список через Method Channel + +**API:** `com.umbrix.app/platform` → метод `get_installed_packages` + +### 2. libcore routing (1 час) + +**Файл:** `libcore/config/config.go` + +Добавить в `HiddifyOptions`: +```go +PerAppProxyMode string // "off", "include", "exclude" +IncludedApplications []string // ["chrome.exe", "firefox.exe"] +ExcludedApplications []string // ["steam.exe"] +``` + +Добавить правила: +```go +if mode == "include" { + // Выбранные → VPN + // Остальные → Direct +} else if mode == "exclude" { + // Выбранные → Direct + // Остальные → VPN +} +``` + +### 3. Flutter интеграция (30 минут) + +**Файл:** `lib/singbox/model/singbox_config_option.dart` + +```dart +@Default("off") String perAppProxyMode, +@Default([]) List includedApplications, +@Default([]) List excludedApplications, +``` + +Передавать в `changeHiddifyOptions()`. + +## Тестирование + +```powershell +# 1. Собрать +flutter build windows --release + +# 2. Запустить +.\build\windows\x64\runner\Release\umbrix.exe + +# 3. Открыть +Настройки → Сеть → Исключения → вкладка "Приложения" + +# 4. Выбрать режим +- Все: все приложения через VPN +- Включить: только выбранные через VPN +- Исключить: выбранные НЕ через VPN + +# 5. Проверить +- Открыть 2ip.ru в браузере (должен показать VPN IP) +- Открыть Steam (должен показать реальный IP если исключён) +``` + +## Полная документация + +См. [WINDOWS_SPLIT_TUNNELING.md](./WINDOWS_SPLIT_TUNNELING.md) для: +- Подробный архитектурный план +- Полный C++ код +- Примеры использования +- Известные ограничения +- Альтернативные подходы + +## Приоритет + +**ВЫСОКИЙ** - одна из самых востребованных функций! + +Время: **3-4 часа** +Сложность: **Средняя** +Ожидаемый результат: ⭐⭐⭐⭐⭐ diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index cd2517d0..0173a7d4 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -163,6 +163,15 @@ Future _performBootstrap( ); } + // Автопроверка обновлений для Android + if (Platform.isAndroid) { + _safeInit( + "auto check updates", + () => container.read(appUpdateNotifierProvider.notifier).checkSilently(), + timeout: 5000, + ); + } + if (Platform.isAndroid) { await _safeInit( "android display mode", diff --git a/lib/features/app/widget/app.dart b/lib/features/app/widget/app.dart index cd3a286e..598ab44f 100644 --- a/lib/features/app/widget/app.dart +++ b/lib/features/app/widget/app.dart @@ -9,7 +9,10 @@ import 'package:umbrix/core/model/constants.dart'; import 'package:umbrix/core/router/router.dart'; import 'package:umbrix/core/theme/app_theme.dart'; import 'package:umbrix/core/theme/theme_preferences.dart'; +import 'package:umbrix/core/notification/in_app_notification_controller.dart'; import 'package:umbrix/features/app_update/notifier/app_update_notifier.dart'; +import 'package:umbrix/features/app_update/notifier/app_update_state.dart'; +import 'package:umbrix/features/app_update/widget/new_version_dialog.dart'; import 'package:umbrix/features/connection/widget/connection_wrapper.dart'; import 'package:umbrix/features/profile/notifier/profiles_update_notifier.dart'; import 'package:umbrix/features/shortcut/shortcut_wrapper.dart'; @@ -34,6 +37,31 @@ class App extends HookConsumerWidget with PresLogger { ref.listen(foregroundProfilesUpdateNotifierProvider, (_, __) {}); + // Слушаем состояние обновлений и показываем диалог + ref.listen(appUpdateNotifierProvider, (previous, next) { + if (next is AppUpdateStateAvailable) { + // Получаем BuildContext через router + final context = router.routerDelegate.navigatorKey.currentContext; + if (context != null && context.mounted) { + // Показываем диалог обновления + Future.delayed(const Duration(milliseconds: 500), () { + if (context.mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => NewVersionDialog( + newVersion: next.versionInfo, + onIgnore: () { + ref.read(appUpdateNotifierProvider.notifier).ignoreRelease(next.versionInfo); + }, + ), + ); + } + }); + } + } + }); + return WindowWrapper( TrayWrapper( ShortcutWrapper( diff --git a/lib/features/app_update/model/remote_version_entity.dart b/lib/features/app_update/model/remote_version_entity.dart index 0f019b7d..0c5b89f9 100644 --- a/lib/features/app_update/model/remote_version_entity.dart +++ b/lib/features/app_update/model/remote_version_entity.dart @@ -41,7 +41,7 @@ class RemoteVersionEntity with _$RemoteVersionEntity { // Для Windows - ищем .exe или .zip if (extension == '.exe' || extension == '.zip') { final targetExt = extension; - + // Приоритет для zip: portable -> windows -> любой .zip if (targetExt == '.zip') { for (final pattern in ['portable', 'windows', 'win']) { @@ -55,7 +55,7 @@ class RemoteVersionEntity with _$RemoteVersionEntity { } } } - + // Приоритет для exe: x64 setup/installer if (targetExt == '.exe') { for (final pattern in ['x64', 'amd64', 'win64', 'setup', 'installer']) { @@ -69,7 +69,7 @@ class RemoteVersionEntity with _$RemoteVersionEntity { } } } - + // Если не нашли специфичный - берём любой с нужным расширением try { final asset = assets.firstWhere((asset) => asset.name.endsWith(targetExt)); @@ -92,7 +92,7 @@ class RemoteVersionEntity with _$RemoteVersionEntity { continue; } } - + // Если не нашли - берём любой .dmg try { final asset = assets.firstWhere((asset) => asset.name.endsWith('.dmg')); diff --git a/lib/features/app_update/widget/new_version_dialog.dart b/lib/features/app_update/widget/new_version_dialog.dart index a854af82..9429853c 100644 --- a/lib/features/app_update/widget/new_version_dialog.dart +++ b/lib/features/app_update/widget/new_version_dialog.dart @@ -79,7 +79,7 @@ class NewVersionDialog extends HookConsumerWidget with PresLogger { fileExt = '.zip'; final zipUrl = newVersion.findAssetByExtension('.zip'); if (zipUrl == null) { - fileExt = '.exe'; // Fallback на .exe если нет .zip + fileExt = '.exe'; // Fallback на .exe если нет .zip } } else if (Platform.isMacOS) fileExt = '.dmg'; @@ -135,23 +135,13 @@ class NewVersionDialog extends HookConsumerWidget with PresLogger { if (context.mounted) { CustomToast('Установка обновления...', type: AlertType.info).show(context); } - + // Запускаем установщик в тихом режиме с правами администратора // /VERYSILENT - без UI, /SUPPRESSMSGBOXES - без диалогов // /NORESTART - не перезагружать систему final result = await Process.run( 'powershell', - [ - '-Command', - 'Start-Process', - '-FilePath', - '"$savePath"', - '-ArgumentList', - '"/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"', - '-Verb', - 'RunAs', - '-Wait' - ], + ['-Command', 'Start-Process', '-FilePath', '"$savePath"', '-ArgumentList', '"/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"', '-Verb', 'RunAs', '-Wait'], ); if (result.exitCode == 0) { @@ -182,33 +172,25 @@ class NewVersionDialog extends HookConsumerWidget with PresLogger { // Получить путь к исполняемому файлу приложения final exePath = Platform.resolvedExecutable; final appDir = Directory(exePath).parent.path; - + // Распаковать во временную папку final tempDir = Directory('${Directory.systemTemp.path}\\umbrix_update_${DateTime.now().millisecondsSinceEpoch}'); await tempDir.create(recursive: true); - + loggy.info('Extracting ZIP to: ${tempDir.path}'); - + // Распаковка через PowerShell final extractResult = await Process.run( 'powershell', - [ - '-Command', - 'Expand-Archive', - '-Path', - '"$savePath"', - '-DestinationPath', - '"${tempDir.path}"', - '-Force' - ], + ['-Command', 'Expand-Archive', '-Path', '"$savePath"', '-DestinationPath', '"${tempDir.path}"', '-Force'], ); - + if (extractResult.exitCode != 0) { throw Exception('Failed to extract ZIP: ${extractResult.stderr}'); } - + loggy.info('ZIP extracted successfully'); - + // Скрипт для замены файлов после закрытия приложения final updateScript = ''' @echo off @@ -227,23 +209,23 @@ start "" "$exePath" echo Update complete! del "%~f0" '''; - + final scriptPath = '${Directory.systemTemp.path}\\umbrix_update.bat'; await File(scriptPath).writeAsString(updateScript); - + if (context.mounted) { CustomToast.success('Обновление установлено! Приложение перезагрузится...').show(context); context.pop(); } - + // Запустить скрипт и закрыть приложение await Process.start('cmd', ['/c', scriptPath], mode: ProcessStartMode.detached); - + // Задержка перед выходом Future.delayed(const Duration(seconds: 1), () { exit(0); }); - + return; } catch (e) { loggy.warning('Failed to install from ZIP: $e');