This commit is contained in:
problematicconsumer
2023-07-06 17:18:41 +03:30
commit b617c95f62
352 changed files with 21017 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:hiddify/services/clash/async_ffi_response.dart';
import 'package:hiddify/utils/utils.dart';
// TODO: add timeout
// TODO: test and improve
mixin AsyncFFI implements LoggerMixin {
Future<AsyncFfiResponse> runAsync(void Function(int port) run) async {
final receivePort = ReceivePort();
final responseFuture = receivePort.map(
(event) {
if (event is String) {
receivePort.close();
return AsyncFfiResponse.fromJson(
jsonDecode(event) as Map<String, dynamic>,
);
}
receivePort.close();
throw Exception("unexpected data type[${event.runtimeType}]");
},
).first;
run(receivePort.sendPort.nativePort);
return responseFuture;
}
}

View File

@@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'async_ffi_response.freezed.dart';
part 'async_ffi_response.g.dart';
@freezed
class AsyncFfiResponse with _$AsyncFfiResponse {
const AsyncFfiResponse._();
const factory AsyncFfiResponse({
@JsonKey(name: 'success') required bool success,
@JsonKey(name: 'message') String? message,
@JsonKey(name: 'data') String? data,
}) = _AsyncFfiResponse;
factory AsyncFfiResponse.fromJson(Map<String, dynamic> json) =>
_$AsyncFfiResponseFromJson(json);
}

View File

@@ -0,0 +1,2 @@
export 'clash_service.dart';
export 'clash_service_impl.dart';

View File

@@ -0,0 +1,35 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/domain/clash/clash.dart';
abstract class ClashService {
Future<void> init();
Future<void> start({String configFileName = "config"});
TaskEither<String, bool> validateConfig(String configPath);
TaskEither<String, List<ClashProxy>> getProxies();
TaskEither<String, Unit> changeProxy(
String selectorName,
String proxyName,
);
TaskEither<String, int> getProxyDelay(
String name,
String url, {
Duration timeout = const Duration(seconds: 10),
});
TaskEither<String, ClashConfig> getConfigs();
TaskEither<String, Unit> updateConfigs(String path);
TaskEither<String, Unit> patchConfigs(ClashConfig config);
Stream<ClashLog> watchLogs(LogLevel level);
TaskEither<String, ClashTraffic> getTraffic();
}

View File

