This commit is contained in:
problematicconsumer
2023-12-01 12:56:24 +03:30
parent 9c165e178b
commit ed614988a2
181 changed files with 3092 additions and 2341 deletions

View File

@@ -0,0 +1,68 @@
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/utils/platform_utils.dart';
import 'package:json_annotation/json_annotation.dart';
enum ServiceMode {
proxy,
systemProxy,
tun;
static ServiceMode get defaultMode =>
PlatformUtils.isDesktop ? systemProxy : tun;
static List<ServiceMode> get choices {
if (PlatformUtils.isDesktop) {
return values;
}
return [proxy, tun];
}
String present(TranslationsEn t) => switch (this) {
proxy => t.settings.config.serviceModes.proxy,
systemProxy => t.settings.config.serviceModes.systemProxy,
tun => t.settings.config.serviceModes.tun,
};
}
@JsonEnum(valueField: 'key')
enum IPv6Mode {
disable("ipv4_only"),
enable("prefer_ipv4"),
prefer("prefer_ipv6"),
only("ipv6_only");
const IPv6Mode(this.key);
final String key;
String present(TranslationsEn t) => switch (this) {
disable => t.settings.config.ipv6Modes.disable,
enable => t.settings.config.ipv6Modes.enable,
prefer => t.settings.config.ipv6Modes.prefer,
only => t.settings.config.ipv6Modes.only,
};
}
@JsonEnum(valueField: 'key')
enum DomainStrategy {
auto(""),
preferIpv6("prefer_ipv6"),
preferIpv4("prefer_ipv4"),
ipv4Only("ipv4_only"),
ipv6Only("ipv6_only");
const DomainStrategy(this.key);
final String key;
String get displayName => switch (this) {
auto => "auto",
_ => key,
};
}
enum TunImplementation {
mixed,
system,
gVisor;
}

View File

@@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/features/log/model/log_level.dart';
import 'package:hiddify/singbox/model/singbox_config_enum.dart';
import 'package:hiddify/singbox/model/singbox_rule.dart';
part 'singbox_config_option.freezed.dart';
part 'singbox_config_option.g.dart';
@freezed
class SingboxConfigOption with _$SingboxConfigOption {
const SingboxConfigOption._();
@JsonSerializable(fieldRename: FieldRename.kebab)
const factory SingboxConfigOption({
required bool executeConfigAsIs,
required LogLevel logLevel,
required bool resolveDestination,
required IPv6Mode ipv6Mode,
required String remoteDnsAddress,
required DomainStrategy remoteDnsDomainStrategy,
required String directDnsAddress,
required DomainStrategy directDnsDomainStrategy,
required int mixedPort,
required int localDnsPort,
required TunImplementation tunImplementation,
required int mtu,
required bool strictRoute,
required String connectionTestUrl,
@IntervalConverter() required Duration urlTestInterval,
required bool enableClashApi,
required int clashApiPort,
required bool enableTun,
required bool setSystemProxy,
required bool bypassLan,
required bool enableFakeDns,
required bool independentDnsCache,
required String geoipPath,
required String geositePath,
required List<SingboxRule> rules,
}) = _SingboxConfigOption;
String format() {
const encoder = JsonEncoder.withIndent(' ');
return encoder.convert(toJson());
}
factory SingboxConfigOption.fromJson(Map<String, dynamic> json) =>
_$SingboxConfigOptionFromJson(json);
}
class IntervalConverter implements JsonConverter<Duration, String> {
const IntervalConverter();
@override
Duration fromJson(String json) =>
Duration(minutes: int.parse(json.replaceAll("m", "")));
@override
String toJson(Duration object) => "${object.inMinutes}m";
}

View File

