import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:fpdart/fpdart.dart'; import 'package:umbrix/core/model/directories.dart'; import 'package:umbrix/singbox/model/singbox_config_option.dart'; import 'package:umbrix/singbox/model/singbox_outbound.dart'; import 'package:umbrix/singbox/model/singbox_stats.dart'; import 'package:umbrix/singbox/model/singbox_status.dart'; import 'package:umbrix/singbox/model/warp_account.dart'; import 'package:umbrix/singbox/service/singbox_service.dart'; import 'package:umbrix/utils/custom_loggers.dart'; import 'package:rxdart/rxdart.dart'; Map _normalizeOutboundGroupJson(Object? raw) { if (raw is Map) { if (raw.containsKey('tag')) return raw; if (raw.containsKey('a') && raw.containsKey('b')) { return { 'tag': raw['a'], 'type': raw['b'], 'selected': raw['c'] ?? '', 'items': (raw['d'] as List? ?? const []).map(_normalizeOutboundGroupItemJson).toList(), }; } } throw FormatException('Invalid outbound group payload: ${raw.runtimeType}'); } Map _normalizeOutboundGroupItemJson(Object? raw) { if (raw is Map) { if (raw.containsKey('tag')) return raw; if (raw.containsKey('a') && raw.containsKey('b')) { return { 'tag': raw['a'], 'type': raw['b'], 'url-test-delay': raw['c'] ?? 999999, }; } } throw FormatException('Invalid outbound group item payload: ${raw.runtimeType}'); } class PlatformSingboxService with InfraLogger implements SingboxService { static const channelPrefix = "com.umbrix.app"; static const methodChannel = MethodChannel("$channelPrefix/method"); static const statusChannel = EventChannel("$channelPrefix/service.status", JSONMethodCodec()); static const alertsChannel = EventChannel("$channelPrefix/service.alerts", JSONMethodCodec()); static const statsChannel = EventChannel("$channelPrefix/stats", JSONMethodCodec()); static const groupsChannel = EventChannel("$channelPrefix/groups"); static const activeGroupsChannel = EventChannel("$channelPrefix/active-groups"); static const logsChannel = EventChannel("$channelPrefix/service.logs"); late final ValueStream _status; @override Future init() async { loggy.debug("initializing"); final status = statusChannel.receiveBroadcastStream().map(SingboxStatus.fromEvent); final alerts = alertsChannel.receiveBroadcastStream().map(SingboxStatus.fromEvent); _status = ValueConnectableStream(Rx.merge([status, alerts])).autoConnect(); await _status.first; } @override TaskEither setup(Directories directories, bool debug) { return TaskEither( () async { if (!Platform.isIOS) { return right(unit); } await methodChannel.invokeMethod("setup"); return right(unit); }, ); } @override TaskEither validateConfigByPath( String path, String tempPath, bool debug, ) { return TaskEither( () async { final message = await methodChannel.invokeMethod( "parse_config", {"path": path, "tempPath": tempPath, "debug": debug}, ); if (message == null || message.isEmpty) return right(unit); return left(message); }, ); } @override TaskEither changeOptions(SingboxConfigOption options) { return TaskEither( () async { loggy.debug("changing options"); await methodChannel.invokeMethod( "change_options", jsonEncode(options.toJson()), ); return right(unit); }, ); } @override TaskEither generateFullConfigByPath(String path) { return TaskEither( () async { loggy.debug("generating full config by path"); final configJson = await methodChannel.invokeMethod( "generate_config", {"path": path}, ); if (configJson == null || configJson.isEmpty) { return left("null response"); } return right(configJson); }, ); } @override TaskEither start( String path, String name, bool disableMemoryLimit, ) { return TaskEither( () async { loggy.debug("starting"); await methodChannel.invokeMethod( "start", {"path": path, "name": name}, ); return right(unit); }, ); } @override TaskEither stop() { return TaskEither( () async { loggy.debug("stopping"); await methodChannel.invokeMethod("stop"); return right(unit); }, ); } @override TaskEither restart( String path, String name, bool disableMemoryLimit, ) { return TaskEither( () async { loggy.debug("restarting"); await methodChannel.invokeMethod( "restart", {"path": path, "name": name}, ); return right(unit); }, ); } @override TaskEither resetTunnel() { return TaskEither( () async { // only available on iOS (and macOS later) if (!Platform.isIOS) { throw UnimplementedError( "reset tunnel function unavailable on platform", ); } loggy.debug("resetting tunnel"); await methodChannel.invokeMethod("reset"); return right(unit); }, ); } @override Stream> watchGroups() { loggy.debug("watching groups"); return groupsChannel.receiveBroadcastStream().map( (event) { if (event case String _) { final decoded = jsonDecode(event) as List; return decoded.map((e) => SingboxOutboundGroup.fromJson(_normalizeOutboundGroupJson(e))).toList(); } loggy.error("[group client] unexpected type, msg: $event"); throw "invalid type"; }, ); } @override Stream> watchActiveGroups() { loggy.debug("watching active groups"); return activeGroupsChannel.receiveBroadcastStream().map( (event) { if (event case String _) { final decoded = jsonDecode(event) as List; return decoded.map((e) => SingboxOutboundGroup.fromJson(_normalizeOutboundGroupJson(e))).toList(); } loggy.error("[active group client] unexpected type, msg: $event"); throw "invalid type"; }, ); } @override Stream watchStatus() => _status; @override Stream watchStats() { loggy.debug("watching stats"); return statsChannel.receiveBroadcastStream().throttleTime(const Duration(milliseconds: 500)).map( (event) { if (event case Map _) { return SingboxStats.fromJson(event); } loggy.error( "[stats client] unexpected type(${event.runtimeType}), msg: $event", ); throw "invalid type"; }, ); } @override TaskEither 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 urlTest(String groupTag) { return TaskEither( () async { await methodChannel.invokeMethod( "url_test", {"groupTag": groupTag}, ); return right(unit); }, ); } @override Stream> watchLogs(String path) async* { yield* logsChannel.receiveBroadcastStream().map((event) => (event as List).map((e) => e as String).toList()); } @override TaskEither clearLogs() { return TaskEither( () async { await methodChannel.invokeMethod("clear_logs"); return right(unit); }, ); } @override TaskEither generateWarpConfig({ required String licenseKey, required String previousAccountId, required String previousAccessToken, }) { return TaskEither( () async { loggy.debug("generating warp config"); final warpConfig = await methodChannel.invokeMethod( "generate_warp_config", { "license-key": licenseKey, "previous-account-id": previousAccountId, "previous-access-token": previousAccessToken, }, ); return right(warpFromJson(jsonDecode(warpConfig as String))); }, ); } }