@@ -0,0 +1,235 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'package:combine/combine.dart';
import 'package:ffi/ffi.dart';
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/domain/clash/clash.dart';
import 'package:hiddify/gen/clash_generated_bindings.dart';
import 'package:hiddify/services/clash/async_ffi.dart';
import 'package:hiddify/services/clash/clash_service.dart';
import 'package:hiddify/services/files_editor_service.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:path/path.dart' as p;
import 'package:rxdart/rxdart.dart';
// TODO: logging has potential memory leak
class ClashServiceImpl with AsyncFFI, InfraLogger implements ClashService {
ClashServiceImpl({required this.filesEditor});
final FilesEditorService filesEditor;
late final ClashNativeLibrary _clash;
@override
Future<void> init() async {
loggy.debug('initializing');
_initClashLib();
_clash.initNativeDartBridge(NativeApi.initializeApiDLData);
}
void _initClashLib() {
String fullPath = "";
if (Platform.environment.containsKey('FLUTTER_TEST')) {
fullPath = "core";
}
if (Platform.isWindows) {
fullPath = p.join(fullPath, "libclash.dll");
} else if (Platform.isMacOS) {
fullPath = p.join(fullPath, "libclash.dylib");
} else {
fullPath = p.join(fullPath, "libclash.so");
}
loggy.debug('clash native libs path: "$fullPath"');
final lib = DynamicLibrary.open(fullPath);
_clash = ClashNativeLibrary(lib);
}
@override
Future<void> start({String configFileName = "config"}) async {
loggy.debug('starting clash with config: [$configFileName]');
final stopWatch = Stopwatch()..start();
final configPath = filesEditor.configPath(configFileName);
final response = await runAsync(
(port) => _clash.setOptions(
port,
filesEditor.clashDirPath.toNativeUtf8().cast(),
configPath.toNativeUtf8().cast(),
),
);
if (!response.success) throw ClashFailure.core(response.message);
stopWatch.stop();
loggy.info(
"started clash service [${stopWatch.elapsedMilliseconds}ms]",
);
}
@override
TaskEither<String, bool> validateConfig(String configPath) {
return TaskEither(
() async {
final response = await runAsync(
(port) =>
_clash.validateConfig(port, configPath.toNativeUtf8().cast()),
);
if (!response.success) return left(response.message ?? '');
return right(response.data! == "true");
},
);
}
@override
TaskEither<String, Unit> updateConfigs(String path) {
return TaskEither(() async {
final stopWatch = Stopwatch()..start();
final response = await runAsync(
(port) => _clash.updateConfigs(port, path.toNativeUtf8().cast(), 0),
);
stopWatch.stop();
if (response.success) {
loggy.info("changed config in [${stopWatch.elapsedMilliseconds}ms]");
return right(unit);
}
return left(response.message ?? '');
});
}
@override
TaskEither<String, List<ClashProxy>> getProxies() {
return TaskEither(
() async {
final response = await runAsync((port) => _clash.getProxies(port));
if (!response.success) return left(response.message ?? "");
final proxies = await CombineWorker().executeWithArg(
(data) {
if (data == null) return <ClashProxy>[];
final json = jsonDecode(data)['proxies'] as Map<String, dynamic>;
final parsed = json.entries.map(
(e) {
final proxyMap = (e.value as Map<String, dynamic>)
..putIfAbsent('name', () => e.key);
return ClashProxy.fromJson(proxyMap);
},
).toList();
return parsed;
},
response.data,
);
return right(proxies);
},
);
}
@override
TaskEither<String, Unit> patchConfigs(ClashConfig config) {
return TaskEither(
() async {
final response = await runAsync(
(port) => _clash.patchConfigs(
port,
jsonEncode(config.toJson()).toNativeUtf8().cast(),
),
);
if (!response.success) return left(response.message ?? "");
return right(unit);
},
);
}
@override
TaskEither<String, ClashConfig> getConfigs() {
return TaskEither(
() async {
final response = await runAsync(
(port) => _clash.getConfigs(port),
);
if (!response.success) return left(response.message ?? "");
return right(
ClashConfig.fromJson(
jsonDecode(response.data!) as Map<String, dynamic>,
),
);
},
);
}
@override
TaskEither<String, Unit> changeProxy(
String selectorName,
String proxyName,
) {
return TaskEither(
() async {
final response = await runAsync(
(port) => _clash.updateProxy(
port,
selectorName.toNativeUtf8().cast(),
proxyName.toNativeUtf8().cast(),
),
);
if (!response.success) return left(response.message ?? "");
return right(unit);
},
);
}
@override
TaskEither<String, int> getProxyDelay(
String name,
String url, {
Duration timeout = const Duration(seconds: 10),
}) {
return TaskEither(
() async {
final response = await runAsync(
(port) => _clash.getProxyDelay(
port,
name.toNativeUtf8().cast(),
url.toNativeUtf8().cast(),
timeout.inMilliseconds,
),
);
if (!response.success) return left(response.message ?? "");
return right(
(jsonDecode(response.data!) as Map<String, dynamic>)["delay"] as int,
);
},
);
}
@override
Stream<ClashLog> watchLogs(LogLevel level) {
final logsPort = ReceivePort();
final logsStream = logsPort.map(
(event) {
final json = jsonDecode(event as String) as Map<String, dynamic>;
return ClashLog.fromJson(json);
},
);
_clash.startLog(
logsPort.sendPort.nativePort,
level.name.toNativeUtf8().cast(),
);
return logsStream.doOnCancel(() => _clash.stopLog());
}
@override
TaskEither<String, ClashTraffic> getTraffic() {
return TaskEither(
() async {
final response = await runAsync(
(port) => _clash.getTraffic(port),
);
if (!response.success) return left(response.message ?? "");
return right(
ClashTraffic.fromJson(
jsonDecode(response.data!) as Map<String, dynamic>,
),
);
},
);
}
}

View File

@@ -0,0 +1,3 @@
export 'connectivity_service.dart';
export 'desktop_connectivity_service.dart';
export 'mobile_connectivity_service.dart';