@@ -0,0 +1,40 @@
import 'package:dartx/dartx.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/singbox/model/singbox_proxy_type.dart';
part 'singbox_outbound.freezed.dart';
part 'singbox_outbound.g.dart';
@freezed
class SingboxOutboundGroup with _$SingboxOutboundGroup {
@JsonSerializable(fieldRename: FieldRename.kebab)
const factory SingboxOutboundGroup({
required String tag,
@JsonKey(fromJson: _typeFromJson) required ProxyType type,
required String selected,
@Default([]) List<SingboxOutboundGroupItem> items,
}) = _SingboxOutboundGroup;
factory SingboxOutboundGroup.fromJson(Map<String, dynamic> json) =>
_$SingboxOutboundGroupFromJson(json);
}
@freezed
class SingboxOutboundGroupItem with _$SingboxOutboundGroupItem {
const SingboxOutboundGroupItem._();
@JsonSerializable(fieldRename: FieldRename.kebab)
const factory SingboxOutboundGroupItem({
required String tag,
@JsonKey(fromJson: _typeFromJson) required ProxyType type,
required int urlTestDelay,
}) = _SingboxOutboundGroupItem;
factory SingboxOutboundGroupItem.fromJson(Map<String, dynamic> json) =>
_$SingboxOutboundGroupItemFromJson(json);
}
ProxyType _typeFromJson(dynamic type) =>
ProxyType.values
.firstOrNullWhere((e) => e.key == (type as String?)?.toLowerCase()) ??
ProxyType.unknown;

View File

@@ -0,0 +1,35 @@
enum ProxyType {
direct("Direct"),
block("Block"),
dns("DNS"),
socks("SOCKS"),
http("HTTP"),
shadowsocks("Shadowsocks"),
vmess("VMess"),
trojan("Trojan"),
naive("Naive"),
wireguard("WireGuard"),
hysteria("Hysteria"),
tor("Tor"),
ssh("SSH"),
shadowtls("ShadowTLS"),
shadowsocksr("ShadowsocksR"),
vless("VLESS"),
tuic("TUIC"),
hysteria2("Hysteria2"),
selector("Selector"),
urltest("URLTest"),
unknown("Unknown");
const ProxyType(this.label);
final String label;
String get key => name;
static List<ProxyType> groupValues = [selector, urltest];
bool get isGroup => ProxyType.groupValues.contains(this);
}

View File

@@ -0,0 +1,35 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'singbox_rule.freezed.dart';
part 'singbox_rule.g.dart';
@freezed
class SingboxRule with _$SingboxRule {
const SingboxRule._();
@JsonSerializable(fieldRename: FieldRename.kebab)
const factory SingboxRule({
String? domains,
String? ip,
String? port,
String? protocol,
@Default(RuleNetwork.tcpAndUdp) RuleNetwork network,
@Default(RuleOutbound.proxy) RuleOutbound outbound,
}) = _SingboxRule;
factory SingboxRule.fromJson(Map<String, dynamic> json) =>
_$SingboxRuleFromJson(json);
}
enum RuleOutbound { proxy, bypass, block }
@JsonEnum(valueField: 'key')
enum RuleNetwork {
tcpAndUdp(""),
tcp("tcp"),
udp("udp");
const RuleNetwork(this.key);
final String? key;
}

View File

@@ -0,0 +1,22 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'singbox_stats.freezed.dart';
part 'singbox_stats.g.dart';
@freezed
class SingboxStats with _$SingboxStats {
const SingboxStats._();
@JsonSerializable(fieldRename: FieldRename.kebab)
const factory SingboxStats({
required int connectionsIn,
required int connectionsOut,
required int uplink,
required int downlink,
required int uplinkTotal,
required int downlinkTotal,
}) = _SingboxStats;
factory SingboxStats.fromJson(Map<String, dynamic> json) =>
_$SingboxStatsFromJson(json);
}

View File

