初始化提交

This commit is contained in:
2025-09-23 16:23:15 +08:00
commit 877b18a70f
590 changed files with 53617 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import 'package:fpdart/fpdart.dart';
import 'package:grpc/grpc.dart';
import 'package:kaer_with_panels/singbox/generated/core.pbgrpc.dart';
import 'package:kaer_with_panels/singbox/service/singbox_service.dart';
abstract class CoreSingboxService extends CoreServiceClient
implements SingboxService {
CoreSingboxService()
: super(
ClientChannel(
'localhost',
port: 7078,
options: const ChannelOptions(
credentials: ChannelCredentials.insecure(),
),
),
);
@override
TaskEither<String, Unit> validateConfigByPath(
String path,
String tempPath,
bool debug,
) {
return TaskEither(
() async {
final response = await parseConfig(
ParseConfigRequest(tempPath: tempPath, path: path, debug: false),
);
if (response.error != "") return left(response.error);
return right(unit);
},
);
}
@override
TaskEither<String, String> generateFullConfigByPath(String path) {
return TaskEither(
() async {
final response = await generateFullConfig(
GenerateConfigRequest(path: path, debug: false),
);
if (response.error != "") return left(response.error);
return right(response.config);
},
);
}
}
+489
View File
@@ -0,0 +1,489 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
// import 'package:combine/combine.dart'; // 暂时注释掉,使用 Isolate.run 替代
import 'package:ffi/ffi.dart';
import 'package:fpdart/fpdart.dart';
import 'package:kaer_with_panels/core/model/directories.dart';
import 'package:kaer_with_panels/gen/singbox_generated_bindings.dart';
import 'package:kaer_with_panels/singbox/model/singbox_config_option.dart';
import 'package:kaer_with_panels/singbox/model/singbox_outbound.dart';
import 'package:kaer_with_panels/singbox/model/singbox_stats.dart';
import 'package:kaer_with_panels/singbox/model/singbox_status.dart';
import 'package:kaer_with_panels/singbox/model/warp_account.dart';
import 'package:kaer_with_panels/singbox/service/singbox_service.dart';
import 'package:kaer_with_panels/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(
() => Isolate.run(
() {
_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(
() => Isolate.run(
() {
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(
() => Isolate.run(
() {
final json = jsonEncode(options.toJson());
final err = _box
.changeHiddifyOptions(json.toNativeUtf8().cast())
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
return left(err);
}
return right(unit);
},
),
);
}
@override
TaskEither<String, String> generateFullConfigByPath(
String path,
) {
return TaskEither(
() => Isolate.run(
() {
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,
String name,
bool disableMemoryLimit,
) {
loggy.debug("starting, memory limit: [${!disableMemoryLimit}]");
return TaskEither(
() => Isolate.run(
() {
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(
() => Isolate.run(
() {
final err = _box.stop().cast<Utf8>().toDartString();
if (err.isNotEmpty) {
return left(err);
}
return right(unit);
},
),
);
}
@override
TaskEither<String, Unit> restart(
String configPath,
String name,
bool disableMemoryLimit,
) {
loggy.debug("restarting, memory limit: [${!disableMemoryLimit}]");
return TaskEither(
() => Isolate.run(
() {
final err = _box
.restart(
configPath.toNativeUtf8().cast(),
disableMemoryLimit ? 1 : 0,
)
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
return left(err);
}
return right(unit);
},
),
);
}
@override
TaskEither<String, Unit> resetTunnel() {
throw UnimplementedError(
"reset tunnel function unavailable on platform",
);
}
@override
Stream<SingboxStatus> watchStatus() => _status;
@override
Stream<SingboxStats> watchStats() {
if (_serviceStatsStream != null) return _serviceStatsStream!;
final receiver = ReceivePort('stats');
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>> watchGroups() {
final logger = newLoggy("watchGroups");
if (_outboundsStream != null) return _outboundsStream!;
final receiver = ReceivePort('groups');
final outboundsStream = receiver.asBroadcastStream(
onCancel: (_) {
logger.debug("stopping");
receiver.close();
_outboundsStream = null;
final err = _box.stopCommandClient(5).cast<Utf8>().toDartString();
if (err.isNotEmpty) {
_logger.error("error stopping group client");
}
},
).map(
(event) {
if (event case String _) {
if (event.startsWith('error:')) {
logger.error("error received: $event");
throw event.replaceFirst('error:', "");
}
return (jsonDecode(event) as List).map((e) {
return SingboxOutboundGroup.fromJson(e as Map<String, dynamic>);
}).toList();
}
logger.error("unexpected type, msg: $event");
throw "invalid type";
},
);
try {
final err = _box
.startCommandClient(5, receiver.sendPort.nativePort)
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
logger.error("error starting group command: $err");
throw err;
}
} catch (e) {
receiver.close();
rethrow;
}
return _outboundsStream = outboundsStream;
}
@override
Stream<List<SingboxOutboundGroup>> watchActiveGroups() {
final logger = newLoggy("[ActiveGroupsClient]");
final receiver = ReceivePort('active groups');
final outboundsStream = receiver.asBroadcastStream(
onCancel: (_) {
logger.debug("stopping");
receiver.close();
final err = _box.stopCommandClient(13).cast<Utf8>().toDartString();
if (err.isNotEmpty) {
logger.error("failed stopping: $err");
}
},
).map(
(event) {
if (event case String _) {
if (event.startsWith('error:')) {
logger.error(event);
throw event.replaceFirst('error:', "");
}
return (jsonDecode(event) as List).map((e) {
return SingboxOutboundGroup.fromJson(e as Map<String, dynamic>);
}).toList();
}
logger.error("unexpected type, msg: $event");
throw "invalid type";
},
);
try {
final err = _box
.startCommandClient(13, receiver.sendPort.nativePort)
.cast<Utf8>()
.toDartString();
if (err.isNotEmpty) {
logger.error("error starting: $err");
throw err;
}
} catch (e) {
receiver.close();
rethrow;
}
return outboundsStream;
}
@override
TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag) {
return TaskEither(
() => Isolate.run(
() {
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(
() => Isolate.run(
() {
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(
() => Isolate.run(
() {
_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;
}
@override
TaskEither<String, WarpResponse> generateWarpConfig({
required String licenseKey,
required String previousAccountId,
required String previousAccessToken,
}) {
loggy.debug("generating warp config");
return TaskEither(
() => Isolate.run(
() {
final response = _box
.generateWarpConfig(
licenseKey.toNativeUtf8().cast(),
previousAccountId.toNativeUtf8().cast(),
previousAccessToken.toNativeUtf8().cast(),
)
.cast<Utf8>()
.toDartString();
if (response.startsWith("error:")) {
return left(response.replaceFirst('error:', ""));
}
return right(warpFromJson(jsonDecode(response)));
},
),
);
}
}
+289
View File
@@ -0,0 +1,289 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:fpdart/fpdart.dart';
import 'package:kaer_with_panels/core/model/directories.dart';
import 'package:kaer_with_panels/singbox/model/singbox_config_option.dart';
import 'package:kaer_with_panels/singbox/model/singbox_outbound.dart';
import 'package:kaer_with_panels/singbox/model/singbox_stats.dart';
import 'package:kaer_with_panels/singbox/model/singbox_status.dart';
import 'package:kaer_with_panels/singbox/model/warp_account.dart';
import 'package:kaer_with_panels/singbox/service/singbox_service.dart';
import 'package:kaer_with_panels/utils/custom_loggers.dart';
import 'package:rxdart/rxdart.dart';
class PlatformSingboxService with InfraLogger implements SingboxService {
static const channelPrefix = "com.baer.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<SingboxStatus> _status;
@override
Future<void> 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<String, Unit> setup(Directories directories, bool debug) {
return TaskEither(
() async {
if (!Platform.isIOS) {
return right(unit);
}
await methodChannel.invokeMethod("setup");
return right(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 {
loggy.debug("changing options");
await methodChannel.invokeMethod(
"change_hiddify_options",
jsonEncode(options.toJson()),
);
return right(unit);
},
);
}
@override
TaskEither<String, String> generateFullConfigByPath(String path) {
return TaskEither(
() async {
loggy.debug("generating full config by path");
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,
String name,
bool disableMemoryLimit,
) {
return TaskEither(
() async {
loggy.debug("starting");
await methodChannel.invokeMethod(
"start",
{"path": path, "name": name},
);
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,
String name,
bool disableMemoryLimit,
) {
return TaskEither(
() async {
loggy.debug("restarting");
await methodChannel.invokeMethod(
"restart",
{"path": path, "name": name},
);
return right(unit);
},
);
}
@override
TaskEither<String, Unit> 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<List<SingboxOutboundGroup>> watchGroups() {
loggy.debug("watching groups");
return groupsChannel.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<List<SingboxOutboundGroup>> watchActiveGroups() {
loggy.debug("watching active groups");
return activeGroupsChannel.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("[active group client] unexpected type, msg: $event");
throw "invalid type";
},
);
}
@override
Stream<SingboxStatus> watchStatus() => _status;
@override
Stream<SingboxStats> watchStats() {
loggy.debug("watching stats");
return statsChannel.receiveBroadcastStream().map(
(event) {
if (event case Map<String, dynamic> _) {
return SingboxStats.fromJson(event);
}
loggy.error(
"[stats client] unexpected type(${event.runtimeType}), msg: $event",
);
throw "invalid type";
},
);
}
@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);
},
);
}
@override
TaskEither<String, WarpResponse> 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)));
},
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'dart:io';
import 'package:fpdart/fpdart.dart';
import 'package:kaer_with_panels/core/model/directories.dart';
import 'package:kaer_with_panels/singbox/model/singbox_config_option.dart';
import 'package:kaer_with_panels/singbox/model/singbox_outbound.dart';
import 'package:kaer_with_panels/singbox/model/singbox_stats.dart';
import 'package:kaer_with_panels/singbox/model/singbox_status.dart';
import 'package:kaer_with_panels/singbox/model/warp_account.dart';
import 'package:kaer_with_panels/singbox/service/ffi_singbox_service.dart';
import 'package:kaer_with_panels/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();
/// setup directories and other initial platform services
TaskEither<String, Unit> setup(
Directories directories,
bool debug,
);
/// validates config by path and save it
///
/// [path] is used to save validated config
/// [tempPath] includes base config, possibly invalid
/// [debug] indicates if debug mode (avoid in prod)
TaskEither<String, Unit> validateConfigByPath(
String path,
String tempPath,
bool debug,
);
TaskEither<String, Unit> changeOptions(SingboxConfigOption options);
/// generates full sing-box configuration
///
/// [path] is the path to the base config file
/// returns full patched json config file as string
TaskEither<String, String> generateFullConfigByPath(String path);
/// start sing-box service
///
/// [path] is the path to the base config file (to be patched by previously set [SingboxConfigOption])
/// [name] is the name of the active profile (not unique, used for presentation in platform specific ui)
/// [disableMemoryLimit] is used to disable service memory limit (mostly used in mobile platforms i.e. iOS)
TaskEither<String, Unit> start(
String path,
String name,
bool disableMemoryLimit,
);
TaskEither<String, Unit> stop();
/// similar to [start], but uses platform dependent behavior to restart the service
TaskEither<String, Unit> restart(
String path,
String name,
bool disableMemoryLimit,
);
TaskEither<String, Unit> resetTunnel();
Stream<List<SingboxOutboundGroup>> watchGroups();
Stream<List<SingboxOutboundGroup>> watchActiveGroups();
TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag);
TaskEither<String, Unit> urlTest(String groupTag);
/// watch status of sing-box service (started, starting, etc.)
Stream<SingboxStatus> watchStatus();
/// watch stats of sing-box service (uplink, downlink, etc.)
Stream<SingboxStats> watchStats();
Stream<List<String>> watchLogs(String path);
TaskEither<String, Unit> clearLogs();
TaskEither<String, WarpResponse> generateWarpConfig({
required String licenseKey,
required String previousAccountId,
required String previousAccessToken,
});
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:kaer_with_panels/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();
}