View File

@@ -0,0 +1,27 @@
import 'package:hiddify/services/connectivity/desktop_connectivity_service.dart';
import 'package:hiddify/services/connectivity/mobile_connectivity_service.dart';
import 'package:hiddify/services/notification/notification.dart';
import 'package:hiddify/utils/utils.dart';
abstract class ConnectivityService {
factory ConnectivityService(NotificationService notification) {
if (PlatformUtils.isDesktop) return DesktopConnectivityService();
return MobileConnectivityService(notification);
}
Future<void> init();
// TODO: use declarative states
Stream<bool> watchConnectionStatus();
// TODO: remove
Future<bool> grantVpnPermission();
Future<void> connect({
required int httpPort,
required int socksPort,
bool systemProxy = true,
});
Future<void> disconnect();
}

View File

@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:hiddify/domain/constants.dart';
import 'package:hiddify/services/connectivity/connectivity_service.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:proxy_manager/proxy_manager.dart';
import 'package:rxdart/rxdart.dart';
// TODO: rewrite
class DesktopConnectivityService
with InfraLogger
implements ConnectivityService {
// TODO: possibly replace
final _proxyManager = ProxyManager();
final _connectionStatus = BehaviorSubject.seeded(false);
@override
Future<void> init() async {}
@override
Stream<bool> watchConnectionStatus() {
return _connectionStatus;
}
@override
Future<bool> grantVpnPermission() async => true;
@override
Future<void> connect({
required int httpPort,
required int socksPort,
bool systemProxy = true,
}) async {
loggy.debug('connecting');
await Future.wait([
_proxyManager.setAsSystemProxy(
ProxyTypes.http,
Constants.localHost,
httpPort,
),
_proxyManager.setAsSystemProxy(
ProxyTypes.https,
Constants.localHost,
httpPort,
)
]);
if (!Platform.isWindows) {
await _proxyManager.setAsSystemProxy(
ProxyTypes.socks,
Constants.localHost,
socksPort,
);
}
_connectionStatus.value = true;
}
@override
Future<void> disconnect() async {
loggy.debug("disconnecting");
await _proxyManager.cleanSystemProxy();
_connectionStatus.value = false;
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/services.dart';
import 'package:hiddify/domain/connectivity/connectivity_failure.dart';
import 'package:hiddify/services/connectivity/connectivity_service.dart';
import 'package:hiddify/services/notification/notification.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:rxdart/rxdart.dart';
// TODO: rewrite
class MobileConnectivityService
with InfraLogger
implements ConnectivityService {
MobileConnectivityService(this._notificationService);
final NotificationService _notificationService;
static const _methodChannel = MethodChannel("Hiddify/VpnService");
static const _eventChannel = EventChannel("Hiddify/VpnServiceEvents");
final _connectionStatus = ValueConnectableStream(
_eventChannel.receiveBroadcastStream().map((event) => event as bool),
).autoConnect();
@override
Future<void> init() async {
loggy.debug("initializing");
final initialStatus = _connectionStatus.first;
await _methodChannel.invokeMethod("refresh_status");
await initialStatus;
}
@override
Stream<bool> watchConnectionStatus() {
return _connectionStatus;
}
@override
Future<bool> grantVpnPermission() async {
loggy.debug('requesting vpn permission');
final result = await _methodChannel.invokeMethod<bool>("grant_permission");
if (!(result ?? false)) {
loggy.info("vpn permission denied");
}
return result ?? false;
}
@override
Future<void> connect({
required int httpPort,
required int socksPort,
bool systemProxy = true,
}) async {
loggy.debug("connecting");
await setPrefs(httpPort, socksPort, systemProxy);
final hasNotificationPermission =
await _notificationService.grantPermission();
if (!hasNotificationPermission) {
loggy.warning("notification permission denied");
throw const ConnectivityFailure.unexpected();
}
await _methodChannel.invokeMethod<bool>("start");
}
@override
Future<void> disconnect() async {
loggy.debug("disconnecting");
await _methodChannel.invokeMethod<bool>("stop");
}
Future<void> setPrefs(int port, int socksPort, bool systemProxy) async {
loggy.debug(
'setting connection prefs: httpPort: $port, socksPort: $socksPort, systemProxy: $systemProxy',
);
final result = await _methodChannel.invokeMethod<bool>(
"set_prefs",
{
"port": port,
"socks-port": socksPort,
"system-proxy": systemProxy,
},
);
if (!(result ?? false)) {
loggy.error("failed to set connection prefs");
// TODO: throw
}
}
}

View File

@@ -0,0 +1,44 @@
import 'package:hiddify/utils/utils.dart';
import 'package:protocol_handler/protocol_handler.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'deep_link_service.g.dart';
typedef NewProfileLink = ({String? url, String? name});
@Riverpod(keepAlive: true)
class DeepLinkService extends _$DeepLinkService
with ProtocolListener, InfraLogger {
@override
Future<NewProfileLink?> build() async {
for (final protocol in _protocols) {
await protocolHandler.register(protocol);
}
protocolHandler.addListener(this);
ref.onDispose(() {
protocolHandler.removeListener(this);
});
final initialPayload = await protocolHandler.getInitialUrl();
if (initialPayload != null) {
loggy.debug('initial payload: [$initialPayload]');
final link = LinkParser.deep(initialPayload);
return link;
}
return null;
}
static const _protocols = ['clash', 'clashmeta'];
@override
void onProtocolUrlReceived(String url) {
super.onProtocolUrlReceived(url);
loggy.debug("url received: [$url]");
final link = LinkParser.deep(url);
if (link == null) {
loggy.debug("link was not valid");
return;
}
update((_) => link);
}
}

View File

@@ -0,0 +1,67 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:hiddify/domain/constants.dart';
import 'package:hiddify/gen/assets.gen.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
class FilesEditorService with InfraLogger {
late final Directory _supportDir;
late final Directory _clashDirectory;
late final Directory _logsDirectory;
Future<void> init() async {
loggy.debug('initializing');
_supportDir = await getApplicationSupportDirectory();
_clashDirectory =
Directory(p.join(_supportDir.path, Constants.clashFolderName));
loggy.debug('clash directory: $_clashDirectory');
if (!await _clashDirectory.exists()) {
await _clashDirectory.create(recursive: true);
}
if (!await File(countryMMDBPath).exists()) {
await _populateDefaultCountryMMDB();
}
if (!await File(defaultConfigPath).exists()) await _populateDefaultConfig();
}
String get clashDirPath => _clashDirectory.path;
late final logsPath = p.join(
_logsDirectory.path,
"${DateTime.now().toUtc().toIso8601String().split('T').first}.txt",
);
String get defaultConfigPath => configPath("config");
String configPath(String fileName) {
return p.join(_clashDirectory.path, "$fileName.yaml");
}
Future<void> deleteConfig(String fileName) {
return File(configPath(fileName)).delete();
}
String get countryMMDBPath {
return p.join(
_clashDirectory.path,
"${Constants.countryMMDBFileName}.mmdb",
);
}
Future<void> _populateDefaultConfig() async {
loggy.debug('populating default config file');
final defaultConfig = await rootBundle.load(Assets.core.clash.config);
await File(defaultConfigPath)
.writeAsBytes(defaultConfig.buffer.asInt8List());
}
Future<void> _populateDefaultCountryMMDB() async {
loggy.debug('populating default country mmdb file');
final defaultCountryMMDB = await rootBundle.load(Assets.core.clash.country);
await File(countryMMDBPath)
.writeAsBytes(defaultCountryMMDB.buffer.asInt8List());
}
}

View File

@@ -0,0 +1,9 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
const mainChannel = AndroidNotificationChannel(
"com.hiddify.hiddify",
"Hiddify",
importance: Importance.high,
enableVibration: false,
playSound: false,
);

View File

@@ -0,0 +1,105 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hiddify/services/notification/constants.dart';
import 'package:hiddify/services/notification/notification_service.dart';
import 'package:hiddify/utils/utils.dart';
// TODO: rewrite
@pragma('vm:entry-point')
void notificationTapBackground(NotificationResponse notificationResponse) {
// TODO: handle action
}
// ignore: unreachable_from_main
class LocalNotificationService with InfraLogger implements NotificationService {
LocalNotificationService(this.flutterLocalNotificationsPlugin);
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin;
String? payload;
@override
Future<void> init() async {
loggy.debug('initializing');
const initializationSettings = InitializationSettings(
android: AndroidInitializationSettings('mipmap/ic_launcher'),
);
await _initDetails();
await _initChannels();
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: onDidReceiveNotificationResponse,
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
);
}
Future<void> _initDetails() async {
if (kIsWeb || Platform.isLinux) return;
final initialDetails =
await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails();
if (initialDetails?.didNotificationLaunchApp ?? false) {
payload = initialDetails!.notificationResponse?.payload;
loggy.debug('app launched from notification, payload: $payload');
// TODO: use payload
}
}
Future<void> _initChannels() async {
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(mainChannel);
}
@override
void onDidReceiveNotificationResponse(
NotificationResponse notificationResponse,
) {
// TODO: complete
loggy.debug('received notification response, $notificationResponse');
}
@override
Future<void> showNotification({
required int id,
required String title,
String? body,
NotificationDetails? details,
String? payload,
}) async {
loggy.debug('showing notification');
await flutterLocalNotificationsPlugin.show(
id,
title,
body,
details ??
NotificationDetails(
android: AndroidNotificationDetails(
mainChannel.id,
mainChannel.name,
),
),
payload: payload,
);
}
@override
Future<void> removeNotification(int id) async {
loggy.debug('removing notification');
await flutterLocalNotificationsPlugin.cancel(id);
}
@override
Future<bool> grantPermission() async {
final result = await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestPermission();
return result ?? false;
}
}

View File

@@ -0,0 +1 @@
export 'notification_service.dart';

View File

@@ -0,0 +1,30 @@
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hiddify/services/notification/local_notification_service.dart';
import 'package:hiddify/services/notification/stub_notification_service.dart';
abstract class NotificationService {
factory NotificationService() {
if (Platform.isWindows) return StubNotificationService();
return LocalNotificationService(FlutterLocalNotificationsPlugin());
}
Future<void> init();
void onDidReceiveNotificationResponse(
NotificationResponse notificationResponse,
);
Future<bool> grantPermission();
Future<void> showNotification({
required int id,
required String title,
String? body,
NotificationDetails? details,
String? payload,
});
Future<void> removeNotification(int id);
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hiddify/services/notification/notification_service.dart';
class StubNotificationService implements NotificationService {
@override
Future<void> init() async {
return;
}
@override
void onDidReceiveNotificationResponse(
NotificationResponse notificationResponse,
) {}
@override
Future<void> removeNotification(int id) async {}
@override
Future<void> showNotification({
required int id,
required String title,
String? body,
NotificationDetails? details,
String? payload,
}) async {}
@override
Future<bool> grantPermission() async {
return true;
}
}

View File

@@ -0,0 +1,31 @@
import 'package:hiddify/services/clash/clash.dart';
import 'package:hiddify/services/connectivity/connectivity.dart';
import 'package:hiddify/services/files_editor_service.dart';
import 'package:hiddify/services/notification/notification.dart';
import 'package:hiddify/services/window_manager_service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'service_providers.g.dart';
@Riverpod(keepAlive: true)
NotificationService notificationService(NotificationServiceRef ref) =>
NotificationService();
@Riverpod(keepAlive: true)
FilesEditorService filesEditorService(FilesEditorServiceRef ref) =>
FilesEditorService();
@Riverpod(keepAlive: true)
WindowManagerService windowManagerService(WindowManagerServiceRef ref) =>
WindowManagerService();
@Riverpod(keepAlive: true)
ConnectivityService connectivityService(ConnectivityServiceRef ref) =>
ConnectivityService(
ref.watch(notificationServiceProvider),
);
@Riverpod(keepAlive: true)
ClashService clashService(ClashServiceRef ref) => ClashServiceImpl(
filesEditor: ref.read(filesEditorServiceProvider),
);

View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
// TODO: rewrite
class WindowManagerService with WindowListener {
Future<void> init() async {
await windowManager.ensureInitialized();
const windowOptions = WindowOptions(
size: Size(868, 768),
minimumSize: Size(868, 648),
center: true,
);
await windowManager.waitUntilReadyToShow(windowOptions);
windowManager.addListener(this);
}
void dispose() {
windowManager.removeListener(this);
}
}