diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3c612fb..b7da9dbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,10 @@ jobs: make gen make translate + - name: Get Geo Assets + run: | + make get-geo-assets + - name: Get Libs ${{ matrix.platform }} run: | make ${{ matrix.platform }}-libs diff --git a/.gitignore b/.gitignore index acffe873..73b48f62 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ migrate_working_dir/ **/*.dylib /dist/ +/assets/core/* +!/assets/core/.gitkeep + # Symbolication related app.*.symbols diff --git a/.gitmodules b/.gitmodules index f4bf3532..d0ce6bf0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "core"] - path = core - url = https://github.com/hiddify/hiddify-libclash +[submodule "libcore"] + path = libcore + url = https://github.com/hiddify/hiddify-next-core diff --git a/Makefile b/Makefile index 730efb58..a7cbd30b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ -ANDROID_OUT=./android/app/src/main/jniLibs -DESKTOP_OUT=./core/bin -NDK_BIN=$(ANDROID_HOME)/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin -GOBUILD=CGO_ENABLED=1 go build -trimpath -tags with_gvisor,with_lwip -ldflags="-w -s" -buildmode=c-shared +BINDIR=./libcore/bin +ANDROID_OUT=./android/app/libs +DESKTOP_OUT=./libcore/bin +GEO_ASSETS_DIR=./assets/core +LIBS_DOWNLOAD_URL=https://github.com/hiddify/hiddify-next-core/releases/download/draft get: flutter pub get @@ -20,40 +21,40 @@ windows-release: linux-release: flutter_distributor package --platform linux --targets appimage + macos-realase: flutter build macos --release &&\ tree ./build/macos/Build &&\ create-dmg --app-drop-link 600 185 "hiddify-amd64.dmg" ./build/macos/Build/Products/Release/hiddify-clash.app + android-libs: - mkdir -p $(ANDROID_OUT)/x86_64 $(ANDROID_OUT)/arm64-v8a/ $(ANDROID_OUT)/armeabi-v7a/ &&\ - curl -L https://github.com/hiddify/hiddify-libclash/releases/latest/download/hiddify-clashlib-android-amd64.so.gz | gunzip > $(ANDROID_OUT)/x86_64/libclash.so &&\ - curl -L https://github.com/hiddify/hiddify-libclash/releases/latest/download/hiddify-clashlib-android-arm64.so.gz | gunzip > $(ANDROID_OUT)/arm64-v8a/libclash.so &&\ - curl -L https://github.com/hiddify/hiddify-libclash/releases/latest/download/hiddify-clashlib-android-arm.so.gz | gunzip > $(ANDROID_OUT)/armeabi-v7a/libclash.so + mkdir -p $(ANDROID_OUT) + curl -L $(LIBS_DOWNLOAD_URL)/hiddify-libcore-android.aar.gz | gunzip > $(ANDROID_OUT)/libcore.aar windows-libs: - mkdir -p $(DESKTOP_OUT)/ &&\ - curl -L https://github.com/hiddify/hiddify-libclash/releases/latest/download/hiddify-clashlib-windows-amd64.dll.gz | gunzip > $(DESKTOP_OUT)/libclash.dll + mkdir -p $(DESKTOP_OUT) + curl -L $(LIBS_DOWNLOAD_URL)/hiddify-libcore-windows-amd64.dll.gz | gunzip > $(DESKTOP_OUT)/libcore.dll linux-libs: - mkdir -p $(DESKTOP_OUT)/ &&\ - curl -L https://github.com/hiddify/hiddify-libclash/releases/latest/download/hiddify-clashlib-linux-amd64.so.gz | gunzip > $(DESKTOP_OUT)/libclash.so + mkdir -p $(DESKTOP_OUT) + curl -L $(LIBS_DOWNLOAD_URL)/hiddify-libcore-linux-amd64.so.gz | gunzip > $(DESKTOP_OUT)/libcore.so macos-libs: mkdir -p $(DESKTOP_OUT)/ &&\ curl -L https://github.com/hiddify/hiddify-libclash/releases/latest/download/hiddify-clashlib-macos-amd64.so.gz | gunzip > $(DESKTOP_OUT)/libclash.dylib +get-geo-assets: + curl -L https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip.db -o $(GEO_ASSETS_DIR)/geoip.db + curl -L https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db -o $(GEO_ASSETS_DIR)/geosite.db + +build-headers: + make -C libcore -f Makefile headers && mv $(BINDIR)/hiddify-libcore-headers.h $(BINDIR)/libcore.h + build-android-libs: - cd core &&\ - mkdir -p .$(ANDROID_OUT)/x86_64/ .$(ANDROID_OUT)/arm64-v8a/ .$(ANDROID_OUT)/armeabi-v7a/ &&\ - make android-amd64 && mv bin/hiddify-clashlib-android-amd64.so .$(ANDROID_OUT)/x86_64/libclash.so &&\ - make android-arm && mv bin/hiddify-clashlib-android-arm.so .$(ANDROID_OUT)/armeabi-v7a/libclash.so &&\ - make android-arm64 && mv bin/hiddify-clashlib-android-arm64.so .$(ANDROID_OUT)/arm64-v8a/libclash.so + make -C libcore -f Makefile android && mv $(BINDIR)/hiddify-libcore-android.aar $(ANDROID_OUT)/libcore.aar build-windows-libs: - cd core &&\ - make windows-amd64 && mv bin/hiddify-clashlib-windows-amd64.dll bin/libclash.dll + make -C libcore -f Makefile windows-amd64 && mv $(BINDIR)/hiddify-libcore-windows-amd64.dll $(DESKTOP_OUT)/libcore.dll build-linux-libs: - cd core &&\ - make linux-amd64 && mv bin/hiddify-clashlib-linux-amd64.dll bin/libclash.so - + make -C libcore -f Makefile linux-amd64 && mv $(BINDIR)/hiddify-libcore-linux-amd64.dll $(DESKTOP_OUT)/libcore.so \ No newline at end of file diff --git a/android/.gitignore b/android/.gitignore index 6f568019..61ff8258 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +/app/libs/* +!/app/libs/.gitkeep \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 6c5b35b6..1288fb4b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -60,6 +60,11 @@ android { signingConfig signingConfigs.debug } } + + buildFeatures { + viewBinding true + aidl true + } } flutter { @@ -67,7 +72,11 @@ flutter { } dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar','*.aar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'androidx.core:core-ktx:1.10.1' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' implementation 'androidx.window:window:1.0.0' implementation 'androidx.window:window-java:1.0.0' diff --git a/android/app/libs/.gitkeep b/android/app/libs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2e05c8af..8c210295 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,14 +1,24 @@ - + - + + + + - + + + + + android:roundIcon="@mipmap/ic_launcher_round" + tools:targetApi="31"> - - - - - + + + + + messages); +} \ No newline at end of file diff --git a/android/app/src/main/jniLibs/arm64-v8a/libtun2socks.so b/android/app/src/main/jniLibs/arm64-v8a/libtun2socks.so deleted file mode 100644 index 3dbdde7a..00000000 Binary files a/android/app/src/main/jniLibs/arm64-v8a/libtun2socks.so and /dev/null differ diff --git a/android/app/src/main/jniLibs/armeabi-v7a/libtun2socks.so b/android/app/src/main/jniLibs/armeabi-v7a/libtun2socks.so deleted file mode 100644 index 79e3a96a..00000000 Binary files a/android/app/src/main/jniLibs/armeabi-v7a/libtun2socks.so and /dev/null differ diff --git a/android/app/src/main/jniLibs/x86_64/libtun2socks.so b/android/app/src/main/jniLibs/x86_64/libtun2socks.so deleted file mode 100644 index ce965f06..00000000 Binary files a/android/app/src/main/jniLibs/x86_64/libtun2socks.so and /dev/null differ diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/Application.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/Application.kt new file mode 100644 index 00000000..ef2ce64a --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/Application.kt @@ -0,0 +1,42 @@ +package com.hiddify.hiddify + +import android.app.Application +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.os.PowerManager +import androidx.core.content.getSystemService +import com.hiddify.hiddify.bg.AppChangeReceiver +import go.Seq +import com.hiddify.hiddify.Application as BoxApplication + +class Application : Application() { + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + + application = this + } + + override fun onCreate() { + super.onCreate() + + Seq.setContext(this) + + registerReceiver(AppChangeReceiver(), IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addDataScheme("package") + }) + } + + companion object { + lateinit var application: BoxApplication + val notification by lazy { application.getSystemService()!! } + val connectivity by lazy { application.getSystemService()!! } + val packageManager by lazy { application.packageManager } + val powerManager by lazy { application.getSystemService()!! } + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/EventHandler.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/EventHandler.kt new file mode 100644 index 00000000..52564687 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/EventHandler.kt @@ -0,0 +1,77 @@ +package com.hiddify.hiddify + +import android.util.Log +import androidx.lifecycle.Observer +import com.hiddify.hiddify.constant.Alert +import com.hiddify.hiddify.constant.Status +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel + +class EventHandler : FlutterPlugin { + + companion object { + const val TAG = "A/EventHandler" + const val SERVICE_STATUS = "com.hiddify.app/service.status" + const val SERVICE_ALERTS = "com.hiddify.app/service.alerts" + } + + private lateinit var statusChannel: EventChannel + private lateinit var alertsChannel: EventChannel + + private lateinit var statusObserver: Observer + private lateinit var alertsObserver: Observer + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + statusChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_STATUS) + alertsChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_ALERTS) + + statusChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + statusObserver = Observer { + Log.d(TAG, "new status: $it") + val map = listOf( + Pair("status", it.name) + ) + .toMap() + events?.success(map) + } + MainActivity.instance.serviceStatus.observeForever(statusObserver) + } + + override fun onCancel(arguments: Any?) { + MainActivity.instance.serviceStatus.removeObserver(statusObserver) + } + }) + + alertsChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + alertsObserver = Observer { + if (it == null) return@Observer + Log.d(TAG, "new alert: $it") + val map = listOf( + Pair("status", it.status.name), + Pair("failure", it.alert?.name), + Pair("message", it.message) + ) + .mapNotNull { p -> p.second?.let { Pair(p.first, p.second) } } + .toMap() + events?.success(map) + } + MainActivity.instance.serviceAlerts.observeForever(alertsObserver) + } + + override fun onCancel(arguments: Any?) { + MainActivity.instance.serviceAlerts.removeObserver(alertsObserver) + } + }) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + MainActivity.instance.serviceStatus.removeObserver(statusObserver) + statusChannel.setStreamHandler(null) + MainActivity.instance.serviceAlerts.removeObserver(alertsObserver) + alertsChannel.setStreamHandler(null) + } +} + +data class ServiceEvent(val status: Status, val alert: Alert? = null, val message: String? = null) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/HiddifyVpnService.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/HiddifyVpnService.kt deleted file mode 100644 index 30aecfb3..00000000 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/HiddifyVpnService.kt +++ /dev/null @@ -1,317 +0,0 @@ -package com.hiddify.hiddify - -import android.app.PendingIntent -import android.app.Service -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.ConnectivityManager -import android.net.LocalSocket -import android.net.LocalSocketAddress -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import android.net.ProxyInfo -import android.net.VpnService -import android.os.Build -import android.os.ParcelFileDescriptor -import android.os.StrictMode -import android.util.Log -import androidx.annotation.RequiresApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import java.io.File -import java.lang.ref.SoftReference - -class HiddifyVpnService : VpnService() { - companion object { - const val TAG = "Hiddify/VpnService" - const val EVENT_TAG = "Hiddify/VpnServiceEvents" - private const val TUN2SOCKS = "libtun2socks.so" - - private const val TUN_MTU = 9000 - private const val TUN_GATEWAY = "172.19.0.1" - private const val TUN_ROUTER = "172.19.0.2" - private const val TUN_SUBNET_PREFIX = 30 - private const val NET_ANY = "0.0.0.0" - private val HTTP_PROXY_LOCAL_LIST = listOf( - "localhost", - "*.local", - "127.*", - "10.*", - "172.16.*", - "172.17.*", - "172.18.*", - "172.19.*", - "172.2*", - "172.30.*", - "172.31.*", - "192.168.*" - ) - } - - private var vpnBroadcastReceiver: VpnState? = null - private var conn: ParcelFileDescriptor? = null - private lateinit var process: Process - private var isRunning = false - - // prefs - private var includeAppPackages: Set = HashSet() - - fun getService(): Service { - return this - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - startVpnService() - return START_STICKY - } - - override fun onCreate() { - super.onCreate() - Log.d(TAG, "creating vpn service") - val policy = StrictMode.ThreadPolicy.Builder().permitAll().build() - StrictMode.setThreadPolicy(policy) - registerBroadcastReceiver() - VpnServiceManager.vpnService = SoftReference(this) - } - - override fun onRevoke() { - Log.d(TAG, "vpn service revoked") - super.onRevoke() - stopVpnService() - } - - override fun onDestroy() { - Log.d(TAG, "vpn service destroyed") - super.onDestroy() - broadcastVpnStatus(false) - VpnServiceManager.cancelNotification() - unregisterBroadcastReceiver() - } - - private fun registerBroadcastReceiver() { - Log.d(TAG, "registering receiver in service") - vpnBroadcastReceiver = VpnState() - val intentFilter = IntentFilter(VpnState.ACTION_VPN_STATUS) - registerReceiver(vpnBroadcastReceiver, intentFilter) - } - - private fun unregisterBroadcastReceiver() { - Log.d(TAG, "unregistering receiver in service") - if (vpnBroadcastReceiver != null) { - unregisterReceiver(vpnBroadcastReceiver) - vpnBroadcastReceiver = null - } - } - - private fun broadcastVpnStatus(isVpnActive: Boolean) { - Log.d(TAG, "broadcasting status= $isVpnActive") - val intent = Intent(VpnState.ACTION_VPN_STATUS) - intent.putExtra(VpnState.IS_VPN_ACTIVE, isVpnActive) - sendBroadcast(intent) - } - - @delegate:RequiresApi(Build.VERSION_CODES.P) - private val defaultNetworkRequest by lazy { - NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) - .build() - } - - private val connectivity by lazy { getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager } - - @delegate:RequiresApi(Build.VERSION_CODES.P) - private val defaultNetworkCallback by lazy { - object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - setUnderlyingNetworks(arrayOf(network)) - } - - override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { - // it's a good idea to refresh capabilities - setUnderlyingNetworks(arrayOf(network)) - } - - override fun onLost(network: Network) { - setUnderlyingNetworks(null) - } - } - } - - private fun startVpnService() { - val prepare = prepare(this) - if (prepare != null) { - return - } - - with(Builder()) { - addAddress(TUN_GATEWAY, TUN_SUBNET_PREFIX) - setMtu(TUN_MTU) - addRoute(NET_ANY, 0) - addDnsServer(TUN_ROUTER) - allowBypass() - setBlocking(true) - setSession("Hiddify") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - setMetered(false) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && VpnServiceManager.prefs.systemProxy) { - setHttpProxy( - ProxyInfo.buildDirectProxy( - "127.0.0.1", - VpnServiceManager.prefs.httpPort, - HTTP_PROXY_LOCAL_LIST, - ) - ) - } - if (includeAppPackages.isEmpty()) { - addDisallowedApplication(packageName) - } else { - includeAppPackages.forEach { - addAllowedApplication(it) - } - } - setConfigureIntent( - PendingIntent.getActivity( - this@HiddifyVpnService, - 0, - Intent().setComponent(ComponentName(packageName, "$packageName.MainActivity")), - pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT) - ) - ) - - try { - conn?.close() - } catch (ignored: Exception) { - // ignored - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - try { - connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback) - } catch (e: Exception) { - e.printStackTrace() - } - } - try { - conn = establish() - isRunning = true - runTun2socks() - VpnServiceManager.showNotification() - Log.d(TAG, "vpn connection established") - broadcastVpnStatus(true) - } catch (e: Exception) { - Log.w(TAG, "failed to start vpn service: $e") - e.printStackTrace() - stopVpnService() - broadcastVpnStatus(false) - } - } - } - - fun stopVpnService(isForced: Boolean = true) { - Log.d(TAG, "stopping vpn service, forced: [$isForced]") - isRunning = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - try { - connectivity.unregisterNetworkCallback(defaultNetworkCallback) - } catch (ignored: Exception) { - // ignored - } - } - - try { - Log.d(TAG, "destroying tun2socks") - process.destroy() - } catch (e: Exception) { - Log.e(TAG, e.toString()) - } - - if(isForced) { - stopSelf() - try { - conn?.close() - conn = null - } catch (ignored: Exception) { - // ignored - } - } - Log.d(TAG, "vpn service stopped") - } - - private fun runTun2socks() { - val cmd = arrayListOf( - File(applicationContext.applicationInfo.nativeLibraryDir, TUN2SOCKS).absolutePath, - "--netif-ipaddr", TUN_ROUTER, - "--netif-netmask", "255.255.255.252", - "--socks-server-addr", "127.0.0.1:${VpnServiceManager.prefs.socksPort}", - "--tunmtu", TUN_MTU.toString(), - "--sock-path", "sock_path",//File(applicationContext.filesDir, "sock_path").absolutePath, - "--enable-udprelay", - "--loglevel", "notice") - - Log.d(TAG, cmd.toString()) - protect(conn!!.fd) // not sure - - try { - val proBuilder = ProcessBuilder(cmd) - proBuilder.redirectErrorStream(true) - process = proBuilder - .directory(applicationContext.filesDir) - .start() - Thread(Runnable { - Log.d(TAG,"$TUN2SOCKS check") - process.waitFor() - Log.d(TAG,"$TUN2SOCKS exited") - if (isRunning) { - Log.d(packageName,"$TUN2SOCKS restart") - runTun2socks() - } - }).start() - Log.d(TAG, process.toString()) - - sendFd() - } catch (e: Exception) { - Log.d(TAG, e.toString()) - } - } - - private fun sendFd() { - val fd = conn!!.fileDescriptor - val path = File(applicationContext.filesDir, "sock_path").absolutePath - Log.d(TAG, path) - - GlobalScope.launch(Dispatchers.IO) { - var tries = 0 - while (true) try { - Thread.sleep(50L shl tries) - Log.d(TAG, "sendFd tries: $tries") - LocalSocket().use { localSocket -> - localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM)) - localSocket.setFileDescriptorsForSend(arrayOf(fd)) - localSocket.outputStream.write(42) - } - break - } catch (e: Exception) { - Log.d(TAG, e.toString()) - if (tries > 5) break - tries += 1 - } - } - } - - private fun pendingIntentFlags(flags: Int, mutable: Boolean = false): Int { - return if (Build.VERSION.SDK_INT >= 24) { - if (Build.VERSION.SDK_INT > 30 && mutable) { - flags or PendingIntent.FLAG_MUTABLE - } else { - flags or PendingIntent.FLAG_IMMUTABLE - } - } else { - flags - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/LogHandler.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/LogHandler.kt new file mode 100644 index 00000000..6c659703 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/LogHandler.kt @@ -0,0 +1,35 @@ +package com.hiddify.hiddify + +import androidx.annotation.NonNull +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel + + +class LogHandler : FlutterPlugin { + + companion object { + const val TAG = "A/LogHandler" + const val SERVICE_LOGS = "com.hiddify.app/service.logs" + } + + private lateinit var logsChannel: EventChannel + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + logsChannel = EventChannel(flutterPluginBinding.binaryMessenger, SERVICE_LOGS) + + logsChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + MainActivity.instance.serviceLogs.observeForever { it -> + if (it == null) return@observeForever + events?.success(it) + } + } + + override fun onCancel(arguments: Any?) { + } + }) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt index f0d94942..999cfaf2 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt @@ -1,116 +1,148 @@ package com.hiddify.hiddify import android.content.Intent -import android.content.IntentFilter import android.net.VpnService import android.util.Log -import io.flutter.embedding.android.FlutterActivity +import androidx.core.content.ContextCompat +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope +import com.hiddify.hiddify.bg.ServiceConnection +import com.hiddify.hiddify.bg.ServiceNotification +import com.hiddify.hiddify.bg.VPNService +import com.hiddify.hiddify.constant.Alert +import com.hiddify.hiddify.constant.Status +import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel - -class MainActivity : FlutterActivity(), MethodChannel.MethodCallHandler { - private lateinit var methodChannel: MethodChannel - private lateinit var eventChannel: EventChannel - private lateinit var methodResult: MethodChannel.Result - private var vpnBroadcastReceiver: VpnState? = null +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.LinkedList +class MainActivity : FlutterFragmentActivity(), ServiceConnection.Callback { companion object { + private const val TAG = "ANDROID/MyActivity" + lateinit var instance: MainActivity + const val VPN_PERMISSION_REQUEST_CODE = 1001 - - enum class Action(val method: String) { - GrantPermission("grant_permission"), - StartProxy("start"), - StopProxy("stop"), - RefreshStatus("refresh_status"), - SetPrefs("set_prefs") - } + const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1010 } - private fun registerBroadcastReceiver() { - Log.d(HiddifyVpnService.TAG, "registering broadcast receiver") - vpnBroadcastReceiver = VpnState() - val intentFilter = IntentFilter(VpnState.ACTION_VPN_STATUS) - registerReceiver(vpnBroadcastReceiver, intentFilter) - } + private val connection = ServiceConnection(this, this) - private fun unregisterBroadcastReceiver() { - Log.d(HiddifyVpnService.TAG, "unregistering broadcast receiver") - if (vpnBroadcastReceiver != null) { - unregisterReceiver(vpnBroadcastReceiver) - vpnBroadcastReceiver = null - } - } + val logList = LinkedList() + var logCallback: ((Boolean) -> Unit)? = null + val serviceStatus = MutableLiveData(Status.Stopped) + val serviceAlerts = MutableLiveData(null) + val serviceLogs = MutableLiveData(null) override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - methodChannel = - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, HiddifyVpnService.TAG) - methodChannel.setMethodCallHandler(this) - - eventChannel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, HiddifyVpnService.EVENT_TAG) - registerBroadcastReceiver() - eventChannel.setStreamHandler(vpnBroadcastReceiver) + instance = this + reconnect() + flutterEngine.plugins.add(MethodHandler()) + flutterEngine.plugins.add(EventHandler()) + flutterEngine.plugins.add(LogHandler()) } - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - methodResult = result - @Suppress("UNCHECKED_CAST") - when (call.method) { - Action.GrantPermission.method -> { - grantVpnPermission() + fun reconnect() { + connection.reconnect() + } + + fun startService() { + if (!ServiceNotification.checkPermission()) { + Log.d(TAG, "missing notification permission") + return + } + lifecycleScope.launch(Dispatchers.IO) { +// if (Settings.rebuildServiceMode()) { +// reconnect() +// } + if (prepare()) { + Log.d(TAG, "VPN permission required") + return@launch } - Action.StartProxy.method -> { - VpnServiceManager.startVpnService(this) - result.success(true) - } - - Action.StopProxy.method -> { - VpnServiceManager.stopVpnService() - result.success(true) - } - - Action.RefreshStatus.method -> { - val statusIntent = Intent(VpnState.ACTION_VPN_STATUS) - statusIntent.putExtra(VpnState.IS_VPN_ACTIVE, VpnServiceManager.isRunning) - sendBroadcast(statusIntent) - result.success(true) - } - - Action.SetPrefs.method -> { - val args = call.arguments as Map - VpnServiceManager.setPrefs(context, args) - result.success(true) - } - - else -> { - result.notImplemented() + val intent = Intent(Application.application, VPNService::class.java) + withContext(Dispatchers.Main) { + ContextCompat.startForegroundService(Application.application, intent) } } } - override fun onDestroy() { - super.onDestroy() - unregisterBroadcastReceiver() + + override fun onServiceStatusChanged(status: Status) { + Log.d(TAG, "service status changed: $status") + serviceStatus.postValue(status) } - private fun grantVpnPermission() { - val vpnPermissionIntent = VpnService.prepare(this) - if (vpnPermissionIntent == null) { - onActivityResult(VPN_PERMISSION_REQUEST_CODE, RESULT_OK, null) - } else { - startActivityForResult(vpnPermissionIntent, VPN_PERMISSION_REQUEST_CODE) + + override fun onServiceAlert(type: Alert, message: String?) { + Log.d(TAG, "service alert: $type") + serviceAlerts.postValue(ServiceEvent(Status.Stopped, type, message)) + } + + private var paused = false + override fun onPause() { + super.onPause() + + paused = true + } + + override fun onResume() { + super.onResume() + + paused = false + logCallback?.invoke(true) + } + + override fun onServiceWriteLog(message: String?) { + if (paused) { + if (logList.size > 300) { + logList.removeFirst() + } + } + logList.addLast(message) + if (!paused) { + logCallback?.invoke(false) + serviceLogs.postValue(message) + } + } + + override fun onServiceResetLogs(messages: MutableList) { + logList.clear() + logList.addAll(messages) + if (!paused) logCallback?.invoke(true) + } + + override fun onDestroy() { + connection.disconnect() + super.onDestroy() + } + + private suspend fun prepare() = withContext(Dispatchers.Main) { + try { + val intent = VpnService.prepare(this@MainActivity) + if (intent != null) { +// prepareLauncher.launch(intent) + startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE) + true + } else { + false + } + } catch (e: Exception) { + onServiceAlert(Alert.RequestVPNPermission, e.message) + false } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == VPN_PERMISSION_REQUEST_CODE) { - methodResult.success(resultCode == RESULT_OK) - } else if (requestCode == 101010) { - methodResult.success(resultCode == RESULT_OK) + if (resultCode == RESULT_OK) startService() + else onServiceAlert(Alert.RequestVPNPermission, null) + } else if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) { + if (resultCode == RESULT_OK) startService() + else onServiceAlert(Alert.RequestNotificationPermission, null) } } } diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt new file mode 100644 index 00000000..cec43eb8 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt @@ -0,0 +1,67 @@ +package com.hiddify.hiddify + +import androidx.annotation.NonNull +import com.hiddify.hiddify.bg.BoxService +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.StandardMethodCodec + +class MethodHandler : FlutterPlugin, MethodChannel.MethodCallHandler { + private lateinit var channel: MethodChannel + + companion object { + const val channelName = "com.hiddify.app/method" + + enum class Trigger(val method: String) { + ParseConfig("parse_config"), + SetActiveConfigPath("set_active_config_path"), + Start("start"), + Stop("stop"), + } + } + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + val taskQueue = flutterPluginBinding.binaryMessenger.makeBackgroundTaskQueue() + channel = MethodChannel( + flutterPluginBinding.binaryMessenger, + channelName, + StandardMethodCodec.INSTANCE, + taskQueue + ) + channel.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + Trigger.ParseConfig.method -> { + val args = call.arguments as Map<*, *> + val path = args["path"] as String? ?: "" + val msg = BoxService.parseConfig(path) + result.success(msg) + } + + Trigger.SetActiveConfigPath.method -> { + val args = call.arguments as Map<*, *> + Settings.selectedConfigPath = args["path"] as String? ?: "" + result.success(true) + } + + Trigger.Start.method -> { + MainActivity.instance.startService() + result.success(true) + } + + Trigger.Stop.method -> { + BoxService.stop() + result.success(true) + } + + else -> result.notImplemented() + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt new file mode 100644 index 00000000..e30276bb --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt @@ -0,0 +1,33 @@ +package com.hiddify.hiddify + +import android.content.Context +import com.hiddify.hiddify.constant.SettingsKey + +object Settings { + + const val PER_APP_PROXY_DISABLED = 0 + const val PER_APP_PROXY_EXCLUDE = 1 + const val PER_APP_PROXY_INCLUDE = 2 + + private val preferences by lazy { + val context = Application.application.applicationContext + context.getSharedPreferences("preferences", Context.MODE_PRIVATE) + } + + var disableMemoryLimit = preferences.getBoolean(SettingsKey.DISABLE_MEMORY_LIMIT, false) + + var perAppProxyEnabled = preferences.getBoolean(SettingsKey.PER_APP_PROXY_ENABLED, false) + var perAppProxyMode = preferences.getInt(SettingsKey.PER_APP_PROXY_MODE, PER_APP_PROXY_EXCLUDE) + var perAppProxyList = preferences.getStringSet(SettingsKey.PER_APP_PROXY_LIST, emptySet())!! + var perAppProxyUpdateOnChange = + preferences.getInt(SettingsKey.PER_APP_PROXY_UPDATE_ON_CHANGE, PER_APP_PROXY_DISABLED) + + var selectedConfigPath: String + get() = preferences.getString(SettingsKey.SELECTED_CONFIG_PATH, "") ?: "" + set(value) = preferences.edit().putString(SettingsKey.SELECTED_CONFIG_PATH, value).apply() + + var startedByUser: Boolean + get() = preferences.getBoolean(SettingsKey.STARTED_BY_USER, false) + set(value) = preferences.edit().putBoolean(SettingsKey.STARTED_BY_USER, value).apply() +} + diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/VpnServiceManager.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/VpnServiceManager.kt deleted file mode 100644 index cf5afe15..00000000 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/VpnServiceManager.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.hiddify.hiddify - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.content.Context.NOTIFICATION_SERVICE -import android.content.Intent -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import java.lang.ref.SoftReference - -data class VpnServiceConfigs(val httpPort: Int = 12346, val socksPort: Int = 12347, val systemProxy: Boolean = true) - -object VpnServiceManager { - private const val NOTIFICATION_ID = 1 - private const val NOTIFICATION_CHANNEL_ID = "hiddify_vpn" - private const val NOTIFICATION_CHANNEL_NAME = "Hiddify VPN" - - var vpnService: SoftReference? = null - var prefs = VpnServiceConfigs() - var isRunning = false - - private var mBuilder: NotificationCompat.Builder? = null - private var mNotificationManager: NotificationManager? = null - - fun startVpnService(context: Context) { - val intent = Intent(context.applicationContext, HiddifyVpnService::class.java) - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } - } - - fun stopVpnService() { - val service = vpnService?.get() ?: return - service.stopVpnService() - } - - fun setPrefs(context: Context,args: Map) { - prefs = prefs.copy( - httpPort = args["httpPort"] as Int? ?: prefs.httpPort, - socksPort = args["socksPort"] as Int? ?: prefs.socksPort, - systemProxy = args["systemProxy"] as Boolean? ?: prefs.systemProxy, - ) - if(isRunning) { - stopVpnService() - startVpnService(context) - } - } - - fun showNotification() { - val service = vpnService?.get()?.getService() ?: return - val channelId = if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel() - } else { - "" - } - - mBuilder = NotificationCompat.Builder(service, channelId) - .setSmallIcon(R.drawable.ic_stat_logo) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setOngoing(true) - .setShowWhen(false) - .setOnlyAlertOnce(true) - .setContentTitle("Hiddify") - .setContentText("Connected") - - service.startForeground(NOTIFICATION_ID, mBuilder?.build()) - } - - fun cancelNotification() { - val service = vpnService?.get()?.getService() ?: return - service.stopForeground(true) - mBuilder = null - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createNotificationChannel(): String { - val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH) - channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE - getNotificationManager()?.createNotificationChannel( - channel - ) - return NOTIFICATION_CHANNEL_ID - } - - private fun getNotificationManager(): NotificationManager? { - if (mNotificationManager == null) { - val service = vpnService?.get()?.getService() ?: return null - mNotificationManager = service.getSystemService(NOTIFICATION_SERVICE) as NotificationManager - } - return mNotificationManager - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/VpnState.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/VpnState.kt deleted file mode 100644 index 0e1e41e2..00000000 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/VpnState.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.hiddify.hiddify - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.util.Log -import io.flutter.plugin.common.EventChannel - -class VpnState : BroadcastReceiver(), EventChannel.StreamHandler{ - companion object { - const val ACTION_VPN_STATUS = "Hiddify.VpnState.ACTION_VPN_STATUS" - const val IS_VPN_ACTIVE = "isVpnActive" - } - - - private var eventSink: EventChannel.EventSink? = null - - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - eventSink = events - } - - override fun onCancel(arguments: Any?) { - eventSink = null - } - - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == ACTION_VPN_STATUS) { - val isVpnActive = intent.getBooleanExtra(IS_VPN_ACTIVE, false) - Log.d(HiddifyVpnService.TAG, "send to flutter: status= $isVpnActive") - VpnServiceManager.isRunning = isVpnActive - eventSink?.success(isVpnActive) - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/AppChangeReceiver.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/AppChangeReceiver.kt new file mode 100644 index 00000000..0a232e1b --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/AppChangeReceiver.kt @@ -0,0 +1,37 @@ +package com.hiddify.hiddify.bg + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.hiddify.hiddify.Settings + +class AppChangeReceiver : BroadcastReceiver() { + + companion object { + private const val TAG = "A/AppChangeReceiver" + } + + override fun onReceive(context: Context, intent: Intent) { + checkUpdate(context, intent) + } + + private fun checkUpdate(context: Context, intent: Intent) { + if (!Settings.perAppProxyEnabled) { + return + } + val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange + if (perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_DISABLED) { + return + } + val packageName = intent.dataString?.substringAfter("package:") + if (packageName.isNullOrBlank()) { + return + } + if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE)) { + Settings.perAppProxyList = Settings.perAppProxyList + packageName + } else { + Settings.perAppProxyList = Settings.perAppProxyList - packageName + } + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt new file mode 100644 index 00000000..0ebc5cf0 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt @@ -0,0 +1,291 @@ +package com.hiddify.hiddify.bg + +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.lifecycle.MutableLiveData +import com.hiddify.hiddify.Application +import com.hiddify.hiddify.Settings +import com.hiddify.hiddify.constant.Action +import com.hiddify.hiddify.constant.Alert +import com.hiddify.hiddify.constant.Status +import go.Seq +import io.nekohasekai.libbox.BoxService +import io.nekohasekai.libbox.CommandServer +import io.nekohasekai.libbox.CommandServerHandler +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.PProfServer +import io.nekohasekai.libbox.PlatformInterface +import io.nekohasekai.mobile.Mobile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.io.File + +class BoxService( + private val service: Service, + private val platformInterface: PlatformInterface +) : CommandServerHandler { + + companion object { + private const val TAG = "A/BoxService" + + private var initializeOnce = false + private fun initialize() { + if (initializeOnce) return + val baseDir = Application.application.filesDir + baseDir.mkdirs() + val workingDir = Application.application.getExternalFilesDir(null) ?: return + workingDir.mkdirs() + val tempDir = Application.application.cacheDir + tempDir.mkdirs() + Log.d(TAG, "base dir: ${baseDir.path}") + Log.d(TAG, "working dir: ${workingDir.path}") + Log.d(TAG, "temp dir: ${tempDir.path}") + Libbox.setup(baseDir.path, workingDir.path, tempDir.path, false) + Libbox.redirectStderr(File(workingDir, "stderr.log").path) + initializeOnce = true + return + } + + fun parseConfig(path: String): String { + return try { + Mobile.parse(path) + "" + } catch (e: Exception) { + Log.w(TAG, e) + e.message ?: "invalid config" + } + } + + fun start() { + val intent = runBlocking { + withContext(Dispatchers.IO) { + Intent(Application.application, VPNService::class.java) + } + } + ContextCompat.startForegroundService(Application.application, intent) + } + + fun stop() { + Application.application.sendBroadcast( + Intent(Action.SERVICE_CLOSE).setPackage( + Application.application.packageName + ) + ) + } + + fun reload() { + Application.application.sendBroadcast( + Intent(Action.SERVICE_RELOAD).setPackage( + Application.application.packageName + ) + ) + } + } + + var fileDescriptor: ParcelFileDescriptor? = null + + private val status = MutableLiveData(Status.Stopped) + private val binder = ServiceBinder(status) + private val notification = ServiceNotification(service) + private var boxService: BoxService? = null + private var commandServer: CommandServer? = null + private var pprofServer: PProfServer? = null + private var receiverRegistered = false + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Action.SERVICE_CLOSE -> { + stopService() + } + + Action.SERVICE_RELOAD -> { + serviceReload() + } + } + } + } + + private fun startCommandServer() { + val commandServer = + CommandServer(this, 300) + commandServer.start() + this.commandServer = commandServer + } + + private suspend fun startService() { + try { + Log.d(TAG, "starting service") + + val selectedConfigPath = Settings.selectedConfigPath + if (selectedConfigPath.isBlank()) { + stopAndAlert(Alert.EmptyConfiguration) + return + } + + val content = try { + Mobile.applyOverrides(selectedConfigPath) + } catch (e: Exception) { + Log.w(TAG, e) + stopAndAlert(Alert.EmptyConfiguration) + return + } + + withContext(Dispatchers.Main) { + binder.broadcast { + it.onServiceResetLogs(listOf()) + } + } + + DefaultNetworkMonitor.start() + Libbox.registerLocalDNSTransport(LocalResolver) + Libbox.setMemoryLimit(!Settings.disableMemoryLimit) + + val newService = try { + Libbox.newService(content, platformInterface) + } catch (e: Exception) { + stopAndAlert(Alert.CreateService, e.message) + return + } + + newService.start() + boxService = newService + commandServer?.setService(boxService) + status.postValue(Status.Started) + } catch (e: Exception) { + stopAndAlert(Alert.StartService, e.message) + return + } + } + + override fun serviceReload() { + GlobalScope.launch(Dispatchers.IO) { + val pfd = fileDescriptor + if (pfd != null) { + pfd.close() + fileDescriptor = null + } + commandServer?.setService(null) + boxService?.apply { + runCatching { + close() + }.onFailure { + writeLog("service: error when closing: $it") + } + Seq.destroyRef(refnum) + } + boxService = null + startService() + } + } + + private fun stopService() { + if (status.value != Status.Started) return + status.value = Status.Stopping + if (receiverRegistered) { + service.unregisterReceiver(receiver) + receiverRegistered = false + } + notification.close() + GlobalScope.launch(Dispatchers.IO) { + val pfd = fileDescriptor + if (pfd != null) { + pfd.close() + fileDescriptor = null + } + commandServer?.setService(null) + boxService?.apply { + runCatching { + close() + }.onFailure { + writeLog("service: error when closing: $it") + } + Seq.destroyRef(refnum) + } + boxService = null + Libbox.registerLocalDNSTransport(null) + DefaultNetworkMonitor.stop() + + commandServer?.apply { + close() + Seq.destroyRef(refnum) + } + commandServer = null + Settings.startedByUser = false + withContext(Dispatchers.Main) { + status.value = Status.Stopped + service.stopSelf() + } + } + } + + private suspend fun stopAndAlert(type: Alert, message: String? = null) { + Settings.startedByUser = false + withContext(Dispatchers.Main) { + if (receiverRegistered) { + service.unregisterReceiver(receiver) + receiverRegistered = false + } + notification.close() + binder.broadcast { callback -> + callback.onServiceAlert(type.ordinal, message) + } + status.value = Status.Stopped + } + } + + fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (status.value != Status.Stopped) return Service.START_NOT_STICKY + status.value = Status.Starting + + if (!receiverRegistered) { + ContextCompat.registerReceiver(service, receiver, IntentFilter().apply { + addAction(Action.SERVICE_CLOSE) + addAction(Action.SERVICE_RELOAD) + }, ContextCompat.RECEIVER_NOT_EXPORTED) + receiverRegistered = true + } + + notification.show() + GlobalScope.launch(Dispatchers.IO) { + Settings.startedByUser = true + initialize() + try { + startCommandServer() + } catch (e: Exception) { + stopAndAlert(Alert.StartCommandServer, e.message) + return@launch + } + startService() + } + return Service.START_NOT_STICKY + } + + fun onBind(intent: Intent): IBinder { + return binder + } + + fun onDestroy() { + binder.close() + } + + fun onRevoke() { + stopService() + } + + fun writeLog(message: String) { + binder.broadcast { + it.onServiceWriteLog(message) + } + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/DefaultNetworkListener.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/DefaultNetworkListener.kt new file mode 100644 index 00000000..e5608b58 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/DefaultNetworkListener.kt @@ -0,0 +1,176 @@ +package com.hiddify.hiddify.bg + +import android.annotation.TargetApi +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import android.os.Handler +import android.os.Looper +import com.hiddify.hiddify.Application +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.runBlocking +import java.net.UnknownHostException + +object DefaultNetworkListener { + private sealed class NetworkMessage { + class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage() + class Get : NetworkMessage() { + val response = CompletableDeferred() + } + + class Stop(val key: Any) : NetworkMessage() + + class Put(val network: Network) : NetworkMessage() + class Update(val network: Network) : NetworkMessage() + class Lost(val network: Network) : NetworkMessage() + } + + private val networkActor = GlobalScope.actor(Dispatchers.Unconfined) { + val listeners = mutableMapOf Unit>() + var network: Network? = null + val pendingRequests = arrayListOf() + for (message in channel) when (message) { + is NetworkMessage.Start -> { + if (listeners.isEmpty()) register() + listeners[message.key] = message.listener + if (network != null) message.listener(network) + } + + is NetworkMessage.Get -> { + check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" } + if (network == null) pendingRequests += message else message.response.complete( + network + ) + } + + is NetworkMessage.Stop -> if (listeners.isNotEmpty() && // was not empty + listeners.remove(message.key) != null && listeners.isEmpty() + ) { + network = null + unregister() + } + + is NetworkMessage.Put -> { + network = message.network + pendingRequests.forEach { it.response.complete(message.network) } + pendingRequests.clear() + listeners.values.forEach { it(network) } + } + + is NetworkMessage.Update -> if (network == message.network) listeners.values.forEach { + it( + network + ) + } + + is NetworkMessage.Lost -> if (network == message.network) { + network = null + listeners.values.forEach { it(null) } + } + } + } + + suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send( + NetworkMessage.Start( + key, + listener + ) + ) + + suspend fun get() = if (fallback) @TargetApi(23) { + Application.connectivity.activeNetwork + ?: throw UnknownHostException() // failed to listen, return current if available + } else NetworkMessage.Get().run { + networkActor.send(this) + response.await() + } + + suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key)) + + // NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26 + private object Callback : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = runBlocking { + networkActor.send( + NetworkMessage.Put( + network + ) + ) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + // it's a good idea to refresh capabilities + runBlocking { networkActor.send(NetworkMessage.Update(network)) } + } + + override fun onLost(network: Network) = runBlocking { + networkActor.send( + NetworkMessage.Lost( + network + ) + ) + } + } + + private var fallback = false + private val request = NetworkRequest.Builder().apply { + addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs + removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } + }.build() + private val mainHandler = Handler(Looper.getMainLooper()) + + /** + * Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1: + * https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e + * + * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that + * satisfies default network capabilities but only THE default network. Unfortunately, we need to have + * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork. + * + * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887 + */ + private fun register() { + when (Build.VERSION.SDK_INT) { + in 31..Int.MAX_VALUE -> @TargetApi(31) { + Application.connectivity.registerBestMatchingNetworkCallback( + request, + Callback, + mainHandler + ) + } + + in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN + Application.connectivity.requestNetwork(request, Callback, mainHandler) + } + + in 26 until 28 -> @TargetApi(26) { + Application.connectivity.registerDefaultNetworkCallback(Callback, mainHandler) + } + + in 24 until 26 -> @TargetApi(24) { + Application.connectivity.registerDefaultNetworkCallback(Callback) + } + + else -> try { + fallback = false + Application.connectivity.requestNetwork(request, Callback) + } catch (e: RuntimeException) { + fallback = + true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107 + } + } + } + + private fun unregister() = Application.connectivity.unregisterNetworkCallback(Callback) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/DefaultNetworkMonitor.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/DefaultNetworkMonitor.kt new file mode 100644 index 00000000..1c143add --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/DefaultNetworkMonitor.kt @@ -0,0 +1,43 @@ +package com.hiddify.hiddify.bg + +import android.net.Network +import android.os.Build +import com.hiddify.hiddify.Application +import io.nekohasekai.libbox.InterfaceUpdateListener + +object DefaultNetworkMonitor { + + var defaultNetwork: Network? = null + private var listener: InterfaceUpdateListener? = null + + suspend fun start() { + DefaultNetworkListener.start(this) { + defaultNetwork = it + checkDefaultInterfaceUpdate(it) + } + defaultNetwork = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Application.connectivity.activeNetwork + } else { + DefaultNetworkListener.get() + } + } + + suspend fun stop() { + DefaultNetworkListener.stop(this) + } + + fun setListener(listener: InterfaceUpdateListener?) { + this.listener = listener + checkDefaultInterfaceUpdate(defaultNetwork) + } + + private fun checkDefaultInterfaceUpdate( + newNetwork: Network? + ) { + val listener = listener ?: return + val link = Application.connectivity.getLinkProperties(newNetwork ?: return) ?: return + listener.updateDefaultInterface(link.interfaceName, -1) + } + + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/LocalResolver.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/LocalResolver.kt new file mode 100644 index 00000000..06c262f5 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/LocalResolver.kt @@ -0,0 +1,134 @@ +package com.hiddify.hiddify.bg + +import android.net.DnsResolver +import android.os.Build +import android.os.CancellationSignal +import android.system.ErrnoException +import androidx.annotation.RequiresApi +import com.hiddify.hiddify.ktx.tryResumeWithException +import io.nekohasekai.libbox.ExchangeContext +import io.nekohasekai.libbox.LocalDNSTransport +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.runBlocking +import java.net.InetAddress +import java.net.UnknownHostException +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +object LocalResolver : LocalDNSTransport { + + private const val RCODE_NXDOMAIN = 3 + + override fun raw(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun exchange(ctx: ExchangeContext, message: ByteArray) { + return runBlocking { + suspendCoroutine { continuation -> + val signal = CancellationSignal() + ctx.onCancel(signal::cancel) + val callback = object : DnsResolver.Callback { + override fun onAnswer(answer: ByteArray, rcode: Int) { + if (rcode == 0) { + ctx.rawSuccess(answer) + } else { + ctx.errorCode(rcode) + } + continuation.resume(Unit) + } + + override fun onError(error: DnsResolver.DnsException) { + when (val cause = error.cause) { + is ErrnoException -> { + ctx.errnoCode(cause.errno) + continuation.resume(Unit) + return + } + } + continuation.tryResumeWithException(error) + } + } + DnsResolver.getInstance().rawQuery( + DefaultNetworkMonitor.defaultNetwork, + message, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } + } + } + + override fun lookup(ctx: ExchangeContext, network: String, domain: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return runBlocking { + suspendCoroutine { continuation -> + val signal = CancellationSignal() + ctx.onCancel(signal::cancel) + val callback = object : DnsResolver.Callback> { + @Suppress("ThrowableNotThrown") + override fun onAnswer(answer: Collection, rcode: Int) { + if (rcode == 0) { + ctx.success((answer as Collection).mapNotNull { it?.hostAddress } + .joinToString("\n")) + } else { + ctx.errorCode(rcode) + } + continuation.resume(Unit) + } + + override fun onError(error: DnsResolver.DnsException) { + when (val cause = error.cause) { + is ErrnoException -> { + ctx.errnoCode(cause.errno) + continuation.resume(Unit) + return + } + } + continuation.tryResumeWithException(error) + } + } + val type = when { + network.endsWith("4") -> DnsResolver.TYPE_A + network.endsWith("6") -> DnsResolver.TYPE_AAAA + else -> null + } + if (type != null) { + DnsResolver.getInstance().query( + DefaultNetworkMonitor.defaultNetwork, + domain, + type, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } else { + DnsResolver.getInstance().query( + DefaultNetworkMonitor.defaultNetwork, + domain, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } + } + } + } else { + val underlyingNetwork = + DefaultNetworkMonitor.defaultNetwork ?: error("upstream network not found") + val answer = try { + underlyingNetwork.getAllByName(domain) + } catch (e: UnknownHostException) { + ctx.errorCode(RCODE_NXDOMAIN) + return + } + ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n")) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/PlatformInterfaceWrapper.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/PlatformInterfaceWrapper.kt new file mode 100644 index 00000000..3b7114c4 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/PlatformInterfaceWrapper.kt @@ -0,0 +1,144 @@ +package com.hiddify.hiddify.bg + +import android.content.pm.PackageManager +import android.os.Build +import android.os.Process +import androidx.annotation.RequiresApi +import com.hiddify.hiddify.Application +import io.nekohasekai.libbox.InterfaceUpdateListener +import io.nekohasekai.libbox.NetworkInterfaceIterator +import io.nekohasekai.libbox.PlatformInterface +import io.nekohasekai.libbox.StringIterator +import io.nekohasekai.libbox.TunOptions +import java.net.Inet6Address +import java.net.InetSocketAddress +import java.net.InterfaceAddress +import java.net.NetworkInterface +import java.util.Enumeration +import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface + +interface PlatformInterfaceWrapper : PlatformInterface { + + override fun usePlatformAutoDetectInterfaceControl(): Boolean { + return true + } + + override fun autoDetectInterfaceControl(fd: Int) { + } + + override fun openTun(options: TunOptions): Int { + error("invalid argument") + } + + override fun useProcFS(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun findConnectionOwner( + ipProtocol: Int, + sourceAddress: String, + sourcePort: Int, + destinationAddress: String, + destinationPort: Int + ): Int { + val uid = Application.connectivity.getConnectionOwnerUid( + ipProtocol, + InetSocketAddress(sourceAddress, sourcePort), + InetSocketAddress(destinationAddress, destinationPort) + ) + if (uid == Process.INVALID_UID) error("android: connection owner not found") + return uid + } + + override fun packageNameByUid(uid: Int): String { + val packages = Application.packageManager.getPackagesForUid(uid) + if (packages.isNullOrEmpty()) error("android: package not found") + return packages[0] + } + + @Suppress("DEPRECATION") + override fun uidByPackageName(packageName: String): Int { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Application.packageManager.getPackageUid( + packageName, PackageManager.PackageInfoFlags.of(0) + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Application.packageManager.getPackageUid(packageName, 0) + } else { + Application.packageManager.getApplicationInfo(packageName, 0).uid + } + } catch (e: PackageManager.NameNotFoundException) { + error("android: package not found") + } + } + + override fun usePlatformDefaultInterfaceMonitor(): Boolean { + return true + } + + override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + DefaultNetworkMonitor.setListener(listener) + } + + override fun closeDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + DefaultNetworkMonitor.setListener(null) + } + + override fun usePlatformInterfaceGetter(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + } + + override fun getInterfaces(): NetworkInterfaceIterator { + return InterfaceArray(NetworkInterface.getNetworkInterfaces()) + } + + override fun underNetworkExtension(): Boolean { + return false + } + + private class InterfaceArray(private val iterator: Enumeration) : + NetworkInterfaceIterator { + + override fun hasNext(): Boolean { + return iterator.hasMoreElements() + } + + override fun next(): LibboxNetworkInterface { + val element = iterator.nextElement() + return LibboxNetworkInterface().apply { + name = element.name + index = element.index + runCatching { + mtu = element.mtu + } + addresses = + StringArray( + element.interfaceAddresses.mapTo(mutableListOf()) { it.toPrefix() } + .iterator() + ) + } + } + + private fun InterfaceAddress.toPrefix(): String { + return if (address is Inet6Address) { + "${Inet6Address.getByAddress(address.address).hostAddress}/${networkPrefixLength}" + } else { + "${address.hostAddress}/${networkPrefixLength}" + } + } + } + + private class StringArray(private val iterator: Iterator) : StringIterator { + + override fun hasNext(): Boolean { + return iterator.hasNext() + } + + override fun next(): String { + return iterator.next() + } + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/ServiceBinder.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/ServiceBinder.kt new file mode 100644 index 00000000..dd4b748f --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/ServiceBinder.kt @@ -0,0 +1,59 @@ +package com.hiddify.hiddify.bg + +import android.os.RemoteCallbackList +import androidx.lifecycle.MutableLiveData +import com.hiddify.hiddify.IService +import com.hiddify.hiddify.IServiceCallback +import com.hiddify.hiddify.constant.Status +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class ServiceBinder(private val status: MutableLiveData) : IService.Stub() { + private val callbacks = RemoteCallbackList() + private val broadcastLock = Mutex() + + init { + status.observeForever { + broadcast { callback -> + callback.onServiceStatusChanged(it.ordinal) + } + } + } + + fun broadcast(work: (IServiceCallback) -> Unit) { + GlobalScope.launch(Dispatchers.Main) { + broadcastLock.withLock { + val count = callbacks.beginBroadcast() + try { + repeat(count) { + try { + work(callbacks.getBroadcastItem(it)) + } catch (_: Exception) { + } + } + } finally { + callbacks.finishBroadcast() + } + } + } + } + + override fun getStatus(): Int { + return (status.value ?: Status.Stopped).ordinal + } + + override fun registerCallback(callback: IServiceCallback) { + callbacks.register(callback) + } + + override fun unregisterCallback(callback: IServiceCallback?) { + callbacks.unregister(callback) + } + + fun close() { + callbacks.kill() + } +} diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/ServiceConnection.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/ServiceConnection.kt new file mode 100644 index 00000000..4ce74e55 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/ServiceConnection.kt @@ -0,0 +1,109 @@ +package com.hiddify.hiddify.bg + +import com.hiddify.hiddify.IService +import com.hiddify.hiddify.IServiceCallback +import com.hiddify.hiddify.Settings +import com.hiddify.hiddify.constant.Action +import com.hiddify.hiddify.constant.Alert +import com.hiddify.hiddify.constant.Status +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.RemoteException +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +class ServiceConnection( + private val context: Context, + callback: Callback, + private val register: Boolean = true, +) : ServiceConnection { + + companion object { + private const val TAG = "ServiceConnection" + } + + private val callback = ServiceCallback(callback) + private var service: IService? = null + + val status get() = service?.status?.let { Status.values()[it] } ?: Status.Stopped + + fun connect() { + val intent = runBlocking { + withContext(Dispatchers.IO) { + Intent(context, VPNService::class.java).setAction(Action.SERVICE) + } + } + context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) + } + + fun disconnect() { + try { + context.unbindService(this) + } catch (_: IllegalArgumentException) { + } + } + + fun reconnect() { + try { + context.unbindService(this) + } catch (_: IllegalArgumentException) { + } + val intent = runBlocking { + withContext(Dispatchers.IO) { + Intent(context, VPNService::class.java).setAction(Action.SERVICE) + } + } + context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) + } + + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + val service = IService.Stub.asInterface(binder) + this.service = service + try { + if (register) service.registerCallback(callback) + callback.onServiceStatusChanged(service.status) + } catch (e: RemoteException) { + Log.e(TAG, "initialize service connection", e) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + try { + service?.unregisterCallback(callback) + } catch (e: RemoteException) { + Log.e(TAG, "cleanup service connection", e) + } + } + + override fun onBindingDied(name: ComponentName?) { + reconnect() + } + + interface Callback { + fun onServiceStatusChanged(status: Status) + fun onServiceAlert(type: Alert, message: String?) {} + fun onServiceWriteLog(message: String?) {} + fun onServiceResetLogs(messages: MutableList) {} + } + + class ServiceCallback(private val callback: Callback) : IServiceCallback.Stub() { + override fun onServiceStatusChanged(status: Int) { + callback.onServiceStatusChanged(Status.values()[status]) + } + + override fun onServiceAlert(type: Int, message: String?) { + callback.onServiceAlert(Alert.values()[type], message) + } + + override fun onServiceWriteLog(message: String?) = callback.onServiceWriteLog(message) + + override fun onServiceResetLogs(messages: MutableList) = + callback.onServiceResetLogs(messages) + } +} diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/ServiceNotification.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/ServiceNotification.kt new file mode 100644 index 00000000..ccfb28b9 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/ServiceNotification.kt @@ -0,0 +1,80 @@ +package com.hiddify.hiddify.bg + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import com.hiddify.hiddify.Application +import com.hiddify.hiddify.MainActivity +import com.hiddify.hiddify.R +import com.hiddify.hiddify.constant.Action + +class ServiceNotification(private val service: Service) { + companion object { + private const val notificationId = 1 + private const val notificationChannel = "service" + private val flags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + + fun checkPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return true + } + if (Application.notification.areNotificationsEnabled()) { + return true + } + return false + } + } + + + private val notification by lazy { + NotificationCompat.Builder(service, notificationChannel).setWhen(0) + .setContentTitle("hiddify next") + .setContentText("service running").setOnlyAlertOnce(true) + .setSmallIcon(R.drawable.ic_stat_logo) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent( + PendingIntent.getActivity( + service, + 0, + Intent( + service, + MainActivity::class.java + ).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), + flags + ) + ) + .setPriority(NotificationCompat.PRIORITY_LOW).apply { + addAction( + NotificationCompat.Action.Builder( + 0, service.getText(R.string.stop), PendingIntent.getBroadcast( + service, + 0, + Intent(Action.SERVICE_CLOSE).setPackage(service.packageName), + flags + ) + ).build() + ) + } + } + + fun show() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Application.notification.createNotificationChannel( + NotificationChannel( + notificationChannel, "hiddify service", NotificationManager.IMPORTANCE_LOW + ) + ) + } + service.startForeground(notificationId, notification.build()) + } + + fun close() { + ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt new file mode 100644 index 00000000..e1407dee --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt @@ -0,0 +1,147 @@ +package com.hiddify.hiddify.bg + +import com.hiddify.hiddify.Settings +import android.content.Intent +import android.content.pm.PackageManager.NameNotFoundException +import android.net.ProxyInfo +import android.net.VpnService +import android.os.Build +import io.nekohasekai.libbox.TunOptions + +class VPNService : VpnService(), PlatformInterfaceWrapper { + + companion object { + private const val TAG = "A/VPNService" + } + + private val service = BoxService(this, this) + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = + service.onStartCommand(intent, flags, startId) + + override fun onBind(intent: Intent) = service.onBind(intent) + override fun onDestroy() { + service.onDestroy() + } + + override fun onRevoke() { + service.onRevoke() + } + + override fun autoDetectInterfaceControl(fd: Int) { + protect(fd) + } + + override fun openTun(options: TunOptions): Int { + if (prepare(this) != null) error("android: missing vpn permission") + + val builder = Builder() + .setSession("sing-box") + .setMtu(options.mtu) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setMetered(false) + } + + val inet4Address = options.inet4Address + if (inet4Address.hasNext()) { + while (inet4Address.hasNext()) { + val address = inet4Address.next() + builder.addAddress(address.address, address.prefix) + } + } + + val inet6Address = options.inet6Address + if (inet6Address.hasNext()) { + while (inet6Address.hasNext()) { + val address = inet6Address.next() + builder.addAddress(address.address, address.prefix) + } + } + + if (options.autoRoute) { + builder.addDnsServer(options.dnsServerAddress) + + val inet4RouteAddress = options.inet4RouteAddress + if (inet4RouteAddress.hasNext()) { + while (inet4RouteAddress.hasNext()) { + val address = inet4RouteAddress.next() + builder.addRoute(address.address, address.prefix) + } + } else { + builder.addRoute("0.0.0.0", 0) + } + + val inet6RouteAddress = options.inet6RouteAddress + if (inet6RouteAddress.hasNext()) { + while (inet6RouteAddress.hasNext()) { + val address = inet6RouteAddress.next() + builder.addRoute(address.address, address.prefix) + } + } else { + builder.addRoute("::", 0) + } + + if (Settings.perAppProxyEnabled) { + val appList = Settings.perAppProxyList + if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) { + appList.forEach { + try { + builder.addAllowedApplication(it) + } catch (_: NameNotFoundException) { + } + } + builder.addAllowedApplication(packageName) + } else { + appList.forEach { + try { + builder.addDisallowedApplication(it) + } catch (_: NameNotFoundException) { + } + } + } + } else { + val includePackage = options.includePackage + if (includePackage.hasNext()) { + while (includePackage.hasNext()) { + try { + builder.addAllowedApplication(includePackage.next()) + } catch (_: NameNotFoundException) { + } + } + } + + val excludePackage = options.excludePackage + if (excludePackage.hasNext()) { + while (excludePackage.hasNext()) { + try { + builder.addDisallowedApplication(excludePackage.next()) + } catch (_: NameNotFoundException) { + } + } + } + } + } + + if (options.isHTTPProxyEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setHttpProxy( + ProxyInfo.buildDirectProxy( + options.httpProxyServer, + options.httpProxyServerPort + ) + ) + } else { + error("android: tun.platform.http_proxy requires android 10 or higher") + } + } + + val pfd = + builder.establish() ?: error("android: the application is not prepared or is revoked") + service.fileDescriptor = pfd + return pfd.fd + } + + override fun writeLog(message: String) = service.writeLog(message) + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/constant/Action.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/Action.kt new file mode 100644 index 00000000..96565d02 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/Action.kt @@ -0,0 +1,7 @@ +package com.hiddify.hiddify.constant + +object Action { + const val SERVICE = "com.hiddify.app.SERVICE" + const val SERVICE_CLOSE = "com.hiddify.app.SERVICE_CLOSE" + const val SERVICE_RELOAD = "com.hiddify.app.sfa.SERVICE_RELOAD" +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/constant/Alert.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/Alert.kt new file mode 100644 index 00000000..afbb72a4 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/Alert.kt @@ -0,0 +1,10 @@ +package com.hiddify.hiddify.constant + +enum class Alert { + RequestVPNPermission, + RequestNotificationPermission, + EmptyConfiguration, + StartCommandServer, + CreateService, + StartService +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt new file mode 100644 index 00000000..1a630d65 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt @@ -0,0 +1,16 @@ +package com.hiddify.hiddify.constant + +object SettingsKey { + const val SELECTED_CONFIG_PATH = "selected_config_path" + const val DISABLE_MEMORY_LIMIT = "disable_memory_limit" + + const val PER_APP_PROXY_ENABLED = "per_app_proxy_enabled" + const val PER_APP_PROXY_MODE = "per_app_proxy_mode" + const val PER_APP_PROXY_LIST = "per_app_proxy_list" + const val PER_APP_PROXY_UPDATE_ON_CHANGE = "per_app_proxy_update_on_change" + + // cache + + const val STARTED_BY_USER = "started_by_user" + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/constant/Status.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/Status.kt new file mode 100644 index 00000000..f3537cf8 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/Status.kt @@ -0,0 +1,8 @@ +package com.hiddify.hiddify.constant + +enum class Status { + Stopped, + Starting, + Started, + Stopping, +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/ktx/Continuations.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/ktx/Continuations.kt new file mode 100644 index 00000000..244dd328 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/ktx/Continuations.kt @@ -0,0 +1,18 @@ +package com.hiddify.hiddify.ktx + +import kotlin.coroutines.Continuation + + +fun Continuation.tryResume(value: T) { + try { + resumeWith(Result.success(value)) + } catch (ignored: IllegalStateException) { + } +} + +fun Continuation.tryResumeWithException(exception: Throwable) { + try { + resumeWith(Result.failure(exception)) + } catch (ignored: IllegalStateException) { + } +} \ No newline at end of file diff --git a/android/app/src/main/res/values/arrays.xml b/android/app/src/main/res/values/arrays.xml deleted file mode 100644 index 72bd02f5..00000000 --- a/android/app/src/main/res/values/arrays.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - 1.0.0.0/8 - 2.0.0.0/7 - 4.0.0.0/6 - 8.0.0.0/7 - 11.0.0.0/8 - 12.0.0.0/6 - 16.0.0.0/4 - 32.0.0.0/3 - 64.0.0.0/3 - 96.0.0.0/4 - 112.0.0.0/5 - 120.0.0.0/6 - 124.0.0.0/7 - 126.0.0.0/8 - 128.0.0.0/3 - 160.0.0.0/5 - 168.0.0.0/8 - 169.0.0.0/9 - 169.128.0.0/10 - 169.192.0.0/11 - 169.224.0.0/12 - 169.240.0.0/13 - 169.248.0.0/14 - 169.252.0.0/15 - 169.255.0.0/16 - 170.0.0.0/7 - 172.0.0.0/12 - 172.32.0.0/11 - 172.64.0.0/10 - 172.128.0.0/9 - 173.0.0.0/8 - 174.0.0.0/7 - 176.0.0.0/4 - 192.0.0.0/9 - 192.128.0.0/11 - 192.160.0.0/13 - 192.169.0.0/16 - 192.170.0.0/15 - 192.172.0.0/14 - 192.176.0.0/12 - 192.192.0.0/10 - 193.0.0.0/8 - 194.0.0.0/7 - 196.0.0.0/6 - 200.0.0.0/5 - 208.0.0.0/4 - 240.0.0.0/5 - 248.0.0.0/6 - 252.0.0.0/7 - 254.0.0.0/8 - 255.0.0.0/9 - 255.128.0.0/10 - 255.192.0.0/11 - 255.224.0.0/12 - 255.240.0.0/13 - 255.248.0.0/14 - 255.252.0.0/15 - 255.254.0.0/16 - 255.255.0.0/17 - 255.255.128.0/18 - 255.255.192.0/19 - 255.255.224.0/20 - 255.255.240.0/21 - 255.255.248.0/22 - 255.255.252.0/23 - 255.255.254.0/24 - 255.255.255.0/25 - 255.255.255.128/26 - 255.255.255.192/27 - 255.255.255.224/28 - 255.255.255.240/29 - 255.255.255.248/30 - 255.255.255.252/31 - 255.255.255.254/32 - - \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..fbb54ae9 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Stop + \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a3..cc18a0cd 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true android.enableJetifier=true diff --git a/assets/core/.gitkeep b/assets/core/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/assets/core/clash/Country.mmdb b/assets/core/clash/Country.mmdb deleted file mode 100644 index b8795f39..00000000 Binary files a/assets/core/clash/Country.mmdb and /dev/null differ diff --git a/assets/core/clash/config.yaml b/assets/core/clash/config.yaml deleted file mode 100644 index f70a5e7a..00000000 --- a/assets/core/clash/config.yaml +++ /dev/null @@ -1,16 +0,0 @@ -external-controller: 127.0.0.1:22345 - -mixed-port: 22346 - -rules: - # localhost rule - - DOMAIN-KEYWORD,announce,DIRECT - - DOMAIN-KEYWORD,torrent,DIRECT - - DOMAIN-KEYWORD,tracker,DIRECT - - DOMAIN-SUFFIX,smtp,DIRECT - - DOMAIN-SUFFIX,local,DIRECT - - IP-CIDR,192.168.0.0/16,DIRECT - - IP-CIDR,10.0.0.0/8,DIRECT - - IP-CIDR,172.16.0.0/12,DIRECT - - IP-CIDR,127.0.0.0/8,DIRECT - - IP-CIDR,100.64.0.0/10,DIRECT \ No newline at end of file diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index 105d0a69..ac205089 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -145,8 +145,18 @@ "unexpected": "unexpected failure", "core": "clash failure ${reason}" }, + "singbox": { + "unexpected": "unexpected service failure", + "serviceNotRunning": "Service not running", + "invalidConfig": "Configuration is not valid", + "create": "Error creating service", + "start": "Error starting service" + }, "connectivity": { - "unexpected": "unexpected failure" + "unexpected": "unexpected failure", + "missingVpnPermission": "Missing VPN permission", + "missingNotificationPermission": "Missing Notification permission", + "core": "Core failure" }, "profiles": { "unexpected": "unexpected failure", diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 499bacfc..cb48d1db 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -145,8 +145,18 @@ "unexpected": "خطایی رخ داده", "core": "خطای کلش ${reason}" }, + "singbox": { + "unexpected": "خطایی غیر منتظره در سرویس رخ داد", + "serviceNotRunning": "سرویس در حال اجرا نیست", + "invalidConfig": "کانفیگ غیر معتبر", + "create": "در ایجاد سرویس خطایی رخ داده", + "start": "در راه‌اندازی سرویس خطایی رخ داده" + }, "connectivity": { - "unexpected": "خطایی رخ داده" + "unexpected": "خطایی رخ داده", + "missingVpnPermission": "نیازمند دسترسی VPN", + "missingNotificationPermission": "نیازمند دسترسی اعلانات", + "core": "خطای هسته" }, "profiles": { "unexpected": "خطایی رخ داده", diff --git a/core b/core deleted file mode 160000 index 1149e933..00000000 --- a/core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1149e93363f33e7bbe2b59ad4de98efdc6a86bb7 diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 63a767e1..c6fda515 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -39,7 +39,15 @@ Future lazyBootstrap(WidgetsBinding widgetsBinding) async { overrides: [sharedPreferencesProvider.overrideWithValue(sharedPreferences)], ); - Loggy.initLoggy(logPrinter: const PrettyPrinter()); + // Loggy.initLoggy(logPrinter: const PrettyPrinter()); + final filesEditor = container.read(filesEditorServiceProvider); + await filesEditor.init(); + Loggy.initLoggy( + logPrinter: MultiLogPrinter( + const PrettyPrinter(), + FileLogPrinter(filesEditor.appLogsPath), + ), + ); final silentStart = container.read(prefsControllerProvider).general.silentStart; @@ -68,12 +76,10 @@ Future lazyBootstrap(WidgetsBinding widgetsBinding) async { Future initAppServices( Result Function(ProviderListenable) read, ) async { - await read(filesEditorServiceProvider).init(); + // await read(filesEditorServiceProvider).init(); await Future.wait( [ read(connectivityServiceProvider).init(), - read(clashServiceProvider).init(), - read(clashServiceProvider).start(), read(notificationServiceProvider).init(), ], ); @@ -83,6 +89,7 @@ Future initAppServices( Future initControllers( Result Function(ProviderListenable) read, ) async { + _loggy.debug("initializing controllers"); await Future.wait( [ read(activeProfileProvider.future), diff --git a/lib/core/prefs/prefs_controller.dart b/lib/core/prefs/prefs_controller.dart index c33bcbe2..170469f2 100644 --- a/lib/core/prefs/prefs_controller.dart +++ b/lib/core/prefs/prefs_controller.dart @@ -36,7 +36,7 @@ class PrefsController extends _$PrefsController with AppLogger { ClashConfig _getClashPrefs() { final persisted = _prefs.getString(_overridesKey); - if (persisted == null) return ClashConfig.initial; + if (persisted == null) return const ClashConfig(); return ClashConfig.fromJson(jsonDecode(persisted) as Map); } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e65faa03..298f445b 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -33,11 +33,11 @@ GoRouter router(RouterRef ref) { int getCurrentIndex(BuildContext context) { final String location = GoRouterState.of(context).location; - if (location == HomeRoute.path) return 0; - if (location.startsWith(ProxiesRoute.path)) return 1; - if (location.startsWith(LogsRoute.path)) return 2; - if (location.startsWith(SettingsRoute.path)) return 3; - if (location.startsWith(AboutRoute.path)) return 4; + if (location == const HomeRoute().location) return 0; + if (location.startsWith(const ProxiesRoute().location)) return 1; + if (location.startsWith(const LogsRoute().location)) return 2; + if (location.startsWith(const SettingsRoute().location)) return 3; + if (location.startsWith(const AboutRoute().location)) return 4; return 0; } diff --git a/lib/core/router/routes/desktop_routes.dart b/lib/core/router/routes/desktop_routes.dart index 1fa42f74..eeeeb3d5 100644 --- a/lib/core/router/routes/desktop_routes.dart +++ b/lib/core/router/routes/desktop_routes.dart @@ -23,9 +23,9 @@ part 'desktop_routes.g.dart'; TypedGoRoute(path: LogsRoute.path), TypedGoRoute( path: SettingsRoute.path, - routes: [ - TypedGoRoute(path: ClashOverridesRoute.path), - ], + // routes: [ + // TypedGoRoute(path: ClashOverridesRoute.path), + // ], ), TypedGoRoute(path: AboutRoute.path), ], @@ -59,18 +59,18 @@ class SettingsRoute extends GoRouteData { } } -class ClashOverridesRoute extends GoRouteData { - const ClashOverridesRoute(); - static const path = 'clash'; +// class ClashOverridesRoute extends GoRouteData { +// const ClashOverridesRoute(); +// static const path = 'clash'; - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - child: ClashOverridesPage(), - ); - } -} +// @override +// Page buildPage(BuildContext context, GoRouterState state) { +// return const MaterialPage( +// fullscreenDialog: true, +// child: ClashOverridesPage(), +// ); +// } +// } class AboutRoute extends GoRouteData { const AboutRoute(); diff --git a/lib/core/router/routes/mobile_routes.dart b/lib/core/router/routes/mobile_routes.dart index 94f12063..3d9721cb 100644 --- a/lib/core/router/routes/mobile_routes.dart +++ b/lib/core/router/routes/mobile_routes.dart @@ -20,9 +20,9 @@ part 'mobile_routes.g.dart'; TypedGoRoute(path: LogsRoute.path), TypedGoRoute( path: SettingsRoute.path, - routes: [ - TypedGoRoute(path: ClashOverridesRoute.path), - ], + // routes: [ + // TypedGoRoute(path: ClashOverridesRoute.path), + // ], ), TypedGoRoute(path: AboutRoute.path), ], @@ -69,20 +69,20 @@ class SettingsRoute extends GoRouteData { } } -class ClashOverridesRoute extends GoRouteData { - const ClashOverridesRoute(); - static const path = 'clash'; +// class ClashOverridesRoute extends GoRouteData { +// const ClashOverridesRoute(); +// static const path = 'clash'; - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; +// static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - @override - Page buildPage(BuildContext context, GoRouterState state) { - return const MaterialPage( - fullscreenDialog: true, - child: ClashOverridesPage(), - ); - } -} +// @override +// Page buildPage(BuildContext context, GoRouterState state) { +// return const MaterialPage( +// fullscreenDialog: true, +// child: ClashOverridesPage(), +// ); +// } +// } class AboutRoute extends GoRouteData { const AboutRoute(); diff --git a/lib/data/api/clash_api.dart b/lib/data/api/clash_api.dart new file mode 100644 index 00000000..77c88b47 --- /dev/null +++ b/lib/data/api/clash_api.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/domain/clash/clash.dart'; +import 'package:hiddify/domain/constants.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class ClashApi with InfraLogger { + ClashApi(int port) : address = "${Constants.localHost}:$port"; + + final String address; + + late final _clashDio = Dio( + BaseOptions( + baseUrl: "http://$address", + connectTimeout: const Duration(seconds: 3), + receiveTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 3), + ), + ); + + TaskEither> getProxies() { + return TaskEither( + () async { + final response = await _clashDio.get("/proxies"); + if (response.statusCode != 200 || response.data == null) { + return left(response.statusMessage ?? ""); + } + final proxies = (jsonDecode(response.data! as String)["proxies"] + as Map) + .entries + .map( + (e) { + final proxyMap = (e.value as Map) + ..putIfAbsent('name', () => e.key); + return ClashProxy.fromJson(proxyMap); + }, + ); + return right(proxies.toList()); + }, + ); + } + + TaskEither changeProxy(String selectorName, String proxyName) { + return TaskEither( + () async { + final response = await _clashDio.put( + "/proxies/$selectorName", + data: {"name": proxyName}, + ); + if (response.statusCode != HttpStatus.noContent) { + return left(response.statusMessage ?? ""); + } + return right(unit); + }, + ); + } + + TaskEither getProxyDelay( + String name, + String url, { + Duration timeout = const Duration(seconds: 10), + }) { + return TaskEither( + () async { + final response = await _clashDio.get( + "/proxies/$name/delay", + queryParameters: { + "timeout": timeout.inMilliseconds, + "url": url, + }, + ); + if (response.statusCode != 200 || response.data == null) { + return left(response.statusMessage ?? ""); + } + return right(response.data!["delay"] as int); + }, + ); + } + + TaskEither getConfigs() { + return TaskEither( + () async { + final response = await _clashDio.get("/configs"); + if (response.statusCode != 200 || response.data == null) { + return left(response.statusMessage ?? ""); + } + final config = + ClashConfig.fromJson(response.data as Map); + return right(config); + }, + ); + } + + TaskEither updateConfigs(String path) { + return TaskEither.of(unit); + } + + TaskEither patchConfigs(ClashConfig config) { + return TaskEither( + () async { + final response = await _clashDio.patch( + "/configs", + data: config.toJson(), + ); + if (response.statusCode != HttpStatus.noContent) { + return left(response.statusMessage ?? ""); + } + return right(unit); + }, + ); + } + + Stream watchLogs(LogLevel level) { + return const Stream.empty(); + } + + Stream watchTraffic() { + final channel = WebSocketChannel.connect( + Uri.parse("ws://$address/traffic"), + ); + return channel.stream.map( + (event) { + return ClashTraffic.fromJson( + jsonDecode(event as String) as Map, + ); + }, + ); + } + + TaskEither getTraffic() { + return TaskEither( + () async { + final response = await _clashDio.get>("/traffic"); + if (response.statusCode != 200 || response.data == null) { + return left(response.statusMessage ?? ""); + } + return right(ClashTraffic.fromJson(response.data!)); + }, + ); + } +} diff --git a/lib/data/data_providers.dart b/lib/data/data_providers.dart index b07d31b1..d1e23cba 100644 --- a/lib/data/data_providers.dart +++ b/lib/data/data_providers.dart @@ -1,10 +1,12 @@ import 'package:dio/dio.dart'; +import 'package:hiddify/data/api/clash_api.dart'; import 'package:hiddify/data/local/dao/dao.dart'; import 'package:hiddify/data/local/database.dart'; import 'package:hiddify/data/repository/repository.dart'; import 'package:hiddify/data/repository/update_repository_impl.dart'; import 'package:hiddify/domain/app/app.dart'; -import 'package:hiddify/domain/clash/clash.dart'; +import 'package:hiddify/domain/constants.dart'; +import 'package:hiddify/domain/core_facade.dart'; import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/services/service_providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -28,21 +30,26 @@ ProfilesDao profilesDao(ProfilesDaoRef ref) => ProfilesDao( ref.watch(appDatabaseProvider), ); -@Riverpod(keepAlive: true) -ClashFacade clashFacade(ClashFacadeRef ref) => ClashFacadeImpl( - clashService: ref.watch(clashServiceProvider), - filesEditor: ref.watch(filesEditorServiceProvider), - ); - @Riverpod(keepAlive: true) ProfilesRepository profilesRepository(ProfilesRepositoryRef ref) => ProfilesRepositoryImpl( profilesDao: ref.watch(profilesDaoProvider), filesEditor: ref.watch(filesEditorServiceProvider), - clashFacade: ref.watch(clashFacadeProvider), + singbox: ref.watch(coreFacadeProvider), dio: ref.watch(dioProvider), ); @Riverpod(keepAlive: true) UpdateRepository updateRepository(UpdateRepositoryRef ref) => UpdateRepositoryImpl(ref.watch(dioProvider)); + +@Riverpod(keepAlive: true) +ClashApi clashApi(ClashApiRef ref) => ClashApi(Defaults.clashApiPort); + +@Riverpod(keepAlive: true) +CoreFacade coreFacade(CoreFacadeRef ref) => CoreFacadeImpl( + ref.watch(singboxServiceProvider), + ref.watch(filesEditorServiceProvider), + ref.watch(clashApiProvider), + ref.watch(connectivityServiceProvider), + ); diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index 9eb11172..17abc549 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -5,8 +5,8 @@ import 'package:drift/native.dart'; import 'package:hiddify/data/local/dao/dao.dart'; import 'package:hiddify/data/local/tables.dart'; import 'package:hiddify/data/local/type_converters.dart'; +import 'package:hiddify/services/files_editor_service.dart'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; part 'database.g.dart'; @@ -22,8 +22,8 @@ class AppDatabase extends _$AppDatabase { LazyDatabase _openConnection() { return LazyDatabase(() async { - final dbFolder = await getApplicationDocumentsDirectory(); - final file = File(p.join(dbFolder.path, 'db.sqlite')); + final dbDir = await FilesEditorService.getDatabaseDirectory(); + final file = File(p.join(dbDir.path, 'db.sqlite')); return NativeDatabase.createInBackground(file); }); } diff --git a/lib/data/repository/clash_facade_impl.dart b/lib/data/repository/clash_facade_impl.dart deleted file mode 100644 index 3d583879..00000000 --- a/lib/data/repository/clash_facade_impl.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:async'; - -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/data/repository/exception_handlers.dart'; -import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/services/clash/clash.dart'; -import 'package:hiddify/services/files_editor_service.dart'; -import 'package:hiddify/utils/utils.dart'; - -class ClashFacadeImpl - with ExceptionHandler, InfraLogger - implements ClashFacade { - ClashFacadeImpl({ - required ClashService clashService, - required FilesEditorService filesEditor, - }) : _clash = clashService, - _filesEditor = filesEditor; - - final ClashService _clash; - final FilesEditorService _filesEditor; - - @override - TaskEither getConfigs() { - return exceptionHandler( - () async => _clash.getConfigs().mapLeft(ClashFailure.core).run(), - ClashFailure.unexpected, - ); - } - - @override - TaskEither validateConfig(String configFileName) { - return exceptionHandler( - () async { - final path = _filesEditor.configPath(configFileName); - return _clash.validateConfig(path).mapLeft(ClashFailure.core).run(); - }, - ClashFailure.unexpected, - ); - } - - @override - TaskEither changeConfigs(String configFileName) { - return exceptionHandler( - () async { - loggy.debug("changing config, file name: [$configFileName]"); - final path = _filesEditor.configPath(configFileName); - return _clash.updateConfigs(path).mapLeft(ClashFailure.core).run(); - }, - ClashFailure.unexpected, - ); - } - - @override - TaskEither patchOverrides(ClashConfig overrides) { - return exceptionHandler( - () async => - _clash.patchConfigs(overrides).mapLeft(ClashFailure.core).run(), - ClashFailure.unexpected, - ); - } - - @override - TaskEither> getProxies() { - return exceptionHandler( - () async => _clash.getProxies().mapLeft(ClashFailure.core).run(), - ClashFailure.unexpected, - ); - } - - @override - TaskEither changeProxy( - String selectorName, - String proxyName, - ) { - return exceptionHandler( - () async => _clash - .changeProxy(selectorName, proxyName) - .mapLeft(ClashFailure.core) - .run(), - ClashFailure.unexpected, - ); - } - - @override - TaskEither getTraffic() { - return exceptionHandler( - () async => _clash.getTraffic().mapLeft(ClashFailure.core).run(), - ClashFailure.unexpected, - ); - } - - @override - TaskEither testDelay( - String proxyName, { - String testUrl = Constants.delayTestUrl, - }) { - return exceptionHandler( - () async { - final result = _clash - .getProxyDelay(proxyName, testUrl) - .mapLeft(ClashFailure.core) - .run(); - return result; - }, - ClashFailure.unexpected, - ); - } - - @override - Stream> watchLogs() { - return _clash - .watchLogs(LogLevel.info) - .handleExceptions(ClashFailure.unexpected); - } -} diff --git a/lib/data/repository/core_facade_impl.dart b/lib/data/repository/core_facade_impl.dart new file mode 100644 index 00000000..8d160c3b --- /dev/null +++ b/lib/data/repository/core_facade_impl.dart @@ -0,0 +1,187 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/data/api/clash_api.dart'; +import 'package:hiddify/data/repository/exception_handlers.dart'; +import 'package:hiddify/domain/clash/clash.dart'; +import 'package:hiddify/domain/connectivity/connection_status.dart'; +import 'package:hiddify/domain/constants.dart'; +import 'package:hiddify/domain/core_facade.dart'; +import 'package:hiddify/domain/core_service_failure.dart'; +import 'package:hiddify/services/connectivity/connectivity.dart'; +import 'package:hiddify/services/files_editor_service.dart'; +import 'package:hiddify/services/singbox/singbox_service.dart'; +import 'package:hiddify/utils/utils.dart'; + +class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { + CoreFacadeImpl(this.singbox, this.filesEditor, this.clash, this.connectivity); + + final SingboxService singbox; + final FilesEditorService filesEditor; + final ClashApi clash; + final ConnectivityService connectivity; + + bool _initialized = false; + + @override + TaskEither setup() { + if (_initialized) return TaskEither.of(unit); + return exceptionHandler( + () { + loggy.debug("setting up singbox"); + return singbox + .setup( + filesEditor.baseDir.path, + filesEditor.workingDir.path, + filesEditor.tempDir.path, + ) + .map((r) { + loggy.debug("setup complete"); + _initialized = true; + return r; + }) + .mapLeft(CoreServiceFailure.other) + .run(); + }, + CoreServiceFailure.unexpected, + ); + } + + @override + TaskEither parseConfig(String path) { + return exceptionHandler( + () { + return singbox + .parseConfig(path) + .mapLeft(CoreServiceFailure.invalidConfig) + .run(); + }, + CoreServiceFailure.unexpected, + ); + } + + @override + TaskEither changeConfig(String fileName) { + return exceptionHandler( + () { + final configPath = filesEditor.configPath(fileName); + loggy.debug("changing config to: $configPath"); + return setup() + .andThen( + () => + singbox.create(configPath).mapLeft(CoreServiceFailure.create), + ) + .run(); + }, + CoreServiceFailure.unexpected, + ); + } + + @override + TaskEither start() { + return exceptionHandler( + () => singbox.start().mapLeft(CoreServiceFailure.start).run(), + CoreServiceFailure.unexpected, + ); + } + + @override + TaskEither stop() { + return exceptionHandler( + () => singbox.stop().mapLeft(CoreServiceFailure.other).run(), + CoreServiceFailure.unexpected, + ); + } + + @override + Stream> watchLogs() { + return singbox + .watchLogs(filesEditor.logsPath) + .handleExceptions(CoreServiceFailure.unexpected); + } + + @override + TaskEither getConfigs() { + return exceptionHandler( + () async => clash.getConfigs().mapLeft(CoreServiceFailure.other).run(), + CoreServiceFailure.unexpected, + ); + } + + @override + TaskEither patchOverrides(ClashConfig overrides) { + return exceptionHandler( + () async => + clash.patchConfigs(overrides).mapLeft(CoreServiceFailure.other).run(), + CoreServiceFailure.unexpected, + ); + } + + @override + TaskEither> getProxies() { + return exceptionHandler( + () async => clash.getProxies().mapLeft(CoreServiceFailure.other).run(), + CoreServiceFailure.unexpected, + ); + } + + @override + TaskEither changeProxy( + String selectorName, + String proxyName, + ) { + return exceptionHandler( + () async => clash + .changeProxy(selectorName, proxyName) + .mapLeft(CoreServiceFailure.other) + .run(), + CoreServiceFailure.unexpected, + ); + } + + @override + Stream> watchTraffic() { + return clash.watchTraffic().handleExceptions(CoreServiceFailure.unexpected); + } + + @override + TaskEither testDelay( + String proxyName, { + String testUrl = Defaults.delayTestUrl, + }) { + return exceptionHandler( + () async { + final result = clash + .getProxyDelay(proxyName, testUrl) + .mapLeft(CoreServiceFailure.other) + .run(); + return result; + }, + CoreServiceFailure.unexpected, + ); + } + + @override + TaskEither connect() { + return exceptionHandler( + () async { + await connectivity.connect(); + return right(unit); + }, + CoreServiceFailure.unexpected, + ); + } + + @override + TaskEither disconnect() { + return exceptionHandler( + () async { + await connectivity.disconnect(); + return right(unit); + }, + CoreServiceFailure.unexpected, + ); + } + + @override + Stream watchConnectionStatus() => + connectivity.watchConnectionStatus(); +} diff --git a/lib/data/repository/profiles_repository_impl.dart b/lib/data/repository/profiles_repository_impl.dart index 05d5a0a5..93908a77 100644 --- a/lib/data/repository/profiles_repository_impl.dart +++ b/lib/data/repository/profiles_repository_impl.dart @@ -4,9 +4,9 @@ import 'package:dio/dio.dart'; import 'package:fpdart/fpdart.dart'; import 'package:hiddify/data/local/dao/dao.dart'; import 'package:hiddify/data/repository/exception_handlers.dart'; -import 'package:hiddify/domain/clash/clash.dart'; import 'package:hiddify/domain/enums.dart'; import 'package:hiddify/domain/profiles/profiles.dart'; +import 'package:hiddify/domain/singbox/singbox.dart'; import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:meta/meta.dart'; @@ -18,13 +18,13 @@ class ProfilesRepositoryImpl ProfilesRepositoryImpl({ required this.profilesDao, required this.filesEditor, - required this.clashFacade, + required this.singbox, required this.dio, }); final ProfilesDao profilesDao; final FilesEditorService filesEditor; - final ClashFacade clashFacade; + final SingboxFacade singbox; final Dio dio; @override @@ -166,20 +166,17 @@ class ProfilesRepositoryImpl () async { final path = filesEditor.configPath(fileName); final response = await dio.download(url, path); - if (response.statusCode != 200) { - await File(path).delete(); - return left(const ProfileUnexpectedFailure()); - } - final isValid = await clashFacade - .validateConfig(fileName) - .getOrElse((_) => false) - .run(); - if (!isValid) { - await File(path).delete(); - return left(const ProfileFailure.invalidConfig()); - } - final profile = Profile.fromResponse(url, response.headers.map); - return right(profile); + final parseResult = await singbox.parseConfig(path).run(); + return parseResult.fold( + (l) async { + await File(path).delete(); + return left(ProfileFailure.invalidConfig(l.msg)); + }, + (_) { + final profile = Profile.fromResponse(url, response.headers.map); + return right(profile); + }, + ); }, ); } diff --git a/lib/data/repository/repository.dart b/lib/data/repository/repository.dart index 1d44b8b7..4f454cc4 100644 --- a/lib/data/repository/repository.dart +++ b/lib/data/repository/repository.dart @@ -1,2 +1,2 @@ -export 'clash_facade_impl.dart'; +export 'core_facade_impl.dart'; export 'profiles_repository_impl.dart'; diff --git a/lib/domain/clash/clash.dart b/lib/domain/clash/clash.dart index a7a9c962..4db9fff2 100644 --- a/lib/domain/clash/clash.dart +++ b/lib/domain/clash/clash.dart @@ -1,7 +1,6 @@ export 'clash_config.dart'; export 'clash_enums.dart'; export 'clash_facade.dart'; -export 'clash_failures.dart'; export 'clash_log.dart'; export 'clash_proxy.dart'; export 'clash_traffic.dart'; diff --git a/lib/domain/clash/clash_config.dart b/lib/domain/clash/clash_config.dart index 2779ddd7..5e515ea4 100644 --- a/lib/domain/clash/clash_config.dart +++ b/lib/domain/clash/clash_config.dart @@ -24,12 +24,6 @@ class ClashConfig with _$ClashConfig { bool? ipv6, }) = _ClashConfig; - static const initial = ClashConfig( - httpPort: 12346, - socksPort: 12347, - mixedPort: 12348, - ); - ClashConfig patch(ClashConfigPatch patch) { return copyWith( httpPort: (patch.httpPort ?? optionOf(httpPort)).toNullable(), diff --git a/lib/domain/clash/clash_enums.dart b/lib/domain/clash/clash_enums.dart index 1516c874..f8ad5af3 100644 --- a/lib/domain/clash/clash_enums.dart +++ b/lib/domain/clash/clash_enums.dart @@ -38,6 +38,7 @@ enum ProxyType { hysteria("Hysteria"), wireGuard("WireGuard"), tuic("Tuic"), + ssh("SSH"), relay("Relay"), selector("Selector"), fallback("Fallback"), diff --git a/lib/domain/clash/clash_facade.dart b/lib/domain/clash/clash_facade.dart index 210a0662..2c5f2091 100644 --- a/lib/domain/clash/clash_facade.dart +++ b/lib/domain/clash/clash_facade.dart @@ -1,32 +1,24 @@ -import 'dart:async'; - import 'package:fpdart/fpdart.dart'; import 'package:hiddify/domain/clash/clash.dart'; import 'package:hiddify/domain/constants.dart'; +import 'package:hiddify/domain/core_service_failure.dart'; abstract class ClashFacade { - TaskEither getConfigs(); + TaskEither getConfigs(); - TaskEither validateConfig(String configFileName); + TaskEither patchOverrides(ClashConfig overrides); - /// change active configuration file by [configFileName] - TaskEither changeConfigs(String configFileName); + TaskEither> getProxies(); - TaskEither patchOverrides(ClashConfig overrides); - - TaskEither> getProxies(); - - TaskEither changeProxy( + TaskEither changeProxy( String selectorName, String proxyName, ); - TaskEither testDelay( + TaskEither testDelay( String proxyName, { - String testUrl = Constants.delayTestUrl, + String testUrl = Defaults.delayTestUrl, }); - TaskEither getTraffic(); - - Stream> watchLogs(); + Stream> watchTraffic(); } diff --git a/lib/domain/clash/clash_failures.dart b/lib/domain/clash/clash_failures.dart deleted file mode 100644 index 249e366e..00000000 --- a/lib/domain/clash/clash_failures.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/locale/locale.dart'; -import 'package:hiddify/domain/failures.dart'; - -part 'clash_failures.freezed.dart'; - -// TODO: rewrite -@freezed -sealed class ClashFailure with _$ClashFailure, Failure { - const ClashFailure._(); - - const factory ClashFailure.unexpected( - Object error, - StackTrace stackTrace, - ) = ClashUnexpectedFailure; - - const factory ClashFailure.core([String? error]) = ClashCoreFailure; - - @override - String present(TranslationsEn t) { - return switch (this) { - ClashUnexpectedFailure() => t.failure.clash.unexpected, - ClashCoreFailure(:final error) => - t.failure.clash.core(reason: error ?? ""), - }; - } -} diff --git a/lib/domain/clash/clash_proxy.dart b/lib/domain/clash/clash_proxy.dart index 81b020ce..28e5b9ad 100644 --- a/lib/domain/clash/clash_proxy.dart +++ b/lib/domain/clash/clash_proxy.dart @@ -7,7 +7,7 @@ part 'clash_proxy.g.dart'; // TODO: test and improve @Freezed(fromJson: true) -class ClashProxy with _$ClashProxy { +sealed class ClashProxy with _$ClashProxy { const ClashProxy._(); const factory ClashProxy.group({ @@ -15,6 +15,7 @@ class ClashProxy with _$ClashProxy { @JsonKey(fromJson: _typeFromJson) required ProxyType type, required List all, required String now, + @Default(false) bool udp, List? history, @JsonKey(includeFromJson: false, includeToJson: false) int? delay, }) = ClashProxyGroup; @@ -22,6 +23,7 @@ class ClashProxy with _$ClashProxy { const factory ClashProxy.item({ required String name, @JsonKey(fromJson: _typeFromJson) required ProxyType type, + @Default(false) bool udp, List? history, @JsonKey(includeFromJson: false, includeToJson: false) int? delay, }) = ClashProxyItem; diff --git a/lib/domain/connectivity/connection_facade.dart b/lib/domain/connectivity/connection_facade.dart new file mode 100644 index 00000000..87b327d5 --- /dev/null +++ b/lib/domain/connectivity/connection_facade.dart @@ -0,0 +1,11 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/domain/connectivity/connection_status.dart'; +import 'package:hiddify/domain/core_service_failure.dart'; + +abstract interface class ConnectionFacade { + TaskEither connect(); + + TaskEither disconnect(); + + Stream watchConnectionStatus(); +} diff --git a/lib/domain/connectivity/connection_failure.dart b/lib/domain/connectivity/connection_failure.dart new file mode 100644 index 00000000..37a0e105 --- /dev/null +++ b/lib/domain/connectivity/connection_failure.dart @@ -0,0 +1,40 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/locale/locale.dart'; +import 'package:hiddify/domain/core_service_failure.dart'; +import 'package:hiddify/domain/failures.dart'; + +part 'connection_failure.freezed.dart'; + +@freezed +sealed class ConnectionFailure with _$ConnectionFailure, Failure { + const ConnectionFailure._(); + + const factory ConnectionFailure.unexpected([ + Object? error, + StackTrace? stackTrace, + ]) = UnexpectedConnectionFailure; + + const factory ConnectionFailure.missingVpnPermission([String? message]) = + MissingVpnPermission; + + const factory ConnectionFailure.missingNotificationPermission([ + String? message, + ]) = MissingNotificationPermission; + + const factory ConnectionFailure.core(CoreServiceFailure failure) = + CoreConnectionFailure; + + @override + String present(TranslationsEn t) { + return switch (this) { + UnexpectedConnectionFailure() => t.failure.connectivity.unexpected, + MissingVpnPermission(:final message) => + t.failure.connectivity.missingVpnPermission + + (message == null ? "" : ": $message"), + MissingNotificationPermission(:final message) => + t.failure.connectivity.missingNotificationPermission + + (message == null ? "" : ": $message"), + CoreConnectionFailure(:final failure) => failure.present(t), + }; + } +} diff --git a/lib/domain/connectivity/connection_status.dart b/lib/domain/connectivity/connection_status.dart index 048951b2..b08a2fec 100644 --- a/lib/domain/connectivity/connection_status.dart +++ b/lib/domain/connectivity/connection_status.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hiddify/core/locale/locale.dart'; -import 'package:hiddify/domain/connectivity/connectivity_failure.dart'; +import 'package:hiddify/domain/connectivity/connection_failure.dart'; part 'connection_status.freezed.dart'; @@ -9,24 +9,13 @@ sealed class ConnectionStatus with _$ConnectionStatus { const ConnectionStatus._(); const factory ConnectionStatus.disconnected([ - ConnectivityFailure? connectFailure, + ConnectionFailure? connectionFailure, ]) = Disconnected; const factory ConnectionStatus.connecting() = Connecting; - const factory ConnectionStatus.connected([ - ConnectivityFailure? disconnectFailure, - ]) = Connected; + const factory ConnectionStatus.connected() = Connected; const factory ConnectionStatus.disconnecting() = Disconnecting; - factory ConnectionStatus.fromBool(bool connected) { - return connected - ? const ConnectionStatus.connected() - : const Disconnected(); - } - - bool get isConnected => switch (this) { - Connected() => true, - _ => false, - }; + bool get isConnected => switch (this) { Connected() => true, _ => false }; bool get isSwitching => switch (this) { Connecting() => true, diff --git a/lib/domain/connectivity/connectivity.dart b/lib/domain/connectivity/connectivity.dart index 204a0949..1310f74d 100644 --- a/lib/domain/connectivity/connectivity.dart +++ b/lib/domain/connectivity/connectivity.dart @@ -1,4 +1,5 @@ +export 'connection_facade.dart'; +export 'connection_failure.dart'; export 'connection_status.dart'; -export 'connectivity_failure.dart'; export 'network_prefs.dart'; export 'traffic.dart'; diff --git a/lib/domain/connectivity/connectivity_failure.dart b/lib/domain/connectivity/connectivity_failure.dart deleted file mode 100644 index 9043f2e2..00000000 --- a/lib/domain/connectivity/connectivity_failure.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/core/locale/locale.dart'; -import 'package:hiddify/domain/failures.dart'; - -part 'connectivity_failure.freezed.dart'; - -// TODO: rewrite -@freezed -sealed class ConnectivityFailure with _$ConnectivityFailure, Failure { - const ConnectivityFailure._(); - - const factory ConnectivityFailure.unexpected([ - Object? error, - StackTrace? stackTrace, - ]) = ConnectivityUnexpectedFailure; - - @override - String present(TranslationsEn t) { - return t.failure.connectivity.unexpected; - } -} diff --git a/lib/domain/constants.dart b/lib/domain/constants.dart index 2a8ab643..334aa4ba 100644 --- a/lib/domain/constants.dart +++ b/lib/domain/constants.dart @@ -1,9 +1,8 @@ abstract class Constants { - static const localHost = '127.0.0.1'; - static const clashFolderName = "clash"; - static const delayTestUrl = "https://www.google.com"; - static const configFileName = "config"; - static const countryMMDBFileName = "Country"; + static const geoipFileName = "geoip.db"; + static const geositeFileName = "geosite.db"; + static const configsFolderName = "configs"; + static const localHost = "127.0.0.1"; static const githubUrl = "https://github.com/hiddify/hiddify-next"; static const githubReleasesApiUrl = "https://api.github.com/repos/hiddify/hiddify-next/releases"; @@ -11,3 +10,9 @@ abstract class Constants { "https://github.com/hiddify/hiddify-next/releases/latest"; static const telegramChannelUrl = "https://t.me/hiddify"; } + +abstract class Defaults { + static const clashApiPort = 9090; + static const mixedPort = 2334; + static const delayTestUrl = "https://www.gstatic.com/generate_204"; +} diff --git a/lib/domain/core_facade.dart b/lib/domain/core_facade.dart new file mode 100644 index 00000000..2679e5b1 --- /dev/null +++ b/lib/domain/core_facade.dart @@ -0,0 +1,6 @@ +import 'package:hiddify/domain/clash/clash.dart'; +import 'package:hiddify/domain/connectivity/connectivity.dart'; +import 'package:hiddify/domain/singbox/singbox.dart'; + +abstract interface class CoreFacade + implements SingboxFacade, ClashFacade, ConnectionFacade {} diff --git a/lib/domain/core_service_failure.dart b/lib/domain/core_service_failure.dart new file mode 100644 index 00000000..e27d15d6 --- /dev/null +++ b/lib/domain/core_service_failure.dart @@ -0,0 +1,60 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/locale/locale.dart'; +import 'package:hiddify/domain/failures.dart'; + +part 'core_service_failure.freezed.dart'; + +@freezed +sealed class CoreServiceFailure with _$CoreServiceFailure, Failure { + const CoreServiceFailure._(); + + const factory CoreServiceFailure.unexpected( + Object error, + StackTrace stackTrace, + ) = UnexpectedCoreServiceFailure; + + const factory CoreServiceFailure.serviceNotRunning([String? message]) = + CoreServiceNotRunning; + + const factory CoreServiceFailure.invalidConfig([ + String? message, + ]) = InvalidConfig; + + const factory CoreServiceFailure.create([ + String? message, + ]) = CoreServiceCreateFailure; + + const factory CoreServiceFailure.start([ + String? message, + ]) = CoreServiceStartFailure; + + const factory CoreServiceFailure.other([ + String? message, + ]) = CoreServiceOtherFailure; + + String? get msg => switch (this) { + UnexpectedCoreServiceFailure() => null, + CoreServiceNotRunning(:final message) => message, + InvalidConfig(:final message) => message, + CoreServiceCreateFailure(:final message) => message, + CoreServiceStartFailure(:final message) => message, + CoreServiceOtherFailure(:final message) => message, + }; + + @override + String present(TranslationsEn t) { + return switch (this) { + UnexpectedCoreServiceFailure() => t.failure.singbox.unexpected, + CoreServiceNotRunning(:final message) => + t.failure.singbox.serviceNotRunning + + (message == null ? "" : ": $message"), + InvalidConfig(:final message) => + t.failure.singbox.invalidConfig + (message == null ? "" : ": $message"), + CoreServiceCreateFailure(:final message) => + t.failure.singbox.create + (message == null ? "" : ": $message"), + CoreServiceStartFailure(:final message) => + t.failure.singbox.start + (message == null ? "" : ": $message"), + CoreServiceOtherFailure(:final message) => message ?? "", + }; + } +} diff --git a/lib/domain/profiles/profiles_failure.dart b/lib/domain/profiles/profiles_failure.dart index 6419a69a..d8440cf6 100644 --- a/lib/domain/profiles/profiles_failure.dart +++ b/lib/domain/profiles/profiles_failure.dart @@ -15,7 +15,8 @@ sealed class ProfileFailure with _$ProfileFailure, Failure { const factory ProfileFailure.notFound() = ProfileNotFoundFailure; - const factory ProfileFailure.invalidConfig() = ProfileInvalidConfigFailure; + const factory ProfileFailure.invalidConfig([String? message]) = + ProfileInvalidConfigFailure; @override String present(TranslationsEn t) { diff --git a/lib/domain/singbox/singbox.dart b/lib/domain/singbox/singbox.dart new file mode 100644 index 00000000..5d2075d0 --- /dev/null +++ b/lib/domain/singbox/singbox.dart @@ -0,0 +1 @@ +export 'singbox_facade.dart'; diff --git a/lib/domain/singbox/singbox_facade.dart b/lib/domain/singbox/singbox_facade.dart new file mode 100644 index 00000000..bd389cae --- /dev/null +++ b/lib/domain/singbox/singbox_facade.dart @@ -0,0 +1,16 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/domain/core_service_failure.dart'; + +abstract interface class SingboxFacade { + TaskEither setup(); + + TaskEither parseConfig(String path); + + TaskEither changeConfig(String fileName); + + TaskEither start(); + + TaskEither stop(); + + Stream> watchLogs(); +} diff --git a/lib/features/common/clash/clash_controller.dart b/lib/features/common/clash/clash_controller.dart deleted file mode 100644 index abef1944..00000000 --- a/lib/features/common/clash/clash_controller.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'clash_controller.g.dart'; - -@Riverpod(keepAlive: true) -class ClashController extends _$ClashController with AppLogger { - Profile? _oldProfile; - - @override - Future build() async { - final clash = ref.watch(clashFacadeProvider); - - final overridesListener = ref.listen( - prefsControllerProvider.select((value) => value.clash), - (_, overrides) async { - loggy.debug("new clash overrides received, patching..."); - await clash.patchOverrides(overrides).getOrElse((l) => throw l).run(); - }, - ); - final overrides = overridesListener.read(); - - final activeProfile = await ref.watch(activeProfileProvider.future); - final oldProfile = _oldProfile; - _oldProfile = activeProfile; - if (activeProfile != null) { - if (oldProfile == null || - oldProfile.id != activeProfile.id || - oldProfile.lastUpdate != activeProfile.lastUpdate) { - loggy.debug("profile changed or updated, updating clash core"); - await clash - .changeConfigs(activeProfile.id) - .call(clash.patchOverrides(overrides)) - .getOrElse((error) { - loggy.warning("failed to change or patch configs, $error"); - throw error; - }).run(); - } - } else { - if (oldProfile != null) { - loggy.debug("active profile removed, resetting clash"); - await clash - .changeConfigs(Constants.configFileName) - .getOrElse((l) => throw l) - .run(); - } - } - } -} diff --git a/lib/features/common/clash/clash_mode.dart b/lib/features/common/clash/clash_mode.dart index d1e3e848..9288e27e 100644 --- a/lib/features/common/clash/clash_mode.dart +++ b/lib/features/common/clash/clash_mode.dart @@ -1,7 +1,7 @@ import 'package:hiddify/core/prefs/prefs.dart'; import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/features/common/clash/clash_controller.dart'; +import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -11,13 +11,16 @@ part 'clash_mode.g.dart'; class ClashMode extends _$ClashMode with AppLogger { @override Future build() async { - final clash = ref.watch(clashFacadeProvider); - await ref.watch(clashControllerProvider.future); + final clash = ref.watch(coreFacadeProvider); + if (!await ref.watch(serviceRunningProvider.future)) { + return null; + } ref.watch(prefsControllerProvider.select((value) => value.clash.mode)); - return clash - .getConfigs() - .map((r) => r.mode) - .getOrElse((l) => throw l) - .run(); + return clash.getConfigs().map((r) => r.mode).getOrElse( + (l) { + loggy.warning("fetching clash mode: $l"); + throw l; + }, + ).run(); } } diff --git a/lib/features/common/common_controllers.dart b/lib/features/common/common_controllers.dart index 8ef8dd0f..7cd23a0a 100644 --- a/lib/features/common/common_controllers.dart +++ b/lib/features/common/common_controllers.dart @@ -1,6 +1,6 @@ -import 'package:hiddify/features/common/clash/clash_controller.dart'; import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; import 'package:hiddify/features/common/window/window_controller.dart'; +import 'package:hiddify/features/logs/notifier/notifier.dart'; import 'package:hiddify/features/system_tray/controller/system_tray_controller.dart'; import 'package:hiddify/utils/platform_utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -12,9 +12,8 @@ part 'common_controllers.g.dart'; @Riverpod(keepAlive: true) void commonControllers(CommonControllersRef ref) { ref.listen( - clashControllerProvider, + logsNotifierProvider, (previous, next) {}, - fireImmediately: true, ); ref.listen( connectivityControllerProvider, diff --git a/lib/features/common/connectivity/connectivity_controller.dart b/lib/features/common/connectivity/connectivity_controller.dart index 398f41e7..3e7a4ae1 100644 --- a/lib/features/common/connectivity/connectivity_controller.dart +++ b/lib/features/common/connectivity/connectivity_controller.dart @@ -1,62 +1,90 @@ -import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/services/connectivity/connectivity.dart'; -import 'package:hiddify/services/service_providers.dart'; +import 'package:hiddify/domain/core_facade.dart'; +import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'connectivity_controller.g.dart'; -// TODO: test and improve -// TODO: abort connection on clash error @Riverpod(keepAlive: true) class ConnectivityController extends _$ConnectivityController with AppLogger { @override - ConnectionStatus build() { - state = const Disconnected(); - final connection = _connectivity - .watchConnectionStatus() - .map(ConnectionStatus.fromBool) - .listen((event) => state = event); - - // currently changes wont take effect while connected + Stream build() { ref.listen( - prefsControllerProvider.select((value) => value.network), - (_, next) => _networkPrefs = next, - fireImmediately: true, + activeProfileProvider.select((value) => value.asData?.value), + (previous, next) async { + if (previous == null) return; + final shouldReconnect = previous != next; + if (shouldReconnect) { + loggy.debug("active profile modified, reconnect"); + await reconnect(); + } + }, ); - ref.listen( - prefsControllerProvider - .select((value) => (value.clash.httpPort!, value.clash.socksPort!)), - (_, next) => _ports = (http: next.$1, socks: next.$2), - fireImmediately: true, - ); - - ref.onDispose(connection.cancel); - return state; + return _connectivity.watchConnectionStatus(); } - ConnectivityService get _connectivity => - ref.watch(connectivityServiceProvider); - - late ({int http, int socks}) _ports; - // ignore: unused_field - late NetworkPrefs _networkPrefs; + CoreFacade get _connectivity => ref.watch(coreFacadeProvider); Future toggleConnection() async { - switch (state) { - case Disconnected(): - if (!await _connectivity.grantVpnPermission()) { - state = const Disconnected(ConnectivityFailure.unexpected()); - return; - } - await _connectivity.connect( - httpPort: _ports.http, - socksPort: _ports.socks, - ); - case Connected(): - await _connectivity.disconnect(); - default: + if (state case AsyncError()) { + await _connect(); + } else if (state case AsyncData(:final value)) { + switch (value) { + case Disconnected(): + await _connect(); + case Connected(): + await _disconnect(); + default: + loggy.warning("switching status, debounce"); + } } } + + Future reconnect() async { + if (state case AsyncData(:final value)) { + if (value case Connected()) { + loggy.debug("reconnecting"); + await _disconnect(); + await _connect(); + } + } + } + + Future abortConnection() async { + if (state case AsyncData(:final value)) { + switch (value) { + case Connected() || Connecting(): + loggy.debug("aborting connection"); + await _disconnect(); + default: + } + } + } + + Future _connect() async { + final activeProfile = await ref.read(activeProfileProvider.future); + await _connectivity + .changeConfig(activeProfile!.id) + .andThen(_connectivity.connect) + .mapLeft((l) { + loggy.warning("error connecting: $l"); + state = AsyncError(l, StackTrace.current); + }).run(); + } + + Future _disconnect() async { + await _connectivity.disconnect().mapLeft((l) { + loggy.warning("error disconnecting: $l"); + state = AsyncError(l, StackTrace.current); + }).run(); + } } + +@Riverpod(keepAlive: true) +Future serviceRunning(ServiceRunningRef ref) => ref + .watch( + connectivityControllerProvider.selectAsync((data) => data.isConnected), + ) + .onError((error, stackTrace) => false); diff --git a/lib/features/common/traffic/traffic_chart.dart b/lib/features/common/traffic/traffic_chart.dart index b799f44f..b7fd0b16 100644 --- a/lib/features/common/traffic/traffic_chart.dart +++ b/lib/features/common/traffic/traffic_chart.dart @@ -22,73 +22,85 @@ class TrafficChart extends HookConsumerWidget { switch (asyncTraffics) { case AsyncData(value: final traffics): - final latest = - traffics.lastOrNull ?? const Traffic(upload: 0, download: 0); - final latestUploadData = formatByteSpeed(latest.upload); - final latestDownloadData = formatByteSpeed(latest.download); - - final uploadChartSpots = traffics.takeLast(chartSteps).mapIndexed( - (index, p) => FlSpot(index.toDouble(), p.upload.toDouble()), - ); - final downloadChartSpots = traffics.takeLast(chartSteps).mapIndexed( - (index, p) => FlSpot(index.toDouble(), p.download.toDouble()), - ); - - return Column( - mainAxisSize: MainAxisSize.min, - // mainAxisAlignment: MainAxisAlignment.end, - children: [ - SizedBox( - height: 68, - child: LineChart( - LineChartData( - minY: 0, - borderData: FlBorderData(show: false), - titlesData: const FlTitlesData(show: false), - gridData: const FlGridData(show: false), - lineTouchData: const LineTouchData(enabled: false), - lineBarsData: [ - LineChartBarData( - isCurved: true, - preventCurveOverShooting: true, - dotData: const FlDotData(show: false), - spots: uploadChartSpots.toList(), - ), - LineChartBarData( - color: Theme.of(context).colorScheme.tertiary, - isCurved: true, - preventCurveOverShooting: true, - dotData: const FlDotData(show: false), - spots: downloadChartSpots.toList(), - ), - ], - ), - duration: Duration.zero, - ), - ), - const Gap(16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const Text("↑"), - Text(latestUploadData.size), - Text(latestUploadData.unit), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const Text("↓"), - Text(latestDownloadData.size), - Text(latestDownloadData.unit), - ], - ), - const Gap(16), - ], - ); - // TODO: handle loading and error + return _Chart(traffics, chartSteps); + case AsyncLoading(:final value): + if (value == null) return const SizedBox(); + return _Chart(value, chartSteps); default: return const SizedBox(); } } } + +class _Chart extends StatelessWidget { + const _Chart(this.records, this.steps); + + final List records; + final int steps; + + @override + Widget build(BuildContext context) { + final latest = records.lastOrNull ?? const Traffic(upload: 0, download: 0); + final latestUploadData = formatByteSpeed(latest.upload); + final latestDownloadData = formatByteSpeed(latest.download); + + final uploadChartSpots = records.takeLast(steps).mapIndexed( + (index, p) => FlSpot(index.toDouble(), p.upload.toDouble()), + ); + final downloadChartSpots = records.takeLast(steps).mapIndexed( + (index, p) => FlSpot(index.toDouble(), p.download.toDouble()), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 68, + child: LineChart( + LineChartData( + minY: 0, + borderData: FlBorderData(show: false), + titlesData: const FlTitlesData(show: false), + gridData: const FlGridData(show: false), + lineTouchData: const LineTouchData(enabled: false), + lineBarsData: [ + LineChartBarData( + isCurved: true, + preventCurveOverShooting: true, + dotData: const FlDotData(show: false), + spots: uploadChartSpots.toList(), + ), + LineChartBarData( + color: Theme.of(context).colorScheme.tertiary, + isCurved: true, + preventCurveOverShooting: true, + dotData: const FlDotData(show: false), + spots: downloadChartSpots.toList(), + ), + ], + ), + duration: Duration.zero, + ), + ), + const Gap(16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const Text("↑"), + Text(latestUploadData.size), + Text(latestUploadData.unit), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const Text("↓"), + Text(latestDownloadData.size), + Text(latestDownloadData.unit), + ], + ), + const Gap(16), + ], + ); + } +} diff --git a/lib/features/common/traffic/traffic_notifier.dart b/lib/features/common/traffic/traffic_notifier.dart index 985f1ff0..f7a7fb4c 100644 --- a/lib/features/common/traffic/traffic_notifier.dart +++ b/lib/features/common/traffic/traffic_notifier.dart @@ -2,6 +2,7 @@ import 'package:dartx/dartx.dart'; import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/domain/clash/clash.dart'; import 'package:hiddify/domain/connectivity/connectivity.dart'; +import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -13,28 +14,37 @@ class TrafficNotifier extends _$TrafficNotifier with AppLogger { int get _steps => 100; @override - Stream> build() { - return Stream.periodic(const Duration(seconds: 1)).asyncMap( - (_) async { - return ref.read(clashFacadeProvider).getTraffic().match( - (f) { - loggy.warning('failed to watch clash traffic: $f'); - return const ClashTraffic(upload: 0, download: 0); - }, - (traffic) => traffic, - ).run(); - }, - ).map( - (event) => switch (state) { - AsyncData(:final value) => [ - ...value.takeLast(_steps - 1), - Traffic(upload: event.upload, download: event.download), - ], - _ => List.generate( - _steps, - (index) => const Traffic(upload: 0, download: 0), - ) - }, - ); + Stream> build() async* { + final serviceRunning = await ref.watch(serviceRunningProvider.future); + if (serviceRunning) { + yield* ref.watch(coreFacadeProvider).watchTraffic().map( + (event) => _mapToState( + event + .getOrElse((_) => const ClashTraffic(upload: 0, download: 0)), + ), + ); + } else { + yield* Stream.periodic(const Duration(seconds: 1)).asyncMap( + (_) async { + return const ClashTraffic(upload: 0, download: 0); + }, + ).map(_mapToState); + } + } + + List _mapToState(ClashTraffic event) { + final previous = state.valueOrNull ?? + List.generate( + _steps, + (index) => const Traffic(upload: 0, download: 0), + ); + while (previous.length < _steps) { + loggy.debug("previous short, adding"); + previous.insert(0, const Traffic(upload: 0, download: 0)); + } + return [ + ...previous.takeLast(_steps - 1), + Traffic(upload: event.upload, download: event.download), + ]; } } diff --git a/lib/features/common/window/window_controller.dart b/lib/features/common/window/window_controller.dart index 2bc65b64..a29b0ab9 100644 --- a/lib/features/common/window/window_controller.dart +++ b/lib/features/common/window/window_controller.dart @@ -46,6 +46,12 @@ class WindowController extends _$WindowController await windowManager.close(); } + Future quit() async { + loggy.debug("quitting"); + await windowManager.close(); + await windowManager.destroy(); + } + @override Future onWindowClose() async { await windowManager.hide(); diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index a6a9e0a0..fc1bdabd 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -5,7 +5,6 @@ import 'package:hiddify/core/router/router.dart'; import 'package:hiddify/domain/failures.dart'; import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; import 'package:hiddify/features/common/active_profile/has_any_profile_notifier.dart'; -import 'package:hiddify/features/common/clash/clash_controller.dart'; import 'package:hiddify/features/common/common.dart'; import 'package:hiddify/features/home/widgets/widgets.dart'; import 'package:hiddify/utils/utils.dart'; @@ -22,18 +21,6 @@ class HomePage extends HookConsumerWidget { final hasAnyProfile = ref.watch(hasAnyProfileProvider); final activeProfile = ref.watch(activeProfileProvider); - ref.listen( - clashControllerProvider, - (_, next) { - if (next case AsyncError(:final error)) { - CustomToast.error( - t.presentError(error), - duration: const Duration(seconds: 10), - ).show(context); - } - }, - ); - return Scaffold( body: Stack( alignment: Alignment.bottomCenter, diff --git a/lib/features/home/widgets/connection_button.dart b/lib/features/home/widgets/connection_button.dart index c5d803f5..ba242018 100644 --- a/lib/features/home/widgets/connection_button.dart +++ b/lib/features/home/widgets/connection_button.dart @@ -3,8 +3,11 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/theme/theme.dart'; +import 'package:hiddify/domain/connectivity/connectivity.dart'; +import 'package:hiddify/domain/failures.dart'; import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; import 'package:hiddify/gen/assets.gen.dart'; +import 'package:hiddify/utils/alerts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:recase/recase.dart'; @@ -17,12 +20,71 @@ class ConnectionButton extends HookConsumerWidget { final t = ref.watch(translationsProvider); final connectionStatus = ref.watch(connectivityControllerProvider); - final Color connectionLogoColor = connectionStatus.isConnected - ? ConnectionButtonColor.connected - : ConnectionButtonColor.disconnected; + ref.listen( + connectivityControllerProvider, + (_, next) { + if (next case AsyncError(:final error)) { + CustomToast.error(t.presentError(error)).show(context); + } + if (next + case AsyncData(value: Disconnected(:final connectionFailure?))) { + CustomAlertDialog( + message: connectionFailure.present(t), + ).show(context); + } + }, + ); - final bool intractable = !connectionStatus.isSwitching; + switch (connectionStatus) { + case AsyncData(value: final status): + final Color connectionLogoColor = status.isConnected + ? ConnectionButtonColor.connected + : ConnectionButtonColor.disconnected; + return _ConnectionButton( + onTap: () => ref + .read(connectivityControllerProvider.notifier) + .toggleConnection(), + enabled: !status.isSwitching, + label: status.present(t), + buttonColor: connectionLogoColor, + ); + case AsyncError(): + return _ConnectionButton( + onTap: () => ref + .read(connectivityControllerProvider.notifier) + .toggleConnection(), + enabled: true, + label: const Disconnected().present(t), + buttonColor: ConnectionButtonColor.disconnected, + ); + default: + // HACK + return _ConnectionButton( + onTap: () {}, + enabled: false, + label: "", + buttonColor: Colors.red, + ); + } + } +} + +class _ConnectionButton extends StatelessWidget { + const _ConnectionButton({ + required this.onTap, + required this.enabled, + required this.label, + required this.buttonColor, + }); + + final VoidCallback onTap; + final bool enabled; + final String label; + final Color buttonColor; + + @override + Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -33,7 +95,7 @@ class ConnectionButton extends HookConsumerWidget { boxShadow: [ BoxShadow( blurRadius: 16, - color: connectionLogoColor.withOpacity(0.5), + color: buttonColor.withOpacity(0.5), ), ], ), @@ -43,26 +105,24 @@ class ConnectionButton extends HookConsumerWidget { shape: const CircleBorder(), color: Colors.white, child: InkWell( - onTap: () async { - await ref - .read(connectivityControllerProvider.notifier) - .toggleConnection(); - }, + onTap: onTap, child: Padding( padding: const EdgeInsets.all(36), child: Assets.images.logo.svg( colorFilter: ColorFilter.mode( - connectionLogoColor, + buttonColor, BlendMode.srcIn, ), ), ), ), - ).animate(target: intractable ? 0 : 1).blurXY(end: 1), - ).animate(target: intractable ? 0 : 1).scaleXY(end: .88), + ).animate(target: enabled ? 0 : 1).blurXY(end: 1), + ) + .animate(target: enabled ? 0 : 1) + .scaleXY(end: .88, curve: Curves.easeIn), const Gap(16), Text( - connectionStatus.present(t).sentenceCase, + label.sentenceCase, style: Theme.of(context).textTheme.bodyLarge, ), ], diff --git a/lib/features/logs/notifier/logs_notifier.dart b/lib/features/logs/notifier/logs_notifier.dart index e3fce2d2..6c7cfd43 100644 --- a/lib/features/logs/notifier/logs_notifier.dart +++ b/lib/features/logs/notifier/logs_notifier.dart @@ -10,14 +10,14 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'logs_notifier.g.dart'; // TODO: rewrite -@riverpod +@Riverpod(keepAlive: true) class LogsNotifier extends _$LogsNotifier with AppLogger { static const maxLength = 1000; @override Stream build() { state = const AsyncData(LogsState()); - return ref.read(clashFacadeProvider).watchLogs().asyncMap( + return ref.read(coreFacadeProvider).watchLogs().asyncMap( (event) async { _logs = [ event.getOrElse((l) => throw l), @@ -32,16 +32,15 @@ class LogsNotifier extends _$LogsNotifier with AppLogger { ); } - var _logs = []; + var _logs = []; final _debouncer = CallbackDebouncer(const Duration(milliseconds: 200)); LogLevel? _levelFilter; String _filter = ""; - Future> _computeLogs() async { + Future> _computeLogs() async { if (_levelFilter == null && _filter.isEmpty) return _logs; return _logs.where((e) { - return (_filter.isEmpty || e.message.contains(_filter)) && - (_levelFilter == null || e.level == _levelFilter); + return _filter.isEmpty || e.contains(_filter); }).toList(); } diff --git a/lib/features/logs/notifier/logs_state.dart b/lib/features/logs/notifier/logs_state.dart index dbed8842..ba34a075 100644 --- a/lib/features/logs/notifier/logs_state.dart +++ b/lib/features/logs/notifier/logs_state.dart @@ -8,7 +8,7 @@ class LogsState with _$LogsState { const LogsState._(); const factory LogsState({ - @Default([]) List logs, + @Default([]) List logs, @Default("") String filter, LogLevel? levelFilter, }) = _LogsState; diff --git a/lib/features/logs/view/logs_page.dart b/lib/features/logs/view/logs_page.dart index 060731ee..f704172c 100644 --- a/lib/features/logs/view/logs_page.dart +++ b/lib/features/logs/view/logs_page.dart @@ -10,6 +10,7 @@ import 'package:hiddify/features/logs/notifier/notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:recase/recase.dart'; +import 'package:tint/tint.dart'; class LogsPage extends HookConsumerWidget { const LogsPage({super.key}); @@ -80,19 +81,7 @@ class LogsPage extends HookConsumerWidget { children: [ ListTile( dense: true, - title: Text.rich( - TextSpan( - children: [ - TextSpan(text: log.timeStamp), - const TextSpan(text: " "), - TextSpan( - text: log.level.name.toUpperCase(), - style: TextStyle(color: log.level.color), - ), - ], - ), - ), - subtitle: Text(log.message), + subtitle: Text(log.strip()), ), if (index != 0) const Divider( diff --git a/lib/features/proxies/model/group_with_proxies.dart b/lib/features/proxies/model/group_with_proxies.dart index e0128a44..3f9252b3 100644 --- a/lib/features/proxies/model/group_with_proxies.dart +++ b/lib/features/proxies/model/group_with_proxies.dart @@ -24,7 +24,7 @@ class GroupWithProxies with _$GroupWithProxies { final result = []; for (final proxy in proxies) { if (proxy is ClashProxyGroup) { - if (mode != TunnelMode.global && proxy.name == "GLOBAL") continue; + // if (mode != TunnelMode.global && proxy.name == "GLOBAL") continue; final current = []; for (final name in proxy.all) { current.addAll(proxies.where((e) => e.name == name).toList()); diff --git a/lib/features/proxies/notifier/proxies_delay_notifier.dart b/lib/features/proxies/notifier/proxies_delay_notifier.dart index e36cabcc..cd263a7b 100644 --- a/lib/features/proxies/notifier/proxies_delay_notifier.dart +++ b/lib/features/proxies/notifier/proxies_delay_notifier.dart @@ -32,7 +32,7 @@ class ProxiesDelayNotifier extends _$ProxiesDelayNotifier with AppLogger { return {}; } - ClashFacade get _clash => ref.read(clashFacadeProvider); + ClashFacade get _clash => ref.read(coreFacadeProvider); StreamSubscription? _currentTest; Future testDelay(Iterable proxies) async { diff --git a/lib/features/proxies/notifier/proxies_notifier.dart b/lib/features/proxies/notifier/proxies_notifier.dart index 5fb4da77..7c689af7 100644 --- a/lib/features/proxies/notifier/proxies_notifier.dart +++ b/lib/features/proxies/notifier/proxies_notifier.dart @@ -3,8 +3,9 @@ import 'dart:async'; import 'package:fpdart/fpdart.dart'; import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/features/common/clash/clash_controller.dart'; +import 'package:hiddify/domain/core_service_failure.dart'; import 'package:hiddify/features/common/clash/clash_mode.dart'; +import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; import 'package:hiddify/features/proxies/model/model.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -16,23 +17,23 @@ class ProxiesNotifier extends _$ProxiesNotifier with AppLogger { @override Future> build() async { loggy.debug('building'); - await ref.watch(clashControllerProvider.future); + if (!await ref.watch(serviceRunningProvider.future)) { + throw const CoreServiceNotRunning(); + } final mode = await ref.watch(clashModeProvider.future); - return _clash - .getProxies() - .flatMap( - (proxies) { - return TaskEither( - () async => - right(await GroupWithProxies.fromProxies(proxies, mode)), - ); - }, - ) - .getOrElse((l) => throw l) - .run(); + return _clash.getProxies().flatMap( + (proxies) { + return TaskEither( + () async => right(await GroupWithProxies.fromProxies(proxies, mode)), + ); + }, + ).getOrElse((l) { + loggy.warning("failed receiving proxies: $l"); + throw l; + }).run(); } - ClashFacade get _clash => ref.read(clashFacadeProvider); + ClashFacade get _clash => ref.read(coreFacadeProvider); Future changeProxy(String selectorName, String proxyName) async { loggy.debug("changing proxy, selector: $selectorName - proxy: $proxyName "); diff --git a/lib/features/proxies/view/proxies_page.dart b/lib/features/proxies/view/proxies_page.dart index 6a1e1723..82346131 100644 --- a/lib/features/proxies/view/proxies_page.dart +++ b/lib/features/proxies/view/proxies_page.dart @@ -20,7 +20,7 @@ class ProxiesPage extends HookConsumerWidget with PresLogger { final notifier = ref.watch(proxiesNotifierProvider.notifier); final asyncProxies = ref.watch(proxiesNotifierProvider); - final proxies = asyncProxies.value ?? []; + final proxies = asyncProxies.asData?.value ?? []; final delays = ref.watch(proxiesDelayNotifierProvider); final selectActiveProxyMutation = useMutation( @@ -163,7 +163,10 @@ class ProxiesPage extends HookConsumerWidget with PresLogger { NestedTabAppBar( title: Text(t.proxies.pageTitle.titleCase), ), - SliverErrorBodyPlaceholder(t.presentError(error)), + SliverErrorBodyPlaceholder( + t.presentError(error), + icon: null, + ), ], ), ); diff --git a/lib/features/proxies/widgets/proxy_tile.dart b/lib/features/proxies/widgets/proxy_tile.dart index c02854a2..0efc5dc0 100644 --- a/lib/features/proxies/widgets/proxy_tile.dart +++ b/lib/features/proxies/widgets/proxy_tile.dart @@ -19,15 +19,61 @@ class ProxyTile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + return ListTile( title: Text( - proxy.name, + switch (proxy) { + ClashProxyGroup(:final name) => name.toUpperCase(), + ClashProxyItem(:final name) => name, + }, overflow: TextOverflow.ellipsis, ), - subtitle: Text(proxy.type.label), + leading: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container( + width: 6, + height: double.maxFinite, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: selected ? theme.colorScheme.primary : Colors.transparent, + ), + ), + ), + subtitle: Text.rich( + TextSpan( + children: [ + TextSpan(text: proxy.type.label), + if (proxy.udp) + WidgetSpan( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: theme.colorScheme.tertiaryContainer, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + " UDP ", + style: TextStyle( + fontSize: theme.textTheme.labelSmall?.fontSize, + ), + ), + ), + ), + ), + if (proxy case ClashProxyGroup(:final now)) ...[ + TextSpan(text: " ($now)"), + ], + ], + ), + ), trailing: delay != null ? Text(delay.toString()) : null, selected: selected, onTap: onSelect, + horizontalTitleGap: 4, ); } } diff --git a/lib/features/settings/view/clash_overrides_page.dart b/lib/features/settings/view/clash_overrides_page.dart index 7280ebf5..0c5ae222 100644 --- a/lib/features/settings/view/clash_overrides_page.dart +++ b/lib/features/settings/view/clash_overrides_page.dart @@ -1,103 +1,100 @@ -import 'package:flutter/material.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/features/settings/widgets/widgets.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:recase/recase.dart'; +// import 'package:flutter/material.dart'; +// import 'package:hiddify/core/core_providers.dart'; +// import 'package:hiddify/core/prefs/prefs.dart'; +// import 'package:hiddify/domain/clash/clash.dart'; +// import 'package:hiddify/features/settings/widgets/widgets.dart'; +// import 'package:hooks_riverpod/hooks_riverpod.dart'; +// import 'package:recase/recase.dart'; -class ClashOverridesPage extends HookConsumerWidget { - const ClashOverridesPage({super.key}); +// class ClashOverridesPage extends HookConsumerWidget { +// const ClashOverridesPage({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final t = ref.watch(translationsProvider); +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final t = ref.watch(translationsProvider); - final overrides = - ref.watch(prefsControllerProvider.select((value) => value.clash)); - final notifier = ref.watch(prefsControllerProvider.notifier); +// final overrides = +// ref.watch(prefsControllerProvider.select((value) => value.clash)); +// final notifier = ref.watch(prefsControllerProvider.notifier); - return Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - title: Text(t.settings.clash.sectionTitle.titleCase), - pinned: true, - ), - SliverList.list( - children: [ - InputOverrideTile( - title: t.settings.clash.overrides.httpPort, - value: overrides.httpPort, - resetValue: ClashConfig.initial.httpPort, - onChange: (value) => notifier.patchClashOverrides( - ClashConfigPatch(httpPort: value), - ), - ), - InputOverrideTile( - title: t.settings.clash.overrides.socksPort, - value: overrides.socksPort, - resetValue: ClashConfig.initial.socksPort, - onChange: (value) => notifier.patchClashOverrides( - ClashConfigPatch(socksPort: value), - ), - ), - InputOverrideTile( - title: t.settings.clash.overrides.redirPort, - value: overrides.redirPort, - onChange: (value) => notifier.patchClashOverrides( - ClashConfigPatch(redirPort: value), - ), - ), - InputOverrideTile( - title: t.settings.clash.overrides.tproxyPort, - value: overrides.tproxyPort, - onChange: (value) => notifier.patchClashOverrides( - ClashConfigPatch(tproxyPort: value), - ), - ), - InputOverrideTile( - title: t.settings.clash.overrides.mixedPort, - value: overrides.mixedPort, - resetValue: ClashConfig.initial.mixedPort, - onChange: (value) => notifier.patchClashOverrides( - ClashConfigPatch(mixedPort: value), - ), - ), - ToggleOverrideTile( - title: t.settings.clash.overrides.allowLan, - value: overrides.allowLan, - onChange: (value) => notifier.patchClashOverrides( - ClashConfigPatch(allowLan: value), - ), - ), - ToggleOverrideTile( - title: t.settings.clash.overrides.ipv6, - value: overrides.ipv6, - onChange: (value) => notifier.patchClashOverrides( - ClashConfigPatch(ipv6: value), - ), - ), - ChoiceOverrideTile( - title: t.settings.clash.overrides.mode, - value: overrides.mode, - options: TunnelMode.values, - onChange: (value) => notifier.patchClashOverrides( - ClashConfigPatch(mode: value), - ), - ), - ChoiceOverrideTile( - title: t.settings.clash.overrides.logLevel, - value: overrides.logLevel, - options: LogLevel.values, - onChange: (value) => notifier.patchClashOverrides( - ClashConfigPatch(logLevel: value), - ), - ), - ], - ), - ], - ), - ); - } -} +// return Scaffold( +// body: CustomScrollView( +// slivers: [ +// SliverAppBar( +// title: Text(t.settings.clash.sectionTitle.titleCase), +// pinned: true, +// ), +// SliverList.list( +// children: [ +// InputOverrideTile( +// title: t.settings.clash.overrides.httpPort, +// value: overrides.httpPort, +// onChange: (value) => notifier.patchClashOverrides( +// ClashConfigPatch(httpPort: value), +// ), +// ), +// InputOverrideTile( +// title: t.settings.clash.overrides.socksPort, +// value: overrides.socksPort, +// onChange: (value) => notifier.patchClashOverrides( +// ClashConfigPatch(socksPort: value), +// ), +// ), +// InputOverrideTile( +// title: t.settings.clash.overrides.redirPort, +// value: overrides.redirPort, +// onChange: (value) => notifier.patchClashOverrides( +// ClashConfigPatch(redirPort: value), +// ), +// ), +// InputOverrideTile( +// title: t.settings.clash.overrides.tproxyPort, +// value: overrides.tproxyPort, +// onChange: (value) => notifier.patchClashOverrides( +// ClashConfigPatch(tproxyPort: value), +// ), +// ), +// InputOverrideTile( +// title: t.settings.clash.overrides.mixedPort, +// value: overrides.mixedPort, +// onChange: (value) => notifier.patchClashOverrides( +// ClashConfigPatch(mixedPort: value), +// ), +// ), +// ToggleOverrideTile( +// title: t.settings.clash.overrides.allowLan, +// value: overrides.allowLan, +// onChange: (value) => notifier.patchClashOverrides( +// ClashConfigPatch(allowLan: value), +// ), +// ), +// ToggleOverrideTile( +// title: t.settings.clash.overrides.ipv6, +// value: overrides.ipv6, +// onChange: (value) => notifier.patchClashOverrides( +// ClashConfigPatch(ipv6: value), +// ), +// ), +// ChoiceOverrideTile( +// title: t.settings.clash.overrides.mode, +// value: overrides.mode, +// options: TunnelMode.values, +// onChange: (value) => notifier.patchClashOverrides( +// ClashConfigPatch(mode: value), +// ), +// ), +// ChoiceOverrideTile( +// title: t.settings.clash.overrides.logLevel, +// value: overrides.logLevel, +// options: LogLevel.values, +// onChange: (value) => notifier.patchClashOverrides( +// ClashConfigPatch(logLevel: value), +// ), +// ), +// ], +// ), +// ], +// ), +// ); +// } +// } diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index d3ece07e..4d8a1152 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/router/router.dart'; import 'package:hiddify/features/settings/widgets/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:recase/recase.dart'; @@ -13,7 +12,7 @@ class SettingsPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - const divider = Divider(indent: 16, endIndent: 16); + // const divider = Divider(indent: 16, endIndent: 16); return Scaffold( appBar: AppBar( @@ -29,18 +28,18 @@ class SettingsPage extends HookConsumerWidget { t.settings.general.sectionTitle.titleCase, ), const AppearanceSettingTiles(), - divider, - _SettingsSectionHeader(t.settings.network.sectionTitle.titleCase), - const NetworkSettingTiles(), - divider, - ListTile( - title: Text(t.settings.clash.sectionTitle.titleCase), - leading: const Icon(Icons.edit_document), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - onTap: () async { - await const ClashOverridesRoute().push(context); - }, - ), + // divider, + // _SettingsSectionHeader(t.settings.network.sectionTitle.titleCase), + // const NetworkSettingTiles(), + // divider, + // ListTile( + // title: Text(t.settings.clash.sectionTitle.titleCase), + // leading: const Icon(Icons.edit_document), + // contentPadding: const EdgeInsets.symmetric(horizontal: 16), + // onTap: () async { + // await const ClashOverridesRoute().push(context); + // }, + // ), const Gap(16), ], ), diff --git a/lib/features/settings/widgets/network_setting_tiles.dart b/lib/features/settings/widgets/network_setting_tiles.dart index 43d870bc..7f2c11b4 100644 --- a/lib/features/settings/widgets/network_setting_tiles.dart +++ b/lib/features/settings/widgets/network_setting_tiles.dart @@ -1,36 +1,36 @@ -import 'package:flutter/material.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:recase/recase.dart'; +// import 'package:flutter/material.dart'; +// import 'package:hiddify/core/core_providers.dart'; +// import 'package:hiddify/core/prefs/prefs.dart'; +// import 'package:hooks_riverpod/hooks_riverpod.dart'; +// import 'package:recase/recase.dart'; -class NetworkSettingTiles extends HookConsumerWidget { - const NetworkSettingTiles({super.key}); +// class NetworkSettingTiles extends HookConsumerWidget { +// const NetworkSettingTiles({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final t = ref.watch(translationsProvider); +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final t = ref.watch(translationsProvider); - final prefs = - ref.watch(prefsControllerProvider.select((value) => value.network)); - final notifier = ref.watch(prefsControllerProvider.notifier); +// final prefs = +// ref.watch(prefsControllerProvider.select((value) => value.network)); +// final notifier = ref.watch(prefsControllerProvider.notifier); - return Column( - children: [ - SwitchListTile( - title: Text(t.settings.network.systemProxy.titleCase), - subtitle: Text(t.settings.network.systemProxyMsg), - value: prefs.systemProxy, - onChanged: (value) => notifier.patchNetworkPrefs(systemProxy: value), - ), - SwitchListTile( - title: Text(t.settings.network.bypassPrivateNetworks.titleCase), - subtitle: Text(t.settings.network.bypassPrivateNetworksMsg), - value: prefs.bypassPrivateNetworks, - onChanged: (value) => - notifier.patchNetworkPrefs(bypassPrivateNetworks: value), - ), - ], - ); - } -} +// return Column( +// children: [ +// SwitchListTile( +// title: Text(t.settings.network.systemProxy.titleCase), +// subtitle: Text(t.settings.network.systemProxyMsg), +// value: prefs.systemProxy, +// onChanged: (value) => notifier.patchNetworkPrefs(systemProxy: value), +// ), +// SwitchListTile( +// title: Text(t.settings.network.bypassPrivateNetworks.titleCase), +// subtitle: Text(t.settings.network.bypassPrivateNetworksMsg), +// value: prefs.bypassPrivateNetworks, +// onChanged: (value) => +// notifier.patchNetworkPrefs(bypassPrivateNetworks: value), +// ), +// ], +// ); +// } +// } diff --git a/lib/features/system_tray/controller/system_tray_controller.dart b/lib/features/system_tray/controller/system_tray_controller.dart index 6c86c48b..66a1d9c0 100644 --- a/lib/features/system_tray/controller/system_tray_controller.dart +++ b/lib/features/system_tray/controller/system_tray_controller.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:fpdart/fpdart.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/prefs/prefs.dart'; @@ -27,7 +25,7 @@ class SystemTrayController extends _$SystemTrayController _initialized = true; } - final connection = ref.watch(connectivityControllerProvider); + final connection = await ref.watch(connectivityControllerProvider.future); final mode = ref.watch(clashModeProvider.select((value) => value.valueOrNull)); @@ -104,8 +102,9 @@ class SystemTrayController extends _$SystemTrayController return ref.read(connectivityControllerProvider.notifier).toggleConnection(); } - // TODO rewrite Future handleClickExitApp(MenuItem menuItem) async { - exit(0); + await ref.read(connectivityControllerProvider.notifier).abortConnection(); + await trayManager.destroy(); + return ref.read(windowControllerProvider.notifier).quit(); } } diff --git a/lib/gen/clash_generated_bindings.dart b/lib/gen/singbox_generated_bindings.dart similarity index 83% rename from lib/gen/clash_generated_bindings.dart rename to lib/gen/singbox_generated_bindings.dart index f39213fa..76cfdcdb 100644 --- a/lib/gen/clash_generated_bindings.dart +++ b/lib/gen/singbox_generated_bindings.dart @@ -4,18 +4,18 @@ // ignore_for_file: type=lint import 'dart:ffi' as ffi; -/// Bindings to Clash -class ClashNativeLibrary { +/// Bindings to Singbox +class SingboxNativeLibrary { /// Holds the symbol lookup function. final ffi.Pointer Function(String symbolName) _lookup; /// The symbols are looked up in [dynamicLibrary]. - ClashNativeLibrary(ffi.DynamicLibrary dynamicLibrary) + SingboxNativeLibrary(ffi.DynamicLibrary dynamicLibrary) : _lookup = dynamicLibrary.lookup; /// The symbols are looked up with [lookup]. - ClashNativeLibrary.fromLookup( + SingboxNativeLibrary.fromLookup( ffi.Pointer Function(String symbolName) lookup) : _lookup = lookup; @@ -857,206 +857,69 @@ class ClashNativeLibrary { late final __FCmulcr = __FCmulcrPtr.asFunction<_Fcomplex Function(_Fcomplex, double)>(); - void getConfigs( - int port, + void setup( + ffi.Pointer baseDir, + ffi.Pointer workingDir, + ffi.Pointer tempDir, ) { - return _getConfigs( - port, + return _setup( + baseDir, + workingDir, + tempDir, ); } - late final _getConfigsPtr = - _lookup>( - 'getConfigs'); - late final _getConfigs = _getConfigsPtr.asFunction(); - - void patchConfigs( - int port, - ffi.Pointer patchStr, - ) { - return _patchConfigs( - port, - patchStr, - ); - } - - late final _patchConfigsPtr = _lookup< + late final _setupPtr = _lookup< ffi.NativeFunction< - ffi.Void Function( - ffi.LongLong, ffi.Pointer)>>('patchConfigs'); - late final _patchConfigs = - _patchConfigsPtr.asFunction)>(); + ffi.Void Function(ffi.Pointer, ffi.Pointer, + ffi.Pointer)>>('setup'); + late final _setup = _setupPtr.asFunction< + void Function(ffi.Pointer, ffi.Pointer, + ffi.Pointer)>(); - void updateConfigs( - int port, - ffi.Pointer pathStr, - int force, - ) { - return _updateConfigs( - port, - pathStr, - force, - ); - } - - late final _updateConfigsPtr = _lookup< - ffi.NativeFunction< - ffi.Void Function( - ffi.LongLong, ffi.Pointer, GoUint8)>>('updateConfigs'); - late final _updateConfigs = _updateConfigsPtr - .asFunction, int)>(); - - void validateConfig( - int port, + ffi.Pointer parse( ffi.Pointer path, ) { - return _validateConfig( - port, + return _parse( path, ); } - late final _validateConfigPtr = _lookup< + late final _parsePtr = _lookup< ffi.NativeFunction< - ffi.Void Function( - ffi.LongLong, ffi.Pointer)>>('validateConfig'); - late final _validateConfig = _validateConfigPtr - .asFunction)>(); + ffi.Pointer Function(ffi.Pointer)>>('parse'); + late final _parse = _parsePtr + .asFunction Function(ffi.Pointer)>(); - void initNativeDartBridge( - ffi.Pointer api, - ) { - return _initNativeDartBridge( - api, - ); - } - - late final _initNativeDartBridgePtr = - _lookup)>>( - 'initNativeDartBridge'); - late final _initNativeDartBridge = _initNativeDartBridgePtr - .asFunction)>(); - - void setOptions( - int port, - ffi.Pointer dir, + ffi.Pointer create( ffi.Pointer configPath, ) { - return _setOptions( - port, - dir, + return _create( configPath, ); } - late final _setOptionsPtr = _lookup< + late final _createPtr = _lookup< ffi.NativeFunction< - ffi.Void Function(ffi.LongLong, ffi.Pointer, - ffi.Pointer)>>('setOptions'); - late final _setOptions = _setOptionsPtr.asFunction< - void Function(int, ffi.Pointer, ffi.Pointer)>(); + ffi.Pointer Function(ffi.Pointer)>>('create'); + late final _create = _createPtr + .asFunction Function(ffi.Pointer)>(); - void start( - int port, - ) { - return _start( - port, - ); + ffi.Pointer start() { + return _start(); } late final _startPtr = - _lookup>('start'); - late final _start = _startPtr.asFunction(); + _lookup Function()>>('start'); + late final _start = _startPtr.asFunction Function()>(); - void startLog( - int port, - ffi.Pointer levelStr, - ) { - return _startLog( - port, - levelStr, - ); + ffi.Pointer stop() { + return _stop(); } - late final _startLogPtr = _lookup< - ffi.NativeFunction< - ffi.Void Function(ffi.LongLong, ffi.Pointer)>>('startLog'); - late final _startLog = - _startLogPtr.asFunction)>(); - - void stopLog() { - return _stopLog(); - } - - late final _stopLogPtr = - _lookup>('stopLog'); - late final _stopLog = _stopLogPtr.asFunction(); - - void getProxies( - int port, - ) { - return _getProxies( - port, - ); - } - - late final _getProxiesPtr = - _lookup>( - 'getProxies'); - late final _getProxies = _getProxiesPtr.asFunction(); - - void updateProxy( - int port, - ffi.Pointer selectorName, - ffi.Pointer proxyName, - ) { - return _updateProxy( - port, - selectorName, - proxyName, - ); - } - - late final _updateProxyPtr = _lookup< - ffi.NativeFunction< - ffi.Void Function(ffi.LongLong, ffi.Pointer, - ffi.Pointer)>>('updateProxy'); - late final _updateProxy = _updateProxyPtr.asFunction< - void Function(int, ffi.Pointer, ffi.Pointer)>(); - - void getProxyDelay( - int port, - ffi.Pointer name, - ffi.Pointer url, - int timeout, - ) { - return _getProxyDelay( - port, - name, - url, - timeout, - ); - } - - late final _getProxyDelayPtr = _lookup< - ffi.NativeFunction< - ffi.Void Function(ffi.LongLong, ffi.Pointer, - ffi.Pointer, ffi.Long)>>('getProxyDelay'); - late final _getProxyDelay = _getProxyDelayPtr.asFunction< - void Function(int, ffi.Pointer, ffi.Pointer, int)>(); - - void getTraffic( - int port, - ) { - return _getTraffic( - port, - ); - } - - late final _getTrafficPtr = - _lookup>( - 'getTraffic'); - late final _getTraffic = _getTrafficPtr.asFunction(); + late final _stopPtr = + _lookup Function()>>('stop'); + late final _stop = _stopPtr.asFunction Function()>(); } typedef va_list = ffi.Pointer; @@ -1136,7 +999,6 @@ final class GoSlice extends ffi.Struct { typedef GoInt = GoInt64; typedef GoInt64 = ffi.LongLong; -typedef GoUint8 = ffi.UnsignedChar; const int _VCRT_COMPILER_PREPROCESSOR = 1; diff --git a/lib/services/clash/async_ffi.dart b/lib/services/clash/async_ffi.dart deleted file mode 100644 index aa8da0dd..00000000 --- a/lib/services/clash/async_ffi.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:convert'; -import 'dart:ffi'; -import 'dart:isolate'; - -import 'package:hiddify/services/clash/async_ffi_response.dart'; -import 'package:hiddify/utils/utils.dart'; - -// TODO: add timeout -// TODO: test and improve -mixin AsyncFFI implements LoggerMixin { - Future runAsync(void Function(int port) run) async { - final receivePort = ReceivePort(); - final responseFuture = receivePort.map( - (event) { - if (event is String) { - receivePort.close(); - return AsyncFfiResponse.fromJson( - jsonDecode(event) as Map, - ); - } - receivePort.close(); - throw Exception("unexpected data type[${event.runtimeType}]"); - }, - ).first; - run(receivePort.sendPort.nativePort); - return responseFuture; - } -} diff --git a/lib/services/clash/async_ffi_response.dart b/lib/services/clash/async_ffi_response.dart deleted file mode 100644 index 88615d20..00000000 --- a/lib/services/clash/async_ffi_response.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'async_ffi_response.freezed.dart'; -part 'async_ffi_response.g.dart'; - -@freezed -class AsyncFfiResponse with _$AsyncFfiResponse { - const AsyncFfiResponse._(); - - const factory AsyncFfiResponse({ - @JsonKey(name: 'success') required bool success, - @JsonKey(name: 'message') String? message, - @JsonKey(name: 'data') String? data, - }) = _AsyncFfiResponse; - - factory AsyncFfiResponse.fromJson(Map json) => - _$AsyncFfiResponseFromJson(json); -} diff --git a/lib/services/clash/clash.dart b/lib/services/clash/clash.dart deleted file mode 100644 index 6ee6196d..00000000 --- a/lib/services/clash/clash.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'clash_service.dart'; -export 'clash_service_impl.dart'; diff --git a/lib/services/clash/clash_service.dart b/lib/services/clash/clash_service.dart deleted file mode 100644 index db4ecf14..00000000 --- a/lib/services/clash/clash_service.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'dart:async'; - -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/clash/clash.dart'; - -abstract class ClashService { - Future init(); - - Future start({String configFileName = "config"}); - - TaskEither validateConfig(String configPath); - - TaskEither> getProxies(); - - TaskEither changeProxy( - String selectorName, - String proxyName, - ); - - TaskEither getProxyDelay( - String name, - String url, { - Duration timeout = const Duration(seconds: 10), - }); - - TaskEither getConfigs(); - - TaskEither updateConfigs(String path); - - TaskEither patchConfigs(ClashConfig config); - - Stream watchLogs(LogLevel level); - - TaskEither getTraffic(); -} diff --git a/lib/services/clash/clash_service_impl.dart b/lib/services/clash/clash_service_impl.dart deleted file mode 100644 index 50c16223..00000000 --- a/lib/services/clash/clash_service_impl.dart +++ /dev/null @@ -1,235 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:ffi'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:combine/combine.dart'; -import 'package:ffi/ffi.dart'; -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/gen/clash_generated_bindings.dart'; -import 'package:hiddify/services/clash/async_ffi.dart'; -import 'package:hiddify/services/clash/clash_service.dart'; -import 'package:hiddify/services/files_editor_service.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:path/path.dart' as p; -import 'package:rxdart/rxdart.dart'; - -// TODO: logging has potential memory leak -class ClashServiceImpl with AsyncFFI, InfraLogger implements ClashService { - ClashServiceImpl({required this.filesEditor}); - - final FilesEditorService filesEditor; - - late final ClashNativeLibrary _clash; - - @override - Future init() async { - loggy.debug('initializing'); - _initClashLib(); - _clash.initNativeDartBridge(NativeApi.initializeApiDLData); - } - - void _initClashLib() { - String fullPath = ""; - if (Platform.environment.containsKey('FLUTTER_TEST')) { - fullPath = "core"; - } - if (Platform.isWindows) { - fullPath = p.join(fullPath, "libclash.dll"); - } else if (Platform.isMacOS) { - fullPath = p.join(fullPath, "libclash.dylib"); - } else { - fullPath = p.join(fullPath, "libclash.so"); - } - loggy.debug('clash native libs path: "$fullPath"'); - final lib = DynamicLibrary.open(fullPath); - _clash = ClashNativeLibrary(lib); - } - - @override - Future start({String configFileName = "config"}) async { - loggy.debug('starting clash with config: [$configFileName]'); - final stopWatch = Stopwatch()..start(); - final configPath = filesEditor.configPath(configFileName); - final response = await runAsync( - (port) => _clash.setOptions( - port, - filesEditor.clashDirPath.toNativeUtf8().cast(), - configPath.toNativeUtf8().cast(), - ), - ); - if (!response.success) throw ClashFailure.core(response.message); - stopWatch.stop(); - loggy.info( - "started clash service [${stopWatch.elapsedMilliseconds}ms]", - ); - } - - @override - TaskEither validateConfig(String configPath) { - return TaskEither( - () async { - final response = await runAsync( - (port) => - _clash.validateConfig(port, configPath.toNativeUtf8().cast()), - ); - if (!response.success) return left(response.message ?? ''); - return right(response.data! == "true"); - }, - ); - } - - @override - TaskEither updateConfigs(String path) { - return TaskEither(() async { - final stopWatch = Stopwatch()..start(); - final response = await runAsync( - (port) => _clash.updateConfigs(port, path.toNativeUtf8().cast(), 0), - ); - stopWatch.stop(); - if (response.success) { - loggy.info("changed config in [${stopWatch.elapsedMilliseconds}ms]"); - return right(unit); - } - return left(response.message ?? ''); - }); - } - - @override - TaskEither> getProxies() { - return TaskEither( - () async { - final response = await runAsync((port) => _clash.getProxies(port)); - if (!response.success) return left(response.message ?? ""); - final proxies = await CombineWorker().executeWithArg( - (data) { - if (data == null) return []; - final json = jsonDecode(data)['proxies'] as Map; - final parsed = json.entries.map( - (e) { - final proxyMap = (e.value as Map) - ..putIfAbsent('name', () => e.key); - return ClashProxy.fromJson(proxyMap); - }, - ).toList(); - return parsed; - }, - response.data, - ); - return right(proxies); - }, - ); - } - - @override - TaskEither patchConfigs(ClashConfig config) { - return TaskEither( - () async { - final response = await runAsync( - (port) => _clash.patchConfigs( - port, - jsonEncode(config.toJson()).toNativeUtf8().cast(), - ), - ); - if (!response.success) return left(response.message ?? ""); - return right(unit); - }, - ); - } - - @override - TaskEither getConfigs() { - return TaskEither( - () async { - final response = await runAsync( - (port) => _clash.getConfigs(port), - ); - if (!response.success) return left(response.message ?? ""); - return right( - ClashConfig.fromJson( - jsonDecode(response.data!) as Map, - ), - ); - }, - ); - } - - @override - TaskEither changeProxy( - String selectorName, - String proxyName, - ) { - return TaskEither( - () async { - final response = await runAsync( - (port) => _clash.updateProxy( - port, - selectorName.toNativeUtf8().cast(), - proxyName.toNativeUtf8().cast(), - ), - ); - if (!response.success) return left(response.message ?? ""); - return right(unit); - }, - ); - } - - @override - TaskEither getProxyDelay( - String name, - String url, { - Duration timeout = const Duration(seconds: 10), - }) { - return TaskEither( - () async { - final response = await runAsync( - (port) => _clash.getProxyDelay( - port, - name.toNativeUtf8().cast(), - url.toNativeUtf8().cast(), - timeout.inMilliseconds, - ), - ); - if (!response.success) return left(response.message ?? ""); - return right( - (jsonDecode(response.data!) as Map)["delay"] as int, - ); - }, - ); - } - - @override - Stream watchLogs(LogLevel level) { - final logsPort = ReceivePort(); - final logsStream = logsPort.map( - (event) { - final json = jsonDecode(event as String) as Map; - return ClashLog.fromJson(json); - }, - ); - _clash.startLog( - logsPort.sendPort.nativePort, - level.name.toNativeUtf8().cast(), - ); - return logsStream.doOnCancel(() => _clash.stopLog()); - } - - @override - TaskEither getTraffic() { - return TaskEither( - () async { - final response = await runAsync( - (port) => _clash.getTraffic(port), - ); - if (!response.success) return left(response.message ?? ""); - return right( - ClashTraffic.fromJson( - jsonDecode(response.data!) as Map, - ), - ); - }, - ); - } -} diff --git a/lib/services/connectivity/connectivity_service.dart b/lib/services/connectivity/connectivity_service.dart index 2e58d7f7..2eb86893 100644 --- a/lib/services/connectivity/connectivity_service.dart +++ b/lib/services/connectivity/connectivity_service.dart @@ -1,27 +1,26 @@ +import 'package:hiddify/domain/connectivity/connectivity.dart'; import 'package:hiddify/services/connectivity/desktop_connectivity_service.dart'; import 'package:hiddify/services/connectivity/mobile_connectivity_service.dart'; import 'package:hiddify/services/notification/notification.dart'; +import 'package:hiddify/services/singbox/singbox_service.dart'; import 'package:hiddify/utils/utils.dart'; abstract class ConnectivityService { - factory ConnectivityService(NotificationService notification) { - if (PlatformUtils.isDesktop) return DesktopConnectivityService(); - return MobileConnectivityService(notification); + factory ConnectivityService( + SingboxService singboxService, + NotificationService notificationService, + ) { + if (PlatformUtils.isDesktop) { + return DesktopConnectivityService(singboxService); + } + return MobileConnectivityService(singboxService, notificationService); } Future init(); - // TODO: use declarative states - Stream watchConnectionStatus(); + Stream watchConnectionStatus(); - // TODO: remove - Future grantVpnPermission(); - - Future connect({ - required int httpPort, - required int socksPort, - bool systemProxy = true, - }); + Future connect(); Future disconnect(); } diff --git a/lib/services/connectivity/desktop_connectivity_service.dart b/lib/services/connectivity/desktop_connectivity_service.dart index c2b534bc..e7a0dd1d 100644 --- a/lib/services/connectivity/desktop_connectivity_service.dart +++ b/lib/services/connectivity/desktop_connectivity_service.dart @@ -1,64 +1,49 @@ -import 'dart:io'; - -import 'package:hiddify/domain/constants.dart'; +import 'package:hiddify/domain/connectivity/connectivity.dart'; import 'package:hiddify/services/connectivity/connectivity_service.dart'; +import 'package:hiddify/services/singbox/singbox_service.dart'; import 'package:hiddify/utils/utils.dart'; -import 'package:proxy_manager/proxy_manager.dart'; import 'package:rxdart/rxdart.dart'; -// TODO: rewrite class DesktopConnectivityService with InfraLogger implements ConnectivityService { - // TODO: possibly replace - final _proxyManager = ProxyManager(); + DesktopConnectivityService(this._singboxService); - final _connectionStatus = BehaviorSubject.seeded(false); + final SingboxService _singboxService; + + late final BehaviorSubject _connectionStatus; @override - Future init() async {} - - @override - Stream watchConnectionStatus() { - return _connectionStatus; + Future init() async { + loggy.debug("initializing"); + _connectionStatus = + BehaviorSubject.seeded(const ConnectionStatus.disconnected()); } @override - Future grantVpnPermission() async => true; + Stream watchConnectionStatus() => _connectionStatus; @override - Future connect({ - required int httpPort, - required int socksPort, - bool systemProxy = true, - }) async { + Future connect() async { loggy.debug('connecting'); - await Future.wait([ - _proxyManager.setAsSystemProxy( - ProxyTypes.http, - Constants.localHost, - httpPort, - ), - _proxyManager.setAsSystemProxy( - ProxyTypes.https, - Constants.localHost, - httpPort, - ) - ]); - if (!Platform.isWindows) { - await _proxyManager.setAsSystemProxy( - ProxyTypes.socks, - Constants.localHost, - socksPort, - ); - } - _connectionStatus.value = true; + _connectionStatus.value = const ConnectionStatus.connecting(); + await _singboxService.start().getOrElse( + (l) { + _connectionStatus.value = const ConnectionStatus.disconnected(); + throw l; + }, + ).run(); + _connectionStatus.value = const ConnectionStatus.connected(); } @override Future disconnect() async { loggy.debug("disconnecting"); - await _proxyManager.cleanSystemProxy(); - _connectionStatus.value = false; + _connectionStatus.value = const ConnectionStatus.disconnecting(); + await _singboxService.stop().getOrElse((l) { + _connectionStatus.value = const ConnectionStatus.connected(); + throw l; + }).run(); + _connectionStatus.value = const ConnectionStatus.disconnected(); } } diff --git a/lib/services/connectivity/mobile_connectivity_service.dart b/lib/services/connectivity/mobile_connectivity_service.dart index 54794e8c..a923216d 100644 --- a/lib/services/connectivity/mobile_connectivity_service.dart +++ b/lib/services/connectivity/mobile_connectivity_service.dart @@ -1,7 +1,9 @@ import 'package:flutter/services.dart'; -import 'package:hiddify/domain/connectivity/connectivity_failure.dart'; +import 'package:hiddify/domain/connectivity/connectivity.dart'; +import 'package:hiddify/domain/core_service_failure.dart'; import 'package:hiddify/services/connectivity/connectivity_service.dart'; import 'package:hiddify/services/notification/notification.dart'; +import 'package:hiddify/services/singbox/singbox_service.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:rxdart/rxdart.dart'; @@ -9,78 +11,84 @@ import 'package:rxdart/rxdart.dart'; class MobileConnectivityService with InfraLogger implements ConnectivityService { - MobileConnectivityService(this._notificationService); + MobileConnectivityService(this.singbox, this.notifications); - final NotificationService _notificationService; + final SingboxService singbox; + final NotificationService notifications; - static const _methodChannel = MethodChannel("Hiddify/VpnService"); - static const _eventChannel = EventChannel("Hiddify/VpnServiceEvents"); + late final EventChannel _statusChannel; + late final EventChannel _alertsChannel; + late final ValueStream _connectionStatus; - final _connectionStatus = ValueConnectableStream( - _eventChannel.receiveBroadcastStream().map((event) => event as bool), - ).autoConnect(); + static CoreServiceFailure fromServiceAlert(String key, String? message) { + return switch (key) { + "EmptyConfiguration" => InvalidConfig(message), + "StartCommandServer" || + "CreateService" => + CoreServiceCreateFailure(message), + "StartService" => CoreServiceStartFailure(message), + _ => const CoreServiceOtherFailure(), + }; + } + + static ConnectionStatus fromServiceEvent(dynamic event) { + final status = event['status'] as String; + late ConnectionStatus connectionStatus; + switch (status) { + case "Stopped": + final failure = event["failure"] as String?; + final message = event["message"] as String?; + connectionStatus = ConnectionStatus.disconnected( + switch (failure) { + null => null, + "RequestVPNPermission" => MissingVpnPermission(message), + "RequestNotificationPermission" => + MissingNotificationPermission(message), + "EmptyConfiguration" || + "StartCommandServer" || + "CreateService" || + "StartService" => + CoreConnectionFailure(fromServiceAlert(failure, message)), + _ => const UnexpectedConnectionFailure(), + }, + ); + case "Starting": + connectionStatus = const Connecting(); + case "Started": + connectionStatus = const Connected(); + case "Stopping": + connectionStatus = const Disconnecting(); + } + return connectionStatus; + } @override Future init() async { loggy.debug("initializing"); - final initialStatus = _connectionStatus.first; - await _methodChannel.invokeMethod("refresh_status"); - await initialStatus; + _statusChannel = const EventChannel("com.hiddify.app/service.status"); + _alertsChannel = const EventChannel("com.hiddify.app/service.alerts"); + final status = + _statusChannel.receiveBroadcastStream().map(fromServiceEvent); + final alerts = + _alertsChannel.receiveBroadcastStream().map(fromServiceEvent); + _connectionStatus = + ValueConnectableStream(Rx.merge([status, alerts])).autoConnect(); + await _connectionStatus.first; } @override - Stream watchConnectionStatus() { - return _connectionStatus; - } + Stream watchConnectionStatus() => _connectionStatus; @override - Future grantVpnPermission() async { - loggy.debug('requesting vpn permission'); - final result = await _methodChannel.invokeMethod("grant_permission"); - if (!(result ?? false)) { - loggy.info("vpn permission denied"); - } - return result ?? false; - } - - @override - Future connect({ - required int httpPort, - required int socksPort, - bool systemProxy = true, - }) async { + Future connect() async { loggy.debug("connecting"); - await setPrefs(httpPort, socksPort, systemProxy); - final hasNotificationPermission = - await _notificationService.grantPermission(); - if (!hasNotificationPermission) { - loggy.warning("notification permission denied"); - throw const ConnectivityFailure.unexpected(); - } - await _methodChannel.invokeMethod("start"); + await notifications.grantPermission(); + await singbox.start().getOrElse((l) => throw l).run(); } @override Future disconnect() async { loggy.debug("disconnecting"); - await _methodChannel.invokeMethod("stop"); - } - - Future setPrefs(int port, int socksPort, bool systemProxy) async { - loggy.debug( - 'setting connection prefs: httpPort: $port, socksPort: $socksPort, systemProxy: $systemProxy', - ); - final result = await _methodChannel.invokeMethod( - "set_prefs", - { - "port": port, - "socks-port": socksPort, - "system-proxy": systemProxy, - }, - ); - if (!(result ?? false)) { - loggy.error("failed to set connection prefs"); - // TODO: throw - } + await singbox.stop().getOrElse((l) => throw l).run(); } } diff --git a/lib/services/files_editor_service.dart b/lib/services/files_editor_service.dart index 99fbb396..67a0c7f6 100644 --- a/lib/services/files_editor_service.dart +++ b/lib/services/files_editor_service.dart @@ -8,60 +8,89 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; class FilesEditorService with InfraLogger { - late final Directory _supportDir; - late final Directory _clashDirectory; - late final Directory _logsDirectory; + late final Directory baseDir; + late final Directory workingDir; + late final Directory tempDir; + late final Directory _configsDir; Future init() async { - loggy.debug('initializing'); - _supportDir = await getApplicationSupportDirectory(); - _clashDirectory = - Directory(p.join(_supportDir.path, Constants.clashFolderName)); - loggy.debug('clash directory: $_clashDirectory'); - if (!await _clashDirectory.exists()) { - await _clashDirectory.create(recursive: true); + baseDir = await getApplicationSupportDirectory(); + if (Platform.isAndroid) { + final externalDir = await getExternalStorageDirectory(); + workingDir = externalDir!; + } else if (Platform.isWindows) { + workingDir = baseDir; + } else { + workingDir = await getApplicationDocumentsDirectory(); } - if (!await File(countryMMDBPath).exists()) { - await _populateDefaultCountryMMDB(); + tempDir = await getTemporaryDirectory(); + + loggy.debug("base dir: ${baseDir.path}"); + loggy.debug("working dir: ${workingDir.path}"); + loggy.debug("temp dir: ${tempDir.path}"); + + _configsDir = + Directory(p.join(workingDir.path, Constants.configsFolderName)); + if (!await baseDir.exists()) { + await baseDir.create(recursive: true); + } + if (!await workingDir.exists()) { + await workingDir.create(recursive: true); + } + if (!await _configsDir.exists()) { + await _configsDir.create(recursive: true); + } + + final appLogFile = File(appLogsPath); + if (await appLogFile.exists()) { + await appLogFile.writeAsString(""); + } else { + await appLogFile.create(recursive: true); + } + + await _populateGeoAssets(); + if (PlatformUtils.isDesktop) { + final logFile = File(logsPath); + if (await logFile.exists()) { + await logFile.writeAsString(""); + } else { + await logFile.create(recursive: true); + } } - if (!await File(defaultConfigPath).exists()) await _populateDefaultConfig(); } - String get clashDirPath => _clashDirectory.path; + static Future getDatabaseDirectory() async { + if (Platform.isIOS || Platform.isMacOS) { + return getLibraryDirectory(); + } else if (Platform.isWindows) { + return getApplicationSupportDirectory(); + } + return getApplicationDocumentsDirectory(); + } - late final logsPath = p.join( - _logsDirectory.path, - "${DateTime.now().toUtc().toIso8601String().split('T').first}.txt", - ); - - String get defaultConfigPath => configPath("config"); + String get appLogsPath => p.join(workingDir.path, "app.log"); + String get logsPath => p.join(workingDir.path, "box.log"); String configPath(String fileName) { - return p.join(_clashDirectory.path, "$fileName.yaml"); + return p.join(_configsDir.path, "$fileName.json"); } Future deleteConfig(String fileName) { return File(configPath(fileName)).delete(); } - String get countryMMDBPath { - return p.join( - _clashDirectory.path, - "${Constants.countryMMDBFileName}.mmdb", - ); - } + Future _populateGeoAssets() async { + loggy.debug('populating geo assets'); + final geoipPath = p.join(workingDir.path, Constants.geoipFileName); + if (!await File(geoipPath).exists()) { + final defaultGeoip = await rootBundle.load(Assets.core.geoip); + await File(geoipPath).writeAsBytes(defaultGeoip.buffer.asInt8List()); + } - Future _populateDefaultConfig() async { - loggy.debug('populating default config file'); - final defaultConfig = await rootBundle.load(Assets.core.clash.config); - await File(defaultConfigPath) - .writeAsBytes(defaultConfig.buffer.asInt8List()); - } - - Future _populateDefaultCountryMMDB() async { - loggy.debug('populating default country mmdb file'); - final defaultCountryMMDB = await rootBundle.load(Assets.core.clash.country); - await File(countryMMDBPath) - .writeAsBytes(defaultCountryMMDB.buffer.asInt8List()); + final geositePath = p.join(workingDir.path, Constants.geositeFileName); + if (!await File(geositePath).exists()) { + final defaultGeosite = await rootBundle.load(Assets.core.geosite); + await File(geositePath).writeAsBytes(defaultGeosite.buffer.asInt8List()); + } } } diff --git a/lib/services/service_providers.dart b/lib/services/service_providers.dart index d411e386..31e38388 100644 --- a/lib/services/service_providers.dart +++ b/lib/services/service_providers.dart @@ -1,7 +1,7 @@ -import 'package:hiddify/services/clash/clash.dart'; import 'package:hiddify/services/connectivity/connectivity.dart'; import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/services/notification/notification.dart'; +import 'package:hiddify/services/singbox/singbox_service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'service_providers.g.dart'; @@ -15,12 +15,11 @@ FilesEditorService filesEditorService(FilesEditorServiceRef ref) => FilesEditorService(); @Riverpod(keepAlive: true) -ConnectivityService connectivityService(ConnectivityServiceRef ref) => - ConnectivityService( - ref.watch(notificationServiceProvider), - ); +SingboxService singboxService(SingboxServiceRef ref) => SingboxService(); @Riverpod(keepAlive: true) -ClashService clashService(ClashServiceRef ref) => ClashServiceImpl( - filesEditor: ref.read(filesEditorServiceProvider), +ConnectivityService connectivityService(ConnectivityServiceRef ref) => + ConnectivityService( + ref.watch(singboxServiceProvider), + ref.watch(notificationServiceProvider), ); diff --git a/lib/services/singbox/ffi_singbox_service.dart b/lib/services/singbox/ffi_singbox_service.dart new file mode 100644 index 00000000..2aa8c1e8 --- /dev/null +++ b/lib/services/singbox/ffi_singbox_service.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:combine/combine.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/gen/singbox_generated_bindings.dart'; +import 'package:hiddify/services/singbox/singbox_service.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:path/path.dart' as p; + +class FFISingboxService with InfraLogger implements SingboxService { + static final SingboxNativeLibrary _box = _gen(); + + static SingboxNativeLibrary _gen() { + String fullPath = ""; + if (Platform.environment.containsKey('FLUTTER_TEST')) { + fullPath = "libcore"; + } + if (Platform.isWindows) { + fullPath = p.join(fullPath, "libcore.dll"); + } else if (Platform.isMacOS) { + fullPath = p.join(fullPath, "libcore.dylib"); + } else { + fullPath = p.join(fullPath, "libcore.so"); + } + debugPrint('singbox native libs path: "$fullPath"'); + final lib = DynamicLibrary.open(fullPath); + return SingboxNativeLibrary(lib); + } + + @override + TaskEither setup( + String baseDir, + String workingDir, + String tempDir, + ) { + return TaskEither( + () => CombineWorker().execute( + () { + _box.setup( + baseDir.toNativeUtf8().cast(), + workingDir.toNativeUtf8().cast(), + tempDir.toNativeUtf8().cast(), + ); + return right(unit); + }, + ), + ); + } + + @override + TaskEither parseConfig(String path) { + return TaskEither( + () => CombineWorker().execute( + () { + final err = _box + .parse(path.toNativeUtf8().cast()) + .cast() + .toDartString(); + if (err.isNotEmpty) { + return left(err); + } + return right(unit); + }, + ), + ); + } + + @override + TaskEither create(String configPath) { + return TaskEither( + () => CombineWorker().execute( + () { + final err = _box + .create(configPath.toNativeUtf8().cast()) + .cast() + .toDartString(); + if (err.isNotEmpty) { + return left(err); + } + return right(unit); + }, + ), + ); + } + + @override + TaskEither start() { + return TaskEither( + () => CombineWorker().execute( + () { + final err = _box.start().cast().toDartString(); + if (err.isNotEmpty) { + return left(err); + } + return right(unit); + }, + ), + ); + } + + @override + TaskEither stop() { + return TaskEither( + () => CombineWorker().execute( + () { + final err = _box.stop().cast().toDartString(); + if (err.isNotEmpty) { + return left(err); + } + return right(unit); + }, + ), + ); + } + + @override + Stream watchLogs(String path) { + var linesRead = 0; + return Stream.periodic( + const Duration(seconds: 1), + ).asyncMap((_) async { + final result = await _readLogs(path, linesRead); + linesRead = result.$2; + return result.$1; + }).transform( + StreamTransformer.fromHandlers( + handleData: (data, sink) { + for (final item in data) { + sink.add(item); + } + }, + ), + ); + } + + Future<(List, int)> _readLogs(String path, int from) async { + return CombineWorker().execute( + () async { + final lines = await File(path).readAsLines(); + final to = lines.length; + return (lines.sublist(from), to); + }, + ); + } +} diff --git a/lib/services/singbox/mobile_singbox_service.dart b/lib/services/singbox/mobile_singbox_service.dart new file mode 100644 index 00000000..e4c3e4ee --- /dev/null +++ b/lib/services/singbox/mobile_singbox_service.dart @@ -0,0 +1,79 @@ +import 'package:flutter/services.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/services/singbox/singbox_service.dart'; +import 'package:hiddify/utils/utils.dart'; + +class MobileSingboxService with InfraLogger implements SingboxService { + late final MethodChannel _methodChannel = + const MethodChannel("com.hiddify.app/method"); + late final EventChannel _logsChannel = + const EventChannel("com.hiddify.app/service.logs"); + + @override + TaskEither setup( + String baseDir, + String workingDir, + String tempDir, + ) => + TaskEither.of(unit); + + @override + TaskEither parseConfig(String path) { + return TaskEither( + () async { + final message = await _methodChannel.invokeMethod( + "parse_config", + {"path": path}, + ); + if (message == null || message.isEmpty) return right(unit); + return left(message); + }, + ); + } + + @override + TaskEither create(String configPath) { + return TaskEither( + () async { + loggy.debug("creating service for: $configPath"); + await _methodChannel.invokeMethod( + "set_active_config_path", + {"path": configPath}, + ); + return right(unit); + }, + ); + } + + @override + TaskEither start() { + return TaskEither( + () async { + loggy.debug("starting"); + await _methodChannel.invokeMethod("start"); + return right(unit); + }, + ); + } + + @override + TaskEither stop() { + return TaskEither( + () async { + loggy.debug("stopping"); + await _methodChannel.invokeMethod("stop"); + return right(unit); + }, + ); + } + + @override + Stream watchLogs(String path) { + return _logsChannel.receiveBroadcastStream().map( + (event) { + loggy.debug("received log: $event"); + return event as String; + }, + ); + } +} diff --git a/lib/services/singbox/singbox_service.dart b/lib/services/singbox/singbox_service.dart new file mode 100644 index 00000000..a9ba353a --- /dev/null +++ b/lib/services/singbox/singbox_service.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/services/singbox/ffi_singbox_service.dart'; +import 'package:hiddify/services/singbox/mobile_singbox_service.dart'; + +abstract interface class SingboxService { + factory SingboxService() { + if (Platform.isAndroid) { + return MobileSingboxService(); + } + return FFISingboxService(); + } + + TaskEither setup( + String baseDir, + String workingDir, + String tempDir, + ); + + TaskEither parseConfig(String path); + + TaskEither create(String configPath); + + TaskEither start(); + + TaskEither stop(); + + Stream watchLogs(String path); +} diff --git a/lib/utils/alerts.dart b/lib/utils/alerts.dart index 905433a4..7239d8c4 100644 --- a/lib/utils/alerts.dart +++ b/lib/utils/alerts.dart @@ -1,6 +1,42 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; +class CustomAlertDialog extends StatelessWidget { + const CustomAlertDialog({ + super.key, + this.title, + required this.message, + }); + + final String? title; + final String message; + + Future show(BuildContext context) async { + await showDialog( + context: context, + builder: (context) => this, + ); + } + + @override + Widget build(BuildContext context) { + final localizations = MaterialLocalizations.of(context); + + return AlertDialog( + title: title != null ? Text(title!) : null, + content: Text(message), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(localizations.okButtonLabel), + ), + ], + ); + } +} + enum AlertType { info, error, success } class CustomToast extends StatelessWidget { diff --git a/lib/utils/custom_log_printer.dart b/lib/utils/custom_log_printer.dart new file mode 100644 index 00000000..d364675e --- /dev/null +++ b/lib/utils/custom_log_printer.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:loggy/loggy.dart'; + +class MultiLogPrinter extends LoggyPrinter { + MultiLogPrinter(this.consolePrinter, this.filePrinter); + + final LoggyPrinter consolePrinter; + final LoggyPrinter filePrinter; + + @override + void onLog(LogRecord record) { + consolePrinter.onLog(record); + filePrinter.onLog(record); + } +} + +class FileLogPrinter extends LoggyPrinter { + FileLogPrinter(String filePath) : _logFile = File(filePath); + + final File _logFile; + + late final _sink = _logFile.openWrite( + mode: FileMode.writeOnly, + ); + + @override + void onLog(LogRecord record) { + _sink.writeln(record.toString()); + } +} diff --git a/lib/utils/placeholders.dart b/lib/utils/placeholders.dart index 201ea194..f56183b6 100644 --- a/lib/utils/placeholders.dart +++ b/lib/utils/placeholders.dart @@ -36,7 +36,11 @@ class SliverLoadingBodyPlaceholder extends HookConsumerWidget { } class SliverErrorBodyPlaceholder extends HookConsumerWidget { - const SliverErrorBodyPlaceholder(this.msg, {super.key, this.icon}); + const SliverErrorBodyPlaceholder( + this.msg, { + super.key, + this.icon = Icons.error, + }); final String msg; final IconData? icon; @@ -48,8 +52,10 @@ class SliverErrorBodyPlaceholder extends HookConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon ?? Icons.error), - const Gap(16), + if (icon != null) ...[ + Icon(icon), + const Gap(16), + ], Text(msg), ], ), diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index bb32efd6..0e7ab7a7 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -2,6 +2,7 @@ export 'alerts.dart'; export 'async_mutation.dart'; export 'bottom_sheet_page.dart'; export 'callback_debouncer.dart'; +export 'custom_log_printer.dart'; export 'custom_loggers.dart'; export 'custom_text_form_field.dart'; export 'link_parsers.dart'; diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index c9bfc74d..8b39e8e6 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -117,7 +117,7 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR} install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) -install(FILES "../core/bin/libclash.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" +install(FILES "../libcore/bin/libcore.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 0e9e5827..08565b49 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,7 +6,6 @@ #include "generated_plugin_registrant.h" -#include #include #include #include @@ -14,9 +13,6 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) proxy_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ProxyManagerPlugin"); - proxy_manager_plugin_register_with_registrar(proxy_manager_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 792677d6..fa081fc5 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - proxy_manager screen_retriever sqlite3_flutter_libs tray_manager diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7ff62db9..36233a18 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,7 +10,6 @@ import mobile_scanner import package_info_plus import path_provider_foundation import protocol_handler -import proxy_manager import screen_retriever import share_plus import shared_preferences_foundation @@ -25,7 +24,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ProtocolHandlerPlugin.register(with: registry.registrar(forPlugin: "ProtocolHandlerPlugin")) - ProxyManagerPlugin.register(with: registry.registrar(forPlugin: "ProxyManagerPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 7d04eae9..356c05e5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -957,14 +957,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.5" - proxy_manager: - dependency: "direct main" - description: - name: proxy_manager - sha256: "4cdb8853bcedc1a6879c6d940d624d740e1c76ee6b83377b13270f96a8415a37" - url: "https://pub.dev" - source: hosted - version: "0.0.3" pub_semver: dependency: transitive description: @@ -1322,6 +1314,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + tint: + dependency: "direct main" + description: + name: tint + sha256: "9652d9a589f4536d5e392cf790263d120474f15da3cf1bee7f1fdb31b4de5f46" + url: "https://pub.dev" + source: hosted + version: "2.0.1" tray_manager: dependency: "direct main" description: @@ -1467,7 +1467,7 @@ packages: source: hosted version: "1.1.0" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b diff --git a/pubspec.yaml b/pubspec.yaml index 7eec3548..c5cb4dc3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,10 +40,10 @@ dependencies: # networking dio: ^5.3.0 + web_socket_channel: ^2.4.0 # native ffi: ^2.0.2 - proxy_manager: ^0.0.3 path_provider: ^2.0.15 flutter_local_notifications: ^15.1.0+1 mobile_scanner: ^3.3.0 @@ -66,6 +66,7 @@ dependencies: stack_trace: ^1.11.0 dartx: ^1.2.0 uuid: ^3.0.7 + tint: ^2.0.1 # widgets go_router: ^9.1.0 @@ -98,7 +99,7 @@ dev_dependencies: flutter: uses-material-design: true assets: - - assets/core/clash/ + - assets/core/ - assets/images/ fonts: @@ -132,9 +133,9 @@ flutter_native_splash: color: "#ffffff" ffigen: - name: 'ClashNativeLibrary' - description: 'Bindings to Clash' - output: 'lib/gen/clash_generated_bindings.dart' + name: 'SingboxNativeLibrary' + description: 'Bindings to Singbox' + output: 'lib/gen/singbox_generated_bindings.dart' headers: entry-points: - - 'core/bin/libclash.h' \ No newline at end of file + - 'libcore/bin/libcore.h' \ No newline at end of file diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 2a02c5dd..dd3dda1d 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -81,8 +81,12 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR} install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) -install(FILES "../core/bin/libclash.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) +# install(FILES "../libcore/bin/libcore.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" +# COMPONENT Runtime) +set(HIDDIFY_NEXT_LIB "../libcore/bin/libcore.dll") +install(FILES "${HIDDIFY_NEXT_LIB}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" +COMPONENT Runtime RENAME libcore.dll) + if(PLUGIN_BUNDLED_LIBRARIES) install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0ee8c2fb..ef7c9ae4 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,7 +7,6 @@ #include "generated_plugin_registrant.h" #include -#include #include #include #include @@ -18,8 +17,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { ProtocolHandlerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ProtocolHandlerPlugin")); - ProxyManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ProxyManagerPlugin")); ScreenRetrieverPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 39c9d456..af9bdc3b 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,7 +4,6 @@ list(APPEND FLUTTER_PLUGIN_LIST protocol_handler - proxy_manager screen_retriever share_plus sqlite3_flutter_libs diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index d9acb3eb..cf22cd0d 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "com.example" "\0" + VALUE "CompanyName", "hiddify" "\0" VALUE "FileDescription", "hiddify" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "hiddify" "\0" - VALUE "LegalCopyright", "Copyright (C) 2023 com.example. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2023 hiddify. All rights reserved." "\0" VALUE "OriginalFilename", "hiddify.exe" "\0" VALUE "ProductName", "hiddify" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"