@@ -0,0 +1,48 @@
import 'package:dartx/dartx.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'singbox_status.freezed.dart';
@freezed
sealed class SingboxStatus with _$SingboxStatus {
const SingboxStatus._();
const factory SingboxStatus.stopped({
SingboxAlert? alert,
String? message,
}) = SingboxStopped;
const factory SingboxStatus.starting() = SingboxStarting;
const factory SingboxStatus.started() = SingboxStarted;
const factory SingboxStatus.stopping() = SingboxStopping;
factory SingboxStatus.fromEvent(dynamic event) {
switch (event) {
case {
"status": "Stopped",
"alert": final String? alertStr,
"message": final String? messageStr,
}:
final alert = SingboxAlert.values.firstOrNullWhere(
(e) => alertStr?.toLowerCase() == e.name.toLowerCase(),
);
return SingboxStatus.stopped(alert: alert, message: messageStr);
case {"status": "Starting"}:
return const SingboxStarting();
case {"status": "Started"}:
return const SingboxStarted();
case {"status": "Stopping"}:
return const SingboxStopping();
default:
throw Exception("unexpected status [$event]");
}
}
}
enum SingboxAlert {
requestVPNPermission,
requestNotificationPermission,
emptyConfiguration,
startCommandServer,
createService,
startService;
}

View File

