feat: Android auto-update notifications with dialog
Some checks are pending
CI / run (push) Waiting to run
Some checks are pending
CI / run (push) Waiting to run
- Add auto-check updates for Android in bootstrap - Show update dialog instead of toast notification - Same UX as Desktop: dialog with 'Later' and 'Update' buttons - Notifications appear 5 seconds after app launch Part of v1.7.6
This commit is contained in:
36
GIT_SAFETY.txt
Normal file
36
GIT_SAFETY.txt
Normal file
@@ -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 ← Потом мержим если нужно
|
||||
685
WINDOWS_SPLIT_TUNNELING.md
Normal file
685
WINDOWS_SPLIT_TUNNELING.md
Normal file
@@ -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 <windows.h>
|
||||
#include <tlhelp32.h>
|
||||
#include <shlobj.h>
|
||||
#include <atlbase.h>
|
||||
#include <string>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
// Структура для хранения информации о программе
|
||||
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<BYTE> 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<std::wstring> GetRunningProcesses() {
|
||||
std::set<std::wstring> 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<ProgramInfo> ScanInstalledPrograms() {
|
||||
std::vector<ProgramInfo> programs;
|
||||
std::set<std::wstring> runningProcs = GetRunningProcesses();
|
||||
|
||||
// Папки для сканирования
|
||||
std::vector<std::wstring> 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<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> 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 <flutter/method_channel.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
// В FlutterWindow::OnCreate() после создания flutter_controller_:
|
||||
|
||||
auto channel = std::make_unique<flutter::MethodChannel<>>(
|
||||
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<String, List<InstalledPackageInfo>> getInstalledPackages() {
|
||||
return TaskEither(
|
||||
() async {
|
||||
loggy.debug("getting installed packages info");
|
||||
|
||||
// ✅ УЖЕ РАБОТАЕТ на Android
|
||||
// ✅ БУДЕТ РАБОТАТЬ на Windows после добавления C++ кода
|
||||
final result = await _methodChannel.invokeMethod<String>("get_installed_packages");
|
||||
|
||||
if (result == null) return left("null response");
|
||||
return right(
|
||||
(jsonDecode(result) as List).map((e) {
|
||||
return InstalledPackageInfo.fromJson(e as Map<String, dynamic>);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<String, Uint8List> 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<String>(
|
||||
"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<SingboxRule> rules,
|
||||
required SingboxMuxOption mux,
|
||||
required SingboxTlsTricks tlsTricks,
|
||||
required SingboxWarpOption warp,
|
||||
required SingboxWarpOption warp2,
|
||||
|
||||
// ✨ НОВЫЕ ПОЛЯ
|
||||
@Default("off") String perAppProxyMode, // "off", "include", "exclude"
|
||||
@Default([]) List<String> includedApplications, // ["chrome.exe"]
|
||||
@Default([]) List<String> excludedApplications, // ["steam.exe"]
|
||||
}) = _SingboxConfigOption;
|
||||
|
||||
factory SingboxConfigOption.fromJson(Map<String, dynamic> json) =>
|
||||
_$SingboxConfigOptionFromJson(json);
|
||||
}
|
||||
```
|
||||
|
||||
**Файл:** `lib/features/config_option/data/config_option_repository.dart`
|
||||
|
||||
Добавить считывание настроек:
|
||||
|
||||
```dart
|
||||
Future<SingboxConfigOption> getConfigOptions() async {
|
||||
final preferences = await ref.read(sharedPreferencesProvider.future);
|
||||
|
||||
// ...существующие настройки...
|
||||
|
||||
// ✨ Читать Per-App Proxy настройки
|
||||
final perAppProxyMode = preferences.getString("per_app_proxy_mode") ?? "off";
|
||||
final List<String> 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)
|
||||
Приоритет: **Высокий** (очень востребованная функция)
|
||||
106
WINDOWS_SPLIT_TUNNELING_QUICK.md
Normal file
106
WINDOWS_SPLIT_TUNNELING_QUICK.md
Normal file
@@ -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<String> includedApplications,
|
||||
@Default([]) List<String> 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 часа**
|
||||
Сложность: **Средняя**
|
||||
Ожидаемый результат: ⭐⭐⭐⭐⭐
|
||||
@@ -163,6 +163,15 @@ Future<void> _performBootstrap(
|
||||
);
|
||||
}
|
||||
|
||||
// Автопроверка обновлений для Android
|
||||
if (Platform.isAndroid) {
|
||||
_safeInit(
|
||||
"auto check updates",
|
||||
() => container.read(appUpdateNotifierProvider.notifier).checkSilently(),
|
||||
timeout: 5000,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
await _safeInit(
|
||||
"android display mode",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user