Add proxy routing and sing-box support
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Built from official sing-box source by scripts/build-libbox-android.sh.
|
||||
/android/libs/*.aar
|
||||
!/android/libs/.gitkeep
|
||||
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: android
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,59 @@
|
||||
# flutter_singbox_proxy
|
||||
|
||||
Android sing-box proxy runtime plugin for WebLibre.
|
||||
|
||||
The public API is Pigeon-based and intentionally uses a generic JSON profile
|
||||
boundary. Flutter can add strongly typed editors over time while the native API
|
||||
stays stable across sing-box protocol/schema updates.
|
||||
|
||||
Current state:
|
||||
|
||||
- Builds sing-box JSON for multiple simultaneous profile outbounds.
|
||||
- Creates one authenticated local SOCKS inbound per active profile.
|
||||
- Returns runtime endpoint metadata for Gecko container proxy integration.
|
||||
- Exposes status/log callbacks.
|
||||
- Starts libbox via reflection against the `io.nekohasekai.libbox.*` classes
|
||||
shipped in the AAR — if the AAR is missing, the plugin still compiles but
|
||||
`start` reports an `IllegalStateException` at runtime.
|
||||
|
||||
## Building official sing-box libbox
|
||||
|
||||
The plugin does not commit binary AARs. Build them from the official sing-box
|
||||
checkout before release/F-Droid builds:
|
||||
|
||||
```sh
|
||||
packages/flutter_singbox_proxy/scripts/build-libbox-android.sh \
|
||||
--source /path/to/sing-box
|
||||
```
|
||||
|
||||
By default the script looks for `../../../sing-box` relative to this package,
|
||||
which matches the local workspace layout used during development. It copies
|
||||
`libbox.aar` into `android/libs/`; Gradle consumes that AAR automatically when
|
||||
present.
|
||||
|
||||
For F-Droid metadata, call the package-local prebuild script:
|
||||
|
||||
```sh
|
||||
packages/flutter_singbox_proxy/scripts/fdroid-prebuild.sh \
|
||||
--source /path/to/sing-box
|
||||
```
|
||||
|
||||
The build requires OpenJDK 17, Go, Android SDK/NDK, and gomobile/gobind. If
|
||||
`JAVA_HOME` is unset and `/usr/lib/jvm/java-17-openjdk` exists, the script uses
|
||||
that automatically. If gomobile/gobind are missing, the script installs the
|
||||
official SagerNet gomobile tools with `go install`.
|
||||
|
||||
### Version compatibility
|
||||
|
||||
The script pins `github.com/sagernet/gomobile/cmd/gomobile@v0.1.12` and
|
||||
`gobind@v0.1.12`. The sing-box source itself is whatever you point `--source`
|
||||
at; the runtime accesses `io.nekohasekai.libbox.*` via reflection, so API drift
|
||||
across sing-box releases surfaces as a `NoSuchMethodException` at start time
|
||||
rather than a build failure. When bumping sing-box:
|
||||
|
||||
1. Rebuild the AAR with the new tag.
|
||||
2. Smoke-test `start`/`stop` on a profile to catch missing/renamed setup
|
||||
methods (notably `SetupOptions.setOomKillerDisabled` /
|
||||
`setOomKillerEnabled` — `LibboxRuntime` falls back between the two).
|
||||
3. Update this README with the verified sing-box tag if relying on it for a
|
||||
release.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Analysis options for flutter_singbox_proxy package
|
||||
# Includes root configuration from monorepo
|
||||
|
||||
include: ../../analysis_options.yaml
|
||||
@@ -0,0 +1,9 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
/.idea/libraries
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.cxx
|
||||
@@ -0,0 +1,81 @@
|
||||
group = "eu.weblibre.flutter_singbox_proxy"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = "2.3.21"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.13.2")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
def weblibreGoMobileAar = file("../../../native/go_mobile_runtime/build/weblibre-go.aar")
|
||||
def requireWebLibreGoMobileAar = {
|
||||
if (!weblibreGoMobileAar.exists()) {
|
||||
throw new GradleException(
|
||||
"Missing combined gomobile runtime AAR: ${weblibreGoMobileAar}. " +
|
||||
"Run native/go_mobile_runtime/scripts/build-android.sh from the repository root."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "com.android.library"
|
||||
apply plugin: "kotlin-android"
|
||||
|
||||
android {
|
||||
namespace = "eu.weblibre.flutter_singbox_proxy"
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += "src/main/kotlin"
|
||||
test.java.srcDirs += "src/test/kotlin"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||
outputs.upToDateWhen { false }
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
requireWebLibreGoMobileAar()
|
||||
compileOnly(files(weblibreGoMobileAar))
|
||||
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
testImplementation("org.json:json:20250517")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.23.0")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'flutter_singbox_proxy'
|
||||
@@ -0,0 +1,3 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.weblibre.flutter_singbox_proxy">
|
||||
</manifest>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyApi
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyEventsApi
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyLogMessage
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeState
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
|
||||
/** Flutter plugin entry point for the sing-box proxy runtime. */
|
||||
class FlutterSingboxProxyPlugin : FlutterPlugin {
|
||||
private var runtimeManager: SingboxRuntimeManager? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
val eventsApi = SingboxProxyEventsApi(binding.binaryMessenger)
|
||||
val manager = SingboxRuntimeManager(
|
||||
context = binding.applicationContext,
|
||||
onStateChanged = { state: SingboxProxyRuntimeState ->
|
||||
eventsApi.onStateChanged(state) { }
|
||||
},
|
||||
onLogMessage = { message: SingboxProxyLogMessage ->
|
||||
eventsApi.onLogMessage(message) { }
|
||||
}
|
||||
)
|
||||
runtimeManager = manager
|
||||
SingboxProxyApi.setUp(binding.binaryMessenger, manager)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
SingboxProxyApi.setUp(binding.binaryMessenger, null)
|
||||
runtimeManager?.close()
|
||||
runtimeManager = null
|
||||
}
|
||||
}
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import android.content.Context
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
open class LibboxRuntime(
|
||||
private val context: Context,
|
||||
private val dohResolver: PlatformDohResolver = PlatformDohResolver(),
|
||||
) {
|
||||
private var setupComplete = false
|
||||
private var commandServer: Any? = null
|
||||
private var commandServerStarted = false
|
||||
private var commandClient: Any? = null
|
||||
private var logSink: ((Int, String) -> Unit)? = null
|
||||
private var logClientThread: Thread? = null
|
||||
|
||||
// Read by the LocalDNSTransport proxy on libbox worker threads, written
|
||||
// from the platform thread via setBootstrapDohUrl(). Volatile so the
|
||||
// bridge sees the URL configured by the most recent start() call.
|
||||
@Volatile
|
||||
private var bootstrapDohUrl: String? = null
|
||||
|
||||
/**
|
||||
* Set the DoH endpoint the platform LocalDNSTransport will use for
|
||||
* bootstrap lookups. Pass null to disable the bridge (sing-box's broken
|
||||
* /etc/resolv.conf path will run, which is rarely what you want on
|
||||
* Android).
|
||||
*/
|
||||
open fun setBootstrapDohUrl(url: String?) {
|
||||
bootstrapDohUrl = url?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
open fun isAvailable(): Boolean = runCatching {
|
||||
Class.forName(LIBBOX_CLASS)
|
||||
}.isSuccess
|
||||
|
||||
/**
|
||||
* Register a callback that receives every log message emitted by sing-box
|
||||
* (level: int, message: String). Pass null to clear. The callback is
|
||||
* invoked from a background thread; the receiver must be thread-safe.
|
||||
*/
|
||||
@Synchronized
|
||||
open fun setLogSink(sink: ((Int, String) -> Unit)?) {
|
||||
logSink = sink
|
||||
if (sink == null) {
|
||||
disconnectLogClient()
|
||||
} else if (commandServer != null) {
|
||||
ensureLogClientConnected()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
open fun start(configJson: String) {
|
||||
ensureSetup()
|
||||
val server = commandServer ?: newCommandServer().also { commandServer = it }
|
||||
ensureCommandServerStarted(server)
|
||||
val overrideOptions = newInstance(OVERRIDE_OPTIONS_CLASS)
|
||||
invoke(overrideOptions, "setAutoRedirect", false)
|
||||
invoke(overrideOptions, "setIncludePackage", emptyStringIterator())
|
||||
invoke(overrideOptions, "setExcludePackage", emptyStringIterator())
|
||||
invoke(server, "startOrReloadService", configJson, overrideOptions)
|
||||
if (logSink != null) {
|
||||
ensureLogClientConnected()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
open fun stopService() {
|
||||
commandServer?.let { server ->
|
||||
runCatching { invoke(server, "closeService") }
|
||||
}
|
||||
disconnectLogClient()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
open fun close() {
|
||||
disconnectLogClient()
|
||||
commandServer?.let { server ->
|
||||
runCatching { invoke(server, "close") }
|
||||
}
|
||||
commandServer = null
|
||||
commandServerStarted = false
|
||||
}
|
||||
|
||||
private fun ensureSetup() {
|
||||
if (setupComplete) return
|
||||
val baseDir = context.filesDir.resolve("singbox_proxy")
|
||||
val workingDir = baseDir.resolve("working")
|
||||
val tempDir = baseDir.resolve("tmp")
|
||||
workingDir.mkdirs()
|
||||
tempDir.mkdirs()
|
||||
|
||||
val options = newInstance(SETUP_OPTIONS_CLASS)
|
||||
invoke(options, "setBasePath", baseDir.absolutePath)
|
||||
invoke(options, "setWorkingPath", workingDir.absolutePath)
|
||||
invoke(options, "setTempPath", tempDir.absolutePath)
|
||||
invoke(options, "setFixAndroidStack", true)
|
||||
invoke(options, "setCommandServerListenPort", 0)
|
||||
invoke(options, "setCommandServerSecret", "")
|
||||
invoke(options, "setLogMaxLines", 300L)
|
||||
invoke(options, "setDebug", false)
|
||||
invokeIfAvailable(options, "setCrashReportSource", "flutter_singbox_proxy")
|
||||
// sing-box renamed the OOM killer toggle between releases; only one of
|
||||
// these exists on the linked libbox AAR, so call whichever responds.
|
||||
invokeFirstAvailable(
|
||||
options,
|
||||
methodNames = listOf("setOomKillerDisabled", "setOomKillerEnabled"),
|
||||
args = arrayOf(true),
|
||||
)
|
||||
invokeIfAvailable(options, "setOomMemoryLimit", 0L)
|
||||
|
||||
val libbox = Class.forName(LIBBOX_CLASS)
|
||||
libbox.getMethod("setup", Class.forName(SETUP_OPTIONS_CLASS)).invoke(null, options)
|
||||
setupComplete = true
|
||||
}
|
||||
|
||||
private fun newCommandServer(): Any {
|
||||
val handlerInterface = Class.forName(COMMAND_SERVER_HANDLER_CLASS)
|
||||
val platformInterface = Class.forName(PLATFORM_INTERFACE_CLASS)
|
||||
val handler = Proxy.newProxyInstance(
|
||||
handlerInterface.classLoader,
|
||||
arrayOf(handlerInterface),
|
||||
commandServerHandler()
|
||||
)
|
||||
val platform = Proxy.newProxyInstance(
|
||||
platformInterface.classLoader,
|
||||
arrayOf(platformInterface),
|
||||
platformHandler()
|
||||
)
|
||||
return Class.forName(COMMAND_SERVER_CLASS)
|
||||
.getConstructor(handlerInterface, platformInterface)
|
||||
.newInstance(handler, platform)
|
||||
}
|
||||
|
||||
private fun ensureCommandServerStarted(server: Any) {
|
||||
if (commandServerStarted) return
|
||||
invoke(server, "start")
|
||||
commandServerStarted = true
|
||||
}
|
||||
|
||||
private fun commandServerHandler(): InvocationHandler {
|
||||
return InvocationHandler { _, method, args ->
|
||||
when (method.name) {
|
||||
"getSystemProxyStatus" -> newInstance(SYSTEM_PROXY_STATUS_CLASS).also { status ->
|
||||
invoke(status, "setAvailable", false)
|
||||
invoke(status, "setEnabled", false)
|
||||
}
|
||||
"serviceReload", "serviceStop", "setSystemProxyEnabled", "writeDebugMessage" -> null
|
||||
"triggerNativeCrash" -> throw UnsupportedOperationException("Native crash trigger is disabled")
|
||||
else -> defaultValue(method.returnType, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun platformHandler(): InvocationHandler {
|
||||
return InvocationHandler { _, method, args ->
|
||||
when (method.name) {
|
||||
"autoDetectInterfaceControl",
|
||||
"clearDNSCache",
|
||||
"closeDefaultInterfaceMonitor",
|
||||
"closeNeighborMonitor",
|
||||
"registerMyInterface",
|
||||
"sendNotification",
|
||||
"startDefaultInterfaceMonitor",
|
||||
"startNeighborMonitor" -> null
|
||||
"findConnectionOwner" -> newInstance(CONNECTION_OWNER_CLASS).also { owner ->
|
||||
invoke(owner, "setUserId", -1)
|
||||
invoke(owner, "setUserName", "")
|
||||
invoke(owner, "setProcessPath", "")
|
||||
invoke(owner, "setAndroidPackageNames", emptyStringIterator())
|
||||
}
|
||||
"getInterfaces" -> emptyIterator(NETWORK_INTERFACE_ITERATOR_CLASS)
|
||||
"includeAllNetworks",
|
||||
"underNetworkExtension",
|
||||
"usePlatformAutoDetectInterfaceControl",
|
||||
"useProcFS" -> false
|
||||
"localDNSTransport" -> createLocalDnsTransport()
|
||||
"openTun" -> throw UnsupportedOperationException("TUN is not supported by WebLibre proxy routing")
|
||||
"readWIFIState" -> Class.forName(WIFI_STATE_CLASS)
|
||||
.getConstructor(String::class.java, String::class.java)
|
||||
.newInstance("", "")
|
||||
"systemCertificates" -> emptyStringIterator()
|
||||
else -> defaultValue(method.returnType, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureLogClientConnected() {
|
||||
if (commandClient != null) return
|
||||
val handlerInterface = runCatching {
|
||||
Class.forName(COMMAND_CLIENT_HANDLER_CLASS)
|
||||
}.getOrNull() ?: return
|
||||
val optionsClass = runCatching {
|
||||
Class.forName(COMMAND_CLIENT_OPTIONS_CLASS)
|
||||
}.getOrNull() ?: return
|
||||
val clientClass = runCatching {
|
||||
Class.forName(COMMAND_CLIENT_CLASS)
|
||||
}.getOrNull() ?: return
|
||||
|
||||
val handler = Proxy.newProxyInstance(
|
||||
handlerInterface.classLoader,
|
||||
arrayOf(handlerInterface),
|
||||
commandClientHandler()
|
||||
)
|
||||
val options = optionsClass.getConstructor().newInstance()
|
||||
// Subscribe to the log stream (CommandLog == 0 in sing-box/libbox).
|
||||
invoke(options, "addCommand", 0)
|
||||
// Subscribe to connection events (CommandConnections == 4). Some
|
||||
// transports, including WireGuard endpoint routing, don't emit useful
|
||||
// per-connection lines through the regular log stream.
|
||||
invoke(options, "addCommand", 4)
|
||||
val client = clientClass
|
||||
.getConstructor(handlerInterface, optionsClass)
|
||||
.newInstance(handler, options)
|
||||
commandClient = client
|
||||
|
||||
// Connect dials the local command socket with retries; do it off the
|
||||
// platform thread so we don't block start().
|
||||
val thread = Thread({
|
||||
runCatching { invoke(client, "connect") }
|
||||
.onFailure { error ->
|
||||
logSink?.invoke(3, "sing-box log stream connection failed: ${error.message}")
|
||||
}
|
||||
}, "singbox-log-client")
|
||||
thread.isDaemon = true
|
||||
thread.start()
|
||||
logClientThread = thread
|
||||
}
|
||||
|
||||
private fun disconnectLogClient() {
|
||||
val client = commandClient ?: return
|
||||
commandClient = null
|
||||
runCatching { invoke(client, "disconnect") }
|
||||
logClientThread = null
|
||||
}
|
||||
|
||||
private fun commandClientHandler(): InvocationHandler {
|
||||
return InvocationHandler { _, method, args ->
|
||||
when (method.name) {
|
||||
"writeLogs" -> {
|
||||
val iterator = args?.firstOrNull()
|
||||
if (iterator != null) {
|
||||
forwardLogIterator(iterator)
|
||||
}
|
||||
null
|
||||
}
|
||||
"clearLogs",
|
||||
"connected",
|
||||
"disconnected",
|
||||
"setDefaultLogLevel",
|
||||
"writeStatus",
|
||||
"writeGroups",
|
||||
"writeOutbounds",
|
||||
"initializeClashMode",
|
||||
"updateClashMode" -> null
|
||||
"writeConnectionEvents" -> {
|
||||
val events = args?.firstOrNull()
|
||||
if (events != null) {
|
||||
forwardConnectionEvents(events)
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> defaultValue(method.returnType, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun forwardLogIterator(iterator: Any) {
|
||||
val sink = logSink ?: return
|
||||
runCatching {
|
||||
while (invoke(iterator, "hasNext") as? Boolean == true) {
|
||||
val entry = invoke(iterator, "next") ?: continue
|
||||
val level = (runCatching { invoke(entry, "getLevel") }.getOrNull() as? Number)
|
||||
?.toInt() ?: 0
|
||||
val message = runCatching { invoke(entry, "getMessage") }
|
||||
.getOrNull() as? String ?: continue
|
||||
sink(level, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun forwardConnectionEvents(events: Any) {
|
||||
val sink = logSink ?: return
|
||||
runCatching {
|
||||
val iterator = invokeFirstAvailableResult(events, listOf("iterator", "Iterator")) ?: return
|
||||
while (invoke(iterator, "hasNext") as? Boolean == true) {
|
||||
val event = invoke(iterator, "next") ?: continue
|
||||
val type = (invokeFirstAvailableResult(event, listOf("getType", "type")) as? Number)
|
||||
?.toInt() ?: continue
|
||||
if (type != CONNECTION_EVENT_NEW && type != CONNECTION_EVENT_CLOSED) continue
|
||||
|
||||
val connection = invokeFirstAvailableResult(
|
||||
event,
|
||||
listOf("getConnection", "connection"),
|
||||
) ?: continue
|
||||
val eventLabel = if (type == CONNECTION_EVENT_CLOSED) "closed" else "opened"
|
||||
sink(4, "connection $eventLabel ${describeConnection(connection)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun describeConnection(connection: Any): String {
|
||||
val network = stringValue(connection, "getNetwork", "network")
|
||||
val source = stringValue(connection, "getSource", "source")
|
||||
val destination = stringValue(
|
||||
connection,
|
||||
"displayDestination",
|
||||
"DisplayDestination",
|
||||
"getDestination",
|
||||
"destination",
|
||||
)
|
||||
val outbound = stringValue(connection, "getOutbound", "outbound")
|
||||
val inbound = stringValue(connection, "getInbound", "inbound")
|
||||
|
||||
return buildString {
|
||||
if (network.isNotBlank()) append(network).append(' ')
|
||||
if (source.isNotBlank()) append(source).append(" -> ")
|
||||
append(destination.ifBlank { "unknown destination" })
|
||||
if (outbound.isNotBlank()) append(" via ").append(outbound)
|
||||
if (inbound.isNotBlank()) append(" (").append(inbound).append(')')
|
||||
}
|
||||
}
|
||||
|
||||
private fun stringValue(target: Any, vararg methodNames: String): String {
|
||||
return invokeFirstAvailableResult(target, methodNames.toList()) as? String ?: ""
|
||||
}
|
||||
|
||||
private fun createLocalDnsTransport(): Any? {
|
||||
val iface = runCatching {
|
||||
Class.forName(LOCAL_DNS_TRANSPORT_CLASS)
|
||||
}.getOrNull() ?: return null
|
||||
|
||||
return Proxy.newProxyInstance(
|
||||
iface.classLoader,
|
||||
arrayOf(iface),
|
||||
) { _, method, args ->
|
||||
when (method.name) {
|
||||
"raw" -> true
|
||||
"exchange" -> {
|
||||
val ctx = args?.getOrNull(0)
|
||||
val request = args?.getOrNull(1) as? ByteArray
|
||||
if (ctx != null && request != null) {
|
||||
runDohExchange(ctx, request)
|
||||
}
|
||||
null
|
||||
}
|
||||
// Lookup is only reachable when raw() returns false. We
|
||||
// always return true above, so this path is dead.
|
||||
"lookup" -> null
|
||||
else -> defaultValue(method.returnType, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runDohExchange(ctx: Any, request: ByteArray) {
|
||||
val url = bootstrapDohUrl
|
||||
if (url == null) {
|
||||
// No bootstrap URL configured — return SERVFAIL so sing-box gets a
|
||||
// clean failure instead of hanging on a half-initialized bridge.
|
||||
invokeIfAvailable(ctx, "errorCode", DNS_RCODE_SERVFAIL)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val response = dohResolver.exchange(url, request)
|
||||
invokeIfAvailable(ctx, "rawSuccess", response)
|
||||
} catch (error: Throwable) {
|
||||
logSink?.invoke(3, "DoH bootstrap exchange failed: ${error.message}")
|
||||
invokeIfAvailable(ctx, "errorCode", DNS_RCODE_SERVFAIL)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyStringIterator(): Any = emptyIterator(STRING_ITERATOR_CLASS)
|
||||
|
||||
private fun emptyIterator(interfaceName: String): Any {
|
||||
val iteratorInterface = Class.forName(interfaceName)
|
||||
return Proxy.newProxyInstance(
|
||||
iteratorInterface.classLoader,
|
||||
arrayOf(iteratorInterface),
|
||||
) { _, method, _ ->
|
||||
when (method.name) {
|
||||
"hasNext" -> false
|
||||
"len" -> 0
|
||||
"next" -> null
|
||||
else -> defaultValue(method.returnType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun newInstance(className: String): Any {
|
||||
return Class.forName(className).getConstructor().newInstance()
|
||||
}
|
||||
|
||||
private fun invoke(target: Any, methodName: String, vararg args: Any?): Any? {
|
||||
val method = findMethod(target.javaClass, methodName, args.size)
|
||||
return method.invoke(target, *args)
|
||||
}
|
||||
|
||||
private fun invokeIfAvailable(target: Any, methodName: String, vararg args: Any?) {
|
||||
val method = target.javaClass.methods.firstOrNull { candidate ->
|
||||
candidate.name == methodName && candidate.parameterTypes.size == args.size
|
||||
}
|
||||
method?.invoke(target, *args)
|
||||
}
|
||||
|
||||
private fun invokeFirstAvailable(
|
||||
target: Any,
|
||||
methodNames: List<String>,
|
||||
args: Array<Any?>,
|
||||
) {
|
||||
for (name in methodNames) {
|
||||
val method = target.javaClass.methods.firstOrNull { candidate ->
|
||||
candidate.name == name && candidate.parameterTypes.size == args.size
|
||||
}
|
||||
if (method != null) {
|
||||
method.invoke(target, *args)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeFirstAvailableResult(
|
||||
target: Any,
|
||||
methodNames: List<String>,
|
||||
vararg args: Any?,
|
||||
): Any? {
|
||||
for (name in methodNames) {
|
||||
val method = target.javaClass.methods.firstOrNull { candidate ->
|
||||
candidate.name == name && candidate.parameterTypes.size == args.size
|
||||
}
|
||||
if (method != null) {
|
||||
return method.invoke(target, *args)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun findMethod(clazz: Class<*>, methodName: String, argCount: Int): Method {
|
||||
return clazz.methods.firstOrNull { method ->
|
||||
method.name == methodName && method.parameterTypes.size == argCount
|
||||
} ?: throw NoSuchMethodError(
|
||||
"${clazz.name}.$methodName($argCount args) is missing — linked libbox AAR " +
|
||||
"may be incompatible. Available '${methodName}' overloads: " +
|
||||
clazz.methods
|
||||
.filter { it.name == methodName }
|
||||
.joinToString { "${it.name}(${it.parameterTypes.joinToString { p -> p.simpleName }})" }
|
||||
.ifEmpty { "<none>" }
|
||||
)
|
||||
}
|
||||
|
||||
private fun defaultValue(returnType: Class<*>, args: Array<Any?>? = null): Any? {
|
||||
return when (returnType) {
|
||||
java.lang.Boolean.TYPE -> false
|
||||
java.lang.Integer.TYPE -> 0
|
||||
java.lang.Long.TYPE -> 0L
|
||||
java.lang.Float.TYPE -> 0f
|
||||
java.lang.Double.TYPE -> 0.0
|
||||
java.lang.Void.TYPE -> null
|
||||
else -> args?.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LIBBOX_CLASS = "io.nekohasekai.libbox.Libbox"
|
||||
const val SETUP_OPTIONS_CLASS = "io.nekohasekai.libbox.SetupOptions"
|
||||
const val COMMAND_SERVER_CLASS = "io.nekohasekai.libbox.CommandServer"
|
||||
const val COMMAND_SERVER_HANDLER_CLASS = "io.nekohasekai.libbox.CommandServerHandler"
|
||||
const val PLATFORM_INTERFACE_CLASS = "io.nekohasekai.libbox.PlatformInterface"
|
||||
const val LOCAL_DNS_TRANSPORT_CLASS = "io.nekohasekai.libbox.LocalDNSTransport"
|
||||
const val DNS_RCODE_SERVFAIL = 2
|
||||
const val OVERRIDE_OPTIONS_CLASS = "io.nekohasekai.libbox.OverrideOptions"
|
||||
const val STRING_ITERATOR_CLASS = "io.nekohasekai.libbox.StringIterator"
|
||||
const val NETWORK_INTERFACE_ITERATOR_CLASS = "io.nekohasekai.libbox.NetworkInterfaceIterator"
|
||||
const val CONNECTION_OWNER_CLASS = "io.nekohasekai.libbox.ConnectionOwner"
|
||||
const val SYSTEM_PROXY_STATUS_CLASS = "io.nekohasekai.libbox.SystemProxyStatus"
|
||||
const val WIFI_STATE_CLASS = "io.nekohasekai.libbox.WIFIState"
|
||||
const val COMMAND_CLIENT_CLASS = "io.nekohasekai.libbox.CommandClient"
|
||||
const val COMMAND_CLIENT_HANDLER_CLASS = "io.nekohasekai.libbox.CommandClientHandler"
|
||||
const val COMMAND_CLIENT_OPTIONS_CLASS = "io.nekohasekai.libbox.CommandClientOptions"
|
||||
const val CONNECTION_EVENT_NEW = 0
|
||||
const val CONNECTION_EVENT_CLOSED = 2
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* RFC 8484 DoH client used as the bootstrap resolver for sing-box's
|
||||
* `type: "local"` DNS transport.
|
||||
*
|
||||
* Sing-box calls the platform LocalDNSTransport whenever a DNS server's own
|
||||
* hostname (or any other hostname referenced by `domain_resolver` /
|
||||
* `default_domain_resolver`) needs resolving. We bounce the wire-format query
|
||||
* straight to a configured DoH endpoint so no query ever hits Android's
|
||||
* system resolver.
|
||||
*
|
||||
* The DoH endpoint's own hostname is resolved exactly once by the JVM HTTP
|
||||
* stack. Configure the URL with an IP literal (e.g. `https://1.1.1.1/dns-query`)
|
||||
* if even that one lookup must not leak.
|
||||
*/
|
||||
class PlatformDohResolver(
|
||||
private val connectTimeoutMillis: Int = 5_000,
|
||||
private val readTimeoutMillis: Int = 5_000,
|
||||
) {
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(connectTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(readTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Send [request] as a DoH POST and return the raw DNS wire-format reply. */
|
||||
@Throws(IOException::class)
|
||||
fun exchange(url: String, request: ByteArray): ByteArray {
|
||||
val httpRequest = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", DNS_MESSAGE_MIME)
|
||||
.post(request.toRequestBody(DNS_MESSAGE_MEDIA_TYPE))
|
||||
.build()
|
||||
|
||||
client.newCall(httpRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException(
|
||||
"DoH endpoint returned HTTP ${response.code} via ${response.protocol}"
|
||||
)
|
||||
}
|
||||
val contentType = response.header("Content-Type").orEmpty()
|
||||
if (!contentType.startsWith(DNS_MESSAGE_MIME, ignoreCase = true)) {
|
||||
throw IOException(
|
||||
"DoH endpoint returned unexpected Content-Type: $contentType"
|
||||
)
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IOException("DoH endpoint returned no body")
|
||||
val contentLength = body.contentLength()
|
||||
if (contentLength > MAX_DNS_RESPONSE) {
|
||||
throw IOException("DoH response exceeds maximum DNS message size")
|
||||
}
|
||||
|
||||
return body.bytes().also { bytes ->
|
||||
if (bytes.size > MAX_DNS_RESPONSE) {
|
||||
throw IOException("DoH response exceeds maximum DNS message size")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DNS_MESSAGE_MIME = "application/dns-message"
|
||||
val DNS_MESSAGE_MEDIA_TYPE = DNS_MESSAGE_MIME.toMediaType()
|
||||
// EDNS0 typically caps responses at 4 KiB; we allow a little slack.
|
||||
const val MAX_DNS_RESPONSE = 8 * 1024
|
||||
}
|
||||
}
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyConfigResult
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsConfig
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsServerConfig
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfileType
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeEndpoint
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
|
||||
import java.net.URI
|
||||
import java.security.SecureRandom
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val LOCALHOST = "127.0.0.1"
|
||||
private const val DEFAULT_BASE_PORT = 12000L
|
||||
private const val BASE64_URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
|
||||
/**
|
||||
* Tag of the always-emitted `type: "local"` DNS server. Hooked at runtime by
|
||||
* our LocalDNSTransport bridge (PlatformDohResolver), so every hostname in
|
||||
* the config — including DoH endpoint hostnames and WireGuard peer
|
||||
* hostnames — resolves through DoH instead of `/etc/resolv.conf`.
|
||||
*/
|
||||
private const val DNS_BOOTSTRAP_TAG = "local"
|
||||
|
||||
class SingboxConfigBuilder(
|
||||
private val random: SecureRandom = SecureRandom()
|
||||
) {
|
||||
fun validateProfile(profile: SingboxProxyProfile): String? {
|
||||
if (profile.id.isBlank()) return "Profile id is required."
|
||||
if (profile.name.isBlank()) return "Profile name is required."
|
||||
|
||||
val outbound = try {
|
||||
buildOutbound(profile)
|
||||
} catch (error: JSONException) {
|
||||
return "Invalid outbound JSON: ${error.message}"
|
||||
} catch (error: IllegalArgumentException) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
val expectedType = expectedOutboundType(profile.type)
|
||||
val actualType = outbound.optString("type")
|
||||
if (expectedType != null && actualType != expectedType) {
|
||||
return "Profile type ${profile.type.name} requires sing-box outbound type '$expectedType'."
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun build(
|
||||
profiles: List<SingboxProxyProfile>,
|
||||
options: SingboxProxyRuntimeOptions
|
||||
): SingboxProxyConfigResult {
|
||||
profiles.forEach { profile ->
|
||||
validateProfile(profile)?.let { throw IllegalArgumentException(it) }
|
||||
}
|
||||
|
||||
val inbounds = JSONArray()
|
||||
val endpointsJson = JSONArray()
|
||||
val outbounds = JSONArray()
|
||||
val rules = JSONArray()
|
||||
val endpoints = mutableListOf<SingboxProxyRuntimeEndpoint>()
|
||||
val basePort = options.preferredBasePort ?: DEFAULT_BASE_PORT
|
||||
|
||||
profiles.forEachIndexed { index, profile ->
|
||||
val inboundTag = inboundTag(profile.id)
|
||||
val outboundTag = outboundTag(profile.id)
|
||||
val port = basePort + index
|
||||
val username = generateToken("u")
|
||||
val password = generateToken("p")
|
||||
|
||||
inbounds.put(JSONObject().apply {
|
||||
put("type", "socks")
|
||||
put("tag", inboundTag)
|
||||
put("listen", LOCALHOST)
|
||||
put("listen_port", port)
|
||||
put("users", JSONArray().put(JSONObject().apply {
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
}))
|
||||
})
|
||||
|
||||
if (profile.type == SingboxProxyProfileType.WIREGUARD) {
|
||||
endpointsJson.put(buildWireGuardEndpoint(profile, outboundTag))
|
||||
} else {
|
||||
outbounds.put(buildOutbound(profile).apply {
|
||||
put("tag", outboundTag)
|
||||
})
|
||||
}
|
||||
|
||||
rules.put(JSONObject().apply {
|
||||
put("inbound", JSONArray().put(inboundTag))
|
||||
put("action", "route")
|
||||
put("outbound", outboundTag)
|
||||
})
|
||||
|
||||
endpoints += SingboxProxyRuntimeEndpoint(
|
||||
profileId = profile.id,
|
||||
host = LOCALHOST,
|
||||
port = port,
|
||||
username = username,
|
||||
password = password
|
||||
)
|
||||
}
|
||||
|
||||
val finalOutbound = if (options.blockUnmatchedTraffic) "block" else "direct"
|
||||
outbounds.put(JSONObject().apply {
|
||||
put("type", finalOutbound)
|
||||
put("tag", finalOutbound)
|
||||
})
|
||||
// Always expose a `direct` outbound even when blockUnmatchedTraffic =
|
||||
// true so internal bootstrap/fallback paths still have a direct route.
|
||||
if (finalOutbound != "direct") {
|
||||
outbounds.put(JSONObject().apply {
|
||||
put("type", "direct")
|
||||
put("tag", "direct")
|
||||
})
|
||||
}
|
||||
|
||||
val dnsBlock = options.dnsConfig?.let(::buildDnsBlock)
|
||||
if (dnsBlock != null && options.bootstrapDohUrl.isNullOrBlank()) {
|
||||
throw IllegalArgumentException(
|
||||
"bootstrapDohUrl is required when dnsConfig is provided."
|
||||
)
|
||||
}
|
||||
|
||||
val config = JSONObject().apply {
|
||||
put("log", JSONObject().apply { put("level", "info") })
|
||||
put("inbounds", inbounds)
|
||||
if (endpointsJson.length() > 0) {
|
||||
put("endpoints", endpointsJson)
|
||||
}
|
||||
put("outbounds", outbounds)
|
||||
put("route", JSONObject().apply {
|
||||
put("rules", rules)
|
||||
put("final", finalOutbound)
|
||||
// When DNS is configured, route everything's hostname
|
||||
// resolution through the platform LocalDNSTransport bridge so
|
||||
// WireGuard peer hostnames and any other outbound-dialer
|
||||
// hostname go through DoH, not /etc/resolv.conf.
|
||||
if (dnsBlock != null) {
|
||||
put("default_domain_resolver", DNS_BOOTSTRAP_TAG)
|
||||
}
|
||||
})
|
||||
dnsBlock?.let { put("dns", it) }
|
||||
}
|
||||
|
||||
return SingboxProxyConfigResult(
|
||||
configJson = config.toString(2),
|
||||
endpoints = endpoints
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildDnsBlock(dns: SingboxProxyDnsConfig): JSONObject? {
|
||||
if (dns.servers.isEmpty()) return null
|
||||
|
||||
val serversJson = JSONArray()
|
||||
val rulesJson = JSONArray()
|
||||
|
||||
// Always emit the platform `local` server first. It is the foundation
|
||||
// every other server's `domain_resolver` (and the route's
|
||||
// default_domain_resolver) points at, and the LocalDNSTransport
|
||||
// bridge backs it with DoH at runtime.
|
||||
serversJson.put(JSONObject().apply {
|
||||
put("type", "local")
|
||||
put("tag", DNS_BOOTSTRAP_TAG)
|
||||
})
|
||||
|
||||
for (server in dns.servers) {
|
||||
serversJson.put(buildDnsServer(server))
|
||||
|
||||
if (server.matchDomainSuffixes.isNotEmpty() ||
|
||||
server.matchInbounds.isNotEmpty()
|
||||
) {
|
||||
rulesJson.put(JSONObject().apply {
|
||||
if (server.matchDomainSuffixes.isNotEmpty()) {
|
||||
put(
|
||||
"domain_suffix",
|
||||
JSONArray(server.matchDomainSuffixes)
|
||||
)
|
||||
}
|
||||
if (server.matchInbounds.isNotEmpty()) {
|
||||
put("inbound", JSONArray(server.matchInbounds))
|
||||
}
|
||||
put("action", "route")
|
||||
put("server", server.tag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return JSONObject().apply {
|
||||
put("servers", serversJson)
|
||||
if (rulesJson.length() > 0) {
|
||||
put("rules", rulesJson)
|
||||
}
|
||||
if (dns.domainStrategy.isNotBlank()) {
|
||||
put("strategy", dns.domainStrategy)
|
||||
}
|
||||
val finalTag = dns.finalServerTag ?: DNS_BOOTSTRAP_TAG
|
||||
put("final", finalTag)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDnsServer(server: SingboxProxyDnsServerConfig): JSONObject {
|
||||
val parsed = parseDnsAddress(server.address)
|
||||
return JSONObject().apply {
|
||||
put("type", parsed.type)
|
||||
put("tag", server.tag)
|
||||
parsed.server?.let { put("server", it) }
|
||||
parsed.serverPort?.let { put("server_port", it) }
|
||||
parsed.path?.let { put("path", it) }
|
||||
server.detourTag?.takeUnless { it == "direct" }?.let { put("detour", it) }
|
||||
// Hostname targets always bootstrap through `local`; sing-box
|
||||
// ignores `domain_resolver` for IP-literal servers, so emitting
|
||||
// it unconditionally is fine and keeps the JSON uniform.
|
||||
if (parsed.server != null && !parsed.serverIsIpLiteral) {
|
||||
put("domain_resolver", DNS_BOOTSTRAP_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDnsAddress(address: String): ParsedDnsAddress {
|
||||
val trimmed = address.trim()
|
||||
if (trimmed == "local") {
|
||||
return ParsedDnsAddress(type = "local")
|
||||
}
|
||||
|
||||
val uri = if (trimmed.contains("://")) URI(trimmed) else null
|
||||
val scheme = uri?.scheme?.lowercase()
|
||||
return when (scheme) {
|
||||
null -> parseHostPort(trimmed, "udp", 53)
|
||||
"udp" -> parseUriHostPort(uri!!, "udp", 53)
|
||||
"tcp" -> parseUriHostPort(uri!!, "tcp", 53)
|
||||
"tls" -> parseUriHostPort(uri!!, "tls", 853)
|
||||
"quic" -> parseUriHostPort(uri!!, "quic", 853)
|
||||
"https", "h3" -> parseUriHostPort(uri, scheme, 443).copy(
|
||||
path = uri.path.takeUnless { it.isNullOrBlank() || it == "/dns-query" }
|
||||
)
|
||||
else -> throw IllegalArgumentException("Unsupported DNS server scheme: $scheme")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseUriHostPort(uri: URI, type: String, defaultPort: Int): ParsedDnsAddress {
|
||||
val host = uri.host ?: throw IllegalArgumentException("Invalid DNS server address")
|
||||
val bare = host.removePrefix("[").removeSuffix("]")
|
||||
return ParsedDnsAddress(
|
||||
type = type,
|
||||
server = bare,
|
||||
serverPort = uri.port.takeIf { it >= 0 && it != defaultPort },
|
||||
serverIsIpLiteral = isIpLiteral(bare),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseHostPort(value: String, type: String, defaultPort: Int): ParsedDnsAddress {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) throw IllegalArgumentException("DNS server address is required")
|
||||
|
||||
val splitPort = trimmed.lastIndexOf(':')
|
||||
val hasSingleColon = splitPort > 0 && trimmed.indexOf(':') == splitPort
|
||||
val host = if (hasSingleColon) trimmed.substring(0, splitPort) else trimmed
|
||||
val port = if (hasSingleColon) trimmed.substring(splitPort + 1).toIntOrNull() else null
|
||||
val bare = host.removePrefix("[").removeSuffix("]")
|
||||
|
||||
return ParsedDnsAddress(
|
||||
type = type,
|
||||
server = bare,
|
||||
serverPort = port?.takeUnless { it == defaultPort },
|
||||
serverIsIpLiteral = isIpLiteral(bare),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isIpLiteral(host: String): Boolean {
|
||||
if (host.matches(Regex("^\\d{1,3}(\\.\\d{1,3}){3}$"))) return true
|
||||
if (host.contains(":") && host.matches(Regex("^[0-9A-Fa-f:.]+$"))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private data class ParsedDnsAddress(
|
||||
val type: String,
|
||||
val server: String? = null,
|
||||
val serverPort: Int? = null,
|
||||
val path: String? = null,
|
||||
val serverIsIpLiteral: Boolean = false,
|
||||
)
|
||||
|
||||
private fun buildOutbound(profile: SingboxProxyProfile): JSONObject {
|
||||
val outbound = JSONObject(profile.configJson)
|
||||
profile.secretJson?.takeIf { it.isNotBlank() }?.let { secretJson ->
|
||||
deepMerge(outbound, JSONObject(secretJson))
|
||||
}
|
||||
|
||||
expectedOutboundType(profile.type)?.let { expectedType ->
|
||||
val actualType = outbound.optString("type")
|
||||
if (actualType.isBlank()) {
|
||||
outbound.put("type", expectedType)
|
||||
}
|
||||
}
|
||||
|
||||
return outbound
|
||||
}
|
||||
|
||||
private fun buildWireGuardEndpoint(profile: SingboxProxyProfile, tag: String): JSONObject {
|
||||
val endpoint = buildOutbound(profile)
|
||||
endpoint.put("tag", tag)
|
||||
|
||||
endpoint.remove("server")?.let { server ->
|
||||
val peer = JSONObject().apply {
|
||||
put("address", server)
|
||||
endpoint.remove("server_port")?.let { put("port", it) }
|
||||
endpoint.remove("peer_public_key")?.let { put("public_key", it) }
|
||||
endpoint.remove("pre_shared_key")?.let { put("pre_shared_key", it) }
|
||||
endpoint.remove("reserved")?.let { put("reserved", it) }
|
||||
endpoint.remove("persistent_keepalive_interval")?.let {
|
||||
put("persistent_keepalive_interval", it)
|
||||
}
|
||||
put("allowed_ips", endpoint.remove("allowed_ips") ?: JSONArray().apply {
|
||||
put("0.0.0.0/0")
|
||||
put("::/0")
|
||||
})
|
||||
}
|
||||
endpoint.put("peers", JSONArray().put(peer))
|
||||
}
|
||||
|
||||
endpoint.remove("local_address")?.let { endpoint.put("address", it) }
|
||||
endpoint.remove("system_interface")?.let { endpoint.put("system", it) }
|
||||
endpoint.remove("interface_name")?.let { endpoint.put("name", it) }
|
||||
endpoint.remove("gso")
|
||||
|
||||
return endpoint
|
||||
}
|
||||
|
||||
private fun deepMerge(target: JSONObject, source: JSONObject) {
|
||||
val keys = source.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val value = source.get(key)
|
||||
if (value is JSONObject && target.opt(key) is JSONObject) {
|
||||
deepMerge(target.getJSONObject(key), value)
|
||||
} else {
|
||||
target.put(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateToken(prefix: String): String {
|
||||
val bytes = ByteArray(18)
|
||||
random.nextBytes(bytes)
|
||||
return prefix + base64UrlNoPadding(bytes)
|
||||
}
|
||||
|
||||
private fun base64UrlNoPadding(bytes: ByteArray): String {
|
||||
val output = StringBuilder((bytes.size * 4 + 2) / 3)
|
||||
var index = 0
|
||||
while (index < bytes.size) {
|
||||
val b0 = bytes[index++].toInt() and 0xff
|
||||
val b1 = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1
|
||||
val b2 = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1
|
||||
|
||||
output.append(BASE64_URL_ALPHABET[b0 ushr 2])
|
||||
if (b1 < 0) {
|
||||
output.append(BASE64_URL_ALPHABET[(b0 and 0x03) shl 4])
|
||||
} else {
|
||||
output.append(BASE64_URL_ALPHABET[((b0 and 0x03) shl 4) or (b1 ushr 4)])
|
||||
if (b2 < 0) {
|
||||
output.append(BASE64_URL_ALPHABET[(b1 and 0x0f) shl 2])
|
||||
} else {
|
||||
output.append(BASE64_URL_ALPHABET[((b1 and 0x0f) shl 2) or (b2 ushr 6)])
|
||||
output.append(BASE64_URL_ALPHABET[b2 and 0x3f])
|
||||
}
|
||||
}
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun inboundTag(profileId: String) = SingboxTagFormat.inboundTag(profileId)
|
||||
|
||||
private fun outboundTag(profileId: String) = SingboxTagFormat.outboundTag(profileId)
|
||||
|
||||
private fun expectedOutboundType(type: SingboxProxyProfileType): String? = when (type) {
|
||||
SingboxProxyProfileType.SOCKS -> "socks"
|
||||
SingboxProxyProfileType.HTTP -> "http"
|
||||
SingboxProxyProfileType.SHADOWSOCKS -> "shadowsocks"
|
||||
SingboxProxyProfileType.VMESS -> "vmess"
|
||||
SingboxProxyProfileType.VLESS -> "vless"
|
||||
SingboxProxyProfileType.TROJAN -> "trojan"
|
||||
SingboxProxyProfileType.NAIVE -> "naive"
|
||||
SingboxProxyProfileType.HYSTERIA -> "hysteria"
|
||||
SingboxProxyProfileType.HYSTERIA2 -> "hysteria2"
|
||||
SingboxProxyProfileType.TUIC -> "tuic"
|
||||
SingboxProxyProfileType.SSH -> "ssh"
|
||||
SingboxProxyProfileType.WIREGUARD -> "wireguard"
|
||||
SingboxProxyProfileType.SHADOW_TLS -> "shadowtls"
|
||||
SingboxProxyProfileType.ANY_TLS -> "anytls"
|
||||
SingboxProxyProfileType.CUSTOM_OUTBOUND -> null
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyApi
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyConfigResult
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyLogMessage
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeEndpoint
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeState
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeStatus
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class SingboxRuntimeManager(
|
||||
context: Context,
|
||||
private val configBuilder: SingboxConfigBuilder = SingboxConfigBuilder(),
|
||||
private val libboxRuntime: LibboxRuntime = LibboxRuntime(context),
|
||||
private val onStateChanged: (SingboxProxyRuntimeState) -> Unit = {},
|
||||
private val onLogMessage: (SingboxProxyLogMessage) -> Unit = {},
|
||||
private val dispatchToMain: ((() -> Unit) -> Unit) = { action ->
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
action()
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post(action)
|
||||
}
|
||||
}
|
||||
) : SingboxProxyApi {
|
||||
// Pigeon dispatches Dart-side calls on the platform thread, but libbox
|
||||
// callbacks fire from native threads. Guard all state transitions with
|
||||
// a single lock so concurrent stop / start / event paths cannot tear
|
||||
// activeProfiles, activeOptions, or `state` against each other.
|
||||
private val stateLock = Any()
|
||||
private val runtimeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "singbox-runtime").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
private var state = SingboxProxyRuntimeState(
|
||||
status = SingboxProxyRuntimeStatus.STOPPED,
|
||||
endpoints = emptyList(),
|
||||
message = null
|
||||
)
|
||||
private var activeProfiles = emptyList<SingboxProxyProfile>()
|
||||
private var activeOptions = SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = null,
|
||||
blockUnmatchedTraffic = true
|
||||
)
|
||||
|
||||
init {
|
||||
// Forward every sing-box log entry to Dart. libbox levels follow
|
||||
// sing/common/logger: 0 = panic, 1 = fatal, 2 = error, 3 = warn,
|
||||
// 4 = info, 5 = debug, 6 = trace.
|
||||
libboxRuntime.setLogSink { level, message ->
|
||||
emitLogMessage(level, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun libboxLogLevelName(level: Int): String = when (level) {
|
||||
0 -> "panic"
|
||||
1 -> "fatal"
|
||||
2 -> "error"
|
||||
3 -> "warn"
|
||||
4 -> "info"
|
||||
5 -> "debug"
|
||||
6 -> "trace"
|
||||
else -> "info"
|
||||
}
|
||||
|
||||
private fun emitLogMessage(level: Int, message: String) {
|
||||
emitLogMessage(
|
||||
SingboxProxyLogMessage(
|
||||
level = libboxLogLevelName(level),
|
||||
message = message,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
profileId = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitLogMessage(logMessage: SingboxProxyLogMessage) {
|
||||
dispatchToMain {
|
||||
onLogMessage(logMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitStateChanged(nextState: SingboxProxyRuntimeState) {
|
||||
dispatchToMain {
|
||||
onStateChanged(nextState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusLogMessage(
|
||||
status: SingboxProxyRuntimeStatus,
|
||||
message: String
|
||||
) = SingboxProxyLogMessage(
|
||||
level = if (status == SingboxProxyRuntimeStatus.ERROR) "warn" else "info",
|
||||
message = message,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
profileId = null
|
||||
)
|
||||
|
||||
override fun validateProfile(
|
||||
profile: SingboxProxyProfile,
|
||||
callback: (Result<String?>) -> Unit
|
||||
) {
|
||||
callback(Result.success(configBuilder.validateProfile(profile)))
|
||||
}
|
||||
|
||||
override fun buildConfig(
|
||||
profiles: List<SingboxProxyProfile>,
|
||||
options: SingboxProxyRuntimeOptions,
|
||||
callback: (Result<SingboxProxyConfigResult>) -> Unit
|
||||
) {
|
||||
runCatching { configBuilder.build(profiles, options) }
|
||||
.onSuccess { callback(Result.success(it)) }
|
||||
.onFailure { callback(Result.failure(it)) }
|
||||
}
|
||||
|
||||
override fun start(
|
||||
profiles: List<SingboxProxyProfile>,
|
||||
options: SingboxProxyRuntimeOptions,
|
||||
callback: (Result<SingboxProxyRuntimeState>) -> Unit
|
||||
) {
|
||||
runtimeExecutor.execute {
|
||||
val result = synchronized(stateLock) {
|
||||
val previousState = state
|
||||
runCatching {
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.STARTING,
|
||||
emptyList(),
|
||||
"Building sing-box config"
|
||||
)
|
||||
val config = configBuilder.build(profiles, options)
|
||||
|
||||
if (!libboxRuntime.isAvailable()) {
|
||||
val message = "sing-box libbox runtime is not linked"
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.ERROR,
|
||||
emptyList(),
|
||||
message
|
||||
)
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
|
||||
val previousBootstrapDohUrl = activeOptions.bootstrapDohUrl
|
||||
libboxRuntime.setBootstrapDohUrl(options.bootstrapDohUrl)
|
||||
try {
|
||||
libboxRuntime.start(config.configJson)
|
||||
} catch (error: Throwable) {
|
||||
libboxRuntime.setBootstrapDohUrl(previousBootstrapDohUrl)
|
||||
throw error
|
||||
}
|
||||
// Only commit profiles/options after start() returns without
|
||||
// throwing, so a failed start leaves the previous active set
|
||||
// intact rather than half-replaced.
|
||||
activeProfiles = profiles
|
||||
activeOptions = options
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.RUNNING,
|
||||
config.endpoints,
|
||||
null
|
||||
)
|
||||
state
|
||||
}.onFailure { error ->
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.ERROR,
|
||||
previousState.endpoints,
|
||||
error.message ?: error::class.java.simpleName
|
||||
)
|
||||
}
|
||||
}
|
||||
dispatchToMain { callback(result) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop(profileIds: List<String>, callback: (Result<Unit>) -> Unit) {
|
||||
runtimeExecutor.execute {
|
||||
val result = synchronized(stateLock) {
|
||||
runCatching {
|
||||
val remaining = activeProfiles.filterNot { profile ->
|
||||
profile.id in profileIds
|
||||
}
|
||||
if (remaining.isEmpty()) {
|
||||
libboxRuntime.stopService()
|
||||
activeProfiles = emptyList()
|
||||
activeOptions = defaultRuntimeOptions()
|
||||
libboxRuntime.setBootstrapDohUrl(null)
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.STOPPED,
|
||||
emptyList(),
|
||||
null
|
||||
)
|
||||
} else {
|
||||
val config = configBuilder.build(remaining, activeOptions)
|
||||
// Partial stop keeps activeOptions.bootstrapDohUrl, so
|
||||
// no setBootstrapDohUrl call is needed here — the
|
||||
// libbox bridge already holds the right URL from the
|
||||
// most recent start().
|
||||
libboxRuntime.start(config.configJson)
|
||||
activeProfiles = remaining
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.RUNNING,
|
||||
config.endpoints,
|
||||
null
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.ERROR,
|
||||
state.endpoints,
|
||||
error.message ?: error::class.java.simpleName
|
||||
)
|
||||
}
|
||||
}
|
||||
dispatchToMain { callback(result) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopAll(callback: (Result<Unit>) -> Unit) {
|
||||
runtimeExecutor.execute {
|
||||
val result = synchronized(stateLock) {
|
||||
runCatching {
|
||||
libboxRuntime.stopService()
|
||||
activeProfiles = emptyList()
|
||||
activeOptions = defaultRuntimeOptions()
|
||||
libboxRuntime.setBootstrapDohUrl(null)
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.STOPPED,
|
||||
emptyList(),
|
||||
null
|
||||
)
|
||||
}.onFailure { error ->
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.ERROR,
|
||||
state.endpoints,
|
||||
error.message ?: error::class.java.simpleName
|
||||
)
|
||||
}
|
||||
}
|
||||
dispatchToMain { callback(result) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState(): SingboxProxyRuntimeState = synchronized(stateLock) { state }
|
||||
|
||||
fun close() {
|
||||
runtimeExecutor.execute {
|
||||
synchronized(stateLock) {
|
||||
runCatching { libboxRuntime.stopService() }
|
||||
activeProfiles = emptyList()
|
||||
activeOptions = defaultRuntimeOptions()
|
||||
libboxRuntime.setBootstrapDohUrl(null)
|
||||
libboxRuntime.close()
|
||||
state = SingboxProxyRuntimeState(
|
||||
status = SingboxProxyRuntimeStatus.STOPPED,
|
||||
endpoints = emptyList(),
|
||||
message = null
|
||||
)
|
||||
}
|
||||
}
|
||||
runtimeExecutor.shutdown()
|
||||
}
|
||||
|
||||
private fun updateStateLocked(
|
||||
status: SingboxProxyRuntimeStatus,
|
||||
endpoints: List<SingboxProxyRuntimeEndpoint>,
|
||||
message: String?
|
||||
) {
|
||||
state = SingboxProxyRuntimeState(
|
||||
status = status,
|
||||
endpoints = endpoints,
|
||||
message = message
|
||||
)
|
||||
val snapshot = state
|
||||
emitStateChanged(snapshot)
|
||||
message?.let { emitLogMessage(statusLogMessage(status, it)) }
|
||||
}
|
||||
|
||||
private fun defaultRuntimeOptions() = SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = null,
|
||||
blockUnmatchedTraffic = true,
|
||||
dnsConfig = null,
|
||||
bootstrapDohUrl = null
|
||||
)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
/**
|
||||
* Inbound/outbound tag format shared between the Kotlin config builder and
|
||||
* the Dart DNS resolver. Changing this contract requires updating
|
||||
* `dns_config_resolver.dart` in lockstep — the Dart side emits matching tags
|
||||
* so DNS detours bind to the outbound this builder actually creates.
|
||||
*/
|
||||
internal object SingboxTagFormat {
|
||||
fun inboundTag(profileId: String): String = "in-${sanitizeTag(profileId)}"
|
||||
|
||||
fun outboundTag(profileId: String): String = "out-${sanitizeTag(profileId)}"
|
||||
|
||||
fun sanitizeTag(value: String): String {
|
||||
return value.replace(Regex("[^A-Za-z0-9_.-]"), "_")
|
||||
}
|
||||
}
|
||||
+947
@@ -0,0 +1,947 @@
|
||||
// Autogenerated from Pigeon (v26.3.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package eu.weblibre.flutter_singbox_proxy.generated
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object SingboxProxyApiPigeonUtils {
|
||||
|
||||
fun createConnectionError(channelName: String): FlutterError {
|
||||
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun doubleEquals(a: Double, b: Double): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun floatEquals(a: Float, b: Float): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun doubleHash(d: Double): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (d == 0.0) 0.0 else d
|
||||
val bits = java.lang.Double.doubleToLongBits(normalized)
|
||||
return (bits xor (bits ushr 32)).toInt()
|
||||
}
|
||||
|
||||
fun floatHash(f: Float): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (f == 0.0f) 0.0f else f
|
||||
return java.lang.Float.floatToIntBits(normalized)
|
||||
}
|
||||
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return false
|
||||
}
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!doubleEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is FloatArray && b is FloatArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!floatEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!deepEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
if (a.size != b.size) return false
|
||||
val iterA = a.iterator()
|
||||
val iterB = b.iterator()
|
||||
while (iterA.hasNext() && iterB.hasNext()) {
|
||||
if (!deepEquals(iterA.next(), iterB.next())) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
if (a.size != b.size) return false
|
||||
for (entry in a) {
|
||||
val key = entry.key
|
||||
var found = false
|
||||
for (bEntry in b) {
|
||||
if (deepEquals(key, bEntry.key)) {
|
||||
if (deepEquals(entry.value, bEntry.value)) {
|
||||
found = true
|
||||
break
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Double && b is Double) {
|
||||
return doubleEquals(a, b)
|
||||
}
|
||||
if (a is Float && b is Float) {
|
||||
return floatEquals(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
fun deepHash(value: Any?): Int {
|
||||
return when (value) {
|
||||
null -> 0
|
||||
is ByteArray -> value.contentHashCode()
|
||||
is IntArray -> value.contentHashCode()
|
||||
is LongArray -> value.contentHashCode()
|
||||
is DoubleArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + doubleHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is FloatArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + floatHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Array<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is List<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Map<*, *> -> {
|
||||
var result = 0
|
||||
for (entry in value) {
|
||||
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
|
||||
}
|
||||
result
|
||||
}
|
||||
is Double -> doubleHash(value)
|
||||
is Float -> floatHash(value)
|
||||
else -> value.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
|
||||
enum class SingboxProxyProfileType(val raw: Int) {
|
||||
SOCKS(0),
|
||||
HTTP(1),
|
||||
SHADOWSOCKS(2),
|
||||
VMESS(3),
|
||||
VLESS(4),
|
||||
TROJAN(5),
|
||||
NAIVE(6),
|
||||
HYSTERIA(7),
|
||||
HYSTERIA2(8),
|
||||
TUIC(9),
|
||||
SSH(10),
|
||||
WIREGUARD(11),
|
||||
SHADOW_TLS(12),
|
||||
ANY_TLS(13),
|
||||
CUSTOM_OUTBOUND(14);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): SingboxProxyProfileType? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SingboxProxyRuntimeStatus(val raw: Int) {
|
||||
STOPPED(0),
|
||||
STARTING(1),
|
||||
RUNNING(2),
|
||||
STOPPING(3),
|
||||
ERROR(4);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): SingboxProxyRuntimeStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyProfile (
|
||||
val id: String,
|
||||
val name: String,
|
||||
val type: SingboxProxyProfileType,
|
||||
/**
|
||||
* Public profile configuration as JSON. The schema is intentionally owned by
|
||||
* the profile type so the Pigeon API stays stable while sing-box evolves.
|
||||
*/
|
||||
val configJson: String,
|
||||
/**
|
||||
* Resolved secret values as JSON. Flutter stores secrets independently and
|
||||
* only passes them to native code when building or starting a runtime config.
|
||||
*/
|
||||
val secretJson: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyProfile {
|
||||
val id = pigeonVar_list[0] as String
|
||||
val name = pigeonVar_list[1] as String
|
||||
val type = pigeonVar_list[2] as SingboxProxyProfileType
|
||||
val configJson = pigeonVar_list[3] as String
|
||||
val secretJson = pigeonVar_list[4] as String?
|
||||
return SingboxProxyProfile(id, name, type, configJson, secretJson)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
configJson,
|
||||
secretJson,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyProfile
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.id, other.id) && SingboxProxyApiPigeonUtils.deepEquals(this.name, other.name) && SingboxProxyApiPigeonUtils.deepEquals(this.type, other.type) && SingboxProxyApiPigeonUtils.deepEquals(this.configJson, other.configJson) && SingboxProxyApiPigeonUtils.deepEquals(this.secretJson, other.secretJson)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.id)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.name)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.type)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.configJson)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.secretJson)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyRuntimeOptions (
|
||||
/** Optional preferred start port for generated local SOCKS inbounds. */
|
||||
val preferredBasePort: Long? = null,
|
||||
/**
|
||||
* If true, traffic entering sing-box without a matching inbound rule is
|
||||
* rejected instead of falling through to direct.
|
||||
*/
|
||||
val blockUnmatchedTraffic: Boolean,
|
||||
/**
|
||||
* Optional DNS block emitted into sing-box config. When null, sing-box
|
||||
* uses its built-in default (system resolver), which can leak DNS outside
|
||||
* the proxy — callers should always provide an explicit configuration.
|
||||
*/
|
||||
val dnsConfig: SingboxProxyDnsConfig? = null,
|
||||
/**
|
||||
* DoH endpoint used by the native LocalDNSTransport bridge to bootstrap
|
||||
* hostname-only DNS server addresses (and any other hostname appearing in
|
||||
* the sing-box config). When null, the bridge refuses to resolve and
|
||||
* sing-box's stock `/etc/resolv.conf`/127.0.0.1:53 path runs — which is
|
||||
* broken on Android. Callers should always pass the browser DoH URL.
|
||||
*/
|
||||
val bootstrapDohUrl: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyRuntimeOptions {
|
||||
val preferredBasePort = pigeonVar_list[0] as Long?
|
||||
val blockUnmatchedTraffic = pigeonVar_list[1] as Boolean
|
||||
val dnsConfig = pigeonVar_list[2] as SingboxProxyDnsConfig?
|
||||
val bootstrapDohUrl = pigeonVar_list[3] as String?
|
||||
return SingboxProxyRuntimeOptions(preferredBasePort, blockUnmatchedTraffic, dnsConfig, bootstrapDohUrl)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
preferredBasePort,
|
||||
blockUnmatchedTraffic,
|
||||
dnsConfig,
|
||||
bootstrapDohUrl,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyRuntimeOptions
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.preferredBasePort, other.preferredBasePort) && SingboxProxyApiPigeonUtils.deepEquals(this.blockUnmatchedTraffic, other.blockUnmatchedTraffic) && SingboxProxyApiPigeonUtils.deepEquals(this.dnsConfig, other.dnsConfig) && SingboxProxyApiPigeonUtils.deepEquals(this.bootstrapDohUrl, other.bootstrapDohUrl)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.preferredBasePort)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.blockUnmatchedTraffic)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.dnsConfig)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.bootstrapDohUrl)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyDnsServerConfig (
|
||||
/** sing-box server tag, used to reference the server from `dns.rules`. */
|
||||
val tag: String,
|
||||
/**
|
||||
* Server address. `https://...`, `tls://...`, `quic://...`, or plain IP.
|
||||
* Hostnames are resolved on demand via the platform LocalDNSTransport
|
||||
* bridge (sing-box `type: "local"` with our DoH-backed implementation).
|
||||
*/
|
||||
val address: String,
|
||||
/**
|
||||
* Outbound tag to dial the resolver through, or `direct` for direct, or
|
||||
* null when sing-box should pick automatically.
|
||||
*/
|
||||
val detourTag: String? = null,
|
||||
/**
|
||||
* If non-empty, attaches a `dns.rules` entry routing matching domains to
|
||||
* this server.
|
||||
*/
|
||||
val matchDomainSuffixes: List<String>,
|
||||
/** Advanced sing-box geosite selectors (e.g. `geosite:cn`). */
|
||||
val matchGeosites: List<String>,
|
||||
/**
|
||||
* If non-empty, attaches a `dns.rules` entry routing queries that *the
|
||||
* listed outbounds* originate to this server. Note: sing-box treats an
|
||||
* outbound's own bootstrap lookups (e.g. WireGuard peer hostname
|
||||
* resolution) as queries from that outbound, so using this for per-profile
|
||||
* scoping creates a chicken-and-egg loop at startup. Prefer
|
||||
* [matchInbounds] for "queries from tabs routed through this profile".
|
||||
*/
|
||||
val matchOutbounds: List<String>,
|
||||
/**
|
||||
* If non-empty, attaches a `dns.rules` entry matching the listed inbound
|
||||
* tags. Queries entering via that inbound (e.g. a tab whose container is
|
||||
* bound to this profile's local SOCKS inbound) resolve through this
|
||||
* server. Endpoint-bootstrap lookups don't come from any inbound, so this
|
||||
* scope safely excludes them.
|
||||
*/
|
||||
val matchInbounds: List<String>
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyDnsServerConfig {
|
||||
val tag = pigeonVar_list[0] as String
|
||||
val address = pigeonVar_list[1] as String
|
||||
val detourTag = pigeonVar_list[2] as String?
|
||||
val matchDomainSuffixes = pigeonVar_list[3] as List<String>
|
||||
val matchGeosites = pigeonVar_list[4] as List<String>
|
||||
val matchOutbounds = pigeonVar_list[5] as List<String>
|
||||
val matchInbounds = pigeonVar_list[6] as List<String>
|
||||
return SingboxProxyDnsServerConfig(tag, address, detourTag, matchDomainSuffixes, matchGeosites, matchOutbounds, matchInbounds)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
tag,
|
||||
address,
|
||||
detourTag,
|
||||
matchDomainSuffixes,
|
||||
matchGeosites,
|
||||
matchOutbounds,
|
||||
matchInbounds,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyDnsServerConfig
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.tag, other.tag) && SingboxProxyApiPigeonUtils.deepEquals(this.address, other.address) && SingboxProxyApiPigeonUtils.deepEquals(this.detourTag, other.detourTag) && SingboxProxyApiPigeonUtils.deepEquals(this.matchDomainSuffixes, other.matchDomainSuffixes) && SingboxProxyApiPigeonUtils.deepEquals(this.matchGeosites, other.matchGeosites) && SingboxProxyApiPigeonUtils.deepEquals(this.matchOutbounds, other.matchOutbounds) && SingboxProxyApiPigeonUtils.deepEquals(this.matchInbounds, other.matchInbounds)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.tag)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.address)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.detourTag)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchDomainSuffixes)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchGeosites)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchOutbounds)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchInbounds)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyDnsConfig (
|
||||
val servers: List<SingboxProxyDnsServerConfig>,
|
||||
/**
|
||||
* Server tag used as `dns.final`. When null, sing-box uses the first
|
||||
* server in the list as the fallback.
|
||||
*/
|
||||
val finalServerTag: String? = null,
|
||||
/** sing-box `dns.strategy` string. e.g. `prefer_ipv4`, `ipv4_only`. */
|
||||
val domainStrategy: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyDnsConfig {
|
||||
val servers = pigeonVar_list[0] as List<SingboxProxyDnsServerConfig>
|
||||
val finalServerTag = pigeonVar_list[1] as String?
|
||||
val domainStrategy = pigeonVar_list[2] as String
|
||||
return SingboxProxyDnsConfig(servers, finalServerTag, domainStrategy)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
servers,
|
||||
finalServerTag,
|
||||
domainStrategy,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyDnsConfig
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.servers, other.servers) && SingboxProxyApiPigeonUtils.deepEquals(this.finalServerTag, other.finalServerTag) && SingboxProxyApiPigeonUtils.deepEquals(this.domainStrategy, other.domainStrategy)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.servers)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.finalServerTag)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.domainStrategy)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyRuntimeEndpoint (
|
||||
val profileId: String,
|
||||
val host: String,
|
||||
val port: Long,
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyRuntimeEndpoint {
|
||||
val profileId = pigeonVar_list[0] as String
|
||||
val host = pigeonVar_list[1] as String
|
||||
val port = pigeonVar_list[2] as Long
|
||||
val username = pigeonVar_list[3] as String
|
||||
val password = pigeonVar_list[4] as String
|
||||
return SingboxProxyRuntimeEndpoint(profileId, host, port, username, password)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
profileId,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyRuntimeEndpoint
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.profileId, other.profileId) && SingboxProxyApiPigeonUtils.deepEquals(this.host, other.host) && SingboxProxyApiPigeonUtils.deepEquals(this.port, other.port) && SingboxProxyApiPigeonUtils.deepEquals(this.username, other.username) && SingboxProxyApiPigeonUtils.deepEquals(this.password, other.password)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.host)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.port)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.username)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.password)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyRuntimeState (
|
||||
val status: SingboxProxyRuntimeStatus,
|
||||
val endpoints: List<SingboxProxyRuntimeEndpoint>,
|
||||
val message: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyRuntimeState {
|
||||
val status = pigeonVar_list[0] as SingboxProxyRuntimeStatus
|
||||
val endpoints = pigeonVar_list[1] as List<SingboxProxyRuntimeEndpoint>
|
||||
val message = pigeonVar_list[2] as String?
|
||||
return SingboxProxyRuntimeState(status, endpoints, message)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
status,
|
||||
endpoints,
|
||||
message,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyRuntimeState
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.status, other.status) && SingboxProxyApiPigeonUtils.deepEquals(this.endpoints, other.endpoints) && SingboxProxyApiPigeonUtils.deepEquals(this.message, other.message)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.status)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyConfigResult (
|
||||
val configJson: String,
|
||||
val endpoints: List<SingboxProxyRuntimeEndpoint>
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyConfigResult {
|
||||
val configJson = pigeonVar_list[0] as String
|
||||
val endpoints = pigeonVar_list[1] as List<SingboxProxyRuntimeEndpoint>
|
||||
return SingboxProxyConfigResult(configJson, endpoints)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
configJson,
|
||||
endpoints,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyConfigResult
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.configJson, other.configJson) && SingboxProxyApiPigeonUtils.deepEquals(this.endpoints, other.endpoints)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.configJson)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyLogMessage (
|
||||
val level: String,
|
||||
val message: String,
|
||||
val timestamp: Long,
|
||||
val profileId: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyLogMessage {
|
||||
val level = pigeonVar_list[0] as String
|
||||
val message = pigeonVar_list[1] as String
|
||||
val timestamp = pigeonVar_list[2] as Long
|
||||
val profileId = pigeonVar_list[3] as String?
|
||||
return SingboxProxyLogMessage(level, message, timestamp, profileId)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
level,
|
||||
message,
|
||||
timestamp,
|
||||
profileId,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyLogMessage
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.level, other.level) && SingboxProxyApiPigeonUtils.deepEquals(this.message, other.message) && SingboxProxyApiPigeonUtils.deepEquals(this.timestamp, other.timestamp) && SingboxProxyApiPigeonUtils.deepEquals(this.profileId, other.profileId)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.level)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.timestamp)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class SingboxProxyApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
SingboxProxyProfileType.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
SingboxProxyRuntimeStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyProfile.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyRuntimeOptions.fromList(it)
|
||||
}
|
||||
}
|
||||
133.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyDnsServerConfig.fromList(it)
|
||||
}
|
||||
}
|
||||
134.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyDnsConfig.fromList(it)
|
||||
}
|
||||
}
|
||||
135.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyRuntimeEndpoint.fromList(it)
|
||||
}
|
||||
}
|
||||
136.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyRuntimeState.fromList(it)
|
||||
}
|
||||
}
|
||||
137.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyConfigResult.fromList(it)
|
||||
}
|
||||
}
|
||||
138.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyLogMessage.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is SingboxProxyProfileType -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is SingboxProxyRuntimeStatus -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is SingboxProxyProfile -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyRuntimeOptions -> {
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyDnsServerConfig -> {
|
||||
stream.write(133)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyDnsConfig -> {
|
||||
stream.write(134)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyRuntimeEndpoint -> {
|
||||
stream.write(135)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyRuntimeState -> {
|
||||
stream.write(136)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyConfigResult -> {
|
||||
stream.write(137)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyLogMessage -> {
|
||||
stream.write(138)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface SingboxProxyApi {
|
||||
fun validateProfile(profile: SingboxProxyProfile, callback: (Result<String?>) -> Unit)
|
||||
fun buildConfig(profiles: List<SingboxProxyProfile>, options: SingboxProxyRuntimeOptions, callback: (Result<SingboxProxyConfigResult>) -> Unit)
|
||||
fun start(profiles: List<SingboxProxyProfile>, options: SingboxProxyRuntimeOptions, callback: (Result<SingboxProxyRuntimeState>) -> Unit)
|
||||
fun stop(profileIds: List<String>, callback: (Result<Unit>) -> Unit)
|
||||
fun stopAll(callback: (Result<Unit>) -> Unit)
|
||||
fun getState(): SingboxProxyRuntimeState
|
||||
|
||||
companion object {
|
||||
/** The codec used by SingboxProxyApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
SingboxProxyApiPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `SingboxProxyApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: SingboxProxyApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.validateProfile$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val profileArg = args[0] as SingboxProxyProfile
|
||||
api.validateProfile(profileArg) { result: Result<String?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.buildConfig$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val profilesArg = args[0] as List<SingboxProxyProfile>
|
||||
val optionsArg = args[1] as SingboxProxyRuntimeOptions
|
||||
api.buildConfig(profilesArg, optionsArg) { result: Result<SingboxProxyConfigResult> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.start$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val profilesArg = args[0] as List<SingboxProxyProfile>
|
||||
val optionsArg = args[1] as SingboxProxyRuntimeOptions
|
||||
api.start(profilesArg, optionsArg) { result: Result<SingboxProxyRuntimeState> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stop$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val profileIdsArg = args[0] as List<String>
|
||||
api.stop(profileIdsArg) { result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stopAll$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.stopAll{ result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.getState$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.getState())
|
||||
} catch (exception: Throwable) {
|
||||
SingboxProxyApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
|
||||
class SingboxProxyEventsApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||
companion object {
|
||||
/** The codec used by SingboxProxyEventsApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
SingboxProxyApiPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onStateChanged(stateArg: SingboxProxyRuntimeState, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onStateChanged$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(stateArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
} else {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(SingboxProxyApiPigeonUtils.createConnectionError(channelName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onLogMessage(messageArg: SingboxProxyLogMessage, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onLogMessage$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(messageArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
} else {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(SingboxProxyApiPigeonUtils.createConnectionError(channelName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfileType
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsConfig
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsServerConfig
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class FlutterSingboxProxyPluginTest {
|
||||
@Test
|
||||
fun buildConfig_wrapsProfileOutboundWithAuthenticatedSocksInbound() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "wg-home",
|
||||
name = "WireGuard Home",
|
||||
type = SingboxProxyProfileType.WIREGUARD,
|
||||
configJson = "{\"server\":\"example.test\"}",
|
||||
secretJson = "{\"private_key\":\"secret\"}"
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(preferredBasePort = 12500, blockUnmatchedTraffic = true)
|
||||
)
|
||||
|
||||
assertEquals(1, result.endpoints.size)
|
||||
assertEquals(12500, result.endpoints.single().port)
|
||||
assertContains(result.configJson, "\"type\": \"socks\"")
|
||||
assertContains(result.configJson, "\"type\": \"wireguard\"")
|
||||
assertContains(result.configJson, "\"endpoints\"")
|
||||
assertContains(result.configJson, "\"private_key\": \"secret\"")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildConfig_migratesWireGuardOutboundProfileToEndpoint() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "wg-home",
|
||||
name = "WireGuard Home",
|
||||
type = SingboxProxyProfileType.WIREGUARD,
|
||||
configJson = """
|
||||
{
|
||||
"type": "wireguard",
|
||||
"server": "example.test",
|
||||
"server_port": 51820,
|
||||
"local_address": ["10.7.0.2/32"],
|
||||
"peer_public_key": "peer",
|
||||
"mtu": 1408
|
||||
}
|
||||
""".trimIndent(),
|
||||
secretJson = """
|
||||
{
|
||||
"private_key": "secret",
|
||||
"pre_shared_key": "psk"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(preferredBasePort = 12500, blockUnmatchedTraffic = true)
|
||||
)
|
||||
|
||||
assertContains(result.configJson, "\"endpoints\"")
|
||||
assertContains(result.configJson, "\"address\": \"example.test\"")
|
||||
assertContains(result.configJson, "\"port\": 51820")
|
||||
assertContains(result.configJson, "\"address\": [")
|
||||
assertContains(result.configJson, "\"public_key\": \"peer\"")
|
||||
assertContains(result.configJson, "\"allowed_ips\": [")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateProfile_acceptsTypeSpecificConfigWithoutExplicitType() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "ss-main",
|
||||
name = "Shadowsocks",
|
||||
type = SingboxProxyProfileType.SHADOWSOCKS,
|
||||
configJson = "{\"server\":\"example.test\"}",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
assertNull(builder.validateProfile(profile))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildConfig_emitsLocalBootstrapAndDomainResolverForHostnames() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "profile-1",
|
||||
name = "SOCKS",
|
||||
type = SingboxProxyProfileType.SOCKS,
|
||||
configJson = "{\"server\":\"127.0.0.1\",\"server_port\":1080}",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = 12500,
|
||||
blockUnmatchedTraffic = true,
|
||||
bootstrapDohUrl = "https://dns.example/dns-query",
|
||||
dnsConfig = SingboxProxyDnsConfig(
|
||||
servers = listOf(
|
||||
dnsServer(
|
||||
tag = "corp",
|
||||
address = "tls://dns.example",
|
||||
detourTag = "out-profile-1",
|
||||
matchInbounds = listOf("in-profile-1")
|
||||
)
|
||||
),
|
||||
finalServerTag = null,
|
||||
domainStrategy = ""
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val config = JSONObject(result.configJson)
|
||||
val dns = config.getJSONObject("dns")
|
||||
val servers = dns.getJSONArray("servers")
|
||||
val local = servers.getJSONObject(0)
|
||||
val corp = servers.getJSONObject(1)
|
||||
val rule = dns.getJSONArray("rules").getJSONObject(0)
|
||||
|
||||
// `local` is always emitted; LocalDNSTransport bridge backs it at runtime.
|
||||
assertEquals("local", local.getString("type"))
|
||||
assertEquals("local", local.getString("tag"))
|
||||
assertFalse(local.has("detour"))
|
||||
|
||||
assertEquals("tls", corp.getString("type"))
|
||||
assertEquals("dns.example", corp.getString("server"))
|
||||
// Hostname target → bootstrap through `local`.
|
||||
assertEquals("local", corp.getString("domain_resolver"))
|
||||
assertEquals("out-profile-1", corp.getString("detour"))
|
||||
// The original `tls.server_name` plumbing is gone; SNI is derived
|
||||
// from the preserved hostname in `server`.
|
||||
assertFalse(corp.has("tls"))
|
||||
|
||||
assertEquals("route", rule.getString("action"))
|
||||
assertEquals("corp", rule.getString("server"))
|
||||
assertEquals("local", dns.getString("final"))
|
||||
|
||||
// route.default_domain_resolver ties WG peers and other outbound
|
||||
// hostnames into the same `local` bridge.
|
||||
val route = config.getJSONObject("route")
|
||||
assertEquals("local", route.getString("default_domain_resolver"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildConfig_omitsDomainResolverForIpLiteralServers() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "profile-1",
|
||||
name = "SOCKS",
|
||||
type = SingboxProxyProfileType.SOCKS,
|
||||
configJson = "{\"server\":\"127.0.0.1\",\"server_port\":1080}",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = 12500,
|
||||
blockUnmatchedTraffic = true,
|
||||
bootstrapDohUrl = "https://dns.example/dns-query",
|
||||
dnsConfig = SingboxProxyDnsConfig(
|
||||
servers = listOf(
|
||||
dnsServer(
|
||||
tag = "plain",
|
||||
address = "udp://1.2.3.4",
|
||||
matchInbounds = emptyList()
|
||||
)
|
||||
),
|
||||
finalServerTag = "plain",
|
||||
domainStrategy = ""
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val dns = JSONObject(result.configJson).getJSONObject("dns")
|
||||
// `local` is still emitted unconditionally as the bootstrap anchor.
|
||||
assertEquals("local", dns.getJSONArray("servers").getJSONObject(0).getString("type"))
|
||||
val plain = dns.getJSONArray("servers").getJSONObject(1)
|
||||
assertEquals("udp", plain.getString("type"))
|
||||
assertEquals("1.2.3.4", plain.getString("server"))
|
||||
// IP literal: no domain_resolver needed.
|
||||
assertFalse(plain.has("domain_resolver"))
|
||||
assertFalse(plain.has("tls"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildConfig_rejectsDnsConfigWithoutBootstrapDohUrl() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "profile-1",
|
||||
name = "SOCKS",
|
||||
type = SingboxProxyProfileType.SOCKS,
|
||||
configJson = "{\"server\":\"127.0.0.1\",\"server_port\":1080}",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
val error = assertFailsWith<IllegalArgumentException> {
|
||||
builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = 12500,
|
||||
blockUnmatchedTraffic = true,
|
||||
dnsConfig = SingboxProxyDnsConfig(
|
||||
servers = listOf(
|
||||
dnsServer(tag = "plain", address = "udp://1.2.3.4")
|
||||
),
|
||||
finalServerTag = "plain",
|
||||
domainStrategy = ""
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
"bootstrapDohUrl is required when dnsConfig is provided.",
|
||||
error.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dnsServer(
|
||||
tag: String,
|
||||
address: String,
|
||||
detourTag: String? = null,
|
||||
matchDomainSuffixes: List<String> = emptyList(),
|
||||
matchInbounds: List<String> = emptyList()
|
||||
) = SingboxProxyDnsServerConfig(
|
||||
tag = tag,
|
||||
address = address,
|
||||
detourTag = detourTag,
|
||||
matchDomainSuffixes = matchDomainSuffixes,
|
||||
matchGeosites = emptyList(),
|
||||
matchOutbounds = emptyList(),
|
||||
matchInbounds = matchInbounds
|
||||
)
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import android.content.Context
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfileType
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeState
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeStatus
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
import org.mockito.Mockito.mock
|
||||
|
||||
internal class SingboxRuntimeManagerTest {
|
||||
@Test
|
||||
fun startFailure_preservesPreviouslyRunningEndpoints() {
|
||||
val runtime = FakeLibboxRuntime(failOnStartAttempt = 2)
|
||||
val manager = SingboxRuntimeManager(
|
||||
context = mock(Context::class.java),
|
||||
libboxRuntime = runtime,
|
||||
dispatchToMain = { action -> action() }
|
||||
)
|
||||
|
||||
val firstState = manager.awaitStart(listOf(profile(id = "profile-a"))).getOrThrow()
|
||||
val failedResult = manager.awaitStart(listOf(profile(id = "profile-b")))
|
||||
val stateAfterFailure = manager.getState()
|
||||
manager.close()
|
||||
|
||||
assertTrue(failedResult.isFailure)
|
||||
assertIs<IllegalStateException>(failedResult.exceptionOrNull())
|
||||
assertEquals(SingboxProxyRuntimeStatus.ERROR, stateAfterFailure.status)
|
||||
assertEquals(firstState.endpoints, stateAfterFailure.endpoints)
|
||||
assertEquals("start failed", stateAfterFailure.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun profile(id: String) = SingboxProxyProfile(
|
||||
id = id,
|
||||
name = id,
|
||||
type = SingboxProxyProfileType.SOCKS,
|
||||
configJson = """{"server":"127.0.0.1","server_port":1080}""",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
private fun SingboxRuntimeManager.awaitStart(
|
||||
profiles: List<SingboxProxyProfile>
|
||||
): Result<SingboxProxyRuntimeState> {
|
||||
val latch = CountDownLatch(1)
|
||||
var result: Result<SingboxProxyRuntimeState>? = null
|
||||
|
||||
start(profiles, SingboxProxyRuntimeOptions(preferredBasePort = 12080, blockUnmatchedTraffic = true)) {
|
||||
result = it
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for start callback")
|
||||
return result!!
|
||||
}
|
||||
|
||||
private class FakeLibboxRuntime(
|
||||
private val failOnStartAttempt: Int,
|
||||
) : LibboxRuntime(mock(Context::class.java)) {
|
||||
private var startAttempts = 0
|
||||
|
||||
override fun isAvailable(): Boolean = true
|
||||
|
||||
override fun start(configJson: String) {
|
||||
startAttempts += 1
|
||||
if (startAttempts == failOnStartAttempt) {
|
||||
throw IllegalStateException("start failed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopService() {}
|
||||
|
||||
override fun close() {}
|
||||
|
||||
override fun setLogSink(sink: ((Int, String) -> Unit)?) {}
|
||||
|
||||
override fun setBootstrapDohUrl(url: String?) {}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Tag format is part of the Dart↔Kotlin contract: the Dart DNS resolver in
|
||||
* `dns_config_resolver.dart` emits `out-`/`in-` tags that must match what the
|
||||
* config builder writes. The mirrored Dart test lives in
|
||||
* `apps/weblibre/test/features/proxy/domain/services/singbox_tag_format_test.dart`
|
||||
* — both must update together if this format ever changes.
|
||||
*/
|
||||
internal class SingboxTagFormatTest {
|
||||
@Test
|
||||
fun outboundTag_isPrefixedAndSanitized() {
|
||||
assertEquals("out-singbox_foo-bar", SingboxTagFormat.outboundTag("singbox:foo-bar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundTag_isPrefixedAndSanitized() {
|
||||
assertEquals("in-singbox_foo-bar", SingboxTagFormat.inboundTag("singbox:foo-bar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeTag_preservesAlphanumericDotsDashesUnderscores() {
|
||||
assertEquals(
|
||||
"Abc_123.x-Y",
|
||||
SingboxTagFormat.sanitizeTag("Abc_123.x-Y")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeTag_replacesSpacesSlashesAndColons() {
|
||||
assertEquals(
|
||||
"a_b_c_d_e",
|
||||
SingboxTagFormat.sanitizeTag("a:b c/d\\e")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,17 @@
|
||||
# flutter_singbox_proxy_example
|
||||
|
||||
Demonstrates how to use the flutter_singbox_proxy plugin.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Analysis options for flutter_singbox_proxy example
|
||||
# Includes root configuration from monorepo
|
||||
|
||||
include: ../../../analysis_options.yaml
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,38 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "eu.weblibre.flutter_singbox_proxy_example"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "eu.weblibre.flutter_singbox_proxy_example"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="flutter_singbox_proxy_example"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package eu.weblibre.flutter_singbox_proxy_example
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
+18
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,18 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = "../../build"
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
|
||||
@@ -0,0 +1,25 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}()
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.13.2" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.3.21" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
@@ -0,0 +1,23 @@
|
||||
// This is a basic Flutter integration test.
|
||||
//
|
||||
// Since integration tests run in a full Flutter application, they can interact
|
||||
// with the host side of a plugin implementation, unlike Dart unit tests.
|
||||
//
|
||||
// For more information about Flutter integration tests, please see
|
||||
// https://flutter.dev/to/integration-testing
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('getState test', (WidgetTester tester) async {
|
||||
final FlutterSingboxProxy plugin = FlutterSingboxProxy();
|
||||
final state = await plugin.getState();
|
||||
|
||||
expect(state.status, SingboxProxyRuntimeStatus.stopped);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final proxy = FlutterSingboxProxy();
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
appBar: AppBar(title: const Text('Plugin example app')),
|
||||
body: FutureBuilder<SingboxProxyRuntimeState>(
|
||||
future: proxy.getState(),
|
||||
builder: (context, snapshot) {
|
||||
final status = snapshot.data?.status.name ?? 'unknown';
|
||||
return Center(child: Text('sing-box proxy status: $status'));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
name: flutter_singbox_proxy_example
|
||||
description: "Demonstrates how to use the flutter_singbox_proxy plugin."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
flutter_singbox_proxy:
|
||||
# When depending on this package from a real application you should use:
|
||||
# flutter_singbox_proxy: ^x.y.z
|
||||
# See https://dart.dev/tools/pub/dependencies#version-constraints
|
||||
# The example app is bundled with the plugin so we use a path dependency on
|
||||
# the parent directory to use the current plugin's version.
|
||||
path: ../
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
dev_dependencies:
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
@@ -0,0 +1,28 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:flutter_singbox_proxy_example/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shows proxy runtime status label', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Without a native messenger in widget tests the future remains pending,
|
||||
// but the static label should still be present.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(Widget widget) =>
|
||||
widget is Text && widget.data!.startsWith('sing-box proxy status:'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'src/singbox_proxy_api.g.dart';
|
||||
|
||||
export 'src/singbox_proxy_api.g.dart'
|
||||
show
|
||||
SingboxProxyConfigResult,
|
||||
SingboxProxyDnsConfig,
|
||||
SingboxProxyDnsServerConfig,
|
||||
SingboxProxyLogMessage,
|
||||
SingboxProxyProfile,
|
||||
SingboxProxyProfileType,
|
||||
SingboxProxyRuntimeEndpoint,
|
||||
SingboxProxyRuntimeOptions,
|
||||
SingboxProxyRuntimeState,
|
||||
SingboxProxyRuntimeStatus;
|
||||
|
||||
class FlutterSingboxProxy implements SingboxProxyEventsApi {
|
||||
FlutterSingboxProxy({SingboxProxyApi? api})
|
||||
: _api = api ?? SingboxProxyApi() {
|
||||
SingboxProxyEventsApi.setUp(this);
|
||||
}
|
||||
|
||||
final SingboxProxyApi _api;
|
||||
final _stateController =
|
||||
StreamController<SingboxProxyRuntimeState>.broadcast();
|
||||
final _logController = StreamController<SingboxProxyLogMessage>.broadcast();
|
||||
|
||||
Stream<SingboxProxyRuntimeState> get stateStream => _stateController.stream;
|
||||
Stream<SingboxProxyLogMessage> get logStream => _logController.stream;
|
||||
|
||||
Future<String?> validateProfile(SingboxProxyProfile profile) {
|
||||
return _api.validateProfile(profile);
|
||||
}
|
||||
|
||||
Future<SingboxProxyConfigResult> buildConfig(
|
||||
List<SingboxProxyProfile> profiles, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) {
|
||||
return _api.buildConfig(profiles, options ?? SingboxProxyRuntimeOptions());
|
||||
}
|
||||
|
||||
Future<SingboxProxyRuntimeState> start(
|
||||
List<SingboxProxyProfile> profiles, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) {
|
||||
return _api.start(profiles, options ?? SingboxProxyRuntimeOptions());
|
||||
}
|
||||
|
||||
Future<void> stop(List<String> profileIds) => _api.stop(profileIds);
|
||||
|
||||
Future<void> stopAll() => _api.stopAll();
|
||||
|
||||
Future<SingboxProxyRuntimeState> getState() => _api.getState();
|
||||
|
||||
@override
|
||||
void onStateChanged(SingboxProxyRuntimeState state) {
|
||||
_stateController.add(state);
|
||||
}
|
||||
|
||||
@override
|
||||
void onLogMessage(SingboxProxyLogMessage message) {
|
||||
_logController.add(message);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
SingboxProxyEventsApi.setUp(null);
|
||||
await _stateController.close();
|
||||
await _logController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
// Autogenerated from Pigeon (v26.3.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: unused_import, unused_shown_name
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(
|
||||
List<Object?>? replyList,
|
||||
String channelName, {
|
||||
required bool isNullValid,
|
||||
}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: replyList[0]! as String,
|
||||
message: replyList[1] as String?,
|
||||
details: replyList[2],
|
||||
);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
}
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
List<Object?> wrapResponse({
|
||||
Object? result,
|
||||
PlatformException? error,
|
||||
bool empty = false,
|
||||
}) {
|
||||
if (empty) {
|
||||
return <Object?>[];
|
||||
}
|
||||
if (error == null) {
|
||||
return <Object?>[result];
|
||||
}
|
||||
return <Object?>[error.code, error.message, error.details];
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (identical(a, b)) {
|
||||
return true;
|
||||
}
|
||||
if (a is double && b is double) {
|
||||
if (a.isNaN && b.isNaN) {
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length &&
|
||||
a.indexed.every(
|
||||
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
|
||||
);
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
for (final MapEntry<Object?, Object?> entryA in a.entries) {
|
||||
bool found = false;
|
||||
for (final MapEntry<Object?, Object?> entryB in b.entries) {
|
||||
if (_deepEquals(entryA.key, entryB.key)) {
|
||||
if (_deepEquals(entryA.value, entryB.value)) {
|
||||
found = true;
|
||||
break;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
int _deepHash(Object? value) {
|
||||
if (value is List) {
|
||||
return Object.hashAll(value.map(_deepHash));
|
||||
}
|
||||
if (value is Map) {
|
||||
int result = 0;
|
||||
for (final MapEntry<Object?, Object?> entry in value.entries) {
|
||||
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value is double && value.isNaN) {
|
||||
// Normalize NaN to a consistent hash.
|
||||
return 0x7FF8000000000000.hashCode;
|
||||
}
|
||||
if (value is double && value == 0.0) {
|
||||
// Normalize -0.0 to 0.0 so they have the same hash code.
|
||||
return 0.0.hashCode;
|
||||
}
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
enum SingboxProxyProfileType {
|
||||
socks,
|
||||
http,
|
||||
shadowsocks,
|
||||
vmess,
|
||||
vless,
|
||||
trojan,
|
||||
naive,
|
||||
hysteria,
|
||||
hysteria2,
|
||||
tuic,
|
||||
ssh,
|
||||
wireguard,
|
||||
shadowTls,
|
||||
anyTls,
|
||||
customOutbound,
|
||||
}
|
||||
|
||||
enum SingboxProxyRuntimeStatus { stopped, starting, running, stopping, error }
|
||||
|
||||
class SingboxProxyProfile {
|
||||
SingboxProxyProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.configJson,
|
||||
this.secretJson,
|
||||
});
|
||||
|
||||
String id;
|
||||
|
||||
String name;
|
||||
|
||||
SingboxProxyProfileType type;
|
||||
|
||||
/// Public profile configuration as JSON. The schema is intentionally owned by
|
||||
/// the profile type so the Pigeon API stays stable while sing-box evolves.
|
||||
String configJson;
|
||||
|
||||
/// Resolved secret values as JSON. Flutter stores secrets independently and
|
||||
/// only passes them to native code when building or starting a runtime config.
|
||||
String? secretJson;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[id, name, type, configJson, secretJson];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static SingboxProxyProfile decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return SingboxProxyProfile(
|
||||
id: result[0]! as String,
|
||||
name: result[1]! as String,
|
||||
type: result[2]! as SingboxProxyProfileType,
|
||||
configJson: result[3]! as String,
|
||||
secretJson: result[4] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! SingboxProxyProfile || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(id, other.id) &&
|
||||
_deepEquals(name, other.name) &&
|
||||
_deepEquals(type, other.type) &&
|
||||
_deepEquals(configJson, other.configJson) &&
|
||||
_deepEquals(secretJson, other.secretJson);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class SingboxProxyRuntimeOptions {
|
||||
SingboxProxyRuntimeOptions({
|
||||
this.preferredBasePort,
|
||||
this.blockUnmatchedTraffic = true,
|
||||
this.dnsConfig,
|
||||
this.bootstrapDohUrl,
|
||||
});
|
||||
|
||||
/// Optional preferred start port for generated local SOCKS inbounds.
|
||||
int? preferredBasePort;
|
||||
|
||||
/// If true, traffic entering sing-box without a matching inbound rule is
|
||||
/// rejected instead of falling through to direct.
|
||||
bool blockUnmatchedTraffic;
|
||||
|
||||
/// Optional DNS block emitted into sing-box config. When null, sing-box
|
||||
/// uses its built-in default (system resolver), which can leak DNS outside
|
||||
/// the proxy — callers should always provide an explicit configuration.
|
||||
SingboxProxyDnsConfig? dnsConfig;
|
||||
|
||||
/// DoH endpoint used by the native LocalDNSTransport bridge to bootstrap
|
||||
/// hostname-only DNS server addresses (and any other hostname appearing in
|
||||
/// the sing-box config). When null, the bridge refuses to resolve and
|
||||
/// sing-box's stock `/etc/resolv.conf`/127.0.0.1:53 path runs — which is
|
||||
/// broken on Android. Callers should always pass the browser DoH URL.
|
||||
String? bootstrapDohUrl;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
preferredBasePort,
|
||||
blockUnmatchedTraffic,
|
||||
dnsConfig,
|
||||
bootstrapDohUrl,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static SingboxProxyRuntimeOptions decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return SingboxProxyRuntimeOptions(
|
||||
preferredBasePort: result[0] as int?,
|
||||
blockUnmatchedTraffic: result[1]! as bool,
|
||||
dnsConfig: result[2] as SingboxProxyDnsConfig?,
|
||||
bootstrapDohUrl: result[3] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! SingboxProxyRuntimeOptions ||
|
||||
other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(preferredBasePort, other.preferredBasePort) &&
|
||||
_deepEquals(blockUnmatchedTraffic, other.blockUnmatchedTraffic) &&
|
||||
_deepEquals(dnsConfig, other.dnsConfig) &&
|
||||
_deepEquals(bootstrapDohUrl, other.bootstrapDohUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class SingboxProxyDnsServerConfig {
|
||||
SingboxProxyDnsServerConfig({
|
||||
required this.tag,
|
||||
required this.address,
|
||||
this.detourTag,
|
||||
this.matchDomainSuffixes = const <String>[],
|
||||
this.matchGeosites = const <String>[],
|
||||
this.matchOutbounds = const <String>[],
|
||||
this.matchInbounds = const <String>[],
|
||||
});
|
||||
|
||||
/// sing-box server tag, used to reference the server from `dns.rules`.
|
||||
String tag;
|
||||
|
||||
/// Server address. `https://...`, `tls://...`, `quic://...`, or plain IP.
|
||||
/// Hostnames are resolved on demand via the platform LocalDNSTransport
|
||||
/// bridge (sing-box `type: "local"` with our DoH-backed implementation).
|
||||
String address;
|
||||
|
||||
/// Outbound tag to dial the resolver through, or `direct` for direct, or
|
||||
/// null when sing-box should pick automatically.
|
||||
String? detourTag;
|
||||
|
||||
/// If non-empty, attaches a `dns.rules` entry routing matching domains to
|
||||
/// this server.
|
||||
List<String> matchDomainSuffixes;
|
||||
|
||||
/// Advanced sing-box geosite selectors (e.g. `geosite:cn`).
|
||||
List<String> matchGeosites;
|
||||
|
||||
/// If non-empty, attaches a `dns.rules` entry routing queries that *the
|
||||
/// listed outbounds* originate to this server. Note: sing-box treats an
|
||||
/// outbound's own bootstrap lookups (e.g. WireGuard peer hostname
|
||||
/// resolution) as queries from that outbound, so using this for per-profile
|
||||
/// scoping creates a chicken-and-egg loop at startup. Prefer
|
||||
/// [matchInbounds] for "queries from tabs routed through this profile".
|
||||
List<String> matchOutbounds;
|
||||
|
||||
/// If non-empty, attaches a `dns.rules` entry matching the listed inbound
|
||||
/// tags. Queries entering via that inbound (e.g. a tab whose container is
|
||||
/// bound to this profile's local SOCKS inbound) resolve through this
|
||||
/// server. Endpoint-bootstrap lookups don't come from any inbound, so this
|
||||
/// scope safely excludes them.
|
||||
List<String> matchInbounds;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
tag,
|
||||
address,
|
||||
detourTag,
|
||||
matchDomainSuffixes,
|
||||
matchGeosites,
|
||||
matchOutbounds,
|
||||
matchInbounds,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static SingboxProxyDnsServerConfig decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return SingboxProxyDnsServerConfig(
|
||||
tag: result[0]! as String,
|
||||
address: result[1]! as String,
|
||||
detourTag: result[2] as String?,
|
||||
matchDomainSuffixes: (result[3]! as List<Object?>).cast<String>(),
|
||||
matchGeosites: (result[4]! as List<Object?>).cast<String>(),
|
||||
matchOutbounds: (result[5]! as List<Object?>).cast<String>(),
|
||||
matchInbounds: (result[6]! as List<Object?>).cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! SingboxProxyDnsServerConfig ||
|
||||
other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(tag, other.tag) &&
|
||||
_deepEquals(address, other.address) &&
|
||||
_deepEquals(detourTag, other.detourTag) &&
|
||||
_deepEquals(matchDomainSuffixes, other.matchDomainSuffixes) &&
|
||||
_deepEquals(matchGeosites, other.matchGeosites) &&
|
||||
_deepEquals(matchOutbounds, other.matchOutbounds) &&
|
||||
_deepEquals(matchInbounds, other.matchInbounds);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class SingboxProxyDnsConfig {
|
||||
SingboxProxyDnsConfig({
|
||||
required this.servers,
|
||||
this.finalServerTag,
|
||||
required this.domainStrategy,
|
||||
});
|
||||
|
||||
List<SingboxProxyDnsServerConfig> servers;
|
||||
|
||||
/// Server tag used as `dns.final`. When null, sing-box uses the first
|
||||
/// server in the list as the fallback.
|
||||
String? finalServerTag;
|
||||
|
||||
/// sing-box `dns.strategy` string. e.g. `prefer_ipv4`, `ipv4_only`.
|
||||
String domainStrategy;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[servers, finalServerTag, domainStrategy];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static SingboxProxyDnsConfig decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return SingboxProxyDnsConfig(
|
||||
servers: (result[0]! as List<Object?>)
|
||||
.cast<SingboxProxyDnsServerConfig>(),
|
||||
finalServerTag: result[1] as String?,
|
||||
domainStrategy: result[2]! as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! SingboxProxyDnsConfig || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(servers, other.servers) &&
|
||||
_deepEquals(finalServerTag, other.finalServerTag) &&
|
||||
_deepEquals(domainStrategy, other.domainStrategy);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class SingboxProxyRuntimeEndpoint {
|
||||
SingboxProxyRuntimeEndpoint({
|
||||
required this.profileId,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.username,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
String profileId;
|
||||
|
||||
String host;
|
||||
|
||||
int port;
|
||||
|
||||
String username;
|
||||
|
||||
String password;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[profileId, host, port, username, password];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static SingboxProxyRuntimeEndpoint decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return SingboxProxyRuntimeEndpoint(
|
||||
profileId: result[0]! as String,
|
||||
host: result[1]! as String,
|
||||
port: result[2]! as int,
|
||||
username: result[3]! as String,
|
||||
password: result[4]! as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! SingboxProxyRuntimeEndpoint ||
|
||||
other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(profileId, other.profileId) &&
|
||||
_deepEquals(host, other.host) &&
|
||||
_deepEquals(port, other.port) &&
|
||||
_deepEquals(username, other.username) &&
|
||||
_deepEquals(password, other.password);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class SingboxProxyRuntimeState {
|
||||
SingboxProxyRuntimeState({
|
||||
required this.status,
|
||||
required this.endpoints,
|
||||
this.message,
|
||||
});
|
||||
|
||||
SingboxProxyRuntimeStatus status;
|
||||
|
||||
List<SingboxProxyRuntimeEndpoint> endpoints;
|
||||
|
||||
String? message;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[status, endpoints, message];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static SingboxProxyRuntimeState decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return SingboxProxyRuntimeState(
|
||||
status: result[0]! as SingboxProxyRuntimeStatus,
|
||||
endpoints: (result[1]! as List<Object?>)
|
||||
.cast<SingboxProxyRuntimeEndpoint>(),
|
||||
message: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! SingboxProxyRuntimeState ||
|
||||
other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(status, other.status) &&
|
||||
_deepEquals(endpoints, other.endpoints) &&
|
||||
_deepEquals(message, other.message);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class SingboxProxyConfigResult {
|
||||
SingboxProxyConfigResult({required this.configJson, required this.endpoints});
|
||||
|
||||
String configJson;
|
||||
|
||||
List<SingboxProxyRuntimeEndpoint> endpoints;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[configJson, endpoints];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static SingboxProxyConfigResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return SingboxProxyConfigResult(
|
||||
configJson: result[0]! as String,
|
||||
endpoints: (result[1]! as List<Object?>)
|
||||
.cast<SingboxProxyRuntimeEndpoint>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! SingboxProxyConfigResult ||
|
||||
other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(configJson, other.configJson) &&
|
||||
_deepEquals(endpoints, other.endpoints);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class SingboxProxyLogMessage {
|
||||
SingboxProxyLogMessage({
|
||||
required this.level,
|
||||
required this.message,
|
||||
required this.timestamp,
|
||||
this.profileId,
|
||||
});
|
||||
|
||||
String level;
|
||||
|
||||
String message;
|
||||
|
||||
int timestamp;
|
||||
|
||||
String? profileId;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[level, message, timestamp, profileId];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static SingboxProxyLogMessage decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return SingboxProxyLogMessage(
|
||||
level: result[0]! as String,
|
||||
message: result[1]! as String,
|
||||
timestamp: result[2]! as int,
|
||||
profileId: result[3] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! SingboxProxyLogMessage || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(level, other.level) &&
|
||||
_deepEquals(message, other.message) &&
|
||||
_deepEquals(timestamp, other.timestamp) &&
|
||||
_deepEquals(profileId, other.profileId);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is SingboxProxyProfileType) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is SingboxProxyRuntimeStatus) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is SingboxProxyProfile) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SingboxProxyRuntimeOptions) {
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SingboxProxyDnsServerConfig) {
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SingboxProxyDnsConfig) {
|
||||
buffer.putUint8(134);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SingboxProxyRuntimeEndpoint) {
|
||||
buffer.putUint8(135);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SingboxProxyRuntimeState) {
|
||||
buffer.putUint8(136);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SingboxProxyConfigResult) {
|
||||
buffer.putUint8(137);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SingboxProxyLogMessage) {
|
||||
buffer.putUint8(138);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : SingboxProxyProfileType.values[value];
|
||||
case 130:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : SingboxProxyRuntimeStatus.values[value];
|
||||
case 131:
|
||||
return SingboxProxyProfile.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
return SingboxProxyRuntimeOptions.decode(readValue(buffer)!);
|
||||
case 133:
|
||||
return SingboxProxyDnsServerConfig.decode(readValue(buffer)!);
|
||||
case 134:
|
||||
return SingboxProxyDnsConfig.decode(readValue(buffer)!);
|
||||
case 135:
|
||||
return SingboxProxyRuntimeEndpoint.decode(readValue(buffer)!);
|
||||
case 136:
|
||||
return SingboxProxyRuntimeState.decode(readValue(buffer)!);
|
||||
case 137:
|
||||
return SingboxProxyConfigResult.decode(readValue(buffer)!);
|
||||
case 138:
|
||||
return SingboxProxyLogMessage.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SingboxProxyApi {
|
||||
/// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
SingboxProxyApi({
|
||||
BinaryMessenger? binaryMessenger,
|
||||
String messageChannelSuffix = '',
|
||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<String?> validateProfile(SingboxProxyProfile profile) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.validateProfile$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
||||
<Object?>[profile],
|
||||
);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as String?;
|
||||
}
|
||||
|
||||
Future<SingboxProxyConfigResult> buildConfig(
|
||||
List<SingboxProxyProfile> profiles,
|
||||
SingboxProxyRuntimeOptions options,
|
||||
) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.buildConfig$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
||||
<Object?>[profiles, options],
|
||||
);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as SingboxProxyConfigResult;
|
||||
}
|
||||
|
||||
Future<SingboxProxyRuntimeState> start(
|
||||
List<SingboxProxyProfile> profiles,
|
||||
SingboxProxyRuntimeOptions options,
|
||||
) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.start$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
||||
<Object?>[profiles, options],
|
||||
);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
|
||||
}
|
||||
|
||||
Future<void> stop(List<String> profileIds) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stop$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
||||
<Object?>[profileIds],
|
||||
);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stopAll() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stopAll$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SingboxProxyRuntimeState> getState() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.getState$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class SingboxProxyEventsApi {
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
void onStateChanged(SingboxProxyRuntimeState state);
|
||||
|
||||
void onLogMessage(SingboxProxyLogMessage message);
|
||||
|
||||
static void setUp(
|
||||
SingboxProxyEventsApi? api, {
|
||||
BinaryMessenger? binaryMessenger,
|
||||
String messageChannelSuffix = '',
|
||||
}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onStateChanged$messageChannelSuffix',
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger,
|
||||
);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
final List<Object?> args = message! as List<Object?>;
|
||||
final SingboxProxyRuntimeState arg_state =
|
||||
args[0]! as SingboxProxyRuntimeState;
|
||||
try {
|
||||
api.onStateChanged(arg_state);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(
|
||||
error: PlatformException(code: 'error', message: e.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onLogMessage$messageChannelSuffix',
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger,
|
||||
);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
final List<Object?> args = message! as List<Object?>;
|
||||
final SingboxProxyLogMessage arg_message =
|
||||
args[0]! as SingboxProxyLogMessage;
|
||||
try {
|
||||
api.onLogMessage(arg_message);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(
|
||||
error: PlatformException(code: 'error', message: e.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartPackageName: 'flutter_singbox_proxy',
|
||||
dartOut: 'lib/src/singbox_proxy_api.g.dart',
|
||||
dartOptions: DartOptions(),
|
||||
kotlinOut:
|
||||
'android/src/main/kotlin/eu/weblibre/flutter_singbox_proxy/generated/SingboxProxyApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(
|
||||
package: 'eu.weblibre.flutter_singbox_proxy.generated',
|
||||
),
|
||||
),
|
||||
)
|
||||
enum SingboxProxyProfileType {
|
||||
socks,
|
||||
http,
|
||||
shadowsocks,
|
||||
vmess,
|
||||
vless,
|
||||
trojan,
|
||||
naive,
|
||||
hysteria,
|
||||
hysteria2,
|
||||
tuic,
|
||||
ssh,
|
||||
wireguard,
|
||||
shadowTls,
|
||||
anyTls,
|
||||
customOutbound,
|
||||
}
|
||||
|
||||
enum SingboxProxyRuntimeStatus { stopped, starting, running, stopping, error }
|
||||
|
||||
class SingboxProxyProfile {
|
||||
SingboxProxyProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.configJson,
|
||||
this.secretJson,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final SingboxProxyProfileType type;
|
||||
|
||||
/// Public profile configuration as JSON. The schema is intentionally owned by
|
||||
/// the profile type so the Pigeon API stays stable while sing-box evolves.
|
||||
final String configJson;
|
||||
|
||||
/// Resolved secret values as JSON. Flutter stores secrets independently and
|
||||
/// only passes them to native code when building or starting a runtime config.
|
||||
final String? secretJson;
|
||||
}
|
||||
|
||||
class SingboxProxyRuntimeOptions {
|
||||
SingboxProxyRuntimeOptions({
|
||||
this.preferredBasePort,
|
||||
this.blockUnmatchedTraffic = true,
|
||||
this.dnsConfig,
|
||||
this.bootstrapDohUrl,
|
||||
});
|
||||
|
||||
/// Optional preferred start port for generated local SOCKS inbounds.
|
||||
final int? preferredBasePort;
|
||||
|
||||
/// If true, traffic entering sing-box without a matching inbound rule is
|
||||
/// rejected instead of falling through to direct.
|
||||
final bool blockUnmatchedTraffic;
|
||||
|
||||
/// Optional DNS block emitted into sing-box config. When null, sing-box
|
||||
/// uses its built-in default (system resolver), which can leak DNS outside
|
||||
/// the proxy — callers should always provide an explicit configuration.
|
||||
final SingboxProxyDnsConfig? dnsConfig;
|
||||
|
||||
/// DoH endpoint used by the native LocalDNSTransport bridge to bootstrap
|
||||
/// hostname-only DNS server addresses (and any other hostname appearing in
|
||||
/// the sing-box config). When null, the bridge refuses to resolve and
|
||||
/// sing-box's stock `/etc/resolv.conf`/127.0.0.1:53 path runs — which is
|
||||
/// broken on Android. Callers should always pass the browser DoH URL.
|
||||
final String? bootstrapDohUrl;
|
||||
}
|
||||
|
||||
class SingboxProxyDnsServerConfig {
|
||||
SingboxProxyDnsServerConfig({
|
||||
required this.tag,
|
||||
required this.address,
|
||||
this.detourTag,
|
||||
this.matchDomainSuffixes = const <String>[],
|
||||
this.matchGeosites = const <String>[],
|
||||
this.matchOutbounds = const <String>[],
|
||||
this.matchInbounds = const <String>[],
|
||||
});
|
||||
|
||||
/// sing-box server tag, used to reference the server from `dns.rules`.
|
||||
final String tag;
|
||||
|
||||
/// Server address. `https://...`, `tls://...`, `quic://...`, or plain IP.
|
||||
/// Hostnames are resolved on demand via the platform LocalDNSTransport
|
||||
/// bridge (sing-box `type: "local"` with our DoH-backed implementation).
|
||||
final String address;
|
||||
|
||||
/// Outbound tag to dial the resolver through, or `direct` for direct, or
|
||||
/// null when sing-box should pick automatically.
|
||||
final String? detourTag;
|
||||
|
||||
/// If non-empty, attaches a `dns.rules` entry routing matching domains to
|
||||
/// this server.
|
||||
final List<String> matchDomainSuffixes;
|
||||
|
||||
/// Advanced sing-box geosite selectors (e.g. `geosite:cn`).
|
||||
final List<String> matchGeosites;
|
||||
|
||||
/// If non-empty, attaches a `dns.rules` entry routing queries that *the
|
||||
/// listed outbounds* originate to this server. Note: sing-box treats an
|
||||
/// outbound's own bootstrap lookups (e.g. WireGuard peer hostname
|
||||
/// resolution) as queries from that outbound, so using this for per-profile
|
||||
/// scoping creates a chicken-and-egg loop at startup. Prefer
|
||||
/// [matchInbounds] for "queries from tabs routed through this profile".
|
||||
final List<String> matchOutbounds;
|
||||
|
||||
/// If non-empty, attaches a `dns.rules` entry matching the listed inbound
|
||||
/// tags. Queries entering via that inbound (e.g. a tab whose container is
|
||||
/// bound to this profile's local SOCKS inbound) resolve through this
|
||||
/// server. Endpoint-bootstrap lookups don't come from any inbound, so this
|
||||
/// scope safely excludes them.
|
||||
final List<String> matchInbounds;
|
||||
}
|
||||
|
||||
class SingboxProxyDnsConfig {
|
||||
SingboxProxyDnsConfig({
|
||||
required this.servers,
|
||||
this.finalServerTag,
|
||||
required this.domainStrategy,
|
||||
});
|
||||
|
||||
final List<SingboxProxyDnsServerConfig> servers;
|
||||
|
||||
/// Server tag used as `dns.final`. When null, sing-box uses the first
|
||||
/// server in the list as the fallback.
|
||||
final String? finalServerTag;
|
||||
|
||||
/// sing-box `dns.strategy` string. e.g. `prefer_ipv4`, `ipv4_only`.
|
||||
final String domainStrategy;
|
||||
}
|
||||
|
||||
class SingboxProxyRuntimeEndpoint {
|
||||
SingboxProxyRuntimeEndpoint({
|
||||
required this.profileId,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.username,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
final String profileId;
|
||||
final String host;
|
||||
final int port;
|
||||
final String username;
|
||||
final String password;
|
||||
}
|
||||
|
||||
class SingboxProxyRuntimeState {
|
||||
SingboxProxyRuntimeState({
|
||||
required this.status,
|
||||
required this.endpoints,
|
||||
this.message,
|
||||
});
|
||||
|
||||
final SingboxProxyRuntimeStatus status;
|
||||
final List<SingboxProxyRuntimeEndpoint> endpoints;
|
||||
final String? message;
|
||||
}
|
||||
|
||||
class SingboxProxyConfigResult {
|
||||
SingboxProxyConfigResult({required this.configJson, required this.endpoints});
|
||||
|
||||
final String configJson;
|
||||
final List<SingboxProxyRuntimeEndpoint> endpoints;
|
||||
}
|
||||
|
||||
class SingboxProxyLogMessage {
|
||||
SingboxProxyLogMessage({
|
||||
required this.level,
|
||||
required this.message,
|
||||
required this.timestamp,
|
||||
this.profileId,
|
||||
});
|
||||
|
||||
final String level;
|
||||
final String message;
|
||||
final int timestamp;
|
||||
final String? profileId;
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class SingboxProxyApi {
|
||||
@async
|
||||
String? validateProfile(SingboxProxyProfile profile);
|
||||
|
||||
@async
|
||||
SingboxProxyConfigResult buildConfig(
|
||||
List<SingboxProxyProfile> profiles,
|
||||
SingboxProxyRuntimeOptions options,
|
||||
);
|
||||
|
||||
@async
|
||||
SingboxProxyRuntimeState start(
|
||||
List<SingboxProxyProfile> profiles,
|
||||
SingboxProxyRuntimeOptions options,
|
||||
);
|
||||
|
||||
@async
|
||||
void stop(List<String> profileIds);
|
||||
|
||||
@async
|
||||
void stopAll();
|
||||
|
||||
SingboxProxyRuntimeState getState();
|
||||
}
|
||||
|
||||
@FlutterApi()
|
||||
abstract class SingboxProxyEventsApi {
|
||||
void onStateChanged(SingboxProxyRuntimeState state);
|
||||
void onLogMessage(SingboxProxyLogMessage message);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
name: flutter_singbox_proxy
|
||||
description: "Android sing-box proxy runtime plugin for WebLibre."
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
lint: ^2.8.0
|
||||
pigeon: ^26.3.2
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
# This section identifies this Flutter project as a plugin project.
|
||||
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.)
|
||||
# which should be registered in the plugin registry. This is required for
|
||||
# using method channels.
|
||||
# The Android 'package' specifies package in which the registered class is.
|
||||
# This is required for using method channels on Android.
|
||||
# The 'ffiPlugin' specifies that native code should be built and bundled.
|
||||
# This is required for using `dart:ffi`.
|
||||
# All these are used by the tooling to maintain consistency when
|
||||
# adding or updating assets for this project.
|
||||
plugin:
|
||||
platforms:
|
||||
android:
|
||||
package: eu.weblibre.flutter_singbox_proxy
|
||||
pluginClass: FlutterSingboxProxyPlugin
|
||||
|
||||
# To add assets to your plugin package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# To add custom fonts to your plugin package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/to/font-from-package
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
DEFAULT_SING_BOX_SOURCE="$(cd "$PACKAGE_DIR/../../.." && pwd -P)/sing-box"
|
||||
|
||||
SING_BOX_SOURCE="${SING_BOX_SOURCE:-$DEFAULT_SING_BOX_SOURCE}"
|
||||
OUTPUT_DIR="$PACKAGE_DIR/android/libs"
|
||||
BUILD_DEBUG=0
|
||||
PLATFORM=""
|
||||
INSTALL_GOMOBILE=1
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: build-libbox-android.sh [options]
|
||||
|
||||
Build official sing-box libbox Android AARs and copy them into this plugin.
|
||||
|
||||
Options:
|
||||
--source PATH sing-box source checkout (default: ../../../sing-box)
|
||||
--output-dir PATH destination for libbox.aar (default: android/libs)
|
||||
--platform TARGET gomobile target, e.g. android/arm64 for faster local builds
|
||||
--debug build sing-box debug variant
|
||||
--skip-install-gomobile require gomobile/gobind to already exist in GOPATH/bin
|
||||
-h, --help show this help
|
||||
|
||||
Environment:
|
||||
SING_BOX_SOURCE same as --source
|
||||
JAVA_HOME should point to OpenJDK 17; auto-detected locally when unset
|
||||
ANDROID_HOME Android SDK path, required by sing-box build tooling
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source)
|
||||
SING_BOX_SOURCE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--output-dir)
|
||||
OUTPUT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--platform)
|
||||
PLATFORM="$2"
|
||||
shift 2
|
||||
;;
|
||||
--debug)
|
||||
BUILD_DEBUG=1
|
||||
shift
|
||||
;;
|
||||
--skip-install-gomobile)
|
||||
INSTALL_GOMOBILE=0
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$SING_BOX_SOURCE/Makefile" || ! -d "$SING_BOX_SOURCE/experimental/libbox" ]]; then
|
||||
echo "sing-box source not found at: $SING_BOX_SOURCE" >&2
|
||||
echo "Pass --source PATH or set SING_BOX_SOURCE." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${JAVA_HOME:-}" && -x /usr/lib/jvm/java-17-openjdk/bin/java ]]; then
|
||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
|
||||
fi
|
||||
|
||||
JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java"
|
||||
if ! "$JAVA_BIN" --version 2>/dev/null | grep -q 'openjdk 17'; then
|
||||
echo "sing-box Android libbox build requires OpenJDK 17." >&2
|
||||
echo "Current java:" >&2
|
||||
"$JAVA_BIN" --version >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
echo "go is required to build sing-box libbox." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GOPATH="${GOPATH:-$(go env GOPATH)}"
|
||||
if [[ $INSTALL_GOMOBILE -eq 1 ]]; then
|
||||
needs_gomobile_install=0
|
||||
if [[ ! -x "$GOPATH/bin/gomobile" || ! -x "$GOPATH/bin/gobind" ]]; then
|
||||
needs_gomobile_install=1
|
||||
elif ! "$GOPATH/bin/gomobile" bind -h 2>&1 | grep -q -- '-libname'; then
|
||||
needs_gomobile_install=1
|
||||
fi
|
||||
|
||||
if [[ $needs_gomobile_install -eq 1 ]]; then
|
||||
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.12
|
||||
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.12
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
pushd "$SING_BOX_SOURCE" >/dev/null
|
||||
args=(go run ./cmd/internal/build_libbox -target android)
|
||||
if [[ $BUILD_DEBUG -eq 1 ]]; then
|
||||
args+=(-debug)
|
||||
fi
|
||||
if [[ -n "$PLATFORM" ]]; then
|
||||
args+=(-platform "$PLATFORM")
|
||||
fi
|
||||
|
||||
"${args[@]}"
|
||||
|
||||
cp -f libbox.aar "$OUTPUT_DIR/libbox.aar"
|
||||
if [[ -f libbox-legacy.aar ]]; then
|
||||
cp -f libbox-legacy.aar "$OUTPUT_DIR/libbox-legacy.aar"
|
||||
fi
|
||||
popd >/dev/null
|
||||
|
||||
echo "Installed $OUTPUT_DIR/libbox.aar"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$OUTPUT_DIR"/libbox*.aar
|
||||
fi
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
|
||||
|
||||
"$REPO_ROOT/native/go_mobile_runtime/scripts/fdroid-prebuild.sh" "$@"
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
|
||||
void main() {
|
||||
test('profile model keeps generic sing-box config boundary', () {
|
||||
final profile = SingboxProxyProfile(
|
||||
id: 'wg-home',
|
||||
name: 'WireGuard Home',
|
||||
type: SingboxProxyProfileType.wireguard,
|
||||
configJson: '{"server":"example.test"}',
|
||||
secretJson: '{"private_key":"secret"}',
|
||||
);
|
||||
|
||||
expect(profile.id, 'wg-home');
|
||||
expect(profile.type, SingboxProxyProfileType.wireguard);
|
||||
expect(profile.secretJson, contains('private_key'));
|
||||
});
|
||||
|
||||
test('runtime options default to blocking unmatched traffic', () {
|
||||
final options = SingboxProxyRuntimeOptions();
|
||||
|
||||
expect(options.preferredBasePort, isNull);
|
||||
expect(options.blockUnmatchedTraffic, isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user