Migrate to singbox

This commit is contained in:
problematicconsumer
2023-08-19 22:27:23 +03:30
parent 14369d0a03
commit 684acc555d
124 changed files with 3408 additions and 2047 deletions

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

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

View File

@@ -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();
}

View File

@@ -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>,
),
);
},
);
}
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}
}

View File

@@ -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),
);

View 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);
},
);
}
}

View 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;
},
);
}
}

View 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);
}