@@ -0,0 +1,388 @@
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/core/model/directories.dart';
import 'package:hiddify/gen/singbox_generated_bindings.dart';
import 'package:hiddify/singbox/model/singbox_config_option.dart';
import 'package:hiddify/singbox/model/singbox_outbound.dart';
import 'package:hiddify/singbox/model/singbox_stats.dart';
import 'package:hiddify/singbox/model/singbox_status.dart';
import 'package:hiddify/singbox/service/singbox_service.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:loggy/loggy.dart';
import 'package:path/path.dart' as p;
import 'package:rxdart/rxdart.dart';
import 'package:watcher/watcher.dart';
final _logger = Loggy('FFISingboxService');
class FFISingboxService with InfraLogger implements SingboxService {
static final SingboxNativeLibrary _box = _gen();
late final ValueStream<SingboxStatus> _status;
late final ReceivePort _statusReceiver;
Stream<SingboxStats>? _serviceStatsStream;
Stream<List<SingboxOutboundGroup>>? _outboundsStream;
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");
}
_logger.debug('singbox native libs path: "$fullPath"');
final lib = DynamicLibrary.open(fullPath);
return SingboxNativeLibrary(lib);
}
@override
Future<void> init() async {
loggy.debug("initializing");
_statusReceiver = ReceivePort('service status receiver');
final source = _statusReceiver
.asBroadcastStream()
.map((event) => jsonDecode(event as String))
.map(SingboxStatus.fromEvent);
_status = ValueConnectableStream.seeded(
source,
const SingboxStopped(),
).autoConnect();
}
@override
TaskEither<String, Unit> setup(
Directories directories,
bool debug,
) {
final port = _statusReceiver.sendPort.nativePort;
return TaskEither(
() => CombineWorker().execute(
() {
_box.setupOnce(NativeApi.initializeApiDLData);
final err = _box
.setup(
directories.baseDir.path.toNativeUtf8().cast(),
directories.workingDir.path.toNativeUtf8().cast(),
directories.tempDir.path.toNativeUtf8().cast(),
port,
debug ? 1 : 0,
)
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
return left(err);
}
return right(unit);
},
),
);
}
@override
TaskEither<String, Unit> validateConfigByPath(
String path,
String tempPath,
bool debug,
) {
return TaskEither(
() => CombineWorker().execute(
() {
final err = _box
.parse(
path.toNativeUtf8().cast(),
tempPath.toNativeUtf8().cast(),
debug ? 1 : 0,
)
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
return left(err);
}
return right(unit);
},
),
);
}
@override
TaskEither<String, Unit> changeOptions(SingboxConfigOption options) {
return TaskEither(
() => CombineWorker().execute(
() {
final json = jsonEncode(options.toJson());
final err = _box
.changeConfigOptions(json.toNativeUtf8().cast())
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
return left(err);
}
return right(unit);
},
),
);
}
@override
TaskEither<String, String> generateFullConfigByPath(
String path,
) {
return TaskEither(
() => CombineWorker().execute(
() {
final response = _box
.generateConfig(
path.toNativeUtf8().cast(),
)
.cast<Utf8>()
.toDartString();
if (response.startsWith("error")) {
return left(response.replaceFirst("error", ""));
}
return right(response);
},
),
);
}
@override
TaskEither<String, Unit> start(String configPath, bool disableMemoryLimit) {
loggy.debug("starting, memory limit: [${!disableMemoryLimit}]");
return TaskEither(
() => CombineWorker().execute(
() {
final err = _box
.start(
configPath.toNativeUtf8().cast(),
disableMemoryLimit ? 1 : 0,
)
.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
TaskEither<String, Unit> restart(String configPath, bool disableMemoryLimit) {
loggy.debug("restarting, memory limit: [${!disableMemoryLimit}]");
return TaskEither(
() => CombineWorker().execute(
() {
final err = _box
.restart(
configPath.toNativeUtf8().cast(),
disableMemoryLimit ? 1 : 0,
)
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
return left(err);
}
return right(unit);
},
),
);
}
@override
Stream<SingboxStatus> watchStatus() => _status;
@override
Stream<SingboxStats> watchStats() {
if (_serviceStatsStream != null) return _serviceStatsStream!;
final receiver = ReceivePort('service stats receiver');
final statusStream = receiver.asBroadcastStream(
onCancel: (_) {
_logger.debug("stopping stats command client");
final err = _box.stopCommandClient(1).cast<Utf8>().toDartString();
if (err.isNotEmpty) {
_logger.error("error stopping stats client");
}
receiver.close();
_serviceStatsStream = null;
},
).map(
(event) {
if (event case String _) {
if (event.startsWith('error:')) {
loggy.error("[service stats client] error received: $event");
throw event.replaceFirst('error:', "");
}
return SingboxStats.fromJson(
jsonDecode(event) as Map<String, dynamic>,
);
}
loggy.error("[service status client] unexpected type, msg: $event");
throw "invalid type";
},
);
final err = _box
.startCommandClient(1, receiver.sendPort.nativePort)
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
loggy.error("error starting status command: $err");
throw err;
}
return _serviceStatsStream = statusStream;
}
@override
Stream<List<SingboxOutboundGroup>> watchOutbounds() {
if (_outboundsStream != null) return _outboundsStream!;
final receiver = ReceivePort('outbounds receiver');
final outboundsStream = receiver.asBroadcastStream(
onCancel: (_) {
_logger.debug("stopping group command client");
final err = _box.stopCommandClient(4).cast<Utf8>().toDartString();
if (err.isNotEmpty) {
_logger.error("error stopping group client");
}
receiver.close();
_outboundsStream = null;
},
).map(
(event) {
if (event case String _) {
if (event.startsWith('error:')) {
loggy.error("[group client] error received: $event");
throw event.replaceFirst('error:', "");
}
return (jsonDecode(event) as List).map((e) {
return SingboxOutboundGroup.fromJson(e as Map<String, dynamic>);
}).toList();
}
loggy.error("[group client] unexpected type, msg: $event");
throw "invalid type";
},
);
final err = _box
.startCommandClient(4, receiver.sendPort.nativePort)
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
loggy.error("error starting group command: $err");
throw err;
}
return _outboundsStream = outboundsStream;
}
@override
TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag) {
return TaskEither(
() => CombineWorker().execute(
() {
final err = _box
.selectOutbound(
groupTag.toNativeUtf8().cast(),
outboundTag.toNativeUtf8().cast(),
)
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
return left(err);
}
return right(unit);
},
),
);
}
@override
TaskEither<String, Unit> urlTest(String groupTag) {
return TaskEither(
() => CombineWorker().execute(
() {
final err = _box
.urlTest(groupTag.toNativeUtf8().cast())
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
return left(err);
}
return right(unit);
},
),
);
}
final _logBuffer = <String>[];
int _logFilePosition = 0;
@override
Stream<List<String>> watchLogs(String path) async* {
yield await _readLogFile(File(path));
yield* Watcher(path, pollingDelay: const Duration(seconds: 1))
.events
.asyncMap((event) async {
if (event.type == ChangeType.MODIFY) {
await _readLogFile(File(path));
}
return _logBuffer;
});
}
@override
TaskEither<String, Unit> clearLogs() {
return TaskEither(
() async {
_logBuffer.clear();
return right(unit);
},
);
}
Future<List<String>> _readLogFile(File file) async {
if (_logFilePosition == 0 && file.lengthSync() == 0) return [];
final content =
await file.openRead(_logFilePosition).transform(utf8.decoder).join();
_logFilePosition = file.lengthSync();
final lines = const LineSplitter().convert(content);
if (lines.length > 300) {
lines.removeRange(0, lines.length - 300);
}
for (final line in lines) {
_logBuffer.add(line);
if (_logBuffer.length > 300) {
_logBuffer.removeAt(0);
}
}
return _logBuffer;
}
}

View File

@@ -0,0 +1,205 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/core/model/directories.dart';
import 'package:hiddify/singbox/model/singbox_config_option.dart';
import 'package:hiddify/singbox/model/singbox_outbound.dart';
import 'package:hiddify/singbox/model/singbox_stats.dart';
import 'package:hiddify/singbox/model/singbox_status.dart';
import 'package:hiddify/singbox/service/singbox_service.dart';
import 'package:hiddify/utils/custom_loggers.dart';
import 'package:rxdart/rxdart.dart';
class PlatformSingboxService with InfraLogger implements SingboxService {
late final _methodChannel = const MethodChannel("com.hiddify.app/method");
late final _statusChannel =
const EventChannel("com.hiddify.app/service.status");
late final _alertsChannel =
const EventChannel("com.hiddify.app/service.alerts");
late final _logsChannel = const EventChannel("com.hiddify.app/service.logs");
late final ValueStream<SingboxStatus> _status;
@override
Future<void> init() async {
loggy.debug("initializing");
final status = _statusChannel.receiveBroadcastStream().map(
(event) {
return SingboxStatus.fromEvent(event);
},
);
final alerts = _alertsChannel.receiveBroadcastStream().map(
(event) {
return SingboxStatus.fromEvent(event);
},
);
_status = ValueConnectableStream(Rx.merge([status, alerts])).autoConnect();
await _status.first;
}
@override
TaskEither<String, Unit> setup(
Directories directories,
bool debug,
) =>
TaskEither.of(unit);
@override
TaskEither<String, Unit> validateConfigByPath(
String path,
String tempPath,
bool debug,
) {
return TaskEither(
() async {
final message = await _methodChannel.invokeMethod<String>(
"parse_config",
{"path": path, "tempPath": tempPath, "debug": debug},
);
if (message == null || message.isEmpty) return right(unit);
return left(message);
},
);
}
@override
TaskEither<String, Unit> changeOptions(SingboxConfigOption options) {
return TaskEither(
() async {
await _methodChannel.invokeMethod(
"change_config_options",
jsonEncode(options.toJson()),
);
return right(unit);
},
);
}
@override
TaskEither<String, String> generateFullConfigByPath(
String path,
) {
return TaskEither(
() async {
final configJson = await _methodChannel.invokeMethod<String>(
"generate_config",
{"path": path},
);
if (configJson == null || configJson.isEmpty) {
return left("null response");
}
return right(configJson);
},
);
}
@override
TaskEither<String, Unit> start(String path, bool disableMemoryLimit) {
return TaskEither(
() async {
loggy.debug("starting");
await _methodChannel.invokeMethod(
"start",
{"path": path},
);
return right(unit);
},
);
}
@override
TaskEither<String, Unit> stop() {
return TaskEither(
() async {
loggy.debug("stopping");
await _methodChannel.invokeMethod("stop");
return right(unit);
},
);
}
@override
TaskEither<String, Unit> restart(String path, bool disableMemoryLimit) {
return TaskEither(
() async {
loggy.debug("restarting");
await _methodChannel.invokeMethod(
"restart",
{"path": path},
);
return right(unit);
},
);
}
@override
Stream<List<SingboxOutboundGroup>> watchOutbounds() {
const channel = EventChannel("com.hiddify.app/groups");
loggy.debug("watching outbounds");
return channel.receiveBroadcastStream().map(
(event) {
if (event case String _) {
return (jsonDecode(event) as List).map((e) {
return SingboxOutboundGroup.fromJson(e as Map<String, dynamic>);
}).toList();
}
loggy.error("[group client] unexpected type, msg: $event");
throw "invalid type";
},
);
}
@override
Stream<SingboxStatus> watchStatus() => _status;
@override
Stream<SingboxStats> watchStats() {
// TODO: implement watchStats
return const Stream.empty();
}
@override
TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag) {
return TaskEither(
() async {
loggy.debug("selecting outbound");
await _methodChannel.invokeMethod(
"select_outbound",
{"groupTag": groupTag, "outboundTag": outboundTag},
);
return right(unit);
},
);
}
@override
TaskEither<String, Unit> urlTest(String groupTag) {
return TaskEither(
() async {
await _methodChannel.invokeMethod(
"url_test",
{"groupTag": groupTag},
);
return right(unit);
},
);
}
@override
Stream<List<String>> watchLogs(String path) async* {
yield* _logsChannel
.receiveBroadcastStream()
.map((event) => (event as List).map((e) => e as String).toList());
}
@override
TaskEither<String, Unit> clearLogs() {
return TaskEither(
() async {
await _methodChannel.invokeMethod("clear_logs");
return right(unit);
},
);
}
}

