diff --git a/assets/translations/strings_en.i18n.json b/assets/translations/strings_en.i18n.json index f3a0fb71..57f3d155 100644 --- a/assets/translations/strings_en.i18n.json +++ b/assets/translations/strings_en.i18n.json @@ -240,6 +240,8 @@ "singbox": { "unexpected": "Unexpected Service Error", "serviceNotRunning": "Service is not running", + "missingPrivilege": "Missing Privilege", + "missingPrivilegeMsg": "VPN mode requires administrator privileges. Either relaunch the app as administrator or change service mode.", "invalidConfigOptions": "Invalid configuration options", "invalidConfig": "Invalid Configuration", "create": "Service creation error", diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index ac6c197d..e205cadf 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -240,6 +240,8 @@ "singbox": { "unexpected": "خطای غیرمنتظره در سرویس", "serviceNotRunning": "سرویس در حال اجرا نیست", + "missingPrivilege": "نیازمند دسترسی", + "missingPrivilegeMsg": "حالت VPN به دسترسی administrator نیاز دارد. یا برنامه را به عنوان سرپرست راه اندازی مجدد کنید یا حالت سرویس را تغییر دهید.", "invalidConfigOptions": "تنظیمات کانفیگ نامعتبر", "invalidConfig": "کانفیگ غیر معتبر", "create": "در ایجاد سرویس خطایی رخ داده", diff --git a/assets/translations/strings_ru.i18n.json b/assets/translations/strings_ru.i18n.json index cffcbb14..c96469c2 100644 --- a/assets/translations/strings_ru.i18n.json +++ b/assets/translations/strings_ru.i18n.json @@ -243,7 +243,9 @@ "invalidConfigOptions": "Неправильные параметры конфигурации", "invalidConfig": "Неправильная конфигурация", "create": "Ошибка создания сервиса", - "start": "Ошибка запуска сервиса" + "start": "Ошибка запуска сервиса", + "missingPrivilege": "Отсутствующие привилегии", + "missingPrivilegeMsg": "Режим VPN требует прав администратора. Либо перезапустите приложение от имени администратора, либо измените сервисный режим." }, "connectivity": { "unexpected": "Неожиданная ошибка", diff --git a/assets/translations/strings_zh.i18n.json b/assets/translations/strings_zh.i18n.json index 96098348..e4a7b076 100644 --- a/assets/translations/strings_zh.i18n.json +++ b/assets/translations/strings_zh.i18n.json @@ -243,7 +243,9 @@ "invalidConfigOptions": "配置选项无效", "invalidConfig": "无效配置", "create": "服务创建错误", - "start": "服务启动错误" + "start": "服务启动错误", + "missingPrivilege": "缺少特权", + "missingPrivilegeMsg": "VPN 模式需要管理员权限。以管理员身份重新启动应用程序或更改服务模式" }, "connectivity": { "unexpected": "意外失败", diff --git a/lib/data/data_providers.dart b/lib/data/data_providers.dart index 0cf378dd..7a40c5df 100644 --- a/lib/data/data_providers.dart +++ b/lib/data/data_providers.dart @@ -61,6 +61,7 @@ ClashApi clashApi(ClashApiRef ref) => ClashApi(Defaults.clashApiPort); CoreFacade coreFacade(CoreFacadeRef ref) => CoreFacadeImpl( ref.watch(singboxServiceProvider), ref.watch(filesEditorServiceProvider), + ref.watch(platformServicesProvider), ref.watch(clashApiProvider), ref.read(debugModeNotifierProvider), () => ref.read(configOptionsProvider), diff --git a/lib/data/repository/core_facade_impl.dart b/lib/data/repository/core_facade_impl.dart index f0214194..c779e61c 100644 --- a/lib/data/repository/core_facade_impl.dart +++ b/lib/data/repository/core_facade_impl.dart @@ -10,6 +10,7 @@ import 'package:hiddify/domain/core_facade.dart'; import 'package:hiddify/domain/core_service_failure.dart'; import 'package:hiddify/domain/singbox/singbox.dart'; import 'package:hiddify/services/files_editor_service.dart'; +import 'package:hiddify/services/platform_services.dart'; import 'package:hiddify/services/singbox/singbox_service.dart'; import 'package:hiddify/utils/utils.dart'; @@ -17,6 +18,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { CoreFacadeImpl( this.singbox, this.filesEditor, + this.platformServices, this.clash, this.debug, this.configOptions, @@ -24,6 +26,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { final SingboxService singbox; final FilesEditorService filesEditor; + final PlatformServices platformServices; final ClashApi clash; final bool debug; final ConfigOptions Function() configOptions; @@ -93,13 +96,21 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { bool disableMemoryLimit, ) { return exceptionHandler( - () { + () async { final configPath = filesEditor.configPath(fileName); final options = configOptions(); loggy.info( "config options: ${options.format()}\nMemory Limit: ${!disableMemoryLimit}", ); + if (options.enableTun) { + final hasPrivilege = await platformServices.hasPrivilege(); + if (!hasPrivilege) { + loggy.warning("missing privileges for tun mode"); + return left(const CoreMissingPrivilege()); + } + } + return setup() .andThen(() => changeConfigOptions(options)) .andThen( diff --git a/lib/domain/core_service_failure.dart b/lib/domain/core_service_failure.dart index 627e3ed1..8e2041dd 100644 --- a/lib/domain/core_service_failure.dart +++ b/lib/domain/core_service_failure.dart @@ -18,6 +18,9 @@ sealed class CoreServiceFailure with _$CoreServiceFailure, Failure { const factory CoreServiceFailure.serviceNotRunning([String? message]) = CoreServiceNotRunning; + @With() + const factory CoreServiceFailure.missingPrivilege() = CoreMissingPrivilege; + const factory CoreServiceFailure.invalidConfigOptions([ String? message, ]) = InvalidConfigOptions; @@ -42,6 +45,7 @@ sealed class CoreServiceFailure with _$CoreServiceFailure, Failure { String? get msg => switch (this) { UnexpectedCoreServiceFailure() => null, CoreServiceNotRunning(:final message) => message, + CoreMissingPrivilege() => null, InvalidConfigOptions(:final message) => message, InvalidConfig(:final message) => message, CoreServiceCreateFailure(:final message) => message, @@ -60,6 +64,10 @@ sealed class CoreServiceFailure with _$CoreServiceFailure, Failure { type: t.failure.singbox.serviceNotRunning, message: message ), + CoreMissingPrivilege() => ( + type: t.failure.singbox.missingPrivilege, + message: t.failure.singbox.missingPrivilegeMsg, + ), InvalidConfigOptions(:final message) => ( type: t.failure.singbox.invalidConfigOptions, message: message diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index 3d62b754..267f467c 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -1,12 +1,16 @@ import 'dart:convert'; +import 'dart:ffi'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:fpdart/fpdart.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hiddify/services/files_editor_service.dart'; +import 'package:hiddify/utils/ffi_utils.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:posix/posix.dart'; +import 'package:win32/win32.dart'; part 'platform_services.freezed.dart'; part 'platform_services.g.dart'; @@ -44,6 +48,50 @@ class PlatformServices with InfraLogger { ); } + Future hasPrivilege() async { + try { + if (Platform.isWindows) { + bool isElevated = false; + withMemory(sizeOf(), (phToken) { + withMemory(sizeOf(), (pReturnedSize) { + withMemory(sizeOf<_TokenElevation>(), + (pElevation) { + if (OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY, + phToken.cast(), + ) == + 1) { + if (GetTokenInformation( + phToken.value, + TOKEN_INFORMATION_CLASS.TokenElevation, + pElevation, + sizeOf<_TokenElevation>(), + pReturnedSize, + ) == + 1) { + isElevated = pElevation.ref.tokenIsElevated != 0; + } + } + if (phToken.value != 0) { + CloseHandle(phToken.value); + } + }); + }); + }); + return isElevated; + } else if (Platform.isLinux || Platform.isMacOS) { + final euid = geteuid(); + return euid == 0; + } else { + return true; + } + } catch (e) { + loggy.warning("error checking privilege", e); + return true; // return true so core handles it + } + } + TaskEither isIgnoringBatteryOptimizations() { return TaskEither( () async { @@ -119,3 +167,10 @@ class InstalledPackageInfo with _$InstalledPackageInfo { factory InstalledPackageInfo.fromJson(Map json) => _$InstalledPackageInfoFromJson(json); } + +sealed class _TokenElevation extends Struct { + /// A nonzero value if the token has elevated privileges; + /// otherwise, a zero value. + @Int32() + external int tokenIsElevated; +} diff --git a/lib/utils/ffi_utils.dart b/lib/utils/ffi_utils.dart new file mode 100644 index 00000000..7e6f1b81 --- /dev/null +++ b/lib/utils/ffi_utils.dart @@ -0,0 +1,15 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +R withMemory( + int size, + R Function(Pointer memory) action, +) { + final memory = calloc(size); + try { + return action(memory.cast()); + } finally { + calloc.free(memory); + } +} diff --git a/pubspec.lock b/pubspec.lock index a66a260c..2df07782 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -973,6 +973,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + posix: + dependency: "direct main" + description: + name: posix + sha256: "3ad26924254fd2354b0e2b95fc8b45ac392ad87434f8e64807b3a1ac077f2256" + url: "https://pub.dev" + source: hosted + version: "5.0.0" process: dependency: transitive description: @@ -1603,13 +1611,13 @@ packages: source: hosted version: "2.4.0" win32: - dependency: transitive + dependency: "direct main" description: name: win32 - sha256: "9e82a402b7f3d518fb9c02d0e9ae45952df31b9bf34d77baf19da2de03fc2aaa" + sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" url: "https://pub.dev" source: hosted - version: "5.0.7" + version: "5.0.9" win32_registry: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e190e9fa..aa4a95b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,8 @@ dependencies: upgrader: ^8.2.0 toastification: ^1.1.0 version: ^3.0.2 + posix: ^5.0.0 + win32: ^5.0.9 dev_dependencies: flutter_test: