Migrate to singbox
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export 'clash_service.dart';
|
||||
export 'clash_service_impl.dart';
|
||||
@@ -1,35 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
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>,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,26 @@
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
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/services/singbox/singbox_service.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
abstract class ConnectivityService {
|
||||
factory ConnectivityService(NotificationService notification) {
|
||||
if (PlatformUtils.isDesktop) return DesktopConnectivityService();
|
||||
return MobileConnectivityService(notification);
|
||||
factory ConnectivityService(
|
||||
SingboxService singboxService,
|
||||
NotificationService notificationService,
|
||||
) {
|
||||
if (PlatformUtils.isDesktop) {
|
||||
return DesktopConnectivityService(singboxService);
|
||||
}
|
||||
return MobileConnectivityService(singboxService, notificationService);
|
||||
}
|
||||
|
||||
Future<void> init();
|
||||
|
||||
// TODO: use declarative states
|
||||
Stream<bool> watchConnectionStatus();
|
||||
Stream<ConnectionStatus> watchConnectionStatus();
|
||||
|
||||
// TODO: remove
|
||||
Future<bool> grantVpnPermission();
|
||||
|
||||
Future<void> connect({
|
||||
required int httpPort,
|
||||
required int socksPort,
|
||||
bool systemProxy = true,
|
||||
});
|
||||
Future<void> connect();
|
||||
|
||||
Future<void> disconnect();
|
||||
}
|
||||
|
||||
@@ -1,64 +1,49 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hiddify/domain/constants.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/services/connectivity/connectivity_service.dart';
|
||||
import 'package:hiddify/services/singbox/singbox_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();
|
||||
DesktopConnectivityService(this._singboxService);
|
||||
|
||||
final _connectionStatus = BehaviorSubject.seeded(false);
|
||||
final SingboxService _singboxService;
|
||||
|
||||
late final BehaviorSubject<ConnectionStatus> _connectionStatus;
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
Stream<bool> watchConnectionStatus() {
|
||||
return _connectionStatus;
|
||||
Future<void> init() async {
|
||||
loggy.debug("initializing");
|
||||
_connectionStatus =
|
||||
BehaviorSubject.seeded(const ConnectionStatus.disconnected());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> grantVpnPermission() async => true;
|
||||
Stream<ConnectionStatus> watchConnectionStatus() => _connectionStatus;
|
||||
|
||||
@override
|
||||
Future<void> connect({
|
||||
required int httpPort,
|
||||
required int socksPort,
|
||||
bool systemProxy = true,
|
||||
}) async {
|
||||
Future<void> connect() 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;
|
||||
_connectionStatus.value = const ConnectionStatus.connecting();
|
||||
await _singboxService.start().getOrElse(
|
||||
(l) {
|
||||
_connectionStatus.value = const ConnectionStatus.disconnected();
|
||||
throw l;
|
||||
},
|
||||
).run();
|
||||
_connectionStatus.value = const ConnectionStatus.connected();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
loggy.debug("disconnecting");
|
||||
await _proxyManager.cleanSystemProxy();
|
||||
_connectionStatus.value = false;
|
||||
_connectionStatus.value = const ConnectionStatus.disconnecting();
|
||||
await _singboxService.stop().getOrElse((l) {
|
||||
_connectionStatus.value = const ConnectionStatus.connected();
|
||||
throw l;
|
||||
}).run();
|
||||
_connectionStatus.value = const ConnectionStatus.disconnected();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity_failure.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/domain/core_service_failure.dart';
|
||||
import 'package:hiddify/services/connectivity/connectivity_service.dart';
|
||||
import 'package:hiddify/services/notification/notification.dart';
|
||||
import 'package:hiddify/services/singbox/singbox_service.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
@@ -9,78 +11,84 @@ import 'package:rxdart/rxdart.dart';
|
||||
class MobileConnectivityService
|
||||
with InfraLogger
|
||||
implements ConnectivityService {
|
||||
MobileConnectivityService(this._notificationService);
|
||||
MobileConnectivityService(this.singbox, this.notifications);
|
||||
|
||||
final NotificationService _notificationService;
|
||||
final SingboxService singbox;
|
||||
final NotificationService notifications;
|
||||
|
||||
static const _methodChannel = MethodChannel("Hiddify/VpnService");
|
||||
static const _eventChannel = EventChannel("Hiddify/VpnServiceEvents");
|
||||
late final EventChannel _statusChannel;
|
||||
late final EventChannel _alertsChannel;
|
||||
late final ValueStream<ConnectionStatus> _connectionStatus;
|
||||
|
||||
final _connectionStatus = ValueConnectableStream(
|
||||
_eventChannel.receiveBroadcastStream().map((event) => event as bool),
|
||||
).autoConnect();
|
||||
static CoreServiceFailure fromServiceAlert(String key, String? message) {
|
||||
return switch (key) {
|
||||
"EmptyConfiguration" => InvalidConfig(message),
|
||||
"StartCommandServer" ||
|
||||
"CreateService" =>
|
||||
CoreServiceCreateFailure(message),
|
||||
"StartService" => CoreServiceStartFailure(message),
|
||||
_ => const CoreServiceOtherFailure(),
|
||||
};
|
||||
}
|
||||
|
||||
static ConnectionStatus fromServiceEvent(dynamic event) {
|
||||
final status = event['status'] as String;
|
||||
late ConnectionStatus connectionStatus;
|
||||
switch (status) {
|
||||
case "Stopped":
|
||||
final failure = event["failure"] as String?;
|
||||
final message = event["message"] as String?;
|
||||
connectionStatus = ConnectionStatus.disconnected(
|
||||
switch (failure) {
|
||||
null => null,
|
||||
"RequestVPNPermission" => MissingVpnPermission(message),
|
||||
"RequestNotificationPermission" =>
|
||||
MissingNotificationPermission(message),
|
||||
"EmptyConfiguration" ||
|
||||
"StartCommandServer" ||
|
||||
"CreateService" ||
|
||||
"StartService" =>
|
||||
CoreConnectionFailure(fromServiceAlert(failure, message)),
|
||||
_ => const UnexpectedConnectionFailure(),
|
||||
},
|
||||
);
|
||||
case "Starting":
|
||||
connectionStatus = const Connecting();
|
||||
case "Started":
|
||||
connectionStatus = const Connected();
|
||||
case "Stopping":
|
||||
connectionStatus = const Disconnecting();
|
||||
}
|
||||
return connectionStatus;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
loggy.debug("initializing");
|
||||
final initialStatus = _connectionStatus.first;
|
||||
await _methodChannel.invokeMethod("refresh_status");
|
||||
await initialStatus;
|
||||
_statusChannel = const EventChannel("com.hiddify.app/service.status");
|
||||
_alertsChannel = const EventChannel("com.hiddify.app/service.alerts");
|
||||
final status =
|
||||
_statusChannel.receiveBroadcastStream().map(fromServiceEvent);
|
||||
final alerts =
|
||||
_alertsChannel.receiveBroadcastStream().map(fromServiceEvent);
|
||||
_connectionStatus =
|
||||
ValueConnectableStream(Rx.merge([status, alerts])).autoConnect();
|
||||
await _connectionStatus.first;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<bool> watchConnectionStatus() {
|
||||
return _connectionStatus;
|
||||
}
|
||||
Stream<ConnectionStatus> watchConnectionStatus() => _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 {
|
||||
Future<void> connect() 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");
|
||||
await notifications.grantPermission();
|
||||
await singbox.start().getOrElse((l) => throw l).run();
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
await singbox.stop().getOrElse((l) => throw l).run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,60 +8,89 @@ 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;
|
||||
late final Directory baseDir;
|
||||
late final Directory workingDir;
|
||||
late final Directory tempDir;
|
||||
late final Directory _configsDir;
|
||||
|
||||
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);
|
||||
baseDir = await getApplicationSupportDirectory();
|
||||
if (Platform.isAndroid) {
|
||||
final externalDir = await getExternalStorageDirectory();
|
||||
workingDir = externalDir!;
|
||||
} else if (Platform.isWindows) {
|
||||
workingDir = baseDir;
|
||||
} else {
|
||||
workingDir = await getApplicationDocumentsDirectory();
|
||||
}
|
||||
if (!await File(countryMMDBPath).exists()) {
|
||||
await _populateDefaultCountryMMDB();
|
||||
tempDir = await getTemporaryDirectory();
|
||||
|
||||
loggy.debug("base dir: ${baseDir.path}");
|
||||
loggy.debug("working dir: ${workingDir.path}");
|
||||
loggy.debug("temp dir: ${tempDir.path}");
|
||||
|
||||
_configsDir =
|
||||
Directory(p.join(workingDir.path, Constants.configsFolderName));
|
||||
if (!await baseDir.exists()) {
|
||||
await baseDir.create(recursive: true);
|
||||
}
|
||||
if (!await workingDir.exists()) {
|
||||
await workingDir.create(recursive: true);
|
||||
}
|
||||
if (!await _configsDir.exists()) {
|
||||
await _configsDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final appLogFile = File(appLogsPath);
|
||||
if (await appLogFile.exists()) {
|
||||
await appLogFile.writeAsString("");
|
||||
} else {
|
||||
await appLogFile.create(recursive: true);
|
||||
}
|
||||
|
||||
await _populateGeoAssets();
|
||||
if (PlatformUtils.isDesktop) {
|
||||
final logFile = File(logsPath);
|
||||
if (await logFile.exists()) {
|
||||
await logFile.writeAsString("");
|
||||
} else {
|
||||
await logFile.create(recursive: true);
|
||||
}
|
||||
}
|
||||
if (!await File(defaultConfigPath).exists()) await _populateDefaultConfig();
|
||||
}
|
||||
|
||||
String get clashDirPath => _clashDirectory.path;
|
||||
static Future<Directory> getDatabaseDirectory() async {
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
return getLibraryDirectory();
|
||||
} else if (Platform.isWindows) {
|
||||
return getApplicationSupportDirectory();
|
||||
}
|
||||
return getApplicationDocumentsDirectory();
|
||||
}
|
||||
|
||||
late final logsPath = p.join(
|
||||
_logsDirectory.path,
|
||||
"${DateTime.now().toUtc().toIso8601String().split('T').first}.txt",
|
||||
);
|
||||
|
||||
String get defaultConfigPath => configPath("config");
|
||||
String get appLogsPath => p.join(workingDir.path, "app.log");
|
||||
String get logsPath => p.join(workingDir.path, "box.log");
|
||||
|
||||
String configPath(String fileName) {
|
||||
return p.join(_clashDirectory.path, "$fileName.yaml");
|
||||
return p.join(_configsDir.path, "$fileName.json");
|
||||
}
|
||||
|
||||
Future<void> deleteConfig(String fileName) {
|
||||
return File(configPath(fileName)).delete();
|
||||
}
|
||||
|
||||
String get countryMMDBPath {
|
||||
return p.join(
|
||||
_clashDirectory.path,
|
||||
"${Constants.countryMMDBFileName}.mmdb",
|
||||
);
|
||||
}
|
||||
Future<void> _populateGeoAssets() async {
|
||||
loggy.debug('populating geo assets');
|
||||
final geoipPath = p.join(workingDir.path, Constants.geoipFileName);
|
||||
if (!await File(geoipPath).exists()) {
|
||||
final defaultGeoip = await rootBundle.load(Assets.core.geoip);
|
||||
await File(geoipPath).writeAsBytes(defaultGeoip.buffer.asInt8List());
|
||||
}
|
||||
|
||||
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());
|
||||
final geositePath = p.join(workingDir.path, Constants.geositeFileName);
|
||||
if (!await File(geositePath).exists()) {
|
||||
final defaultGeosite = await rootBundle.load(Assets.core.geosite);
|
||||
await File(geositePath).writeAsBytes(defaultGeosite.buffer.asInt8List());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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/singbox/singbox_service.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'service_providers.g.dart';
|
||||
@@ -15,12 +15,11 @@ FilesEditorService filesEditorService(FilesEditorServiceRef ref) =>
|
||||
FilesEditorService();
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ConnectivityService connectivityService(ConnectivityServiceRef ref) =>
|
||||
ConnectivityService(
|
||||
ref.watch(notificationServiceProvider),
|
||||
);
|
||||
SingboxService singboxService(SingboxServiceRef ref) => SingboxService();
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ClashService clashService(ClashServiceRef ref) => ClashServiceImpl(
|
||||
filesEditor: ref.read(filesEditorServiceProvider),
|
||||
ConnectivityService connectivityService(ConnectivityServiceRef ref) =>
|
||||
ConnectivityService(
|
||||
ref.watch(singboxServiceProvider),
|
||||
ref.watch(notificationServiceProvider),
|
||||
);
|
||||
|
||||
149
lib/services/singbox/ffi_singbox_service.dart
Normal file
149
lib/services/singbox/ffi_singbox_service.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:combine/combine.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/gen/singbox_generated_bindings.dart';
|
||||
import 'package:hiddify/services/singbox/singbox_service.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class FFISingboxService with InfraLogger implements SingboxService {
|
||||
static final SingboxNativeLibrary _box = _gen();
|
||||
|
||||
static SingboxNativeLibrary _gen() {
|
||||
String fullPath = "";
|
||||
if (Platform.environment.containsKey('FLUTTER_TEST')) {
|
||||
fullPath = "libcore";
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
fullPath = p.join(fullPath, "libcore.dll");
|
||||
} else if (Platform.isMacOS) {
|
||||
fullPath = p.join(fullPath, "libcore.dylib");
|
||||
} else {
|
||||
fullPath = p.join(fullPath, "libcore.so");
|
||||
}
|
||||
debugPrint('singbox native libs path: "$fullPath"');
|
||||
final lib = DynamicLibrary.open(fullPath);
|
||||
return SingboxNativeLibrary(lib);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> setup(
|
||||
String baseDir,
|
||||
String workingDir,
|
||||
String tempDir,
|
||||
) {
|
||||
return TaskEither(
|
||||
() => CombineWorker().execute(
|
||||
() {
|
||||
_box.setup(
|
||||
baseDir.toNativeUtf8().cast(),
|
||||
workingDir.toNativeUtf8().cast(),
|
||||
tempDir.toNativeUtf8().cast(),
|
||||
);
|
||||
return right(unit);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> parseConfig(String path) {
|
||||
return TaskEither(
|
||||
() => CombineWorker().execute(
|
||||
() {
|
||||
final err = _box
|
||||
.parse(path.toNativeUtf8().cast())
|
||||
.cast<Utf8>()
|
||||
.toDartString();
|
||||
if (err.isNotEmpty) {
|
||||
return left(err);
|
||||
}
|
||||
return right(unit);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> create(String configPath) {
|
||||
return TaskEither(
|
||||
() => CombineWorker().execute(
|
||||
() {
|
||||
final err = _box
|
||||
.create(configPath.toNativeUtf8().cast())
|
||||
.cast<Utf8>()
|
||||
.toDartString();
|
||||
if (err.isNotEmpty) {
|
||||
return left(err);
|
||||
}
|
||||
return right(unit);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> start() {
|
||||
return TaskEither(
|
||||
() => CombineWorker().execute(
|
||||
() {
|
||||
final err = _box.start().cast<Utf8>().toDartString();
|
||||
if (err.isNotEmpty) {
|
||||
return left(err);
|
||||
}
|
||||
return right(unit);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> stop() {
|
||||
return TaskEither(
|
||||
() => CombineWorker().execute(
|
||||
() {
|
||||
final err = _box.stop().cast<Utf8>().toDartString();
|
||||
if (err.isNotEmpty) {
|
||||
return left(err);
|
||||
}
|
||||
return right(unit);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<String> watchLogs(String path) {
|
||||
var linesRead = 0;
|
||||
return Stream.periodic(
|
||||
const Duration(seconds: 1),
|
||||
).asyncMap((_) async {
|
||||
final result = await _readLogs(path, linesRead);
|
||||
linesRead = result.$2;
|
||||
return result.$1;
|
||||
}).transform(
|
||||
StreamTransformer.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
for (final item in data) {
|
||||
sink.add(item);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<(List<String>, int)> _readLogs(String path, int from) async {
|
||||
return CombineWorker().execute(
|
||||
() async {
|
||||
final lines = await File(path).readAsLines();
|
||||
final to = lines.length;
|
||||
return (lines.sublist(from), to);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
79
lib/services/singbox/mobile_singbox_service.dart
Normal file
79
lib/services/singbox/mobile_singbox_service.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/services/singbox/singbox_service.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
class MobileSingboxService with InfraLogger implements SingboxService {
|
||||
late final MethodChannel _methodChannel =
|
||||
const MethodChannel("com.hiddify.app/method");
|
||||
late final EventChannel _logsChannel =
|
||||
const EventChannel("com.hiddify.app/service.logs");
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> setup(
|
||||
String baseDir,
|
||||
String workingDir,
|
||||
String tempDir,
|
||||
) =>
|
||||
TaskEither.of(unit);
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> parseConfig(String path) {
|
||||
return TaskEither(
|
||||
() async {
|
||||
final message = await _methodChannel.invokeMethod<String>(
|
||||
"parse_config",
|
||||
{"path": path},
|
||||
);
|
||||
if (message == null || message.isEmpty) return right(unit);
|
||||
return left(message);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> create(String configPath) {
|
||||
return TaskEither(
|
||||
() async {
|
||||
loggy.debug("creating service for: $configPath");
|
||||
await _methodChannel.invokeMethod(
|
||||
"set_active_config_path",
|
||||
{"path": configPath},
|
||||
);
|
||||
return right(unit);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> start() {
|
||||
return TaskEither(
|
||||
() async {
|
||||
loggy.debug("starting");
|
||||
await _methodChannel.invokeMethod("start");
|
||||
return right(unit);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<String, Unit> stop() {
|
||||
return TaskEither(
|
||||
() async {
|
||||
loggy.debug("stopping");
|
||||
await _methodChannel.invokeMethod("stop");
|
||||
return right(unit);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<String> watchLogs(String path) {
|
||||
return _logsChannel.receiveBroadcastStream().map(
|
||||
(event) {
|
||||
loggy.debug("received log: $event");
|
||||
return event as String;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/services/singbox/singbox_service.dart
Normal file
30
lib/services/singbox/singbox_service.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/services/singbox/ffi_singbox_service.dart';
|
||||
import 'package:hiddify/services/singbox/mobile_singbox_service.dart';
|
||||
|
||||
abstract interface class SingboxService {
|
||||
factory SingboxService() {
|
||||
if (Platform.isAndroid) {
|
||||
return MobileSingboxService();
|
||||
}
|
||||
return FFISingboxService();
|
||||
}
|
||||
|
||||
TaskEither<String, Unit> setup(
|
||||
String baseDir,
|
||||
String workingDir,
|
||||
String tempDir,
|
||||
);
|
||||
|
||||
TaskEither<String, Unit> parseConfig(String path);
|
||||
|
||||
TaskEither<String, Unit> create(String configPath);
|
||||
|
||||
TaskEither<String, Unit> start();
|
||||
|
||||
TaskEither<String, Unit> stop();
|
||||
|
||||
Stream<String> watchLogs(String path);
|
||||
}
|
||||
Reference in New Issue
Block a user