View File

@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/core/model/directories.dart';
import 'package:hiddify/singbox/model/singbox_config_option.dart';
import 'package:hiddify/singbox/model/singbox_outbound.dart';
import 'package:hiddify/singbox/model/singbox_stats.dart';
import 'package:hiddify/singbox/model/singbox_status.dart';
import 'package:hiddify/singbox/service/ffi_singbox_service.dart';
import 'package:hiddify/singbox/service/platform_singbox_service.dart';
abstract interface class SingboxService {
factory SingboxService() {
if (Platform.isAndroid || Platform.isIOS) {
return PlatformSingboxService();
} else if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
return FFISingboxService();
}
throw Exception("unsupported platform");
}
Future<void> init();
TaskEither<String, Unit> setup(
Directories directories,
bool debug,
);
TaskEither<String, Unit> validateConfigByPath(
String path,
String tempPath,
bool debug,
);
TaskEither<String, Unit> changeOptions(SingboxConfigOption options);
TaskEither<String, String> generateFullConfigByPath(
String path,
);
TaskEither<String, Unit> start(String path, bool disableMemoryLimit);
TaskEither<String, Unit> stop();
TaskEither<String, Unit> restart(String path, bool disableMemoryLimit);
Stream<List<SingboxOutboundGroup>> watchOutbounds();
TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag);
TaskEither<String, Unit> urlTest(String groupTag);
Stream<SingboxStatus> watchStatus();
Stream<SingboxStats> watchStats();
Stream<List<String>> watchLogs(String path);
TaskEither<String, Unit> clearLogs();
}

View File

@@ -0,0 +1,9 @@
import 'package:hiddify/singbox/service/singbox_service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'singbox_service_provider.g.dart';
@Riverpod(keepAlive: true)
SingboxService singboxService(SingboxServiceRef ref) {
return SingboxService();
}