Compare commits

...

9 Commits

Author SHA1 Message Date
chen08209
23e3baf534 Optimize ip detection
Support android vpn ipv6 inbound switch

Support log export

Optimize more details
2024-09-26 14:27:52 +08:00
chen08209
d522a64604 Fix android system dns issues
Optimize dns default option

Fix some issues
2024-09-20 11:21:25 +08:00
chen08209
3902ea0064 Update readme 2024-09-18 00:42:20 +08:00
chen08209
5566f2b54f Update README.md 2 2024-09-18 00:22:31 +08:00
chen08209
511b7c521a Update README.md 2 2024-09-18 00:20:00 +08:00
chen08209
fb01d87371 Update README.md 2024-09-18 00:17:12 +08:00
chen08209
043648f998 Fix build error2 2024-09-17 23:06:32 +08:00
chen08209
3eb26e8061 Fix build error 2024-09-17 22:42:10 +08:00
chen08209
5d6bd6466f Support desktop hotkey
Support android ipv6 inbound

Support android system dns

fix some bugs
2024-09-17 21:42:13 +08:00
163 changed files with 40950 additions and 34771 deletions

2
.gitmodules vendored
View File

@@ -1,7 +1,7 @@
[submodule "core/Clash.Meta"]
path = core/Clash.Meta
url = git@github.com:chen08209/Clash.Meta.git
branch = FlClash
branch = FlClash-Alpha
[submodule "plugins/flutter_distributor"]
path = plugins/flutter_distributor
url = git@github.com:chen08209/flutter_distributor.git

View File

@@ -6,13 +6,9 @@
## FlClash
<p style="text-align: left;">
<img alt="stars" src="https://img.shields.io/github/stars/chen08209/FlClash?style=flat-square&logo=github"/>
<img alt="downloads" src="https://img.shields.io/github/downloads/chen08209/FlClash/total"/>
<a href="LICENSE">
<img alt="license" src="https://img.shields.io/github/license/chen08209/FlClash"/>
</a>
</p>
[![Downloads](https://img.shields.io/github/downloads/chen08209/FlClash/total?style=flat-square&logo=github)](https://github.com/chen08209/FlClash/releases/)[![Last Version](https://img.shields.io/github/release/chen08209/FlClash/all.svg?style=flat-square)](https://github.com/chen08209/FlClash/releases/)[![License](https://img.shields.io/github/license/chen08209/FlClash?style=flat-square)](LICENSE)
[![Channel](https://img.shields.io/badge/Telegram-Channel-blue?style=flat-square&logo=telegram)](https://t.me/FlClash)
A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
@@ -42,10 +38,6 @@ on Mobile:
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
## Contact
[Telegram](https://t.me/+G-veVtwBOl4wODc1)
## Build
1. Update submodules
@@ -100,9 +92,6 @@ on Mobile:
```bash
dart .\setup.dart
```
## Star

View File

@@ -6,13 +6,10 @@
## FlClash
<p style="text-align: left;">
<img alt="stars" src="https://img.shields.io/github/stars/chen08209/FlClash?style=flat-square&logo=github"/>
<img alt="downloads" src="https://img.shields.io/github/downloads/chen08209/FlClash/total"/>
<a href="LICENSE">
<img alt="license" src="https://img.shields.io/github/license/chen08209/FlClash"/>
</a>
</p>
[![Downloads](https://img.shields.io/github/downloads/chen08209/FlClash/total?style=flat-square&logo=github)](https://github.com/chen08209/FlClash/releases/)[![Last Version](https://img.shields.io/github/release/chen08209/FlClash/all.svg?style=flat-square)](https://github.com/chen08209/FlClash/releases/)[![License](https://img.shields.io/github/license/chen08209/FlClash?style=flat-square)](LICENSE)
[![Channel](https://img.shields.io/badge/Telegram-Channel-blue?style=flat-square&logo=telegram)](https://t.me/FlClash)
基于ClashMeta的多平台代理客户端简单易用开源无广告。
@@ -42,11 +39,6 @@ on Mobile:
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
## Contact
[Telegram](https://t.me/+G-veVtwBOl4wODc1)
## Build
1. 更新 submodules

View File

@@ -34,22 +34,22 @@ def isRelease = defStoreFile.exists() && defStorePassword != null && defKeyAlias
android {
namespace "com.follow.clash"
compileSdkVersion 34
ndkVersion "25.1.8937393"
ndkVersion "27.1.12297006"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = '17'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
signingConfigs {
if (isRelease){
if (isRelease) {
release {
storeFile defStoreFile
storePassword defStorePassword
@@ -74,10 +74,9 @@ android {
applicationIdSuffix '.debug'
}
release {
minifyEnabled true
if(isRelease){
if (isRelease) {
signingConfig signingConfigs.release
}else{
} else {
signingConfig signingConfigs.debug
}
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"

View File

@@ -10,25 +10,22 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
tools:ignore="SystemPermissionTypo" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
tools:ignore="SystemPermissionTypo" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name="${applicationName}"
android:enableOnBackInvokedCallback="true"
android:extractNativeLibs="true"
android:icon="@mipmap/ic_launcher"
android:label="FlClash"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="tiramisu">
android:hardwareAccelerated="true"
android:label="FlClash">
<activity
android:name="com.follow.clash.MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -72,7 +69,17 @@
<activity
android:name=".TempActivity"
android:theme="@style/TransparentTheme" />
android:exported="true"
android:theme="@style/TransparentTheme">
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="com.follow.clash.action.START" />
</intent-filter>
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="com.follow.clash.action.STOP" />
</intent-filter>
</activity>
<service
android:name=".services.FlClashTileService"
@@ -119,12 +126,19 @@
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn" />
</service>
<service
android:name=".services.FlClashService"
android:exported="false"
android:foregroundServiceType="specialUse" />
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="service" />
</service>
<meta-data
android:name="flutterEmbedding"

View File

@@ -1,9 +1,10 @@
package com.follow.clash
import com.follow.clash.models.Props
import com.follow.clash.models.TunProps
interface BaseServiceInterface {
fun start(port: Int, props: Props?): Int?
fun start(port: Int, props: Props?): TunProps?
fun stop()
fun startForeground(title: String, content: String)
}

View File

@@ -33,14 +33,13 @@ object GlobalState {
return currentEngine?.plugins?.get(AppPlugin::class.java) as AppPlugin?
}
fun getCurrentTitlePlugin(): TilePlugin? {
fun getCurrentTilePlugin(): TilePlugin? {
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
return currentEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
}
fun getCurrentVPNPlugin(): VpnPlugin? {
val currentEngine = if (serviceEngine != null) serviceEngine else flutterEngine
return currentEngine?.plugins?.get(VpnPlugin::class.java) as VpnPlugin?
return serviceEngine?.plugins?.get(VpnPlugin::class.java) as VpnPlugin?
}
fun destroyServiceEngine() {

View File

@@ -1,6 +1,8 @@
package com.follow.clash
import android.content.Intent
import android.os.Bundle
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.VpnPlugin
@@ -9,7 +11,6 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(AppPlugin())

View File

@@ -6,6 +6,15 @@ import android.os.Bundle
class TempActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
"com.follow.clash.action.START" -> {
GlobalState.getCurrentTilePlugin()?.handleStart()
}
"com.follow.clash.action.STOP" -> {
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}
finishAndRemoveTask()
}
}

View File

@@ -1,28 +1,20 @@
package com.follow.clash.extensions
import android.annotation.SuppressLint
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.os.Build
import android.net.ConnectivityManager
import android.net.Network
import android.system.OsConstants.IPPROTO_TCP
import android.system.OsConstants.IPPROTO_UDP
import android.util.Base64
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.MainActivity
import com.follow.clash.R
import com.follow.clash.models.Metadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
suspend fun Drawable.getBase64(): String {
@@ -41,6 +33,37 @@ fun Metadata.getProtocol(): Int? {
return null
}
private val CHANNEL = "FlClash"
private val notificationId: Int = 1
fun ConnectivityManager.resolveDns(network: Network?): List<String> {
val properties = getLinkProperties(network) ?: return listOf()
return properties.dnsServers.map { it.asSocketAddressText(53) }
}
fun InetAddress.asSocketAddressText(port: Int): String {
return when (this) {
is Inet6Address ->
"[${numericToTextFormat(this.address)}]:$port"
is Inet4Address ->
"${this.hostAddress}:$port"
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
}
}
private fun numericToTextFormat(src: ByteArray): String {
val sb = StringBuilder(39)
for (i in 0 until 8) {
sb.append(
Integer.toHexString(
src[i shl 1].toInt() shl 8 and 0xff00
or (src[(i shl 1) + 1].toInt() and 0xff)
)
)
if (i < 7) {
sb.append(":")
}
}
return sb.toString()
}

View File

@@ -1,7 +1,5 @@
package com.follow.clash.models
import java.util.Date
data class Package(
val packageName: String,
val label: String,

View File

@@ -16,4 +16,15 @@ data class Props(
val accessControl: AccessControl?,
val allowBypass: Boolean?,
val systemProxy: Boolean?,
val ipv6: Boolean?,
)
data class TunProps(
val fd: Int,
val gateway: String,
val gateway6: String,
val portal: String,
val portal6: String,
val dns: String,
val dns6: String
)

View File

@@ -8,7 +8,6 @@ import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.VpnService
import android.os.Build
import android.widget.Toast
@@ -17,12 +16,9 @@ import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getSystemService
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import androidx.core.content.FileProvider
import androidx.core.content.getSystemService
import com.follow.clash.GlobalState
import com.follow.clash.extensions.getBase64
import com.follow.clash.extensions.getProtocol
import com.follow.clash.models.Package
import com.follow.clash.models.Process
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
@@ -37,7 +33,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.net.InetSocketAddress
import java.util.zip.ZipFile
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
@@ -52,11 +47,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
private lateinit var scope: CoroutineScope
private var connectivity: ConnectivityManager? = null
private var vpnCallBack: (() -> Unit)? = null
private val iconMap = mutableMapOf<String, String?>()
private val packages = mutableListOf<Package>()
private val skipPrefixList = listOf(
@@ -114,7 +108,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
}
val VPN_PERMISSION_REQUEST_CODE = 1001
val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
@@ -191,48 +184,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
"resolverProcess" -> {
val data = call.argument<String>("data")
val process =
if (data != null) Gson().fromJson(
data,
Process::class.java
) else null
val metadata = process?.metadata
val protocol = metadata?.getProtocol()
if (protocol == null) {
result.success(null)
return
}
scope.launch {
withContext(Dispatchers.Default) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
result.success(null)
return@withContext
}
if (connectivity == null) {
connectivity = context.getSystemService<ConnectivityManager>()
}
val src = InetSocketAddress(metadata.sourceIP, metadata.sourcePort)
val dst = InetSocketAddress(
metadata.destinationIP.ifEmpty { metadata.host },
metadata.destinationPort
)
val uid = try {
connectivity?.getConnectionOwnerUid(protocol, src, dst)
} catch (_: Exception) {
null
}
if (uid == null || uid == -1) {
result.success(null)
return@withContext
}
val packages = context.packageManager?.getPackagesForUid(uid)
result.success(packages?.first())
}
}
}
"tip" -> {
val message = call.argument<String>("message")
tip(message)
@@ -379,7 +330,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
private fun isChinaPackage(packageName: String): Boolean {
val packageManager = context.packageManager ?: return false
skipPrefixList.forEach {
@@ -447,10 +397,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
return false
}
fun requestGc() {
channel.invokeMethod("gc", null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity;
binding.addActivityResultListener(::onActivityResult)
@@ -490,4 +436,4 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
return true
}
}
}

View File

@@ -1,6 +1,8 @@
package com.follow.clash.plugins
import android.content.Context
import android.net.ConnectivityManager
import androidx.core.content.getSystemService
import com.follow.clash.GlobalState
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall

View File

@@ -1,23 +1,36 @@
package com.follow.clash.plugins
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.content.getSystemService
import com.follow.clash.BaseServiceInterface
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.extensions.getProtocol
import com.follow.clash.extensions.resolveDns
import com.follow.clash.models.Props
import com.follow.clash.models.TunProps
import com.follow.clash.services.FlClashService
import com.follow.clash.services.FlClashVpnService
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.InetSocketAddress
import kotlin.concurrent.withLock
import com.follow.clash.models.Process
class VpnPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
@@ -26,6 +39,11 @@ class VpnPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var flClashService: BaseServiceInterface? = null
private var port: Int = 7890
private var props: Props? = null
private lateinit var scope: CoroutineScope
private val connectivity by lazy {
context.getSystemService<ConnectivityManager>()
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
@@ -43,66 +61,171 @@ class VpnPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
context = flutterPluginBinding.applicationContext
scope.launch {
registerNetworkCallback()
}
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "vpn")
flutterMethodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
unRegisterNetworkCallback()
flutterMethodChannel.setMethodCallHandler(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
"start" -> {
port = call.argument<Int>("port")!!
val args = call.argument<String>("args")
props =
if (args != null) Gson().fromJson(args, Props::class.java) else null
when (props?.enable == true) {
true -> handleStartVpn()
false -> start()
}
result.success(true)
}
"stop" -> {
stop()
result.success(true)
}
"setProtect" -> {
val fd = call.argument<Int>("fd")
if (fd != null) {
if (flClashService is FlClashVpnService) {
(flClashService as FlClashVpnService).protect(fd)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"start" -> {
port = call.argument<Int>("port")!!
val args = call.argument<String>("args")
props =
if (args != null) Gson().fromJson(args, Props::class.java) else null
when (props?.enable == true) {
true -> handleStartVpn()
false -> start()
}
result.success(true)
} else {
result.success(false)
}
}
"startForeground" -> {
val title = call.argument<String>("title") as String
val content = call.argument<String>("content") as String
startForeground(title, content)
result.success(true)
}
"stop" -> {
stop()
result.success(true)
}
else -> {
result.notImplemented()
"setProtect" -> {
val fd = call.argument<Int>("fd")
if (fd != null) {
if (flClashService is FlClashVpnService) {
(flClashService as FlClashVpnService).protect(fd)
}
result.success(true)
} else {
result.success(false)
}
}
"startForeground" -> {
val title = call.argument<String>("title") as String
val content = call.argument<String>("content") as String
startForeground(title, content)
result.success(true)
}
"resolverProcess" -> {
val data = call.argument<String>("data")
val process =
if (data != null) Gson().fromJson(
data,
Process::class.java
) else null
val metadata = process?.metadata
if (metadata == null) {
result.success(null)
return
}
val protocol = metadata.getProtocol()
if (protocol == null) {
result.success(null)
return
}
scope.launch {
withContext(Dispatchers.Default) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
result.success(null)
return@withContext
}
val src = InetSocketAddress(metadata.sourceIP, metadata.sourcePort)
val dst = InetSocketAddress(
metadata.destinationIP.ifEmpty { metadata.host },
metadata.destinationPort
)
val uid = try {
connectivity?.getConnectionOwnerUid(protocol, src, dst)
} catch (_: Exception) {
null
}
if (uid == null || uid == -1) {
result.success(null)
return@withContext
}
val packages = context.packageManager?.getPackagesForUid(uid)
result.success(packages?.first())
}
}
}
else -> {
result.notImplemented()
}
}
}
@SuppressLint("ForegroundServiceType")
fun handleStartVpn() {
private fun handleStartVpn() {
GlobalState.getCurrentAppPlugin()?.requestVpnPermission(context) {
start()
}
}
@SuppressLint("ForegroundServiceType")
fun requestGc() {
flutterMethodChannel.invokeMethod("gc", null)
}
val networks = mutableSetOf<Network>()
fun onUpdateNetwork() {
val dns = networks.flatMap { network ->
connectivity?.resolveDns(network) ?: emptyList()
}
.toSet()
.joinToString(",")
scope.launch {
withContext(Dispatchers.Main) {
flutterMethodChannel.invokeMethod("dnsChanged", dns)
}
}
// if (flClashService is FlClashVpnService) {
// val network = networks.maxByOrNull { net ->
// connectivity?.getNetworkCapabilities(net)?.let { cap ->
// TRANSPORT_PRIORITY.indexOfFirst { cap.hasTransport(it) }
// } ?: -1
// }
// network?.let {
// (flClashService as FlClashVpnService).updateUnderlyingNetworks(arrayOf(network))
// }
// }
}
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networks.add(network)
onUpdateNetwork()
}
override fun onLost(network: Network) {
networks.remove(network)
onUpdateNetwork()
}
}
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
}.build()
private fun registerNetworkCallback() {
networks.clear()
connectivity?.registerNetworkCallback(request, callback)
}
private fun unRegisterNetworkCallback() {
connectivity?.unregisterNetworkCallback(callback)
networks.clear()
onUpdateNetwork()
}
private fun startForeground(title: String, content: String) {
GlobalState.runLock.withLock {
if (GlobalState.runState.value != RunState.START) return
@@ -118,8 +241,11 @@ class VpnPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
GlobalState.runLock.withLock {
if (GlobalState.runState.value == RunState.START) return
GlobalState.runState.value = RunState.START
val fd = flClashService?.start(port, props)
flutterMethodChannel.invokeMethod("started", fd)
val tunProps = flClashService?.start(port, props)
flutterMethodChannel.invokeMethod(
"started",
Gson().toJson(tunProps, TunProps::class.java)
)
}
}

View File

@@ -11,14 +11,13 @@ import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.follow.clash.BaseServiceInterface
import com.follow.clash.MainActivity
import com.follow.clash.models.Props
@SuppressLint("WrongConstant")
class FlClashService : Service(), BaseServiceInterface {
private val binder = LocalBinder()
@@ -73,7 +72,7 @@ class FlClashService : Service(), BaseServiceInterface {
}
}
override fun start(port: Int, props: Props?): Int? = null
override fun start(port: Int, props: Props?) = null
override fun stop() {
stopSelf()

View File

@@ -1,5 +1,6 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
@@ -37,6 +38,7 @@ class FlClashTileService : TileService() {
GlobalState.runState.observeForever(observer)
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun activityTransfer() {
val intent = Intent(this, TempActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
@@ -67,15 +69,15 @@ class FlClashTileService : TileService() {
activityTransfer()
if (GlobalState.runState.value == RunState.STOP) {
GlobalState.runState.value = RunState.PENDING
val titlePlugin = GlobalState.getCurrentTitlePlugin()
if (titlePlugin != null) {
titlePlugin.handleStart()
val tilePlugin = GlobalState.getCurrentTilePlugin()
if (tilePlugin != null) {
tilePlugin.handleStart()
} else {
GlobalState.initServiceEngine(applicationContext)
}
} else if (GlobalState.runState.value == RunState.START) {
GlobalState.runState.value = RunState.PENDING
GlobalState.getCurrentTitlePlugin()?.handleStop()
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}

View File

@@ -7,6 +7,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.net.Network
import android.net.ProxyInfo
import android.net.VpnService
import android.os.Binder
@@ -14,52 +15,77 @@ import android.os.Build
import android.os.IBinder
import android.os.Parcel
import android.os.RemoteException
import android.util.Log
import androidx.core.app.NotificationCompat
import com.follow.clash.BaseServiceInterface
import com.follow.clash.GlobalState
import com.follow.clash.MainActivity
import com.follow.clash.R
import com.follow.clash.TempActivity
import com.follow.clash.models.AccessControlMode
import com.follow.clash.models.Props
import com.follow.clash.models.TunProps
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@SuppressLint("WrongConstant")
class FlClashVpnService : VpnService(), BaseServiceInterface {
private val passList = listOf(
"*zhihu.com",
"*zhimg.com",
"*jd.com",
"100ime-iat-api.xfyun.cn",
"*360buyimg.com",
"localhost",
"*.local",
"127.*",
"10.*",
"172.16.*",
"172.17.*",
"172.18.*",
"172.19.*",
"172.2*",
"172.30.*",
"172.31.*",
"192.168.*"
)
companion object {
private val passList = listOf(
"*zhihu.com",
"*zhimg.com",
"*jd.com",
"100ime-iat-api.xfyun.cn",
"*360buyimg.com",
"localhost",
"*.local",
"127.*",
"10.*",
"172.16.*",
"172.17.*",
"172.18.*",
"172.19.*",
"172.2*",
"172.30.*",
"172.31.*",
"192.168.*"
)
private const val TUN_MTU = 9000
private const val TUN_SUBNET_PREFIX = 30
private const val TUN_GATEWAY = "172.19.0.1"
private const val TUN_SUBNET_PREFIX6 = 126
private const val TUN_GATEWAY6 = "fdfe:dcba:9876::1"
private const val TUN_PORTAL = "172.19.0.2"
private const val TUN_PORTAL6 = "fdfe:dcba:9876::2"
private const val TUN_DNS = TUN_PORTAL
private const val TUN_DNS6 = TUN_PORTAL6
private const val NET_ANY = "0.0.0.0"
private const val NET_ANY6 = "::"
}
override fun onCreate() {
super.onCreate()
GlobalState.initServiceEngine(applicationContext)
}
override fun start(port: Int, props: Props?): Int? {
override fun start(port: Int, props: Props?): TunProps {
return with(Builder()) {
addAddress("172.16.0.1", 30)
setMtu(9000)
addRoute("0.0.0.0", 0)
addAddress(TUN_GATEWAY, TUN_SUBNET_PREFIX)
addRoute(NET_ANY, 0)
addDnsServer(TUN_DNS)
if (props?.ipv6 == true) {
try {
addAddress(TUN_GATEWAY6, TUN_SUBNET_PREFIX6)
addRoute(NET_ANY6, 0)
addDnsServer(TUN_DNS6)
} catch (_: Exception) {
}
}
setMtu(TUN_MTU)
props?.accessControl?.let { accessControl ->
when (accessControl.mode) {
AccessControlMode.acceptSelected -> {
@@ -75,7 +101,6 @@ class FlClashVpnService : VpnService(), BaseServiceInterface {
}
}
}
addDnsServer("172.16.0.2")
setSession("FlClash")
setBlocking(false)
if (Build.VERSION.SDK_INT >= 29) {
@@ -93,10 +118,24 @@ class FlClashVpnService : VpnService(), BaseServiceInterface {
)
)
}
establish()?.detachFd()
TunProps(
fd = establish()?.detachFd()
?: throw NullPointerException("Establish VPN rejected by system"),
gateway = "$TUN_GATEWAY/$TUN_SUBNET_PREFIX",
gateway6 = if (props?.ipv6 == true) "$TUN_GATEWAY6/$TUN_SUBNET_PREFIX6" else "",
portal = TUN_PORTAL,
portal6 = if (props?.ipv6 == true) TUN_PORTAL6 else "",
dns = TUN_DNS,
dns6 = if (props?.ipv6 == true) TUN_DNS6 else ""
)
}
}
fun updateUnderlyingNetworks(networks: Array<Network>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
this.setUnderlyingNetworks(networks)
}
}
override fun stop() {
stopSelf()
@@ -127,6 +166,27 @@ class FlClashVpnService : VpnService(), BaseServiceInterface {
PendingIntent.FLAG_UPDATE_CURRENT
)
}
val stopIntent = Intent(this, TempActivity::class.java)
stopIntent.action = "com.follow.clash.action.STOP"
stopIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
val stopPendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
stopIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
with(NotificationCompat.Builder(this, CHANNEL)) {
setSmallIcon(R.drawable.ic_stat_name)
setContentTitle("FlClash")
@@ -140,6 +200,7 @@ class FlClashVpnService : VpnService(), BaseServiceInterface {
setShowWhen(false)
setOnlyAlertOnce(true)
setAutoCancel(true)
addAction(0, "Stop", stopPendingIntent);
}
}
@@ -165,7 +226,7 @@ class FlClashVpnService : VpnService(), BaseServiceInterface {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
GlobalState.getCurrentAppPlugin()?.requestGc()
GlobalState.getCurrentVPNPlugin()?.requestGc()
}
private val binder = LocalBinder()
@@ -178,7 +239,7 @@ class FlClashVpnService : VpnService(), BaseServiceInterface {
val isSuccess = super.onTransact(code, data, reply, flags)
if (!isSuccess) {
CoroutineScope(Dispatchers.Main).launch {
GlobalState.getCurrentTitlePlugin()?.handleStop()
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}
return isSuccess

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

20
core/dns.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build android
package main
import "C"
import (
"github.com/metacubex/mihomo/dns"
"github.com/metacubex/mihomo/log"
"strings"
)
//export updateDns
func updateDns(s *C.char) {
dnsList := C.GoString(s)
go func() {
log.Infoln("[DNS] updateDns %s", dnsList)
dns.UpdateSystemDNS(strings.Split(dnsList, ","))
dns.FlushCacheWithDefaultResolver()
}()
}

View File

@@ -4,12 +4,9 @@ go 1.21.0
replace github.com/metacubex/mihomo => ./Clash.Meta
require (
github.com/Kr328/tun2socket v0.0.0-20220414050025-d07c78d06d34
github.com/metacubex/mihomo v1.17.1
github.com/miekg/dns v1.1.62
golang.org/x/sync v0.8.0
)
require github.com/metacubex/mihomo v1.17.1
replace github.com/sagernet/sing => github.com/metacubex/sing v0.0.0-20240724044459-6f3cf5896297
require (
github.com/3andne/restls-client-go v0.1.6 // indirect
@@ -54,7 +51,7 @@ require (
github.com/metacubex/chacha v0.1.0 // indirect
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect
github.com/metacubex/gvisor v0.0.0-20240320004321-933faba989ec // indirect
github.com/metacubex/quic-go v0.46.1-0.20240807232329-1c6cb2d67f58 // indirect
github.com/metacubex/quic-go v0.47.1-0.20240909010619-6b38f24bfcc4 // indirect
github.com/metacubex/randv2 v0.2.0 // indirect
github.com/metacubex/sing-quic v0.0.0-20240827003841-cd97758ed8b4 // indirect
github.com/metacubex/sing-shadowsocks v0.2.8 // indirect
@@ -64,6 +61,7 @@ require (
github.com/metacubex/sing-wireguard v0.0.0-20240826061955-1e4e67afe5cd // indirect
github.com/metacubex/tfo-go v0.0.0-20240830120620-c5e019b67785 // indirect
github.com/metacubex/utls v1.6.6 // indirect
github.com/miekg/dns v1.1.62 // indirect
github.com/mroth/weightedrand/v2 v2.1.0 // indirect
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
@@ -103,11 +101,12 @@ require (
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.24.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect; indirect`
lukechampine.com/blake3 v1.3.0 // indirect
)

View File

@@ -1,7 +1,5 @@
github.com/3andne/restls-client-go v0.1.6 h1:tRx/YilqW7iHpgmEL4E1D8dAsuB0tFF3uvncS+B6I08=
github.com/3andne/restls-client-go v0.1.6/go.mod h1:iEdTZNt9kzPIxjIGSMScUFSBrUH6bFRNg0BWlP4orEY=
github.com/Kr328/tun2socket v0.0.0-20220414050025-d07c78d06d34 h1:USCTqih5d1bUXUxWNS9ZD5Tx/lb0jXHEtRIIx/F9dMc=
github.com/Kr328/tun2socket v0.0.0-20220414050025-d07c78d06d34/go.mod h1:YR9wK13TgI5ww8iKWm91MHiSoHC7Oz0U4beCCmtXqLw=
github.com/RyuaNerin/elliptic2 v1.0.0/go.mod h1:wWB8fWrJI/6EPJkyV/r1Rj0hxUgrusmqSj8JN6yNf/A=
github.com/RyuaNerin/go-krypto v1.2.4 h1:mXuNdK6M317aPV0llW6Xpjbo4moOlPF7Yxz4tb4b4Go=
github.com/RyuaNerin/go-krypto v1.2.4/go.mod h1:QqCYkoutU3yInyD9INt2PGolVRsc3W4oraQadVGXJ/8=
@@ -106,10 +104,12 @@ github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvO
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759/go.mod h1:UHOv2xu+RIgLwpXca7TLrXleEd4oR3sPatW6IF8wU88=
github.com/metacubex/gvisor v0.0.0-20240320004321-933faba989ec h1:HxreOiFTUrJXJautEo8rnE1uKTVGY8wtZepY1Tii/Nc=
github.com/metacubex/gvisor v0.0.0-20240320004321-933faba989ec/go.mod h1:8BVmQ+3cxjqzWElafm24rb2Ae4jRI6vAXNXWqWjfrXw=
github.com/metacubex/quic-go v0.46.1-0.20240807232329-1c6cb2d67f58 h1:T6OxROLZBr9SOQxN5TzUslv81hEREy/dEgaUKVjaG7U=
github.com/metacubex/quic-go v0.46.1-0.20240807232329-1c6cb2d67f58/go.mod h1:Yza2H7Ax1rxWPUcJx0vW+oAt9EsPuSiyQFhFabUPzwU=
github.com/metacubex/quic-go v0.47.1-0.20240909010619-6b38f24bfcc4 h1:CgdUBRxmNlxEGkp35HwvgQ10jwOOUJKWdOxpi8yWi8o=
github.com/metacubex/quic-go v0.47.1-0.20240909010619-6b38f24bfcc4/go.mod h1:Y7yRGqFE6UQL/3aKPYmiYdjfVkeujJaStP4+jiZMcN8=
github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs=
github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY=
github.com/metacubex/sing v0.0.0-20240724044459-6f3cf5896297 h1:YG/JkwGPbca5rUtEMHIu8ZuqzR7BSVm1iqY8hNoMeMA=
github.com/metacubex/sing v0.0.0-20240724044459-6f3cf5896297/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/metacubex/sing-quic v0.0.0-20240827003841-cd97758ed8b4 h1:HobpULaPK6OoxrHMmgcwLkwwIduXVmwdcznwUfH1GQM=
github.com/metacubex/sing-quic v0.0.0-20240827003841-cd97758ed8b4/go.mod h1:g7Mxj7b7zm7YVqD975mk/hSmrb0A0G4bVvIMr2MMzn8=
github.com/metacubex/sing-shadowsocks v0.2.8 h1:wIhlaigswzjPw4hej75sEvWte3QR0+AJRafgwBHO5B4=
@@ -162,9 +162,6 @@ github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZN
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I=
github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8=
github.com/sagernet/sing v0.2.18/go.mod h1:OL6k2F0vHmEzXz2KW19qQzu172FDgSbUSODylighuVo=
github.com/sagernet/sing v0.5.0-alpha.13 h1:fpR4TFZfu/9V3LbHSAnnnwcaXGMF8ijmAAPoY2WHSKw=
github.com/sagernet/sing v0.5.0-alpha.13/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-mux v0.2.1-0.20240124034317-9bfb33698bb6 h1:5bCAkvDDzSMITiHFjolBwpdqYsvycdTu71FsMEFXQ14=
github.com/sagernet/sing-mux v0.2.1-0.20240124034317-9bfb33698bb6/go.mod h1:khzr9AOPocLa+g53dBplwNDz4gdsyx/YM3swtAhlkHQ=
github.com/sagernet/sing-shadowtls v0.1.4 h1:aTgBSJEgnumzFenPvc+kbD9/W0PywzWevnVpEx6Tw3k=
@@ -190,9 +187,15 @@ github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e/go.mod h1:+e
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
@@ -250,7 +253,7 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=

View File

@@ -378,27 +378,28 @@ func updateGeoData(geoType *C.char, geoName *C.char, port C.longlong) {
geoTypeString := C.GoString(geoType)
geoNameString := C.GoString(geoName)
go func() {
path := constant.Path.Resolve(geoNameString)
switch geoTypeString {
case "MMDB":
err := updater.UpdateMMDB(constant.Path.Resolve(geoNameString))
err := updater.UpdateMMDBWithPath(path)
if err != nil {
bridge.SendToPort(i, err.Error())
return
}
case "ASN":
err := updater.UpdateASN(constant.Path.Resolve(geoNameString))
err := updater.UpdateASNWithPath(path)
if err != nil {
bridge.SendToPort(i, err.Error())
return
}
case "GeoIp":
err := updater.UpdateGeoIp(constant.Path.Resolve(geoNameString))
err := updater.UpdateGeoIpWithPath(path)
if err != nil {
bridge.SendToPort(i, err.Error())
return
}
case "GeoSite":
err := updater.UpdateGeoSite(constant.Path.Resolve(geoNameString))
err := updater.UpdateGeoSiteWithPath(path)
if err != nil {
bridge.SendToPort(i, err.Error())
return

View File

@@ -18,6 +18,7 @@ type AndroidProps struct {
AccessControl *AccessControl `json:"accessControl"`
AllowBypass bool `json:"allowBypass"`
SystemProxy bool `json:"systemProxy"`
Ipv6 bool `json:"ipv6"`
}
type State struct {

View File

@@ -6,7 +6,9 @@ import "C"
import (
"core/platform"
t "core/tun"
"encoding/json"
"errors"
"github.com/metacubex/mihomo/listener/sing_tun"
"strconv"
"sync"
"sync/atomic"
@@ -15,11 +17,9 @@ import (
"github.com/metacubex/mihomo/component/dialer"
"github.com/metacubex/mihomo/log"
"golang.org/x/sync/semaphore"
)
var tunLock sync.Mutex
var tun *t.Tun
var runTime *time.Time
type FdMap struct {
@@ -35,13 +35,18 @@ func (cm *FdMap) Load(key int64) bool {
return ok
}
var fdMap FdMap
var (
tunListener *sing_tun.Listener
fdMap FdMap
fdCounter int64 = 0
)
//export startTUN
func startTUN(fd C.int, port C.longlong) {
func startTUN(s *C.char, port C.longlong) {
i := int64(port)
ServicePort = i
if fd == 0 {
paramsString := C.GoString(s)
if paramsString == "" {
tunLock.Lock()
defer tunLock.Unlock()
now := time.Now()
@@ -57,28 +62,22 @@ func startTUN(fd C.int, port C.longlong) {
tunLock.Lock()
defer tunLock.Unlock()
if tun != nil {
tun.Close()
tun = nil
}
f := int(fd)
gateway := "172.16.0.1/30"
portal := "172.16.0.2"
dns := "0.0.0.0"
tempTun := &t.Tun{Closed: false, Limit: semaphore.NewWeighted(4)}
closer, err := t.Start(f, gateway, portal, dns)
var tunProps = &t.Props{}
err := json.Unmarshal([]byte(paramsString), tunProps)
if err != nil {
log.Errorln("startTUN error: %v", err)
tempTun.Close()
return
}
tempTun.Closer = closer
tunListener, err = t.Start(*tunProps)
tun = tempTun
if err != nil {
return
}
if tunListener != nil {
log.Infoln("TUN address: %v", tunListener.Address())
}
now := time.Now()
@@ -108,9 +107,8 @@ func stopTun() {
runTime = nil
if tun != nil {
tun.Close()
tun = nil
if tunListener != nil {
_ = tunListener.Close()
}
}()
}
@@ -137,18 +135,12 @@ func markSocket(fd Fd) {
})
}
var fdCounter int64 = 0
func initSocketHook() {
dialer.DefaultSocketHook = func(network, address string, conn syscall.RawConn) error {
if platform.ShouldBlockConnection() {
return errBlocked
}
return conn.Control(func(fd uintptr) {
if tun == nil {
return
}
fdInt := int64(fd)
timeout := time.After(100 * time.Millisecond)
id := atomic.AddInt64(&fdCounter, 1)

View File

@@ -1,33 +0,0 @@
//go:build android
package tun
import (
"github.com/metacubex/mihomo/dns"
D "github.com/miekg/dns"
"net"
)
func shouldHijackDns(dns net.IP, target net.IP, targetPort int) bool {
if targetPort != 53 {
return false
}
return net.IPv4zero.Equal(dns) || target.Equal(dns)
}
func relayDns(payload []byte) ([]byte, error) {
msg := &D.Msg{}
if err := msg.Unpack(payload); err != nil {
return nil, err
}
r, err := dns.ServeDNSWithDefaultServer(msg)
if err != nil {
return nil, err
}
r.SetRcode(msg, r.Rcode)
return r.Pack()
}

View File

@@ -1,20 +0,0 @@
//go:build android
package tun
import (
"github.com/metacubex/mihomo/constant"
"net"
)
func createMetadata(lAddr, rAddr *net.TCPAddr) *constant.Metadata {
return &constant.Metadata{
NetWork: constant.TCP,
Type: constant.SOCKS5,
SrcIP: lAddr.AddrPort().Addr(),
DstIP: rAddr.AddrPort().Addr(),
SrcPort: uint16(lAddr.Port),
DstPort: uint16(rAddr.Port),
Host: "",
}
}

View File

@@ -4,182 +4,68 @@ package tun
import "C"
import (
"context"
"encoding/binary"
"github.com/Kr328/tun2socket"
"github.com/Kr328/tun2socket/nat"
"github.com/metacubex/mihomo/adapter/inbound"
"github.com/metacubex/mihomo/common/pool"
"github.com/metacubex/mihomo/constant"
LC "github.com/metacubex/mihomo/listener/config"
"github.com/metacubex/mihomo/listener/sing_tun"
"github.com/metacubex/mihomo/log"
"github.com/metacubex/mihomo/transport/socks5"
"github.com/metacubex/mihomo/tunnel"
"golang.org/x/sync/semaphore"
"io"
"net"
"os"
"time"
"net/netip"
)
type Tun struct {
Closer io.Closer
Closed bool
Limit *semaphore.Weighted
type Props struct {
Fd int `json:"fd"`
Gateway string `json:"gateway"`
Gateway6 string `json:"gateway6"`
Portal string `json:"portal"`
Portal6 string `json:"portal6"`
Dns string `json:"dns"`
Dns6 string `json:"dns6"`
}
func (t *Tun) Close() {
_ = t.Limit.Acquire(context.TODO(), 4)
defer t.Limit.Release(4)
t.Closed = true
if t.Closer != nil {
_ = t.Closer.Close()
}
}
var _, ipv4LoopBack, _ = net.ParseCIDR("127.0.0.0/8")
func Start(fd int, gateway, portal, dns string) (io.Closer, error) {
device := os.NewFile(uintptr(fd), "/dev/tun")
ip, network, err := net.ParseCIDR(gateway)
func Start(tunProps Props) (*sing_tun.Listener, error) {
var prefix4 []netip.Prefix
tempPrefix4, err := netip.ParsePrefix(tunProps.Gateway)
if err != nil {
panic(err.Error())
} else {
network.IP = ip
log.Errorln("startTUN error:", err)
return nil, err
}
stack, err := tun2socket.StartTun2Socket(device, network, net.ParseIP(portal))
prefix4 = append(prefix4, tempPrefix4)
var prefix6 []netip.Prefix
if tunProps.Gateway6 != "" {
tempPrefix6, err := netip.ParsePrefix(tunProps.Gateway6)
if err != nil {
log.Errorln("startTUN error:", err)
return nil, err
}
prefix6 = append(prefix6, tempPrefix6)
}
var dnsHijack []string
dnsHijack = append(dnsHijack, net.JoinHostPort(tunProps.Dns, "53"))
if tunProps.Dns6 != "" {
dnsHijack = append(dnsHijack, net.JoinHostPort(tunProps.Dns6, "53"))
}
options := LC.Tun{
Enable: true,
Device: sing_tun.InterfaceName,
Stack: constant.TunMixed,
DNSHijack: dnsHijack,
AutoRoute: false,
AutoDetectInterface: false,
Inet4Address: prefix4,
Inet6Address: prefix6,
MTU: 9000,
FileDescriptor: tunProps.Fd,
}
listener, err := sing_tun.New(options, tunnel.Tunnel)
if err != nil {
_ = device.Close()
log.Errorln("startTUN error:", err)
return nil, err
}
dnsAddr := net.ParseIP(dns)
tcp := func() {
defer func(tcp *nat.TCP) {
_ = tcp.Close()
}(stack.TCP())
defer log.Debugln("TCP: closed")
for stack.TCP().SetDeadline(time.Time{}) == nil {
conn, err := stack.TCP().Accept()
if err != nil {
continue
}
lAddr := conn.LocalAddr().(*net.TCPAddr)
rAddr := conn.RemoteAddr().(*net.TCPAddr)
if ipv4LoopBack.Contains(rAddr.IP) {
_ = conn.Close()
continue
}
if shouldHijackDns(dnsAddr, rAddr.IP, rAddr.Port) {
go func() {
defer func(conn net.Conn) {
_ = conn.Close()
}(conn)
buf := pool.Get(pool.UDPBufferSize)
defer func(buf []byte) {
_ = pool.Put(buf)
}(buf)
for {
_ = conn.SetReadDeadline(time.Now().Add(constant.DefaultTCPTimeout))
length := uint16(0)
if err := binary.Read(conn, binary.BigEndian, &length); err != nil {
return
}
if int(length) > len(buf) {
return
}
n, err := conn.Read(buf[:length])
if err != nil {
return
}
msg, err := relayDns(buf[:n])
if err != nil {
return
}
_, _ = conn.Write(msg)
}
}()
continue
}
go tunnel.Tunnel.HandleTCPConn(conn, createMetadata(lAddr, rAddr))
}
}
udp := func() {
defer func(udp *nat.UDP) {
_ = udp.Close()
}(stack.UDP())
defer log.Debugln("UDP: closed")
for {
buf := pool.Get(pool.UDPBufferSize)
n, lRAddr, rRAddr, err := stack.UDP().ReadFrom(buf)
if err != nil {
return
}
raw := buf[:n]
lAddr := lRAddr.(*net.UDPAddr)
rAddr := rRAddr.(*net.UDPAddr)
if ipv4LoopBack.Contains(rAddr.IP) {
_ = pool.Put(buf)
continue
}
if shouldHijackDns(dnsAddr, rAddr.IP, rAddr.Port) {
go func() {
defer func(buf []byte) {
_ = pool.Put(buf)
}(buf)
msg, err := relayDns(raw)
if err != nil {
return
}
_, _ = stack.UDP().WriteTo(msg, rAddr, lAddr)
}()
continue
}
pkt := &packet{
local: lAddr,
data: raw,
writeBack: func(b []byte, addr net.Addr) (int, error) {
return stack.UDP().WriteTo(b, addr, lAddr)
},
drop: func() {
_ = pool.Put(buf)
},
}
tunnel.Tunnel.HandleUDPPacket(inbound.NewPacket(socks5.ParseAddrToSocksAddr(rAddr), pkt, constant.SOCKS5))
}
}
go tcp()
go udp()
return stack, nil
return listener, nil
}

View File

@@ -1,28 +0,0 @@
//go:build android
package tun
import "net"
type packet struct {
local *net.UDPAddr
data []byte
writeBack func(b []byte, addr net.Addr) (int, error)
drop func()
}
func (pkt *packet) Data() []byte {
return pkt.data
}
func (pkt *packet) WriteBack(b []byte, addr net.Addr) (n int, err error) {
return pkt.writeBack(b, addr)
}
func (pkt *packet) Drop() {
pkt.drop()
}
func (pkt *packet) LocalAddr() net.Addr {
return pkt.local
}

View File

@@ -1,11 +1,10 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/manager/hotkey_manager.dart';
import 'package:fl_clash/manager/manager.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/proxy_container.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
@@ -28,6 +27,9 @@ runAppWithPreferences(
ChangeNotifierProvider<Config>(
create: (_) => config,
),
ChangeNotifierProvider<AppFlowingState>(
create: (_) => AppFlowingState(),
),
ChangeNotifierProxyProvider2<Config, ClashConfig, AppState>(
create: (_) => appState,
update: (_, config, clashConfig, appState) {
@@ -53,6 +55,7 @@ class Application extends StatefulWidget {
class ApplicationState extends State<Application> {
late SystemColorSchemes systemColorSchemes;
Timer? timer;
final _pageTransitionsTheme = const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
@@ -81,7 +84,9 @@ class ApplicationState extends State<Application> {
@override
void initState() {
super.initState();
_initTimer();
globalState.appController = AppController(context);
globalState.measure = Measure.of(context);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
final currentContext = globalState.navigatorKey.currentContext;
if (currentContext != null) {
@@ -92,18 +97,36 @@ class ApplicationState extends State<Application> {
});
}
_initTimer() {
_cancelTimer();
timer = Timer.periodic(const Duration(milliseconds: 20000), (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
globalState.appController.updateGroupDebounce();
});
});
}
_cancelTimer() {
if (timer != null) {
timer?.cancel();
timer = null;
}
}
_buildApp(Widget app) {
if (system.isDesktop) {
return WindowContainer(
child: TrayContainer(
child: ProxyContainer(
child: app,
return WindowManager(
child: TrayManager(
child: HotKeyManager(
child: ProxyManager(
child: app,
),
),
),
);
}
return AndroidContainer(
child: TileContainer(
return AndroidManager(
child: TileManager(
child: app,
),
);
@@ -115,7 +138,7 @@ class ApplicationState extends State<Application> {
child: page,
);
}
return VpnContainer(
return VpnManager(
child: page,
);
}
@@ -136,8 +159,8 @@ class ApplicationState extends State<Application> {
@override
Widget build(context) {
return _buildApp(
AppStateContainer(
child: ClashContainer(
AppStateManager(
child: ClashManager(
child: Selector2<AppState, Config, ApplicationSelectorState>(
selector: (_, appState, config) => ApplicationSelectorState(
locale: config.locale,
@@ -158,8 +181,15 @@ class ApplicationState extends State<Application> {
GlobalWidgetsLocalizations.delegate
],
builder: (_, child) {
return MediaContainer(
child: _buildPage(child!),
return LayoutBuilder(
builder: (_, container) {
final appController = globalState.appController;
final maxWidth = container.maxWidth;
if (appController.appState.viewWidth != maxWidth) {
globalState.appController.updateViewWidth(maxWidth);
}
return _buildPage(child!);
},
);
},
scrollBehavior: BaseScrollBehavior(),
@@ -203,5 +233,6 @@ class ApplicationState extends State<Application> {
linkManager.destroy();
await globalState.appController.savePreferences();
super.dispose();
_cancelTimer();
}
}

View File

@@ -158,7 +158,7 @@ class ClashCore {
final externalProviderRawString =
externalProviderRaw.cast<Utf8>().toDartString();
clashFFI.freeCString(externalProviderRaw);
if(externalProviderRawString.isEmpty) return null;
if (externalProviderRawString.isEmpty) return null;
return ExternalProvider.fromJson(json.decode(externalProviderRawString));
}
@@ -322,9 +322,18 @@ class ClashCore {
clashFFI.stopLog();
}
startTun(int fd, int port) {
startTun(TunProps? tunProps, int port) {
if (!Platform.isAndroid) return;
clashFFI.startTUN(fd, port);
final tunPropsChar = json.encode(tunProps).toNativeUtf8().cast<Char>();
clashFFI.startTUN(tunPropsChar, port);
malloc.free(tunPropsChar);
}
updateDns(String dns) {
if (!Platform.isAndroid) return;
final dnsChar = dns.toNativeUtf8().cast<Char>();
clashFFI.updateDns(dnsChar);
malloc.free(dnsChar);
}
requestGc() {

View File

@@ -5264,6 +5264,20 @@ class ClashFFI {
late final _getProxies =
_getProxiesPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
void updateDns(
ffi.Pointer<ffi.Char> s,
) {
return _updateDns(
s,
);
}
late final _updateDnsPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Char>)>>(
'updateDns');
late final _updateDns =
_updateDnsPtr.asFunction<void Function(ffi.Pointer<ffi.Char>)>();
void changeProxy(
ffi.Pointer<ffi.Char> s,
) {
@@ -5567,19 +5581,20 @@ class ClashFFI {
_setStatePtr.asFunction<void Function(ffi.Pointer<ffi.Char>)>();
void startTUN(
int fd,
ffi.Pointer<ffi.Char> s,
int port,
) {
return _startTUN(
fd,
s,
port,
);
}
late final _startTUNPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Int, ffi.LongLong)>>(
'startTUN');
late final _startTUN = _startTUNPtr.asFunction<void Function(int, int)>();
late final _startTUNPtr = _lookup<
ffi.NativeFunction<
ffi.Void Function(ffi.Pointer<ffi.Char>, ffi.LongLong)>>('startTUN');
late final _startTUN =
_startTUNPtr.asFunction<void Function(ffi.Pointer<ffi.Char>, int)>();
ffi.Pointer<ffi.Char> getRunTime() {
return _getRunTime();

View File

@@ -1,10 +1,13 @@
import 'dart:io';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/state.dart';
class Android {
init() async {
app?.onExit = () {};
app?.onExit = () async {
await globalState.appController.savePreferences();
};
}
}

View File

@@ -27,4 +27,6 @@ export 'windows.dart';
export 'iterable.dart';
export 'scroll.dart';
export 'icons.dart';
export 'http.dart';
export 'http.dart';
export 'keyboard.dart';
export 'network.dart';

View File

@@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:ui';
import 'package:fl_clash/enum/enum.dart';
@@ -16,14 +17,18 @@ const mmdbFileName = "geoip.metadb";
const asnFileName = "ASN.mmdb";
const geoIpFileName = "GeoIP.dat";
const geoSiteFileName = "GeoSite.dat";
final double kHeaderHeight = system.isDesktop ? 40 : 0;
final double kHeaderHeight = system.isDesktop
? !Platform.isMacOS
? 40
: 26
: 0;
const GeoXMap defaultGeoXMap = {
"mmdb":
"https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb",
"asn":
"https://github.com/xishang0128/geoip/releases/download/latest/GeoLite2-ASN.mmdb",
"https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb",
"geoip":
"https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoIP.dat",
"https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat",
"geosite":
"https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat"
};

View File

@@ -11,7 +11,7 @@ extension BuildContextExtension on BuildContext {
return MediaQuery.of(this).size;
}
double get width {
double get viewWidth {
return appSize.width;
}

View File

@@ -11,8 +11,9 @@ class FlClashHttpOverrides extends HttpOverrides {
client.badCertificateCallback = (_, __, ___) => true;
client.findProxy = (url) {
debugPrint("find $url");
final port = globalState.appController.clashConfig.mixedPort;
final isStart = globalState.appController.appState.isStart;
final appController = globalState.appController;
final port = appController.clashConfig.mixedPort;
final isStart = appController.appFlowingState.isStart;
if (!isStart) return "DIRECT";
return "PROXY localhost:$port";
};

View File

@@ -62,6 +62,6 @@ extension DoubleListExt on List<double> {
}
}
return -1; // 这行理论上不会执行到,但为了完整性保留
return -1;
}
}

106
lib/common/keyboard.dart Normal file
View File

@@ -0,0 +1,106 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:uni_platform/uni_platform.dart';
final Map<PhysicalKeyboardKey, String> _knownKeyLabels =
<PhysicalKeyboardKey, String>{
PhysicalKeyboardKey.keyA: 'A',
PhysicalKeyboardKey.keyB: 'B',
PhysicalKeyboardKey.keyC: 'C',
PhysicalKeyboardKey.keyD: 'D',
PhysicalKeyboardKey.keyE: 'E',
PhysicalKeyboardKey.keyF: 'F',
PhysicalKeyboardKey.keyG: 'G',
PhysicalKeyboardKey.keyH: 'H',
PhysicalKeyboardKey.keyI: 'I',
PhysicalKeyboardKey.keyJ: 'J',
PhysicalKeyboardKey.keyK: 'K',
PhysicalKeyboardKey.keyL: 'L',
PhysicalKeyboardKey.keyM: 'M',
PhysicalKeyboardKey.keyN: 'N',
PhysicalKeyboardKey.keyO: 'O',
PhysicalKeyboardKey.keyP: 'P',
PhysicalKeyboardKey.keyQ: 'Q',
PhysicalKeyboardKey.keyR: 'R',
PhysicalKeyboardKey.keyS: 'S',
PhysicalKeyboardKey.keyT: 'T',
PhysicalKeyboardKey.keyU: 'U',
PhysicalKeyboardKey.keyV: 'V',
PhysicalKeyboardKey.keyW: 'W',
PhysicalKeyboardKey.keyX: 'X',
PhysicalKeyboardKey.keyY: 'Y',
PhysicalKeyboardKey.keyZ: 'Z',
PhysicalKeyboardKey.digit1: '1',
PhysicalKeyboardKey.digit2: '2',
PhysicalKeyboardKey.digit3: '3',
PhysicalKeyboardKey.digit4: '4',
PhysicalKeyboardKey.digit5: '5',
PhysicalKeyboardKey.digit6: '6',
PhysicalKeyboardKey.digit7: '7',
PhysicalKeyboardKey.digit8: '8',
PhysicalKeyboardKey.digit9: '9',
PhysicalKeyboardKey.digit0: '0',
PhysicalKeyboardKey.enter: 'ENTER',
PhysicalKeyboardKey.escape: 'ESCAPE',
PhysicalKeyboardKey.backspace: 'BACKSPACE',
PhysicalKeyboardKey.tab: 'TAB',
PhysicalKeyboardKey.space: 'SPACE',
PhysicalKeyboardKey.minus: '-',
PhysicalKeyboardKey.equal: '=',
PhysicalKeyboardKey.bracketLeft: '[',
PhysicalKeyboardKey.bracketRight: ']',
PhysicalKeyboardKey.backslash: '\\',
PhysicalKeyboardKey.semicolon: ';',
PhysicalKeyboardKey.quote: '"',
PhysicalKeyboardKey.backquote: '`',
PhysicalKeyboardKey.comma: ',',
PhysicalKeyboardKey.period: '.',
PhysicalKeyboardKey.slash: '/',
PhysicalKeyboardKey.capsLock: 'CAPSLOCK',
PhysicalKeyboardKey.f1: 'F1',
PhysicalKeyboardKey.f2: 'F2',
PhysicalKeyboardKey.f3: 'F3',
PhysicalKeyboardKey.f4: 'F4',
PhysicalKeyboardKey.f5: 'F5',
PhysicalKeyboardKey.f6: 'F6',
PhysicalKeyboardKey.f7: 'F7',
PhysicalKeyboardKey.f8: 'F8',
PhysicalKeyboardKey.f9: 'F9',
PhysicalKeyboardKey.f10: 'F10',
PhysicalKeyboardKey.f11: 'F11',
PhysicalKeyboardKey.f12: 'F12',
PhysicalKeyboardKey.home: 'HOME',
PhysicalKeyboardKey.pageUp: 'PAGEUP',
PhysicalKeyboardKey.delete: 'DELETE',
PhysicalKeyboardKey.end: 'END',
PhysicalKeyboardKey.pageDown: 'PAGEDOWN',
PhysicalKeyboardKey.arrowRight: '',
PhysicalKeyboardKey.arrowLeft: '',
PhysicalKeyboardKey.arrowDown: '',
PhysicalKeyboardKey.arrowUp: '',
PhysicalKeyboardKey.controlLeft: "CTRL",
PhysicalKeyboardKey.shiftLeft: 'SHIFT',
PhysicalKeyboardKey.altLeft: "ALT",
PhysicalKeyboardKey.metaLeft: Platform.isMacOS ? '' : 'WIN',
PhysicalKeyboardKey.controlRight: "CTRL",
PhysicalKeyboardKey.shiftRight: 'SHIFT',
PhysicalKeyboardKey.altRight: "ALT",
PhysicalKeyboardKey.metaRight: Platform.isMacOS ? '' : 'WIN',
PhysicalKeyboardKey.fn: 'FN',
};
extension KeyboardKeyExt on KeyboardKey {
String get label {
PhysicalKeyboardKey? physicalKey;
if (this is LogicalKeyboardKey) {
physicalKey = (this as LogicalKeyboardKey).physicalKey;
} else if (this is PhysicalKeyboardKey) {
physicalKey = this as PhysicalKeyboardKey;
}
return _knownKeyLabels[physicalKey] ?? physicalKey?.debugName ?? 'Unknown';
}
}

View File

@@ -15,4 +15,10 @@ extension ListExtension<T> on List<T> {
}
return res;
}
List<T> safeSublist(int start) {
if(start <= 0) return this;
if(start > length) return [];
return sublist(start);
}
}

View File

@@ -6,7 +6,9 @@ class Measure {
final TextScaler _textScale;
late BuildContext context;
Measure.of(this.context) : _textScale = MediaQuery.of(context).textScaler;
Measure.of(this.context)
: _textScale = TextScaler.linear(
WidgetsBinding.instance.platformDispatcher.textScaleFactor);
Size computeTextSize(Text text) {
final textPainter = TextPainter(

25
lib/common/network.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'dart:io';
extension NetworkInterfaceExt on NetworkInterface {
bool get isWifi {
final nameLowCase = name.toLowerCase();
if (nameLowCase.contains('wlan') ||
nameLowCase.contains('wi-fi') ||
nameLowCase == 'en0' ||
nameLowCase == 'eth0') {
return true;
}
return false;
}
bool get includesIPv4 {
return addresses.any((addr) => addr.isIPv4);
}
}
extension InternetAddressExt on InternetAddress {
bool get isIPv4 {
return type == InternetAddressType.IPv4;
}
}

View File

@@ -100,12 +100,18 @@ class Other {
}
}
String getTrayIconPath() {
if (Platform.isWindows) {
return "assets/images/icon.ico";
} else {
return "assets/images/icon_monochrome.png";
String getTrayIconPath({
required bool isStart,
required Brightness brightness,
}) {
final suffix = Platform.isWindows ? "ico" : "png";
if (!isStart && Platform.isWindows) {
return switch (brightness) {
Brightness.dark => "assets/images/icon_white.$suffix",
Brightness.light => "assets/images/icon_black.$suffix",
};
}
return "assets/images/icon.$suffix";
}
int compareVersions(String version1, String version2) {
@@ -165,14 +171,18 @@ class Other {
if (disposition == null) return null;
final parseValue = HeaderValue.parse(disposition);
final parameters = parseValue.parameters;
final key = parameters.keys
.firstWhere((key) => key.startsWith("filename"), orElse: () => '');
if (key.isEmpty) return null;
if (key == "filename*") {
return Uri.decodeComponent((parameters[key] ?? "").split("'").last);
} else {
return parameters[key];
final fileNamePointKey = parameters.keys
.firstWhere((key) => key == "filename*", orElse: () => "");
if (fileNamePointKey.isNotEmpty) {
final res = parameters[fileNamePointKey]?.split("''") ?? [];
if (res.length >= 2) {
return Uri.decodeComponent(res[1]);
}
}
final fileNameKey = parameters.keys
.firstWhere((key) => key == "filename", orElse: () => "");
if (fileNameKey.isEmpty) return null;
return parameters[fileNameKey];
}
double getViewWidth() {
@@ -201,9 +211,9 @@ class Other {
int getProxiesColumns(double viewWidth, ProxiesLayout proxiesLayout) {
final columns = max((viewWidth / 300).ceil(), 2);
return switch (proxiesLayout) {
ProxiesLayout.tight => columns - 1,
ProxiesLayout.tight => columns + 1,
ProxiesLayout.standard => columns,
ProxiesLayout.loose => columns + 1,
ProxiesLayout.loose => columns - 1,
};
}
@@ -214,6 +224,15 @@ class Other {
String getBackupFileName() {
return "${appName}_backup_${DateTime.now().show}.zip";
}
String get logFile {
return "${appName}_${DateTime.now().show}.log";
}
Size getScreenSize() {
final view = WidgetsBinding.instance.platformDispatcher.views.first;
return view.physicalSize / view.devicePixelRatio;
}
}
final other = Other();

View File

@@ -3,7 +3,7 @@ import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/ip.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/cupertino.dart';

View File

@@ -1,6 +1,8 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/services.dart';
import 'window.dart';
@@ -24,6 +26,16 @@ class System {
return result.exitCode == 0;
}
Future<int> get version async {
final deviceInfo = await DeviceInfoPlugin().deviceInfo;
return switch (Platform.operatingSystem) {
"macos" => (deviceInfo as MacOsDeviceInfo).majorVersion,
"android" => (deviceInfo as AndroidDeviceInfo).version.sdkInt,
"windows" => (deviceInfo as WindowsDeviceInfo).majorVersion,
String() => 0
};
}
back() async {
await app?.moveTaskToBack();
await window?.hide();

16
lib/common/window.dart Normal file → Executable file
View File

@@ -9,7 +9,7 @@ import 'protocol.dart';
import 'system.dart';
class Window {
init(WindowProps props) async {
init(WindowProps props, int version) async {
if (Platform.isWindows) {
await WindowsSingleInstance.ensureSingleInstance([], "FlClash");
protocol.register("clash");
@@ -20,8 +20,6 @@ class Window {
WindowOptions windowOptions = WindowOptions(
size: Size(props.width, props.height),
minimumSize: const Size(380, 500),
windowButtonVisibility: false,
titleBarStyle: TitleBarStyle.hidden,
);
if (props.left != null || props.top != null) {
await windowManager.setPosition(
@@ -30,9 +28,9 @@ class Window {
} else {
await windowManager.setAlignment(Alignment.center);
}
// if(Platform.isWindows){
// await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
// }
if(!Platform.isMacOS || version > 10){
await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
}
await windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.setPreventClose(true);
});
@@ -41,6 +39,11 @@ class Window {
show() async {
await windowManager.show();
await windowManager.focus();
await windowManager.setSkipTaskbar(false);
}
Future<bool> isVisible() async {
return await windowManager.isVisible();
}
close() async {
@@ -49,6 +52,7 @@ class Window {
hide() async {
await windowManager.hide();
await windowManager.setSkipTaskbar(true);
}
}

View File

@@ -2,10 +2,12 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:fl_clash/common/archive.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart';
@@ -19,6 +21,7 @@ import 'common/common.dart';
class AppController {
final BuildContext context;
late AppState appState;
late AppFlowingState appFlowingState;
late Config config;
late ClashConfig clashConfig;
late Function updateClashConfigDebounce;
@@ -30,6 +33,7 @@ class AppController {
appState = context.read<AppState>();
config = context.read<Config>();
clashConfig = context.read<ClashConfig>();
appFlowingState = context.read<AppFlowingState>();
updateClashConfigDebounce = debounce<Function()>(() async {
await updateClashConfig();
});
@@ -56,13 +60,15 @@ class AppController {
updateRunTime,
updateTraffic,
];
applyProfileDebounce();
if (!Platform.isAndroid) {
applyProfileDebounce();
}
} else {
await globalState.handleStop();
clashCore.resetTraffic();
appState.traffics = [];
appState.totalTraffic = Traffic();
appState.runTime = null;
appFlowingState.traffics = [];
appFlowingState.totalTraffic = Traffic();
appFlowingState.runTime = null;
addCheckIpNumDebounce();
}
}
@@ -76,15 +82,15 @@ class AppController {
if (startTime != null) {
final startTimeStamp = startTime.millisecondsSinceEpoch;
final nowTimeStamp = DateTime.now().millisecondsSinceEpoch;
appState.runTime = nowTimeStamp - startTimeStamp;
appFlowingState.runTime = nowTimeStamp - startTimeStamp;
} else {
appState.runTime = null;
appFlowingState.runTime = null;
}
}
updateTraffic() {
globalState.updateTraffic(
appState: appState,
appFlowingState: appFlowingState,
);
}
@@ -116,11 +122,15 @@ class AppController {
}
Future<void> updateClashConfig({bool isPatch = true}) async {
await globalState.updateClashConfig(
clashConfig: clashConfig,
config: config,
isPatch: isPatch,
);
final commonScaffoldState = globalState.homeScaffoldKey.currentState;
if (commonScaffoldState?.mounted != true) return;
await commonScaffoldState?.loadingRun(() async {
await globalState.updateClashConfig(
clashConfig: clashConfig,
config: config,
isPatch: isPatch,
);
});
}
Future applyProfile({bool isPrue = false}) async {
@@ -163,7 +173,7 @@ class AppController {
try {
updateProfile(profile);
} catch (e) {
appState.addLog(
appFlowingState.addLog(
Log(
logLevel: LogLevel.info,
payload: e.toString(),
@@ -241,7 +251,7 @@ class AppController {
clashCore.startLog();
} else {
clashCore.stopLog();
appState.logs = [];
appFlowingState.logs = [];
}
}
@@ -295,6 +305,10 @@ class AppController {
}
init() async {
final isDisclaimerAccepted = await handlerDisclaimer();
if (!isDisclaimerAccepted) {
handleExit();
}
updateLogStatus();
if (!config.silentLaunch) {
window?.show();
@@ -311,14 +325,6 @@ class AppController {
autoCheckUpdate();
}
updateTray() {
globalState.updateTray(
appState: appState,
config: config,
clashConfig: clashConfig,
);
}
setDelay(Delay delay) {
appState.setDelay(delay);
}
@@ -381,6 +387,47 @@ class AppController {
globalState.showSnackBar(context, message: message);
}
Future<bool> showDisclaimer() async {
return await globalState.showCommonDialog<bool>(
dismissible: false,
child: AlertDialog(
title: Text(appLocalizations.disclaimer),
content: Container(
width: dialogCommonWidth,
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: SelectableText(
appLocalizations.disclaimerDesc,
),
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop<bool>(false);
},
child: Text(appLocalizations.exit),
),
TextButton(
onPressed: () {
config.isDisclaimerAccepted = true;
Navigator.of(context).pop<bool>(true);
},
child: Text(appLocalizations.agree),
)
],
),
) ??
false;
}
Future<bool> handlerDisclaimer() async {
if (config.isDisclaimerAccepted) {
return true;
}
return showDisclaimer();
}
addProfileFormURL(String url) async {
if (globalState.navigatorKey.currentState?.canPop() ?? false) {
globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst);
@@ -481,6 +528,59 @@ class AppController {
'';
}
updateTun() {
clashConfig.tun = clashConfig.tun.copyWith(
enable: !clashConfig.tun.enable,
);
}
updateSystemProxy() {
config.desktopProps = config.desktopProps.copyWith(
systemProxy: !config.desktopProps.systemProxy,
);
}
updateStart() {
updateStatus(!appFlowingState.isStart);
}
updateAutoLaunch() {
config.autoLaunch = !config.autoLaunch;
}
updateVisible() async {
final visible = await window?.isVisible();
if (visible != null && !visible) {
window?.show();
} else {
window?.hide();
}
}
updateMode() {
final index = Mode.values.indexWhere((item) => item == clashConfig.mode);
if (index == -1) {
return;
}
final nextIndex = index + 1 > Mode.values.length - 1 ? 0 : index + 1;
clashConfig.mode = Mode.values[nextIndex];
}
Future<bool> exportLogs() async {
final logsRaw = appFlowingState.logs.map(
(item) => item.toString(),
);
final data = await Isolate.run<List<int>>(() async {
final logsRawString = logsRaw.join("\n");
return utf8.encode(logsRawString);
});
return await picker.saveFile(
other.logFile,
Uint8List.fromList(data),
) !=
null;
}
Future<List<int>> backupData() async {
final homeDirPath = await appPath.getHomeDirPath();
final profilesPath = await appPath.getProfilesPath();

View File

@@ -1,6 +1,8 @@
// ignore_for_file: constant_identifier_names
import 'package:flutter/services.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
enum GroupType { Selector, URLTest, Fallback, LoadBalance, Relay }
@@ -13,6 +15,10 @@ extension GroupTypeExtension on GroupType {
)
.toList();
bool get isURLTestOrFallback {
return [GroupType.URLTest, GroupType.Fallback].contains(this);
}
static GroupType? getGroupType(String value) {
final index = GroupTypeExtension.valueList.indexOf(value);
if (index == -1) return null;
@@ -92,7 +98,6 @@ enum ProxiesLayout { loose, standard, tight }
enum ProxyCardType { expand, shrink, min }
enum DnsMode {
normal,
@JsonValue("fake-ip")
@@ -102,3 +107,52 @@ enum DnsMode {
hosts
}
enum KeyboardModifier {
alt([
PhysicalKeyboardKey.altLeft,
PhysicalKeyboardKey.altRight,
]),
capsLock([
PhysicalKeyboardKey.capsLock,
]),
control([
PhysicalKeyboardKey.controlLeft,
PhysicalKeyboardKey.controlRight,
]),
fn([
PhysicalKeyboardKey.fn,
]),
meta([
PhysicalKeyboardKey.metaLeft,
PhysicalKeyboardKey.metaRight,
]),
shift([
PhysicalKeyboardKey.shiftLeft,
PhysicalKeyboardKey.shiftRight,
]);
final List<PhysicalKeyboardKey> physicalKeys;
const KeyboardModifier(this.physicalKeys);
}
extension KeyboardModifierExt on KeyboardModifier {
HotKeyModifier toHotKeyModifier() {
return switch (this) {
KeyboardModifier.alt => HotKeyModifier.alt,
KeyboardModifier.capsLock => HotKeyModifier.capsLock,
KeyboardModifier.control => HotKeyModifier.control,
KeyboardModifier.fn => HotKeyModifier.fn,
KeyboardModifier.meta => HotKeyModifier.meta,
KeyboardModifier.shift => HotKeyModifier.shift,
};
}
}
enum HotAction {
start,
view,
mode,
proxy,
tun,
}

View File

@@ -3,8 +3,7 @@ import 'dart:typed_data';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/dav_client.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/config.dart';
import 'package:fl_clash/models/dav.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/fade_box.dart';
import 'package:fl_clash/widgets/list.dart';

View File

@@ -1,11 +1,8 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/config.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' show dirname, join;
import 'package:provider/provider.dart';
class CloseConnectionsSwitch extends StatelessWidget {
@@ -58,32 +55,7 @@ class UsageSwitch extends StatelessWidget {
}
}
class UWPLoopbackUtil extends StatelessWidget {
const UWPLoopbackUtil({super.key});
@override
Widget build(BuildContext context) {
return Selector<Config, bool>(
selector: (_, config) => config.onlyProxy,
builder: (_, onlyProxy, __) {
return ListItem(
leading: const Icon(Icons.lock_open),
title: Text(appLocalizations.loopback),
subtitle: Text(appLocalizations.loopbackDesc),
onTap: () {
windows?.runas(
'"${join(dirname(Platform.resolvedExecutable), "EnableLoopback.exe")}"',
"",
);
},
);
},
);
}
}
final appItems = [
if (Platform.isWindows) const UWPLoopbackUtil(),
const CloseConnectionsSwitch(),
const UsageSwitch(),
];

View File

@@ -67,11 +67,9 @@ class _ConfigFragmentState extends State<ConfigFragment> {
title: const Text("DNS"),
subtitle: Text(appLocalizations.dnsDesc),
leading: const Icon(Icons.dns),
delegate: OpenDelegate(
delegate: const OpenDelegate(
title: "DNS",
widget: generateListView(
dnsItems,
),
widget: DnsListView(),
isScaffold: true,
isBlur: false,
extendPageWidth: 360,

View File

@@ -1,5 +1,4 @@
import 'package:collection/collection.dart';
import 'package:fl_clash/common/app_localizations.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
@@ -11,27 +10,8 @@ import 'package:provider/provider.dart';
class OverrideItem extends StatelessWidget {
const OverrideItem({super.key});
_initActions(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
commonScaffoldState?.actions = [
IconButton(
onPressed: () {
globalState.appController.clashConfig.dns = const Dns();
},
tooltip: appLocalizations.resetDns,
icon: const Icon(
Icons.replay,
),
)
];
});
}
@override
Widget build(BuildContext context) {
_initActions(context);
return Selector<Config, bool>(
selector: (_, config) => config.overrideDns,
builder: (_, override, __) {
@@ -51,34 +31,6 @@ class OverrideItem extends StatelessWidget {
}
}
class DnsDisabledContainer extends StatelessWidget {
final Widget child;
const DnsDisabledContainer(this.child, {
super.key,
});
@override
Widget build(BuildContext context) {
return Selector<Config, bool>(
selector: (_, config) => config.overrideDns,
builder: (_, enable, child) {
return AbsorbPointer(
absorbing: !enable,
child: DisabledMask(
status: !enable,
child: Container(
color: context.colorScheme.surface,
child: child!,
),
),
);
},
child: child,
);
}
}
class StatusItem extends StatelessWidget {
const StatusItem({super.key});
@@ -268,7 +220,7 @@ class FakeIpFilterItem extends StatelessWidget {
widget: Selector<ClashConfig, List<String>>(
selector: (_, clashConfig) => clashConfig.dns.fakeIpFilter,
shouldRebuild: (prev, next) =>
!const ListEquality<String>().equals(prev, next),
!const ListEquality<String>().equals(prev, next),
builder: (_, fakeIpFilter, __) {
return UpdatePage(
title: appLocalizations.fakeipFilter,
@@ -278,8 +230,7 @@ class FakeIpFilterItem extends StatelessWidget {
final clashConfig = globalState.appController.clashConfig;
final dns = clashConfig.dns;
clashConfig.dns = dns.copyWith(
fakeIpFilter: List.from(dns.fakeIpFilter)
..remove(value),
fakeIpFilter: List.from(dns.fakeIpFilter)..remove(value),
);
},
onAdd: (value) {
@@ -287,8 +238,7 @@ class FakeIpFilterItem extends StatelessWidget {
final dns = clashConfig.dns;
if (fakeIpFilter.contains(value)) return;
clashConfig.dns = dns.copyWith(
fakeIpFilter: List.from(dns.fakeIpFilter)
..add(value),
fakeIpFilter: List.from(dns.fakeIpFilter)..add(value),
);
},
);
@@ -314,7 +264,7 @@ class DefaultNameserverItem extends StatelessWidget {
widget: Selector<ClashConfig, List<String>>(
selector: (_, clashConfig) => clashConfig.dns.defaultNameserver,
shouldRebuild: (prev, next) =>
!const ListEquality<String>().equals(prev, next),
!const ListEquality<String>().equals(prev, next),
builder: (_, defaultNameserver, __) {
return UpdatePage(
title: appLocalizations.defaultNameserver,
@@ -360,7 +310,7 @@ class NameserverItem extends StatelessWidget {
widget: Selector<ClashConfig, List<String>>(
selector: (_, clashConfig) => clashConfig.dns.nameserver,
shouldRebuild: (prev, next) =>
!const ListEquality<String>().equals(prev, next),
!const ListEquality<String>().equals(prev, next),
builder: (_, nameserver, __) {
return UpdatePage(
title: "域名服务器",
@@ -370,8 +320,7 @@ class NameserverItem extends StatelessWidget {
final clashConfig = globalState.appController.clashConfig;
final dns = clashConfig.dns;
clashConfig.dns = dns.copyWith(
nameserver: List.from(dns.nameserver)
..remove(value),
nameserver: List.from(dns.nameserver)..remove(value),
);
},
onAdd: (value) {
@@ -379,8 +328,7 @@ class NameserverItem extends StatelessWidget {
final dns = clashConfig.dns;
if (nameserver.contains(value)) return;
clashConfig.dns = dns.copyWith(
nameserver: List.from(dns.nameserver)
..add(value),
nameserver: List.from(dns.nameserver)..add(value),
);
},
);
@@ -458,14 +406,13 @@ class NameserverPolicyItem extends StatelessWidget {
widget: Selector<ClashConfig, Map<String, String>>(
selector: (_, clashConfig) => clashConfig.dns.nameserverPolicy,
shouldRebuild: (prev, next) =>
!const MapEquality<String, String>().equals(prev, next),
!const MapEquality<String, String>().equals(prev, next),
builder: (_, nameserverPolicy, __) {
return UpdatePage(
title: appLocalizations.nameserverPolicy,
items: nameserverPolicy.entries,
titleBuilder: (item) => Text(item.key),
subtitleBuilder: (item) => Text(item.value),
isMap: true,
onRemove: (value) {
final clashConfig = globalState.appController.clashConfig;
final dns = clashConfig.dns;
@@ -505,7 +452,7 @@ class ProxyServerNameserverItem extends StatelessWidget {
widget: Selector<ClashConfig, List<String>>(
selector: (_, clashConfig) => clashConfig.dns.proxyServerNameserver,
shouldRebuild: (prev, next) =>
!const ListEquality<String>().equals(prev, next),
!const ListEquality<String>().equals(prev, next),
builder: (_, proxyServerNameserver, __) {
return UpdatePage(
title: appLocalizations.proxyNameserver,
@@ -551,7 +498,7 @@ class FallbackItem extends StatelessWidget {
widget: Selector<ClashConfig, List<String>>(
selector: (_, clashConfig) => clashConfig.dns.fallback,
shouldRebuild: (prev, next) =>
!const ListEquality<String>().equals(prev, next),
!const ListEquality<String>().equals(prev, next),
builder: (_, fallback, __) {
return UpdatePage(
title: appLocalizations.fallback,
@@ -561,8 +508,7 @@ class FallbackItem extends StatelessWidget {
final clashConfig = globalState.appController.clashConfig;
final dns = clashConfig.dns;
clashConfig.dns = dns.copyWith(
fallback: List.from(dns.fallback)
..remove(value),
fallback: List.from(dns.fallback)..remove(value),
);
},
onAdd: (value) {
@@ -570,8 +516,7 @@ class FallbackItem extends StatelessWidget {
final dns = clashConfig.dns;
if (fallback.contains(value)) return;
clashConfig.dns = dns.copyWith(
fallback: List.from(dns.fallback)
..add(value),
fallback: List.from(dns.fallback)..add(value),
);
},
);
@@ -663,7 +608,7 @@ class GeositeItem extends StatelessWidget {
widget: Selector<ClashConfig, List<String>>(
selector: (_, clashConfig) => clashConfig.dns.fallbackFilter.geosite,
shouldRebuild: (prev, next) =>
!const ListEquality<String>().equals(prev, next),
!const ListEquality<String>().equals(prev, next),
builder: (_, geosite, __) {
return UpdatePage(
title: "Geosite",
@@ -674,8 +619,7 @@ class GeositeItem extends StatelessWidget {
final dns = clashConfig.dns;
clashConfig.dns = dns.copyWith(
fallbackFilter: dns.fallbackFilter.copyWith(
geosite: List.from(geosite)
..remove(value),
geosite: List.from(geosite)..remove(value),
),
);
},
@@ -684,8 +628,7 @@ class GeositeItem extends StatelessWidget {
final dns = clashConfig.dns;
clashConfig.dns = dns.copyWith(
fallbackFilter: dns.fallbackFilter.copyWith(
geosite: List.from(geosite)
..add(value),
geosite: List.from(geosite)..add(value),
),
);
},
@@ -711,7 +654,7 @@ class IpcidrItem extends StatelessWidget {
widget: Selector<ClashConfig, List<String>>(
selector: (_, clashConfig) => clashConfig.dns.fallbackFilter.ipcidr,
shouldRebuild: (prev, next) =>
!const ListEquality<String>().equals(prev, next),
!const ListEquality<String>().equals(prev, next),
builder: (_, ipcidr, __) {
return UpdatePage(
title: appLocalizations.ipcidr,
@@ -722,8 +665,7 @@ class IpcidrItem extends StatelessWidget {
final dns = clashConfig.dns;
clashConfig.dns = dns.copyWith(
fallbackFilter: dns.fallbackFilter.copyWith(
ipcidr: List.from(ipcidr)
..remove(value),
ipcidr: List.from(ipcidr)..remove(value),
),
);
},
@@ -732,8 +674,7 @@ class IpcidrItem extends StatelessWidget {
final dns = clashConfig.dns;
clashConfig.dns = dns.copyWith(
fallbackFilter: dns.fallbackFilter.copyWith(
ipcidr: List.from(ipcidr)
..add(value),
ipcidr: List.from(ipcidr)..add(value),
),
);
},
@@ -759,7 +700,7 @@ class DomainItem extends StatelessWidget {
widget: Selector<ClashConfig, List<String>>(
selector: (_, clashConfig) => clashConfig.dns.fallbackFilter.domain,
shouldRebuild: (prev, next) =>
!const ListEquality<String>().equals(prev, next),
!const ListEquality<String>().equals(prev, next),
builder: (_, domain, __) {
return UpdatePage(
title: appLocalizations.domain,
@@ -770,8 +711,7 @@ class DomainItem extends StatelessWidget {
final dns = clashConfig.dns;
clashConfig.dns = dns.copyWith(
fallbackFilter: dns.fallbackFilter.copyWith(
domain: List.from(domain)
..remove(value),
domain: List.from(domain)..remove(value),
),
);
},
@@ -780,8 +720,7 @@ class DomainItem extends StatelessWidget {
final dns = clashConfig.dns;
clashConfig.dns = dns.copyWith(
fallbackFilter: dns.fallbackFilter.copyWith(
domain: List.from(domain)
..add(value),
domain: List.from(domain)..add(value),
),
);
},
@@ -799,27 +738,25 @@ class DnsOptions extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DnsDisabledContainer(
Column(
children: generateSection(
title: appLocalizations.options,
items: [
const StatusItem(),
const UseHostsItem(),
const UseSystemHostsItem(),
const IPv6Item(),
const RespectRulesItem(),
const PreferH3Item(),
const DnsModeItem(),
const FakeIpRangeItem(),
const FakeIpFilterItem(),
const DefaultNameserverItem(),
const NameserverPolicyItem(),
const NameserverItem(),
const FallbackItem(),
const ProxyServerNameserverItem(),
],
),
return Column(
children: generateSection(
title: appLocalizations.options,
items: [
const StatusItem(),
const UseHostsItem(),
const UseSystemHostsItem(),
const IPv6Item(),
const RespectRulesItem(),
const PreferH3Item(),
const DnsModeItem(),
const FakeIpRangeItem(),
const FakeIpFilterItem(),
const DefaultNameserverItem(),
const NameserverPolicyItem(),
const NameserverItem(),
const FallbackItem(),
const ProxyServerNameserverItem(),
],
),
);
}
@@ -830,18 +767,16 @@ class FallbackFilterOptions extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DnsDisabledContainer(
Column(
children: generateSection(
title: appLocalizations.fallbackFilter,
items: [
const GeoipItem(),
const GeoipCodeItem(),
const GeositeItem(),
const IpcidrItem(),
const DomainItem(),
],
),
return Column(
children: generateSection(
title: appLocalizations.fallbackFilter,
items: [
const GeoipItem(),
const GeoipCodeItem(),
const GeositeItem(),
const IpcidrItem(),
const DomainItem(),
],
),
);
}
@@ -852,3 +787,41 @@ const dnsItems = <Widget>[
DnsOptions(),
FallbackFilterOptions(),
];
class DnsListView extends StatelessWidget {
const DnsListView({super.key});
_initActions(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
commonScaffoldState?.actions = [
IconButton(
onPressed: () {
globalState.showMessage(
title: appLocalizations.resetDns,
message: TextSpan(
text: appLocalizations.dnsResetTip,
),
onTab: () {
globalState.appController.clashConfig.dns = const Dns();
Navigator.of(context).pop();
});
},
tooltip: appLocalizations.resetDns,
icon: const Icon(
Icons.replay,
),
)
];
});
}
@override
Widget build(BuildContext context) {
_initActions(context);
return generateListView(
dnsItems,
);
}
}

View File

@@ -226,7 +226,6 @@ class HostsItem extends StatelessWidget {
clashConfig.hosts = Map.from(clashConfig.hosts)
..addEntries([value]);
},
isMap: true,
);
},
),

View File

@@ -115,6 +115,34 @@ class SystemProxySwitch extends StatelessWidget {
}
}
class Ipv6Switch extends StatelessWidget {
const Ipv6Switch({super.key});
@override
Widget build(BuildContext context) {
return Selector<Config, bool>(
selector: (_, config) => config.vpnProps.ipv6,
builder: (_, ipv6, __) {
return ListItem.switchItem(
leading: const Icon(Icons.water_outlined),
title: const Text("IPv6"),
subtitle: Text(appLocalizations.ipv6InboundDesc),
delegate: SwitchDelegate(
value: ipv6,
onChanged: (bool value) async {
final config = globalState.appController.config;
final vpnProps = config.vpnProps;
config.vpnProps = vpnProps.copyWith(
ipv6: value,
);
},
),
);
},
);
}
}
class VpnOptions extends StatelessWidget {
const VpnOptions({super.key});
@@ -127,6 +155,7 @@ class VpnOptions extends StatelessWidget {
items: [
const SystemProxySwitch(),
const AllowBypassSwitch(),
const Ipv6Switch(),
],
),
),

View File

@@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:math';
import 'package:fl_clash/common/common.dart';

View File

@@ -13,16 +13,55 @@ class IntranetIP extends StatefulWidget {
}
class _IntranetIPState extends State<IntranetIP> {
final ipNotifier = ValueNotifier<String>("");
final ipNotifier = ValueNotifier<String?>("");
Future<String?> getLocalIpAddress() async {
List<NetworkInterface> interfaces = await NetworkInterface.list();
for (final interface in interfaces) {
for (final address in interface.addresses) {
if (!address.isLoopback) {
return address.address;
Future<String> getNetworkType() async {
try {
List<NetworkInterface> interfaces = await NetworkInterface.list(
includeLoopback: false,
type: InternetAddressType.any,
);
for (var interface in interfaces) {
if (interface.name.toLowerCase().contains('wlan') ||
interface.name.toLowerCase().contains('wi-fi')) {
return 'WiFi';
}
if (interface.name.toLowerCase().contains('rmnet') ||
interface.name.toLowerCase().contains('ccmni') ||
interface.name.toLowerCase().contains('cellular')) {
return 'Mobile Data';
}
}
return 'Unknown';
} catch (e) {
return 'Error';
}
}
Future<String?> getLocalIpAddress() async {
List<NetworkInterface> interfaces = await NetworkInterface.list(
includeLoopback: false,
)
..sort((a, b) {
if (a.isWifi && !b.isWifi) return -1;
if (!a.isWifi && b.isWifi) return 1;
if (a.includesIPv4 && !b.includesIPv4) return -1;
if (!a.includesIPv4 && b.includesIPv4) return 1;
return 0;
});
for (final interface in interfaces) {
final addresses = interface.addresses;
if (addresses.isEmpty) {
continue;
}
addresses.sort((a, b) {
if (a.isIPv4 && !b.isIPv4) return -1;
if (!a.isIPv4 && b.isIPv4) return 1;
return 0;
});
return addresses.first.address;
}
return null;
}
@@ -48,17 +87,15 @@ class _IntranetIPState extends State<IntranetIP> {
label: appLocalizations.intranetIP,
iconData: Icons.devices,
),
onPressed: (){
},
onPressed: () {},
child: Container(
padding: const EdgeInsets.all(16).copyWith(top: 0),
height: globalState.measure.titleLargeHeight + 24 - 2,
height: globalState.measure.titleMediumHeight + 24 - 2,
child: ValueListenableBuilder(
valueListenable: ipNotifier,
builder: (_, value, __) {
return FadeBox(
child: value.isNotEmpty
child: value != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
@@ -67,8 +104,9 @@ class _IntranetIPState extends State<IntranetIP> {
flex: 1,
child: TooltipText(
text: Text(
value,
style: context.textTheme.titleLarge?.toSoftBold.toMinus,
value.isNotEmpty ? value : appLocalizations.noNetwork,
style: context
.textTheme.titleLarge?.toSoftBold.toMinus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
@@ -22,14 +24,17 @@ class _NetworkDetectionState extends State<NetworkDetection> {
);
bool? _preIsStart;
Function? _checkIpDebounce;
Timer? _setTimeoutTimer;
CancelToken? cancelToken;
_checkIp() async {
final appState = globalState.appController.appState;
final appFlowingState = globalState.appController.appFlowingState;
final isInit = appState.isInit;
if (!isInit) return;
final isStart = appState.isStart;
final isStart = appFlowingState.isStart;
if (_preIsStart == false && _preIsStart == isStart) return;
_clearSetTimeoutTimer();
networkDetectionState.value = networkDetectionState.value.copyWith(
isTesting: true,
ipInfo: null,
@@ -42,11 +47,32 @@ class _NetworkDetectionState extends State<NetworkDetection> {
cancelToken = CancelToken();
try {
final ipInfo = await request.checkIp(cancelToken: cancelToken);
if (ipInfo != null) {
networkDetectionState.value = networkDetectionState.value.copyWith(
isTesting: false,
ipInfo: ipInfo,
);
return;
}
_setTimeoutTimer = Timer(const Duration(milliseconds: 2000), () {
networkDetectionState.value = networkDetectionState.value.copyWith(
isTesting: false,
ipInfo: null,
);
});
} catch (_) {
networkDetectionState.value = networkDetectionState.value.copyWith(
isTesting: false,
ipInfo: ipInfo,
isTesting: true,
ipInfo: null,
);
} catch (_) {}
}
}
_clearSetTimeoutTimer() {
if(_setTimeoutTimer != null){
_setTimeoutTimer?.cancel();
_setTimeoutTimer = null;
}
}
_checkIpContainer(Widget child) {

View File

@@ -116,8 +116,8 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
label: appLocalizations.networkSpeed,
iconData: Icons.speed_sharp,
),
child: Selector<AppState, List<Traffic>>(
selector: (_, appState) => appState.traffics,
child: Selector<AppFlowingState, List<Traffic>>(
selector: (_, appFlowingState) => appFlowingState.traffics,
builder: (_, traffics, __) {
return Container(
padding: const EdgeInsets.all(16),

View File

@@ -34,7 +34,7 @@ class _StartButtonState extends State<StartButton>
handleSwitchStart() {
final appController = globalState.appController;
if (isStart == appController.appState.isStart) {
if (isStart == appController.appFlowingState.isStart) {
isStart = !isStart;
updateController();
appController.updateStatus(isStart);
@@ -50,8 +50,8 @@ class _StartButtonState extends State<StartButton>
}
Widget _updateControllerContainer(Widget child) {
return Selector<AppState, bool>(
selector: (_, appState) => appState.isStart,
return Selector<AppFlowingState, bool>(
selector: (_, appFlowingState) => appFlowingState.isStart,
builder: (_, isStart, child) {
if (isStart != this.isStart) {
this.isStart = isStart;
@@ -127,8 +127,8 @@ class _StartButtonState extends State<StartButton>
);
},
child: _updateControllerContainer(
Selector<AppState, int?>(
selector: (_, appState) => appState.runTime,
Selector<AppFlowingState, int?>(
selector: (_, appFlowingState) => appFlowingState.runTime,
builder: (_, int? value, __) {
final text = other.getTimeText(value);
return Text(

View File

@@ -47,14 +47,16 @@ class TUNSwitch extends StatelessWidget {
child: Selector<ClashConfig, bool>(
selector: (_, clashConfig) => clashConfig.tun.enable,
builder: (_, enable, __) {
return Switch(
value: enable,
onChanged: (value) {
final clashConfig = globalState.appController.clashConfig;
clashConfig.tun = clashConfig.tun.copyWith(
enable: value,
);
},
return LocaleBuilder(
builder: (_) => Switch(
value: enable,
onChanged: (value) {
final clashConfig = globalState.appController.clashConfig;
clashConfig.tun = clashConfig.tun.copyWith(
enable: value,
);
},
),
);
},
),
@@ -75,14 +77,16 @@ class ProxySwitch extends StatelessWidget {
child: Selector<Config, bool>(
selector: (_, config) => config.desktopProps.systemProxy,
builder: (_, systemProxy, __) {
return Switch(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
value: systemProxy,
onChanged: (value) {
final config = globalState.appController.config;
config.desktopProps =
config.desktopProps.copyWith(systemProxy: value);
},
return LocaleBuilder(
builder: (_) => Switch(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
value: systemProxy,
onChanged: (value) {
final config = globalState.appController.config;
config.desktopProps =
config.desktopProps.copyWith(systemProxy: value);
},
),
);
},
),

View File

@@ -56,8 +56,8 @@ class TrafficUsage extends StatelessWidget {
label: appLocalizations.trafficUsage,
iconData: Icons.data_saver_off,
),
child: Selector<AppState, Traffic>(
selector: (_, appState) => appState.totalTraffic,
child: Selector<AppFlowingState, Traffic>(
selector: (_, appFlowingState) => appFlowingState.totalTraffic,
builder: (_, totalTraffic, __) {
final upTotalTrafficValue = totalTraffic.up;
final downTotalTrafficValue = totalTraffic.down;

250
lib/fragments/hotkey.dart Normal file
View File

@@ -0,0 +1,250 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/card.dart';
import 'package:fl_clash/widgets/list.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
extension IntlExt on Intl {
static actionMessage(String messageText) =>
Intl.message("action_$messageText");
}
class HotKeyFragment extends StatelessWidget {
const HotKeyFragment({super.key});
String getSubtitle(HotKeyAction hotKeyAction) {
final key = hotKeyAction.key;
if (key == null) {
return appLocalizations.noHotKey;
}
final modifierLabels =
hotKeyAction.modifiers.map((item) => item.physicalKeys.first.label);
var text = "";
if (modifierLabels.isNotEmpty) {
text += "${modifierLabels.join(" ")}+";
}
text += PhysicalKeyboardKey(key).label;
return text;
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: HotAction.values.length,
itemBuilder: (_, index) {
final hotAction = HotAction.values[index];
return Selector<Config, HotKeyAction>(
selector: (_, config) {
final index = config.hotKeyActions.indexWhere(
(item) => item.action == hotAction,
);
return index != -1
? config.hotKeyActions[index]
: HotKeyAction(
action: hotAction,
);
},
builder: (_, value, __) {
return ListItem(
title: Text(IntlExt.actionMessage(hotAction.name)),
subtitle: Text(
getSubtitle(value),
style: context.textTheme.bodyMedium
?.copyWith(color: context.colorScheme.primary),
),
onTap: () {
globalState.showCommonDialog(
child: HotKeyRecorder(
hotKeyAction: value,
),
);
},
);
},
);
},
);
}
}
class HotKeyRecorder extends StatefulWidget {
final HotKeyAction hotKeyAction;
const HotKeyRecorder({
super.key,
required this.hotKeyAction,
});
@override
State<HotKeyRecorder> createState() => _HotKeyRecorderState();
}
class _HotKeyRecorderState extends State<HotKeyRecorder> {
late ValueNotifier<HotKeyAction> hotKeyActionNotifier;
@override
void initState() {
super.initState();
hotKeyActionNotifier = ValueNotifier<HotKeyAction>(
widget.hotKeyAction.copyWith(),
);
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
}
bool _handleKeyEvent(KeyEvent keyEvent) {
if (keyEvent is KeyUpEvent) return false;
final keys = HardwareKeyboard.instance.physicalKeysPressed;
final key = keyEvent.physicalKey;
final modifiers = KeyboardModifier.values
.where((e) =>
e.physicalKeys.any(keys.contains) && !e.physicalKeys.contains(key))
.toSet();
hotKeyActionNotifier.value = hotKeyActionNotifier.value.copyWith(
modifiers: modifiers,
key: key.usbHidUsage,
);
return true;
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
super.dispose();
}
_handleRemove() {
Navigator.of(context).pop();
final config = globalState.appController.config;
config.updateOrAddHotKeyAction(
hotKeyActionNotifier.value.copyWith(
modifiers: {},
key: null,
),
);
}
_handleConfirm() {
Navigator.of(context).pop();
final config = globalState.appController.config;
final currentHotkeyAction = hotKeyActionNotifier.value;
if (currentHotkeyAction.key == null ||
currentHotkeyAction.modifiers.isEmpty) {
globalState.showMessage(
title: appLocalizations.tip,
message: TextSpan(text: appLocalizations.inputCorrectHotkey),
);
return;
}
final hotKeyActions = config.hotKeyActions;
final index = hotKeyActions.indexWhere(
(item) =>
item.key == currentHotkeyAction.key &&
keyboardModifiersEquality.equals(
item.modifiers,
currentHotkeyAction.modifiers,
),
);
if (index != -1) {
globalState.showMessage(
title: appLocalizations.tip,
message: TextSpan(text: appLocalizations.hotkeyConflict),
);
return;
}
config.updateOrAddHotKeyAction(
currentHotkeyAction,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(IntlExt.actionMessage((widget.hotKeyAction.action.name))),
content: ValueListenableBuilder(
valueListenable: hotKeyActionNotifier,
builder: (_, hotKeyAction, ___) {
final key = hotKeyAction.key;
final modifiers = hotKeyAction.modifiers;
return SizedBox(
width: dialogCommonWidth,
child: key != null
? Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final modifier in modifiers)
KeyboardKeyBox(
keyboardKey: modifier.physicalKeys.first,
),
if (modifiers.isNotEmpty)
Text(
"+",
style: context.textTheme.titleMedium,
),
KeyboardKeyBox(
keyboardKey: PhysicalKeyboardKey(key),
),
],
)
: Text(
appLocalizations.pressKeyboard,
style: context.textTheme.titleMedium,
),
);
},
),
actions: [
TextButton(
onPressed: () {
_handleRemove();
},
child: Text(appLocalizations.remove),
),
const SizedBox(
width: 8,
),
TextButton(
onPressed: () {
_handleConfirm();
},
child: Text(
appLocalizations.confirm,
),
),
],
);
}
}
class KeyboardKeyBox extends StatelessWidget {
final KeyboardKey keyboardKey;
const KeyboardKeyBox({
super.key,
required this.keyboardKey,
});
@override
Widget build(BuildContext context) {
return CommonCard(
type: CommonCardType.filled,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
keyboardKey.label,
style: const TextStyle(
fontSize: 16,
),
),
),
onPressed: () {},
);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
@@ -29,19 +30,25 @@ class _LogsFragmentState extends State<LogsFragment> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = globalState.appController.appState;
logsNotifier.value = logsNotifier.value.copyWith(logs: appState.logs);
final appFlowingState = globalState.appController.appFlowingState;
logsNotifier.value =
logsNotifier.value.copyWith(logs: appFlowingState.logs);
if (timer != null) {
timer?.cancel();
timer = null;
}
timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
final logs = appState.logs;
final maxLength = Platform.isAndroid ? 1000 : 60;
final logs = appFlowingState.logs.safeSublist(
appFlowingState.logs.length - maxLength,
);
if (!const ListEquality<Log>().equals(
logsNotifier.value.logs,
logs,
)) {
logsNotifier.value = logsNotifier.value.copyWith(logs: logs);
logsNotifier.value = logsNotifier.value.copyWith(
logs: logs,
);
}
});
});
@@ -56,6 +63,21 @@ class _LogsFragmentState extends State<LogsFragment> {
timer = null;
}
_handleExport() async {
final commonScaffoldState = context.commonScaffoldState;
final res = await commonScaffoldState?.loadingRun<bool>(
() async {
return await globalState.appController.exportLogs();
},
title: appLocalizations.exportLogs,
);
if (res != true) return;
globalState.showMessage(
title: appLocalizations.tip,
message: TextSpan(text: appLocalizations.exportSuccess),
);
}
_initActions() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final commonScaffoldState =
@@ -72,6 +94,17 @@ class _LogsFragmentState extends State<LogsFragment> {
},
icon: const Icon(Icons.search),
),
const SizedBox(
width: 8,
),
IconButton(
onPressed: () {
_handleExport();
},
icon: const Icon(
Icons.file_download_outlined,
),
),
];
});
}
@@ -236,7 +269,8 @@ class LogsSearchDelegate extends SearchDelegate {
_addKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)..add(keyword);
final keywords = List<String>.from(logsNotifier.value.keywords)
..add(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
@@ -245,7 +279,8 @@ class LogsSearchDelegate extends SearchDelegate {
_deleteKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)..remove(keyword);
final keywords = List<String>.from(logsNotifier.value.keywords)
..remove(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
@@ -339,7 +374,9 @@ class _LogItemState extends State<LogItem> {
style: context.textTheme.bodySmall
?.copyWith(color: context.colorScheme.primary),
),
const SizedBox(height: 8,),
const SizedBox(
height: 8,
),
Container(
alignment: Alignment.centerLeft,
child: CommonChip(

View File

@@ -497,17 +497,17 @@ class _ReorderableProfilesState extends State<ReorderableProfiles> {
),
Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
vertical: 16,
horizontal: 24,
),
child: FilledButton(
child: FilledButton.tonal(
onPressed: () {
Navigator.of(context).pop();
globalState.appController.config.profiles = profiles;
},
style: ButtonStyle(
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(vertical: 16),
const EdgeInsets.symmetric(vertical: 8),
),
),
child: Row(

View File

@@ -25,6 +25,10 @@ class ProxyCard extends StatelessWidget {
Measure get measure => globalState.measure;
_handleTestCurrentDelay() {
proxyDelayTest(proxy);
}
Widget _buildDelayText() {
return SizedBox(
height: measure.labelSmallHeight,
@@ -36,24 +40,31 @@ class ProxyCard extends StatelessWidget {
return FadeBox(
child: Builder(
builder: (_) {
if (delay == null) {
return Container();
}
if (delay == 0) {
if (delay == 0 || delay == null) {
return SizedBox(
height: measure.labelSmallHeight,
width: measure.labelSmallHeight,
child: const CircularProgressIndicator(
strokeWidth: 2,
),
child: delay == 0
? const CircularProgressIndicator(
strokeWidth: 2,
)
: IconButton(
icon: const Icon(Icons.bolt),
iconSize: globalState.measure.labelSmallHeight,
padding: EdgeInsets.zero,
onPressed: _handleTestCurrentDelay,
),
);
}
return Text(
delay > 0 ? '$delay ms' : "Timeout",
style: context.textTheme.labelSmall?.copyWith(
overflow: TextOverflow.ellipsis,
color: other.getDelayColor(
delay,
return GestureDetector(
onTap: _handleTestCurrentDelay,
child: Text(
delay > 0 ? '$delay ms' : "Timeout",
style: context.textTheme.labelSmall?.copyWith(
overflow: TextOverflow.ellipsis,
color: other.getDelayColor(
delay,
),
),
),
);
@@ -91,12 +102,12 @@ class ProxyCard extends StatelessWidget {
_changeProxy(BuildContext context) async {
final appController = globalState.appController;
final isUrlTest = groupType == GroupType.URLTest;
final isURLTestOrFallback = groupType.isURLTestOrFallback;
final isSelector = groupType == GroupType.Selector;
if (isUrlTest || isSelector) {
if (isURLTestOrFallback || isSelector) {
final currentProxyName =
appController.config.currentSelectedMap[groupName];
final nextProxyName = switch (isUrlTest) {
final nextProxyName = switch (isURLTestOrFallback) {
true => currentProxyName == proxy.name ? "" : proxy.name,
false => proxy.name,
};
@@ -122,7 +133,7 @@ class ProxyCard extends StatelessWidget {
final measure = globalState.measure;
final delayText = _buildDelayText();
final proxyNameText = _buildProxyNameText(context);
return currentGroupProxyNameBuilder(
return currentSelectedProxyNameBuilder(
groupName: groupName,
builder: (currentGroupName) {
return Stack(
@@ -196,30 +207,16 @@ class ProxyCard extends StatelessWidget {
),
),
),
if (groupType == GroupType.URLTest)
if (groupType.isURLTestOrFallback)
Selector<Config, String>(
selector: (_, config) {
final selectedProxyName =
config.currentSelectedMap[groupName];
return selectedProxyName ?? '';
},
builder: (_, value, __) {
builder: (_, value, child) {
if (value != proxy.name) return Container();
return Positioned.fill(
child: Container(
alignment: Alignment.topRight,
margin: const EdgeInsets.all(8),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
Theme.of(context).colorScheme.secondaryContainer,
),
child: const SelectIcon(),
),
),
);
return child!;
},
child: Positioned.fill(
child: Container(

View File

@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:fl_clash/clash/clash.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
@@ -8,7 +6,7 @@ import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
Widget currentGroupProxyNameBuilder({
Widget currentSelectedProxyNameBuilder({
required String groupName,
required Widget Function(String currentGroupName) builder,
}) {
@@ -18,8 +16,8 @@ Widget currentGroupProxyNameBuilder({
final selectedProxyName = config.currentSelectedMap[groupName];
return group?.getCurrentSelectedName(selectedProxyName ?? "") ?? "";
},
builder: (_, currentGroupName, ___) {
return builder(currentGroupName);
builder: (_, currentSelectedProxyName, ___) {
return builder(currentSelectedProxyName);
},
);
}
@@ -40,10 +38,26 @@ double getItemHeight(ProxyCardType proxyCardType) {
};
}
proxyDelayTest(Proxy proxy) async {
final appController = globalState.appController;
final proxyName = appController.appState.getRealProxyName(proxy.name);
globalState.appController.setDelay(
Delay(
name: proxyName,
value: 0,
),
);
globalState.appController.setDelay(await clashCore.getDelay(proxyName));
}
delayTest(List<Proxy> proxies) async {
final appController = globalState.appController;
final delayProxies = proxies.map<Future>((proxy) async {
final proxyName = appController.appState.getRealProxyName(proxy.name);
final proxyNames = proxies
.map((proxy) => appController.appState.getRealProxyName(proxy.name))
.toSet()
.toList();
final delayProxies = proxyNames.map<Future>((proxyName) async {
globalState.appController.setDelay(
Delay(
name: proxyName,
@@ -76,5 +90,5 @@ double getScrollToSelectedOffset({
);
final selectedIndex = findSelectedIndex != -1 ? findSelectedIndex : 0;
final rows = (selectedIndex / columns).floor();
return max(rows * (getItemHeight(proxyCardType) + 8) - 8, 0);
return rows * getItemHeight(proxyCardType) + (rows - 1) * 8;
}

View File

@@ -1,11 +1,13 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/builder.dart';
import 'package:fl_clash/widgets/card.dart';
import 'package:fl_clash/widgets/fade_box.dart';
import 'package:fl_clash/widgets/text.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -219,11 +221,15 @@ class _ProxiesListFragmentState extends State<ProxiesListFragment> {
final currentInitOffset = _headerOffset[index];
final proxies = _lastGroupNameProxiesMap[groupName];
_controller.animateTo(
currentInitOffset +
getScrollToSelectedOffset(
groupName: groupName,
proxies: proxies ?? [],
),
min(
currentInitOffset +
8 +
getScrollToSelectedOffset(
groupName: groupName,
proxies: proxies ?? [],
),
_controller.position.maxScrollExtent,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
);
@@ -258,73 +264,75 @@ class _ProxiesListFragmentState extends State<ProxiesListFragment> {
return prev != next;
},
builder: (_, state, __) {
final items = _buildItems(
groupNames: state.groupNames,
currentUnfoldSet: state.currentUnfoldSet,
columns: state.columns,
type: state.proxyCardType,
);
final itemsOffset = _getItemHeightList(items, state.proxyCardType);
return Scrollbar(
controller: _controller,
thumbVisibility: true,
trackVisibility: true,
thickness: 8,
radius: const Radius.circular(8),
interactive: true,
child: Stack(
children: [
Positioned.fill(
child: ScrollConfiguration(
behavior: HiddenBarScrollBehavior(),
child: ListView.builder(
padding: const EdgeInsets.all(16),
controller: _controller,
itemExtentBuilder: (index, __) {
return itemsOffset[index];
},
itemCount: items.length,
itemBuilder: (_, index) {
return items[index];
},
return ScaleBuilder(builder: (_) {
final items = _buildItems(
groupNames: state.groupNames,
currentUnfoldSet: state.currentUnfoldSet,
columns: state.columns,
type: state.proxyCardType,
);
final itemsOffset = _getItemHeightList(items, state.proxyCardType);
return Scrollbar(
controller: _controller,
thumbVisibility: true,
trackVisibility: true,
thickness: 8,
radius: const Radius.circular(8),
interactive: true,
child: Stack(
children: [
Positioned.fill(
child: ScrollConfiguration(
behavior: HiddenBarScrollBehavior(),
child: ListView.builder(
padding: const EdgeInsets.all(16),
controller: _controller,
itemExtentBuilder: (index, __) {
return itemsOffset[index];
},
itemCount: items.length,
itemBuilder: (_, index) {
return items[index];
},
),
),
),
),
LayoutBuilder(builder: (_, container) {
return ValueListenableBuilder(
valueListenable: _headerStateNotifier,
builder: (_, headerState, ___) {
final index =
headerState.currentIndex > state.groupNames.length - 1
? 0
: headerState.currentIndex;
return Stack(
children: [
Positioned(
top: -headerState.offset,
child: Container(
width: container.maxWidth,
color: context.colorScheme.surface,
padding: const EdgeInsets.only(
top: 16,
left: 16,
right: 16,
bottom: 8,
),
child: _buildHeader(
groupName: state.groupNames[index],
currentUnfoldSet: state.currentUnfoldSet,
LayoutBuilder(builder: (_, container) {
return ValueListenableBuilder(
valueListenable: _headerStateNotifier,
builder: (_, headerState, ___) {
final index =
headerState.currentIndex > state.groupNames.length - 1
? 0
: headerState.currentIndex;
return Stack(
children: [
Positioned(
top: -headerState.offset,
child: Container(
width: container.maxWidth,
color: context.colorScheme.surface,
padding: const EdgeInsets.only(
top: 16,
left: 16,
right: 16,
bottom: 8,
),
child: _buildHeader(
groupName: state.groupNames[index],
currentUnfoldSet: state.currentUnfoldSet,
),
),
),
),
],
);
},
);
}),
],
),
);
],
);
},
);
}),
],
),
);
});
},
);
}
@@ -479,7 +487,7 @@ class _ListHeaderState extends State<ListHeader>
),
Flexible(
flex: 1,
child: currentGroupProxyNameBuilder(
child: currentSelectedProxyNameBuilder(
groupName: groupName,
builder: (currentGroupName) {
return Row(

View File

@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
@@ -138,7 +140,7 @@ class ProxiesTabFragmentState extends State<ProxiesTabFragment>
GroupNameKeyMap keyMap = {};
final children = state.groupNames.map((groupName) {
keyMap[groupName] = GlobalObjectKey(groupName);
return KeepContainer(
return KeepScope(
child: ProxyGroupView(
key: keyMap[groupName],
groupName: groupName,
@@ -266,11 +268,14 @@ class ProxyGroupViewState extends State<ProxyGroupView> {
return;
}
_controller.animateTo(
16 +
getScrollToSelectedOffset(
groupName: groupName,
proxies: _lastProxies,
),
min(
16 +
getScrollToSelectedOffset(
groupName: groupName,
proxies: _lastProxies,
),
_controller.position.maxScrollExtent,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
);
@@ -309,26 +314,33 @@ class ProxyGroupViewState extends State<ProxyGroupView> {
},
child: Align(
alignment: Alignment.topCenter,
child: GridView.builder(
controller: _controller,
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
mainAxisExtent: getItemHeight(proxyCardType),
child: ScaleBuilder(
builder: (_) => GridView.builder(
controller: _controller,
padding: const EdgeInsets.only(
top: 16,
left: 16,
right: 16,
bottom: 96,
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
mainAxisExtent: getItemHeight(proxyCardType),
),
itemCount: sortedProxies.length,
itemBuilder: (_, index) {
final proxy = sortedProxies[index];
return ProxyCard(
groupType: state.groupType,
type: proxyCardType,
key: ValueKey('$groupName.${proxy.name}'),
proxy: proxy,
groupName: groupName,
);
},
),
itemCount: sortedProxies.length,
itemBuilder: (_, index) {
final proxy = sortedProxies[index];
return ProxyCard(
groupType: state.groupType,
type: proxyCardType,
key: ValueKey('$groupName.${proxy.name}'),
proxy: proxy,
groupName: groupName,
);
},
),
),
);

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
@@ -37,7 +38,10 @@ class _RequestsFragmentState extends State<RequestsFragment> {
timer = null;
}
timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
final requests = appState.requests;
final maxLength = Platform.isAndroid ? 1000 : 60;
final requests = appState.requests.safeSublist(
appState.requests.length - maxLength,
);
if (!const ListEquality<Connection>().equals(
requestsNotifier.value.connections,
requests,

View File

@@ -5,15 +5,17 @@ import 'package:fl_clash/fragments/about.dart';
import 'package:fl_clash/fragments/access.dart';
import 'package:fl_clash/fragments/application_setting.dart';
import 'package:fl_clash/fragments/config/config.dart';
import 'package:fl_clash/fragments/hotkey.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../widgets/widgets.dart';
import 'backup_and_recovery.dart';
import 'theme.dart';
import 'package:path/path.dart' show dirname, join;
class ToolsFragment extends StatefulWidget {
const ToolsFragment({super.key});
@@ -61,6 +63,17 @@ class _ToolboxFragmentState extends State<ToolsFragment> {
return generateSection(
title: appLocalizations.other,
items: [
ListItem(
leading: const Icon(Icons.gavel),
title: Text(appLocalizations.disclaimer),
onTap: () async {
final isDisclaimerAccepted =
await globalState.appController.showDisclaimer();
if (!isDisclaimerAccepted) {
globalState.appController.handleExit();
}
},
),
ListItem.open(
leading: const Icon(Icons.info),
title: Text(appLocalizations.about),
@@ -88,10 +101,7 @@ class _ToolboxFragmentState extends State<ToolsFragment> {
subtitle: Text(Intl.message(subTitle)),
delegate: OptionsDelegate(
title: appLocalizations.language,
options: [
null,
...AppLocalizations.delegate.supportedLocales
],
options: [null, ...AppLocalizations.delegate.supportedLocales],
onChanged: (Locale? value) {
final config = context.read<Config>();
config.locale = value?.toString();
@@ -121,6 +131,28 @@ class _ToolboxFragmentState extends State<ToolsFragment> {
widget: const BackupAndRecovery(),
),
),
if (system.isDesktop)
ListItem.open(
leading: const Icon(Icons.keyboard),
title: Text(appLocalizations.hotkeyManagement),
subtitle: Text(appLocalizations.hotkeyManagementDesc),
delegate: OpenDelegate(
title: appLocalizations.hotkeyManagement,
widget: const HotKeyFragment(),
),
),
if (Platform.isWindows)
ListItem(
leading: const Icon(Icons.lock),
title: Text(appLocalizations.loopback),
subtitle: Text(appLocalizations.loopbackDesc),
onTap: () {
windows?.runas(
'"${join(dirname(Platform.resolvedExecutable), "EnableLoopback.exe")}"',
"",
);
},
),
if (Platform.isAndroid)
ListItem.open(
leading: const Icon(Icons.view_list),
@@ -155,9 +187,8 @@ class _ToolboxFragmentState extends State<ToolsFragment> {
@override
Widget build(BuildContext context) {
return Selector<Config, String?>(
selector: (_, config) => config.locale,
builder: (_, __, ___) {
return LocaleBuilder(
builder: (_) {
final items = [
Selector<AppState, MoreToolsSelectorState>(
selector: (_, appState) {
@@ -190,6 +221,7 @@ class _ToolboxFragmentState extends State<ToolsFragment> {
return ListView.builder(
itemCount: items.length,
itemBuilder: (_, index) => items[index],
padding: const EdgeInsets.only(bottom: 20),
);
},
);

View File

@@ -123,7 +123,7 @@
"tabAnimation": "Tab animation",
"tabAnimationDesc": "When enabled, the home tab will add a toggle animation",
"desc": "A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.",
"startVpn": "Staring VPN...",
"startVpn": "Starting VPN...",
"stopVpn": "Stopping VPN...",
"discovery": "Discovery a new version",
"compatible": "Compatibility mode",
@@ -288,5 +288,25 @@
"ipcidr": "Ipcidr",
"domain": "Domain",
"resetDns": "Reset Dns",
"reset": "Reset"
"reset": "Reset",
"action_view": "Show/Hide",
"action_start": "Start/Stop",
"action_mode": "Switch mode",
"action_proxy": "System proxy",
"action_tun": "TUN",
"disclaimer": "Disclaimer",
"disclaimerDesc": "This software is only used for non-commercial purposes such as learning exchanges and scientific research. It is strictly prohibited to use this software for commercial purposes. Any commercial activity, if any, has nothing to do with this software.",
"agree": "Agree",
"hotkeyManagement": "Hotkey Management",
"hotkeyManagementDesc": "Use keyboard to control applications",
"pressKeyboard": "Please press the keyboard.",
"inputCorrectHotkey": "Please enter the correct hotkey",
"hotkeyConflict": "Hotkey conflict",
"remove": "Remove",
"noHotKey": "No HotKey",
"dnsResetTip": "Make sure to reset the DNS",
"noNetwork": "No network",
"ipv6InboundDesc": "Allow IPv6 inbound",
"exportLogs": "Export logs",
"exportSuccess": "Export Success"
}

View File

@@ -238,9 +238,9 @@
"clipboardImport": "剪贴板导入",
"clipboardExport": "导出剪贴板",
"layout": "布局",
"tight": "宽松",
"tight": "紧凑",
"standard": "标准",
"loose": "紧凑",
"loose": "宽松",
"profilesSort": "配置排序",
"start": "启动",
"stop": "暂停",
@@ -288,5 +288,25 @@
"ipcidr": "IP/掩码",
"domain": "域名",
"resetDns": "重置DNS",
"reset": "重置"
"reset": "重置",
"action_view": "显示/隐藏",
"action_start": "启动/停止",
"action_mode": "切换模式",
"action_proxy": "系统代理",
"action_tun": "虚拟网卡",
"disclaimer": "免责声明",
"disclaimerDesc": "本软件仅供学习交流、科研等非商业性质的用途,严禁将本软件用于商业目的。如有任何商业行为,均与本软件无关。",
"agree": "同意",
"hotkeyManagement": "快捷键管理",
"hotkeyManagementDesc": "使用键盘控制应用程序",
"pressKeyboard": "请按下按键",
"inputCorrectHotkey": "请输入正确的快捷键",
"hotkeyConflict": "快捷键冲突",
"remove": "移除",
"noHotKey": "暂无快捷键",
"dnsResetTip": "确定重置DNS",
"noNetwork": "无网络",
"ipv6InboundDesc": "允许IPv6入站",
"exportLogs": "导出日志",
"exportSuccess": "导出成功"
}

View File

@@ -34,6 +34,11 @@ class MessageLookup extends MessageLookupByLibrary {
"accountTip":
MessageLookupByLibrary.simpleMessage("Account cannot be empty"),
"action": MessageLookupByLibrary.simpleMessage("Action"),
"action_mode": MessageLookupByLibrary.simpleMessage("Switch mode"),
"action_proxy": MessageLookupByLibrary.simpleMessage("System proxy"),
"action_start": MessageLookupByLibrary.simpleMessage("Start/Stop"),
"action_tun": MessageLookupByLibrary.simpleMessage("TUN"),
"action_view": MessageLookupByLibrary.simpleMessage("Show/Hide"),
"add": MessageLookupByLibrary.simpleMessage("Add"),
"address": MessageLookupByLibrary.simpleMessage("Address"),
"addressHelp":
@@ -41,6 +46,7 @@ class MessageLookup extends MessageLookupByLibrary {
"addressTip": MessageLookupByLibrary.simpleMessage(
"Please enter a valid WebDAV address"),
"ago": MessageLookupByLibrary.simpleMessage(" Ago"),
"agree": MessageLookupByLibrary.simpleMessage("Agree"),
"allApps": MessageLookupByLibrary.simpleMessage("All apps"),
"allowBypass": MessageLookupByLibrary.simpleMessage(
"Allow applications to bypass VPN"),
@@ -130,6 +136,9 @@ class MessageLookup extends MessageLookupByLibrary {
"desc": MessageLookupByLibrary.simpleMessage(
"A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free."),
"direct": MessageLookupByLibrary.simpleMessage("Direct"),
"disclaimer": MessageLookupByLibrary.simpleMessage("Disclaimer"),
"disclaimerDesc": MessageLookupByLibrary.simpleMessage(
"This software is only used for non-commercial purposes such as learning exchanges and scientific research. It is strictly prohibited to use this software for commercial purposes. Any commercial activity, if any, has nothing to do with this software."),
"discoverNewVersion":
MessageLookupByLibrary.simpleMessage("Discover the new version"),
"discovery":
@@ -137,6 +146,8 @@ class MessageLookup extends MessageLookupByLibrary {
"dnsDesc":
MessageLookupByLibrary.simpleMessage("Update DNS related settings"),
"dnsMode": MessageLookupByLibrary.simpleMessage("DNS mode"),
"dnsResetTip":
MessageLookupByLibrary.simpleMessage("Make sure to reset the DNS"),
"doYouWantToPass":
MessageLookupByLibrary.simpleMessage("Do you want to pass"),
"domain": MessageLookupByLibrary.simpleMessage("Domain"),
@@ -152,6 +163,8 @@ class MessageLookup extends MessageLookupByLibrary {
"expand": MessageLookupByLibrary.simpleMessage("Standard"),
"expirationTime":
MessageLookupByLibrary.simpleMessage("Expiration time"),
"exportLogs": MessageLookupByLibrary.simpleMessage("Export logs"),
"exportSuccess": MessageLookupByLibrary.simpleMessage("Export Success"),
"externalController":
MessageLookupByLibrary.simpleMessage("ExternalController"),
"externalControllerDesc": MessageLookupByLibrary.simpleMessage(
@@ -188,18 +201,28 @@ class MessageLookup extends MessageLookupByLibrary {
"go": MessageLookupByLibrary.simpleMessage("Go"),
"goDownload": MessageLookupByLibrary.simpleMessage("Go to download"),
"hostsDesc": MessageLookupByLibrary.simpleMessage("Add Hosts"),
"hotkeyConflict":
MessageLookupByLibrary.simpleMessage("Hotkey conflict"),
"hotkeyManagement":
MessageLookupByLibrary.simpleMessage("Hotkey Management"),
"hotkeyManagementDesc": MessageLookupByLibrary.simpleMessage(
"Use keyboard to control applications"),
"hours": MessageLookupByLibrary.simpleMessage("Hours"),
"importFromURL":
MessageLookupByLibrary.simpleMessage("Import from URL"),
"infiniteTime":
MessageLookupByLibrary.simpleMessage("Long term effective"),
"init": MessageLookupByLibrary.simpleMessage("Init"),
"inputCorrectHotkey": MessageLookupByLibrary.simpleMessage(
"Please enter the correct hotkey"),
"intelligentSelected":
MessageLookupByLibrary.simpleMessage("Intelligent selection"),
"intranetIP": MessageLookupByLibrary.simpleMessage("Intranet IP"),
"ipcidr": MessageLookupByLibrary.simpleMessage("Ipcidr"),
"ipv6Desc": MessageLookupByLibrary.simpleMessage(
"When turned on it will be able to receive IPv6 traffic"),
"ipv6InboundDesc":
MessageLookupByLibrary.simpleMessage("Allow IPv6 inbound"),
"just": MessageLookupByLibrary.simpleMessage("Just"),
"keepAliveIntervalDesc":
MessageLookupByLibrary.simpleMessage("Tcp keep alive interval"),
@@ -247,8 +270,10 @@ class MessageLookup extends MessageLookupByLibrary {
"networkDetection":
MessageLookupByLibrary.simpleMessage("Network detection"),
"networkSpeed": MessageLookupByLibrary.simpleMessage("Network speed"),
"noHotKey": MessageLookupByLibrary.simpleMessage("No HotKey"),
"noInfo": MessageLookupByLibrary.simpleMessage("No info"),
"noMoreInfoDesc": MessageLookupByLibrary.simpleMessage("No more info"),
"noNetwork": MessageLookupByLibrary.simpleMessage("No network"),
"noProxy": MessageLookupByLibrary.simpleMessage("No proxy"),
"noProxyDesc": MessageLookupByLibrary.simpleMessage(
"Please create a profile or add a valid profile"),
@@ -293,6 +318,8 @@ class MessageLookup extends MessageLookupByLibrary {
"port": MessageLookupByLibrary.simpleMessage("Port"),
"preferH3Desc": MessageLookupByLibrary.simpleMessage(
"Prioritize the use of DOH\'s http/3"),
"pressKeyboard":
MessageLookupByLibrary.simpleMessage("Please press the keyboard."),
"preview": MessageLookupByLibrary.simpleMessage("Preview"),
"profile": MessageLookupByLibrary.simpleMessage("Profile"),
"profileAutoUpdateIntervalInvalidValidationDesc":
@@ -343,6 +370,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Backup local data to WebDAV"),
"remoteRecoveryDesc":
MessageLookupByLibrary.simpleMessage("Recovery data from WebDAV"),
"remove": MessageLookupByLibrary.simpleMessage("Remove"),
"requests": MessageLookupByLibrary.simpleMessage("Requests"),
"requestsDesc": MessageLookupByLibrary.simpleMessage(
"View recently request records"),
@@ -372,7 +400,7 @@ class MessageLookup extends MessageLookupByLibrary {
"source": MessageLookupByLibrary.simpleMessage("Source"),
"standard": MessageLookupByLibrary.simpleMessage("Standard"),
"start": MessageLookupByLibrary.simpleMessage("Start"),
"startVpn": MessageLookupByLibrary.simpleMessage("Staring VPN..."),
"startVpn": MessageLookupByLibrary.simpleMessage("Starting VPN..."),
"status": MessageLookupByLibrary.simpleMessage("Status"),
"statusDesc": MessageLookupByLibrary.simpleMessage(
"System DNS will be used when turned off"),

View File

@@ -32,11 +32,17 @@ class MessageLookup extends MessageLookupByLibrary {
"account": MessageLookupByLibrary.simpleMessage("账号"),
"accountTip": MessageLookupByLibrary.simpleMessage("账号不能为空"),
"action": MessageLookupByLibrary.simpleMessage("操作"),
"action_mode": MessageLookupByLibrary.simpleMessage("切换模式"),
"action_proxy": MessageLookupByLibrary.simpleMessage("系统代理"),
"action_start": MessageLookupByLibrary.simpleMessage("启动/停止"),
"action_tun": MessageLookupByLibrary.simpleMessage("虚拟网卡"),
"action_view": MessageLookupByLibrary.simpleMessage("显示/隐藏"),
"add": MessageLookupByLibrary.simpleMessage("添加"),
"address": MessageLookupByLibrary.simpleMessage("地址"),
"addressHelp": MessageLookupByLibrary.simpleMessage("WebDAV服务器地址"),
"addressTip": MessageLookupByLibrary.simpleMessage("请输入有效的WebDAV地址"),
"ago": MessageLookupByLibrary.simpleMessage(""),
"agree": MessageLookupByLibrary.simpleMessage("同意"),
"allApps": MessageLookupByLibrary.simpleMessage("所有应用"),
"allowBypass": MessageLookupByLibrary.simpleMessage("允许应用绕过VPN"),
"allowBypassDesc":
@@ -107,10 +113,14 @@ class MessageLookup extends MessageLookupByLibrary {
"desc": MessageLookupByLibrary.simpleMessage(
"基于ClashMeta的多平台代理客户端简单易用开源无广告。"),
"direct": MessageLookupByLibrary.simpleMessage("直连"),
"disclaimer": MessageLookupByLibrary.simpleMessage("免责声明"),
"disclaimerDesc": MessageLookupByLibrary.simpleMessage(
"本软件仅供学习交流、科研等非商业性质的用途,严禁将本软件用于商业目的。如有任何商业行为,均与本软件无关。"),
"discoverNewVersion": MessageLookupByLibrary.simpleMessage("发现新版本"),
"discovery": MessageLookupByLibrary.simpleMessage("发现新版本"),
"dnsDesc": MessageLookupByLibrary.simpleMessage("更新DNS相关设置"),
"dnsMode": MessageLookupByLibrary.simpleMessage("DNS模式"),
"dnsResetTip": MessageLookupByLibrary.simpleMessage("确定重置DNS"),
"doYouWantToPass": MessageLookupByLibrary.simpleMessage("是否要通过"),
"domain": MessageLookupByLibrary.simpleMessage("域名"),
"download": MessageLookupByLibrary.simpleMessage("下载"),
@@ -123,6 +133,8 @@ class MessageLookup extends MessageLookupByLibrary {
"exit": MessageLookupByLibrary.simpleMessage("退出"),
"expand": MessageLookupByLibrary.simpleMessage("标准"),
"expirationTime": MessageLookupByLibrary.simpleMessage("到期时间"),
"exportLogs": MessageLookupByLibrary.simpleMessage("导出日志"),
"exportSuccess": MessageLookupByLibrary.simpleMessage("导出成功"),
"externalController": MessageLookupByLibrary.simpleMessage("外部控制器"),
"externalControllerDesc":
MessageLookupByLibrary.simpleMessage("开启后将可以通过9090端口控制Clash内核"),
@@ -151,14 +163,20 @@ class MessageLookup extends MessageLookupByLibrary {
"go": MessageLookupByLibrary.simpleMessage("前往"),
"goDownload": MessageLookupByLibrary.simpleMessage("前往下载"),
"hostsDesc": MessageLookupByLibrary.simpleMessage("追加Hosts"),
"hotkeyConflict": MessageLookupByLibrary.simpleMessage("快捷键冲突"),
"hotkeyManagement": MessageLookupByLibrary.simpleMessage("快捷键管理"),
"hotkeyManagementDesc":
MessageLookupByLibrary.simpleMessage("使用键盘控制应用程序"),
"hours": MessageLookupByLibrary.simpleMessage("小时"),
"importFromURL": MessageLookupByLibrary.simpleMessage("从URL导入"),
"infiniteTime": MessageLookupByLibrary.simpleMessage("长期有效"),
"init": MessageLookupByLibrary.simpleMessage("初始化"),
"inputCorrectHotkey": MessageLookupByLibrary.simpleMessage("请输入正确的快捷键"),
"intelligentSelected": MessageLookupByLibrary.simpleMessage("智能选择"),
"intranetIP": MessageLookupByLibrary.simpleMessage("内网 IP"),
"ipcidr": MessageLookupByLibrary.simpleMessage("IP/掩码"),
"ipv6Desc": MessageLookupByLibrary.simpleMessage("开启后将可以接收IPv6流量"),
"ipv6InboundDesc": MessageLookupByLibrary.simpleMessage("允许IPv6入站"),
"just": MessageLookupByLibrary.simpleMessage("刚刚"),
"keepAliveIntervalDesc":
MessageLookupByLibrary.simpleMessage("TCP保持活动间隔"),
@@ -178,7 +196,7 @@ class MessageLookup extends MessageLookupByLibrary {
"logsDesc": MessageLookupByLibrary.simpleMessage("日志捕获记录"),
"loopback": MessageLookupByLibrary.simpleMessage("回环解锁工具"),
"loopbackDesc": MessageLookupByLibrary.simpleMessage("用于UWP回环解锁"),
"loose": MessageLookupByLibrary.simpleMessage("紧凑"),
"loose": MessageLookupByLibrary.simpleMessage("宽松"),
"min": MessageLookupByLibrary.simpleMessage("最小"),
"minimizeOnExit": MessageLookupByLibrary.simpleMessage("退出时最小化"),
"minimizeOnExitDesc":
@@ -196,8 +214,10 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("指定对应域名服务器策略"),
"networkDetection": MessageLookupByLibrary.simpleMessage("网络检测"),
"networkSpeed": MessageLookupByLibrary.simpleMessage("网络速度"),
"noHotKey": MessageLookupByLibrary.simpleMessage("暂无快捷键"),
"noInfo": MessageLookupByLibrary.simpleMessage("暂无信息"),
"noMoreInfoDesc": MessageLookupByLibrary.simpleMessage("暂无更多信息"),
"noNetwork": MessageLookupByLibrary.simpleMessage("无网络"),
"noProxy": MessageLookupByLibrary.simpleMessage("暂无代理"),
"noProxyDesc":
MessageLookupByLibrary.simpleMessage("请创建配置文件或者添加有效配置文件"),
@@ -231,6 +251,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("请上传有效的二维码"),
"port": MessageLookupByLibrary.simpleMessage("端口"),
"preferH3Desc": MessageLookupByLibrary.simpleMessage("优先使用DOH的http/3"),
"pressKeyboard": MessageLookupByLibrary.simpleMessage("请按下按键"),
"preview": MessageLookupByLibrary.simpleMessage("预览"),
"profile": MessageLookupByLibrary.simpleMessage("配置"),
"profileAutoUpdateIntervalInvalidValidationDesc":
@@ -269,6 +290,7 @@ class MessageLookup extends MessageLookupByLibrary {
"remoteBackupDesc": MessageLookupByLibrary.simpleMessage("备份数据到WebDAV"),
"remoteRecoveryDesc":
MessageLookupByLibrary.simpleMessage("通过WebDAV恢复数据"),
"remove": MessageLookupByLibrary.simpleMessage("移除"),
"requests": MessageLookupByLibrary.simpleMessage("请求"),
"requestsDesc": MessageLookupByLibrary.simpleMessage("查看最近请求记录"),
"reset": MessageLookupByLibrary.simpleMessage("重置"),
@@ -318,7 +340,7 @@ class MessageLookup extends MessageLookupByLibrary {
"themeDesc": MessageLookupByLibrary.simpleMessage("设置深色模式,调整色彩"),
"themeMode": MessageLookupByLibrary.simpleMessage("主题模式"),
"threeColumns": MessageLookupByLibrary.simpleMessage("三列"),
"tight": MessageLookupByLibrary.simpleMessage("宽松"),
"tight": MessageLookupByLibrary.simpleMessage("紧凑"),
"time": MessageLookupByLibrary.simpleMessage("时间"),
"tip": MessageLookupByLibrary.simpleMessage("提示"),
"tools": MessageLookupByLibrary.simpleMessage("工具"),

View File

@@ -1290,10 +1290,10 @@ class AppLocalizations {
);
}
/// `Staring VPN...`
/// `Starting VPN...`
String get startVpn {
return Intl.message(
'Staring VPN...',
'Starting VPN...',
name: 'startVpn',
desc: '',
args: [],
@@ -2949,6 +2949,206 @@ class AppLocalizations {
args: [],
);
}
/// `Show/Hide`
String get action_view {
return Intl.message(
'Show/Hide',
name: 'action_view',
desc: '',
args: [],
);
}
/// `Start/Stop`
String get action_start {
return Intl.message(
'Start/Stop',
name: 'action_start',
desc: '',
args: [],
);
}
/// `Switch mode`
String get action_mode {
return Intl.message(
'Switch mode',
name: 'action_mode',
desc: '',
args: [],
);
}
/// `System proxy`
String get action_proxy {
return Intl.message(
'System proxy',
name: 'action_proxy',
desc: '',
args: [],
);
}
/// `TUN`
String get action_tun {
return Intl.message(
'TUN',
name: 'action_tun',
desc: '',
args: [],
);
}
/// `Disclaimer`
String get disclaimer {
return Intl.message(
'Disclaimer',
name: 'disclaimer',
desc: '',
args: [],
);
}
/// `This software is only used for non-commercial purposes such as learning exchanges and scientific research. It is strictly prohibited to use this software for commercial purposes. Any commercial activity, if any, has nothing to do with this software.`
String get disclaimerDesc {
return Intl.message(
'This software is only used for non-commercial purposes such as learning exchanges and scientific research. It is strictly prohibited to use this software for commercial purposes. Any commercial activity, if any, has nothing to do with this software.',
name: 'disclaimerDesc',
desc: '',
args: [],
);
}
/// `Agree`
String get agree {
return Intl.message(
'Agree',
name: 'agree',
desc: '',
args: [],
);
}
/// `Hotkey Management`
String get hotkeyManagement {
return Intl.message(
'Hotkey Management',
name: 'hotkeyManagement',
desc: '',
args: [],
);
}
/// `Use keyboard to control applications`
String get hotkeyManagementDesc {
return Intl.message(
'Use keyboard to control applications',
name: 'hotkeyManagementDesc',
desc: '',
args: [],
);
}
/// `Please press the keyboard.`
String get pressKeyboard {
return Intl.message(
'Please press the keyboard.',
name: 'pressKeyboard',
desc: '',
args: [],
);
}
/// `Please enter the correct hotkey`
String get inputCorrectHotkey {
return Intl.message(
'Please enter the correct hotkey',
name: 'inputCorrectHotkey',
desc: '',
args: [],
);
}
/// `Hotkey conflict`
String get hotkeyConflict {
return Intl.message(
'Hotkey conflict',
name: 'hotkeyConflict',
desc: '',
args: [],
);
}
/// `Remove`
String get remove {
return Intl.message(
'Remove',
name: 'remove',
desc: '',
args: [],
);
}
/// `No HotKey`
String get noHotKey {
return Intl.message(
'No HotKey',
name: 'noHotKey',
desc: '',
args: [],
);
}
/// `Make sure to reset the DNS`
String get dnsResetTip {
return Intl.message(
'Make sure to reset the DNS',
name: 'dnsResetTip',
desc: '',
args: [],
);
}
/// `No network`
String get noNetwork {
return Intl.message(
'No network',
name: 'noNetwork',
desc: '',
args: [],
);
}
/// `Allow IPv6 inbound`
String get ipv6InboundDesc {
return Intl.message(
'Allow IPv6 inbound',
name: 'ipv6InboundDesc',
desc: '',
args: [],
);
}
/// `Export logs`
String get exportLogs {
return Intl.message(
'Export logs',
name: 'exportLogs',
desc: '',
args: [],
);
}
/// `Export Success`
String get exportSuccess {
return Intl.message(
'Export Success',
name: 'exportSuccess',
desc: '',
args: [],
);
}
}
class AppLocalizationDelegate extends LocalizationsDelegate<AppLocalizations> {

View File

@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:fl_clash/clash/clash.dart';
import 'package:fl_clash/common/http.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/plugins/tile.dart';
import 'package:fl_clash/plugins/vpn.dart';
@@ -18,13 +18,15 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
clashCore.initMessage();
globalState.packageInfo = await PackageInfo.fromPlatform();
final version = await system.version;
final config = await preferences.getConfig() ?? Config();
globalState.autoRun = config.autoRun;
final clashConfig = await preferences.getClashConfig() ?? ClashConfig();
await android?.init();
await window?.init(config.windowProps);
await window?.init(config.windowProps, version);
final appState = AppState(
mode: clashConfig.mode,
version: version,
isCompatible: config.isCompatible,
selectedMap: config.currentSelectedMap,
);
@@ -32,11 +34,6 @@ Future<void> main() async {
openLogs: config.openLogs,
hasProxies: false,
);
globalState.updateTray(
appState: appState,
config: config,
clashConfig: clashConfig,
);
await globalState.init(
appState: appState,
config: config,
@@ -56,12 +53,14 @@ Future<void> vpnService() async {
WidgetsFlutterBinding.ensureInitialized();
globalState.isVpnService = true;
globalState.packageInfo = await PackageInfo.fromPlatform();
final version = await system.version;
final config = await preferences.getConfig() ?? Config();
final clashConfig = await preferences.getClashConfig() ?? ClashConfig();
final appState = AppState(
mode: clashConfig.mode,
isCompatible: config.isCompatible,
selectedMap: config.currentSelectedMap,
version: version,
);
await globalState.init(
appState: appState,
@@ -76,7 +75,7 @@ Future<void> vpnService() async {
clashCore.setFdMap(fd.id);
},
onProcess: (Process process) async {
final packageName = await app?.resolverProcess(process);
final packageName = await vpn?.resolverProcess(process);
clashCore.setProcessMap(
ProcessMapItem(
id: process.id,

View File

@@ -1,24 +1,28 @@
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
class AndroidContainer extends StatefulWidget {
class AndroidManager extends StatefulWidget {
final Widget child;
const AndroidContainer({
const AndroidManager({
super.key,
required this.child,
});
@override
State<AndroidContainer> createState() => _AndroidContainerState();
State<AndroidManager> createState() => _AndroidContainerState();
}
class _AndroidContainerState extends State<AndroidContainer>
with WidgetsBindingObserver {
class _AndroidContainerState extends State<AndroidManager> {
@override
void initState() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.initState();
}
Widget _excludeContainer(Widget child) {
return Selector<Config, bool>(
selector: (_, config) => config.isExclude,
@@ -44,31 +48,10 @@ class _AndroidContainerState extends State<AndroidContainer>
);
}
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addObserver(this);
}
@override
Future<void> didChangeAppLifecycleState(AppLifecycleState state) async {
final isPaused = state == AppLifecycleState.paused;
if (isPaused) {
await globalState.appController.savePreferences();
}
}
@override
Widget build(BuildContext context) {
return _systemUiOverlayContainer(
_excludeContainer(widget.child),
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}

View File

@@ -4,14 +4,21 @@ import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class AppStateContainer extends StatelessWidget {
class AppStateManager extends StatefulWidget {
final Widget child;
const AppStateContainer({
const AppStateManager({
super.key,
required this.child,
});
@override
State<AppStateManager> createState() => _AppStateManagerState();
}
class _AppStateManagerState extends State<AppStateManager>
with WidgetsBindingObserver {
_updateNavigationsContainer(Widget child) {
return Selector2<AppState, Config, UpdateNavigationsSelector>(
selector: (_, appState, config) {
@@ -38,10 +45,36 @@ class AppStateContainer extends StatelessWidget {
);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Future<void> didChangeAppLifecycleState(AppLifecycleState state) async {
final isPaused = state == AppLifecycleState.paused;
if (isPaused) {
await globalState.appController.savePreferences();
}
}
@override
void didChangePlatformBrightness() {
globalState.appController.appState.brightness =
WidgetsBinding.instance.platformDispatcher.platformBrightness;
}
@override
Widget build(BuildContext context) {
return _updateNavigationsContainer(
child,
widget.child,
);
}
}

View File

@@ -5,27 +5,27 @@ import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../common/function.dart';
import '../../common/function.dart';
class ClashContainer extends StatefulWidget {
class ClashManager extends StatefulWidget {
final Widget child;
const ClashContainer({
const ClashManager({
super.key,
required this.child,
});
@override
State<ClashContainer> createState() => _ClashContainerState();
State<ClashManager> createState() => _ClashContainerState();
}
class _ClashContainerState extends State<ClashContainer>
with AppMessageListener {
class _ClashContainerState extends State<ClashManager> with AppMessageListener {
Function? updateClashConfigDebounce;
Function? updateDelayDebounce;
Widget _updateContainer(Widget child) {
return Selector2<Config,ClashConfig, ClashConfigState>(
selector: (_,config, clashConfig) => ClashConfigState(
return Selector2<Config, ClashConfig, ClashConfigState>(
selector: (_, config, clashConfig) => ClashConfigState(
overrideDns: config.overrideDns,
mixedPort: clashConfig.mixedPort,
allowLan: clashConfig.allowLan,
@@ -45,14 +45,16 @@ class _ClashContainerState extends State<ClashContainer>
rules: clashConfig.rules,
globalRealUa: clashConfig.globalRealUa,
),
builder: (__, state, child) {
if (updateClashConfigDebounce == null) {
updateClashConfigDebounce = debounce<Function()>(() async {
shouldRebuild: (prev, next) {
if (prev != next) {
updateClashConfigDebounce ??= debounce<Function()>(() async {
await globalState.appController.updateClashConfig();
});
} else {
updateClashConfigDebounce!();
}
return prev != next;
},
builder: (__, state, child) {
return child!;
},
child: child,
@@ -64,6 +66,7 @@ class _ClashContainerState extends State<ClashContainer>
selector: (_, config, clashConfig) => CoreState(
accessControl: config.isAccessControl ? config.accessControl : null,
enable: config.vpnProps.enable,
ipv6: config.vpnProps.ipv6,
allowBypass: config.vpnProps.allowBypass,
systemProxy: config.vpnProps.systemProxy,
mixedPort: clashConfig.mixedPort,
@@ -130,19 +133,29 @@ class _ClashContainerState extends State<ClashContainer>
final appController = globalState.appController;
appController.setDelay(delay);
super.onDelay(delay);
await globalState.appController.updateGroupDebounce();
updateDelayDebounce ??= debounce(() async {
await appController.updateGroupDebounce();
await appController.addCheckIpNumDebounce();
});
updateDelayDebounce!();
}
@override
void onLog(Log log) {
globalState.appController.appState.addLog(log);
globalState.appController.appFlowingState.addLog(log);
if (log.logLevel == LogLevel.error) {
globalState.appController.showSnackBar(log.payload ?? '');
}
// debugPrint("$log");
debugPrint("$log");
super.onLog(log);
}
@override
void onStarted(String runTime) {
super.onStarted(runTime);
globalState.appController.applyProfileDebounce();
}
@override
void onRequest(Connection connection) async {
globalState.appController.appState.addRequest(connection);
@@ -157,7 +170,7 @@ class _ClashContainerState extends State<ClashContainer>
providerName,
),
);
appController.addCheckIpNumDebounce();
// appController.addCheckIpNumDebounce();
super.onLoaded(providerName);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/models/config.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
class HotKeyManager extends StatelessWidget {
final Widget child;
const HotKeyManager({
super.key,
required this.child,
});
_handleHotKeyAction(HotAction action) async {
switch (action) {
case HotAction.mode:
globalState.appController.updateMode();
case HotAction.start:
globalState.appController.updateStart();
case HotAction.view:
globalState.appController.updateVisible();
case HotAction.proxy:
globalState.appController.updateSystemProxy();
case HotAction.tun:
globalState.appController.updateTun();
}
}
_updateHotKeys({
required List<HotKeyAction> hotKeyActions,
}) async {
await hotKeyManager.unregisterAll();
final hotkeyActionHandles = hotKeyActions.where(
(hotKeyAction) {
return hotKeyAction.key != null && hotKeyAction.modifiers.isNotEmpty;
},
).map<Future>(
(hotKeyAction) async {
final modifiers = hotKeyAction.modifiers
.map((item) => item.toHotKeyModifier())
.toList();
final hotKey = HotKey(
key: PhysicalKeyboardKey(hotKeyAction.key!),
modifiers: modifiers,
);
return await hotKeyManager.register(
hotKey,
keyDownHandler: (_) {
_handleHotKeyAction(hotKeyAction.action);
},
);
},
);
await Future.wait(hotkeyActionHandles);
}
@override
Widget build(BuildContext context) {
return Selector<Config, List<HotKeyAction>>(
selector: (_, config) => config.hotKeyActions,
shouldRebuild: (prev, next) {
return !hotKeyActionsEquality.equals(prev, next);
},
builder: (_, hotKeyActions, __) {
_updateHotKeys(hotKeyActions: hotKeyActions);
return child;
},
child: child,
);
}
}

9
lib/manager/manager.dart Normal file
View File

@@ -0,0 +1,9 @@
export 'tray_manager.dart';
export 'window_manager.dart';
export 'android_manager.dart';
export 'clash_manager.dart';
export 'tile_manager.dart';
export 'app_state_manager.dart';
export 'vpn_manager.dart';
export 'media_manager.dart';
export 'proxy_manager.dart';

View File

@@ -0,0 +1,41 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/config.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class MediaManager extends StatelessWidget {
final Widget child;
const MediaManager({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return Selector<Config, ScaleProps>(
selector: (_, config) => config.scaleProps,
builder: (_, props, child) {
globalState.measure = Measure.of(context);
return child!;
// final textScaleFactor =
// WidgetsBinding.instance.platformDispatcher.textScaleFactor;
// return MediaQuery(
// data: MediaQuery.of(context).copyWith(
// textScaler: props.custom
// ? TextScaler.linear(props.scale * textScaleFactor)
// : null,
// ),
// child: Builder(
// builder: (context) {
// globalState.measure = Measure.of(context);
// return child!;
// },
// ),
// );
},
child: child,
);
}
}

View File

@@ -3,10 +3,10 @@ import 'package:fl_clash/models/models.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ProxyContainer extends StatelessWidget {
class ProxyManager extends StatelessWidget {
final Widget child;
const ProxyContainer({super.key, required this.child});
const ProxyManager({super.key, required this.child});
_updateProxy(ProxyState proxyState) {
final isStart = proxyState.isStart;
@@ -21,9 +21,9 @@ class ProxyContainer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Selector3<AppState, Config, ClashConfig, ProxyState>(
selector: (_, appState, config, clashConfig) => ProxyState(
isStart: appState.isStart,
return Selector3<AppFlowingState, Config, ClashConfig, ProxyState>(
selector: (_, appFlowingState, config, clashConfig) => ProxyState(
isStart: appFlowingState.isStart,
systemProxy: config.desktopProps.systemProxy,
port: clashConfig.mixedPort,
),

View File

@@ -2,19 +2,19 @@ import 'package:fl_clash/plugins/tile.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
class TileContainer extends StatefulWidget {
class TileManager extends StatefulWidget {
final Widget child;
const TileContainer({
const TileManager({
super.key,
required this.child,
});
@override
State<TileContainer> createState() => _TileContainerState();
State<TileManager> createState() => _TileContainerState();
}
class _TileContainerState extends State<TileContainer> with TileListener {
class _TileContainerState extends State<TileManager> with TileListener {
@override

178
lib/manager/tray_manager.dart Executable file
View File

@@ -0,0 +1,178 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:tray_manager/tray_manager.dart';
class TrayManager extends StatefulWidget {
final Widget child;
const TrayManager({
super.key,
required this.child,
});
@override
State<TrayManager> createState() => _TrayContainerState();
}
class _TrayContainerState extends State<TrayManager> with TrayListener {
@override
void initState() {
super.initState();
trayManager.addListener(this);
}
_updateSystemTray({
required bool isStart,
required Brightness? brightness,
}) async {
if (Platform.isLinux) {
await trayManager.destroy();
}
await trayManager.setIcon(
other.getTrayIconPath(
isStart: isStart,
brightness: brightness ??
WidgetsBinding.instance.platformDispatcher.platformBrightness,
),
);
if (!Platform.isLinux) {
await trayManager.setToolTip(
appName,
);
}
}
_updateTray(TrayState trayState) async {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!Platform.isLinux) {
_updateSystemTray(
isStart: trayState.isStart,
brightness: trayState.brightness,
);
}
List<MenuItem> menuItems = [];
final showMenuItem = MenuItem(
label: appLocalizations.show,
onClick: (_) {
window?.show();
},
);
menuItems.add(showMenuItem);
final startMenuItem = MenuItem.checkbox(
label:
trayState.isStart ? appLocalizations.stop : appLocalizations.start,
onClick: (_) async {
globalState.appController.updateStart();
},
checked: false,
);
menuItems.add(startMenuItem);
menuItems.add(MenuItem.separator());
for (final mode in Mode.values) {
menuItems.add(
MenuItem.checkbox(
label: Intl.message(mode.name),
onClick: (_) {
globalState.appController.clashConfig.mode = mode;
},
checked: mode == trayState.mode,
),
);
}
menuItems.add(MenuItem.separator());
if (trayState.isStart) {
menuItems.add(
MenuItem.checkbox(
label: appLocalizations.tun,
onClick: (_) {
globalState.appController.updateTun();
},
checked: trayState.tunEnable,
),
);
menuItems.add(
MenuItem.checkbox(
label: appLocalizations.systemProxy,
onClick: (_) {
globalState.appController.updateSystemProxy();
},
checked: trayState.systemProxy,
),
);
menuItems.add(MenuItem.separator());
}
final autoStartMenuItem = MenuItem.checkbox(
label: appLocalizations.autoLaunch,
onClick: (_) async {
globalState.appController.updateAutoLaunch();
},
checked: trayState.autoLaunch,
);
menuItems.add(autoStartMenuItem);
menuItems.add(MenuItem.separator());
final exitMenuItem = MenuItem(
label: appLocalizations.exit,
onClick: (_) async {
await globalState.appController.handleExit();
},
);
menuItems.add(exitMenuItem);
final menu = Menu();
menu.items = menuItems;
trayManager.setContextMenu(menu);
if (Platform.isLinux) {
_updateSystemTray(
isStart: trayState.isStart,
brightness: trayState.brightness,
);
}
});
}
@override
Widget build(BuildContext context) {
return Selector4<AppState, AppFlowingState, Config, ClashConfig, TrayState>(
selector: (_, appState, appFlowingState, config, clashConfig) =>
TrayState(
mode: clashConfig.mode,
autoLaunch: config.autoLaunch,
isStart: appFlowingState.isStart,
locale: config.locale,
systemProxy: config.desktopProps.systemProxy,
tunEnable: clashConfig.tun.enable,
brightness: appState.brightness,
),
shouldRebuild: (prev, next) {
return prev != next;
},
builder: (_, state, child) {
_updateTray(state);
return child!;
},
child: widget.child,
);
}
@override
void onTrayIconRightMouseDown() {
trayManager.popUpContextMenu();
}
@override
onTrayIconMouseDown() {
window?.show();
}
@override
dispose() {
trayManager.removeListener(this);
super.dispose();
}
}

View File

@@ -4,28 +4,28 @@ import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../common/function.dart';
import '../../common/function.dart';
class VpnContainer extends StatefulWidget {
class VpnManager extends StatefulWidget {
final Widget child;
const VpnContainer({
const VpnManager({
super.key,
required this.child,
});
@override
State<VpnContainer> createState() => _VpnContainerState();
State<VpnManager> createState() => _VpnContainerState();
}
class _VpnContainerState extends State<VpnContainer> {
class _VpnContainerState extends State<VpnManager> {
Function? vpnTipDebounce;
showTip() {
vpnTipDebounce ??= debounce<Function()>(() async {
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = globalState.appController.appState;
if (appState.isStart) {
final appFlowingState = globalState.appController.appFlowingState;
if (appFlowingState.isStart) {
globalState.showSnackBar(
context,
message: appLocalizations.vpnTip,

View File

@@ -7,19 +7,19 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:window_manager/window_manager.dart';
class WindowContainer extends StatefulWidget {
class WindowManager extends StatefulWidget {
final Widget child;
const WindowContainer({
const WindowManager({
super.key,
required this.child,
});
@override
State<WindowContainer> createState() => _WindowContainerState();
State<WindowManager> createState() => _WindowContainerState();
}
class _WindowContainerState extends State<WindowContainer> with WindowListener {
class _WindowContainerState extends State<WindowManager> with WindowListener {
Function? updateLaunchDebounce;
_autoLaunchContainer(Widget child) {
@@ -99,21 +99,30 @@ class WindowHeaderContainer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Stack(
children: [
Column(
return Selector<AppState, int>(
selector: (_, appState) => appState.version,
builder: (_, version, child) {
if (version <= 10 && Platform.isMacOS) {
return child!;
}
return Stack(
children: [
SizedBox(
height: kHeaderHeight,
),
Expanded(
flex: 1,
child: child,
Column(
children: [
SizedBox(
height: kHeaderHeight,
),
Expanded(
flex: 1,
child: child!,
),
],
),
const WindowHeader(),
],
),
const WindowHeader(),
],
);
},
child: child,
);
}
}
@@ -240,14 +249,20 @@ class _WindowHeaderState extends State<WindowHeader> {
),
),
),
const Positioned(
left: 0,
child: AppIcon(),
),
Positioned(
right: 0,
child: _buildActions(),
),
if (Platform.isMacOS)
const Text(
appName,
)
else ...[
const Positioned(
left: 0,
child: AppIcon(),
),
Positioned(
right: 0,
child: _buildActions(),
),
]
],
),
);

View File

@@ -4,27 +4,16 @@ import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/material.dart';
import 'connection.dart';
import 'common.dart';
import 'ffi.dart';
import 'log.dart';
import 'navigation.dart';
import 'package.dart';
import 'profile.dart';
import 'proxy.dart';
import 'system_color_scheme.dart';
import 'traffic.dart';
import 'version.dart';
typedef DelayMap = Map<String, int?>;
class AppState with ChangeNotifier {
List<NavigationItem> _navigationItems;
int? _runTime;
bool _isInit;
VersionInfo? _versionInfo;
List<Traffic> _traffics;
Traffic _totalTraffic;
List<Log> _logs;
String _currentLabel;
SystemColorSchemes _systemColorSchemes;
num _sortNum;
@@ -38,29 +27,31 @@ class AppState with ChangeNotifier {
num _checkIpNum;
List<ExternalProvider> _providers;
List<Package> _packages;
Brightness? _brightness;
int _version;
AppState({
required Mode mode,
required bool isCompatible,
required SelectedMap selectedMap,
required int version,
}) : _navigationItems = [],
_isInit = false,
_currentLabel = "dashboard",
_traffics = [],
_logs = [],
_viewWidth = 0,
_viewWidth = other.getScreenSize().width,
_selectedMap = selectedMap,
_sortNum = 0,
_checkIpNum = 0,
_requests = [],
_mode = mode,
_totalTraffic = Traffic(),
_brightness = null,
_delayMap = {},
_groups = [],
_providers = [],
_packages = [],
_isCompatible = isCompatible,
_systemColorSchemes = const SystemColorSchemes();
_systemColorSchemes = const SystemColorSchemes(),
_version = version;
String get currentLabel => _currentLabel;
@@ -103,17 +94,6 @@ class AppState with ChangeNotifier {
}
}
bool get isStart => _runTime != null;
int? get runTime => _runTime;
set runTime(int? value) {
if (_runTime != value) {
_runTime = value;
notifyListeners();
}
}
String getDesc(String type, String proxyName) {
final groupTypeNamesList = GroupType.values.map((e) => e.name).toList();
if (!groupTypeNamesList.contains(type)) {
@@ -160,33 +140,6 @@ class AppState with ChangeNotifier {
}
}
List<Traffic> get traffics => _traffics;
set traffics(List<Traffic> value) {
if (_traffics != value) {
_traffics = value;
notifyListeners();
}
}
addTraffic(Traffic traffic) {
_traffics = List.from(_traffics)..add(traffic);
const maxLength = 60;
if (_traffics.length > maxLength) {
_traffics = _traffics.sublist(_traffics.length - maxLength);
}
notifyListeners();
}
Traffic get totalTraffic => _totalTraffic;
set totalTraffic(Traffic value) {
if (_totalTraffic != value) {
_totalTraffic = value;
notifyListeners();
}
}
List<Connection> get requests => _requests;
set requests(List<Connection> value) {
@@ -198,28 +151,8 @@ class AppState with ChangeNotifier {
addRequest(Connection value) {
_requests = List.from(_requests)..add(value);
final maxLength = Platform.isAndroid ? 1000 : 60;
if (_requests.length > maxLength) {
_requests = _requests.sublist(_requests.length - maxLength);
}
notifyListeners();
}
List<Log> get logs => _logs;
set logs(List<Log> value) {
if (_logs != value) {
_logs = value;
notifyListeners();
}
}
addLog(Log log) {
_logs = List.from(_logs)..add(log);
final maxLength = Platform.isAndroid ? 1000 : 60;
if (_logs.length > maxLength) {
_logs = _logs.sublist(_logs.length - maxLength);
}
const maxLength = 1000;
_requests = _requests.safeSublist(_requests.length - maxLength);
notifyListeners();
}
@@ -354,7 +287,7 @@ class AppState with ChangeNotifier {
}
setProvider(ExternalProvider? provider) {
if(provider == null) return;
if (provider == null) return;
final index = _providers.indexWhere((item) => item.name == provider.name);
if (index == -1) return;
_providers = List.from(_providers)..[index] = provider;
@@ -366,4 +299,86 @@ class AppState with ChangeNotifier {
currentGroups.indexWhere((element) => element.name == groupName);
return index != -1 ? currentGroups[index] : null;
}
Brightness? get brightness => _brightness;
set brightness(Brightness? value) {
if (_brightness != value) {
_brightness = value;
notifyListeners();
}
}
int get version => _version;
set version(int value) {
if (_version != value) {
_version = value;
notifyListeners();
}
}
}
class AppFlowingState with ChangeNotifier {
int? _runTime;
List<Log> _logs;
List<Traffic> _traffics;
Traffic _totalTraffic;
AppFlowingState()
: _logs = [],
_traffics = [],
_totalTraffic = Traffic();
bool get isStart => _runTime != null;
int? get runTime => _runTime;
set runTime(int? value) {
if (_runTime != value) {
_runTime = value;
notifyListeners();
}
}
List<Log> get logs => _logs;
set logs(List<Log> value) {
if (_logs != value) {
_logs = value;
notifyListeners();
}
}
addLog(Log log) {
_logs = List.from(_logs)..add(log);
const maxLength = 1000;
_logs = _logs.safeSublist(_logs.length - maxLength);
notifyListeners();
}
List<Traffic> get traffics => _traffics;
set traffics(List<Traffic> value) {
if (_traffics != value) {
_traffics = value;
notifyListeners();
}
}
addTraffic(Traffic traffic) {
_traffics = List.from(_traffics)..add(traffic);
const maxLength = 60;
_traffics = _traffics.safeSublist(_traffics.length - maxLength);
notifyListeners();
}
Traffic get totalTraffic => _totalTraffic;
set totalTraffic(Traffic value) {
if (_totalTraffic != value) {
_totalTraffic = value;
notifyListeners();
}
}
}

View File

@@ -52,7 +52,7 @@ class Dns with _$Dns {
@Default(false) @JsonKey(name: "prefer-h3") bool preferH3,
@Default(true) @JsonKey(name: "use-hosts") bool useHosts,
@Default(true) @JsonKey(name: "use-system-hosts") bool useSystemHosts,
@Default(true) @JsonKey(name: "respect-rules") bool respectRules,
@Default(false) @JsonKey(name: "respect-rules") bool respectRules,
@Default(false) bool ipv6,
@Default(["223.5.5.5"])
@JsonKey(name: "default-nameserver")

437
lib/models/common.dart Normal file
View File

@@ -0,0 +1,437 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'generated/common.freezed.dart';
part 'generated/common.g.dart';
@freezed
class NavigationItem with _$NavigationItem {
const factory NavigationItem({
required Icon icon,
required String label,
final String? description,
required Widget fragment,
@Default(true) bool keep,
String? path,
@Default([NavigationItemMode.mobile, NavigationItemMode.desktop])
List<NavigationItemMode> modes,
}) = _NavigationItem;
}
@freezed
class Package with _$Package {
const factory Package({
required String packageName,
required String label,
required bool isSystem,
required int firstInstallTime,
}) = _Package;
factory Package.fromJson(Map<String, Object?> json) =>
_$PackageFromJson(json);
}
@freezed
class Metadata with _$Metadata {
const factory Metadata({
required int uid,
required String network,
required String sourceIP,
required String sourcePort,
required String destinationIP,
required String destinationPort,
required String host,
required String process,
required String remoteDestination,
}) = _Metadata;
factory Metadata.fromJson(Map<String, Object?> json) =>
_$MetadataFromJson(json);
}
@freezed
class Connection with _$Connection {
const factory Connection({
required String id,
num? upload,
num? download,
required DateTime start,
required Metadata metadata,
required List<String> chains,
}) = _Connection;
factory Connection.fromJson(Map<String, Object?> json) =>
_$ConnectionFromJson(json);
}
@JsonSerializable()
class Log {
@JsonKey(name: "LogLevel")
LogLevel logLevel;
@JsonKey(name: "Payload")
String? payload;
DateTime _dateTime;
Log({
required this.logLevel,
this.payload,
}) : _dateTime = DateTime.now();
DateTime get dateTime => _dateTime;
factory Log.fromJson(Map<String, dynamic> json) {
return _$LogFromJson(json);
}
Map<String, dynamic> toJson() {
return _$LogToJson(this);
}
@override
String toString() {
return 'Log{logLevel: $logLevel, payload: $payload, dateTime: $dateTime}';
}
}
@freezed
class LogsAndKeywords with _$LogsAndKeywords {
const factory LogsAndKeywords({
@Default([]) List<Log> logs,
@Default([]) List<String> keywords,
}) = _LogsAndKeywords;
factory LogsAndKeywords.fromJson(Map<String, Object?> json) =>
_$LogsAndKeywordsFromJson(json);
}
extension LogsAndKeywordsExt on LogsAndKeywords {
List<Log> get filteredLogs => logs
.where(
(log) => {log.logLevel.name}.containsAll(keywords),
)
.toList();
}
@freezed
class ConnectionsAndKeywords with _$ConnectionsAndKeywords {
const factory ConnectionsAndKeywords({
@Default([]) List<Connection> connections,
@Default([]) List<String> keywords,
}) = _ConnectionsAndKeywords;
factory ConnectionsAndKeywords.fromJson(Map<String, Object?> json) =>
_$ConnectionsAndKeywordsFromJson(json);
}
extension ConnectionsAndKeywordsExt on ConnectionsAndKeywords {
List<Connection> get filteredConnections => connections
.where((connection) => {
...connection.chains,
connection.metadata.process,
}.containsAll(keywords))
.toList();
}
const defaultDavFileName = "backup.zip";
@freezed
class DAV with _$DAV {
const factory DAV({
required String uri,
required String user,
required String password,
@Default(defaultDavFileName) String fileName,
}) = _DAV;
factory DAV.fromJson(Map<String, Object?> json) => _$DAVFromJson(json);
}
@freezed
class FileInfo with _$FileInfo {
const factory FileInfo({
required int size,
required DateTime lastModified,
}) = _FileInfo;
}
extension FileInfoExt on FileInfo {
String get desc =>
"${TrafficValue(value: size).show} · ${lastModified.lastUpdateTimeDesc}";
}
@freezed
class VersionInfo with _$VersionInfo {
const factory VersionInfo({
@Default("") String clashName,
@Default("") String version,
}) = _VersionInfo;
factory VersionInfo.fromJson(Map<String, Object?> json) =>
_$VersionInfoFromJson(json);
}
class Traffic {
int id;
TrafficValue up;
TrafficValue down;
Traffic({num? up, num? down})
: id = DateTime.now().millisecondsSinceEpoch,
up = TrafficValue(value: up),
down = TrafficValue(value: down);
num get speed => up.value + down.value;
factory Traffic.fromMap(Map<String, dynamic> map) {
return Traffic(
up: map['up'],
down: map['down'],
);
}
@override
String toString() {
return '$up$down';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Traffic &&
runtimeType == other.runtimeType &&
id == other.id &&
up == other.up &&
down == other.down;
@override
int get hashCode => id.hashCode ^ up.hashCode ^ down.hashCode;
}
@immutable
class TrafficValueShow {
final String value;
final TrafficUnit unit;
const TrafficValueShow({
required this.value,
required this.unit,
});
}
@immutable
class TrafficValue {
final num _value;
const TrafficValue({num? value}) : _value = value ?? 0;
num get value => _value;
String get show => "$showValue $showUnit";
String get showValue => trafficValueShow.value;
String get showUnit => trafficValueShow.unit.name;
TrafficValueShow get trafficValueShow {
if (_value > pow(1024, 4)) {
return TrafficValueShow(
value: (_value / pow(1024, 4)).fixed(),
unit: TrafficUnit.TB,
);
}
if (_value > pow(1024, 3)) {
return TrafficValueShow(
value: (_value / pow(1024, 3)).fixed(),
unit: TrafficUnit.GB,
);
}
if (_value > pow(1024, 2)) {
return TrafficValueShow(
value: (_value / pow(1024, 2)).fixed(), unit: TrafficUnit.MB);
}
if (_value > pow(1024, 1)) {
return TrafficValueShow(
value: (_value / pow(1024, 1)).fixed(),
unit: TrafficUnit.KB,
);
}
return TrafficValueShow(
value: _value.fixed(),
unit: TrafficUnit.B,
);
}
@override
String toString() {
return "$showValue$showUnit";
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TrafficValue &&
runtimeType == other.runtimeType &&
_value == other._value;
@override
int get hashCode => _value.hashCode;
}
typedef ProxyMap = Map<String, Proxy>;
@freezed
class Group with _$Group {
const factory Group({
required GroupType type,
@Default([]) List<Proxy> all,
String? now,
bool? hidden,
@Default("") String icon,
required String name,
}) = _Group;
factory Group.fromJson(Map<String, Object?> json) => _$GroupFromJson(json);
}
extension GroupExt on Group {
String get realNow => now ?? "";
String getCurrentSelectedName(String proxyName) {
if (type.isURLTestOrFallback) {
return realNow.isNotEmpty ? realNow : proxyName;
}
return proxyName.isNotEmpty ? proxyName : realNow;
}
}
@freezed
class Proxy with _$Proxy {
const factory Proxy({
required String name,
required String type,
String? now,
}) = _Proxy;
factory Proxy.fromJson(Map<String, Object?> json) => _$ProxyFromJson(json);
}
@immutable
class SystemColorSchemes {
final ColorScheme? lightColorScheme;
final ColorScheme? darkColorScheme;
const SystemColorSchemes({
this.lightColorScheme,
this.darkColorScheme,
});
getSystemColorSchemeForBrightness(Brightness? brightness) {
if (brightness == Brightness.dark) {
return darkColorScheme != null
? ColorScheme.fromSeed(
seedColor: darkColorScheme!.primary,
brightness: Brightness.dark,
)
: ColorScheme.fromSeed(
seedColor: defaultPrimaryColor,
brightness: Brightness.dark,
);
}
return lightColorScheme != null
? ColorScheme.fromSeed(seedColor: darkColorScheme!.primary)
: ColorScheme.fromSeed(seedColor: defaultPrimaryColor);
}
}
class IpInfo {
final String ip;
final String countryCode;
const IpInfo({
required this.ip,
required this.countryCode,
});
static IpInfo fromIpInfoIoJson(Map<String, dynamic> json) {
return switch (json) {
{
"ip": final String ip,
"country": final String country,
} =>
IpInfo(
ip: ip,
countryCode: country,
),
_ => throw const FormatException("invalid json"),
};
}
static IpInfo fromIpApiCoJson(Map<String, dynamic> json) {
return switch (json) {
{
"ip": final String ip,
"country_code": final String countryCode,
} =>
IpInfo(
ip: ip,
countryCode: countryCode,
),
_ => throw const FormatException("invalid json"),
};
}
static IpInfo fromIpSbJson(Map<String, dynamic> json) {
return switch (json) {
{
"ip": final String ip,
"country_code": final String countryCode,
} =>
IpInfo(
ip: ip,
countryCode: countryCode,
),
_ => throw const FormatException("invalid json"),
};
}
static IpInfo fromIpwhoIsJson(Map<String, dynamic> json) {
return switch (json) {
{
"ip": final String ip,
"country_code": final String countryCode,
} =>
IpInfo(
ip: ip,
countryCode: countryCode,
),
_ => throw const FormatException("invalid json"),
};
}
@override
String toString() {
return 'IpInfo{ip: $ip, countryCode: $countryCode}';
}
}
@freezed
class HotKeyAction with _$HotKeyAction {
const factory HotKeyAction({
required HotAction action,
int? key,
@Default({}) Set<KeyboardModifier> modifiers,
}) = _HotKeyAction;
factory HotKeyAction.fromJson(Map<String, Object?> json) =>
_$HotKeyActionFromJson(json);
}
const keyboardModifiersEquality = SetEquality<KeyboardModifier>();
const hotKeyActionsEquality = ListEquality<HotKeyAction>();

View File

@@ -42,6 +42,7 @@ class CoreState with _$CoreState {
required bool allowBypass,
required bool systemProxy,
required int mixedPort,
required bool ipv6,
required bool onlyProxy,
}) = _CoreState;
@@ -77,7 +78,8 @@ class WindowProps with _$WindowProps {
class VpnProps with _$VpnProps {
const factory VpnProps({
@Default(true) bool enable,
@Default(false) bool systemProxy,
@Default(true) bool systemProxy,
@Default(false) bool ipv6,
@Default(true) bool allowBypass,
}) = _VpnProps;
@@ -143,6 +145,8 @@ class Config extends ChangeNotifier {
DesktopProps _desktopProps;
bool _showLabel;
bool _overrideDns;
List<HotKeyAction> _hotKeyActions;
bool _isDisclaimerAccepted;
Config()
: _profiles = [],
@@ -172,7 +176,9 @@ class Config extends ChangeNotifier {
_desktopProps = const DesktopProps(),
_showLabel = false,
_overrideDns = false,
_scaleProps = const ScaleProps();
_scaleProps = const ScaleProps(),
_isDisclaimerAccepted = false,
_hotKeyActions = [];
deleteProfileById(String id) {
_profiles = profiles.where((element) => element.id != id).toList();
@@ -597,6 +603,37 @@ class Config extends ChangeNotifier {
}
}
@JsonKey(defaultValue: false)
bool get isDisclaimerAccepted => _isDisclaimerAccepted;
set isDisclaimerAccepted(bool value) {
if (_isDisclaimerAccepted != value) {
_isDisclaimerAccepted = value;
notifyListeners();
}
}
@JsonKey(defaultValue: [])
List<HotKeyAction> get hotKeyActions => _hotKeyActions;
set hotKeyActions(List<HotKeyAction> value) {
if (_hotKeyActions != value) {
_hotKeyActions = value;
notifyListeners();
}
}
updateOrAddHotKeyAction(HotKeyAction hotKeyAction) {
final index =
_hotKeyActions.indexWhere((item) => item.action == hotKeyAction.action);
if (index == -1) {
_hotKeyActions = List.from(_hotKeyActions)..add(hotKeyAction);
} else {
_hotKeyActions = List.from(_hotKeyActions)..[index] = hotKeyAction;
}
notifyListeners();
}
update([
Config? config,
RecoveryOption recoveryOptions = RecoveryOption.all,
@@ -636,6 +673,7 @@ class Config extends ChangeNotifier {
_vpnProps = config._vpnProps;
_overrideDns = config._overrideDns;
_desktopProps = config._desktopProps;
_hotKeyActions = config._hotKeyActions;
}
notifyListeners();
}

View File

@@ -1,58 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'generated/connection.g.dart';
part 'generated/connection.freezed.dart';
@freezed
class Metadata with _$Metadata {
const factory Metadata({
required int uid,
required String network,
required String sourceIP,
required String sourcePort,
required String destinationIP,
required String destinationPort,
required String host,
required String process,
required String remoteDestination,
}) = _Metadata;
factory Metadata.fromJson(Map<String, Object?> json) =>
_$MetadataFromJson(json);
}
@freezed
class Connection with _$Connection {
const factory Connection({
required String id,
num? upload,
num? download,
required DateTime start,
required Metadata metadata,
required List<String> chains,
}) = _Connection;
factory Connection.fromJson(Map<String, Object?> json) =>
_$ConnectionFromJson(json);
}
@freezed
class ConnectionsAndKeywords with _$ConnectionsAndKeywords {
const factory ConnectionsAndKeywords({
@Default([]) List<Connection> connections,
@Default([]) List<String> keywords,
}) = _ConnectionsAndKeywords;
factory ConnectionsAndKeywords.fromJson(Map<String, Object?> json) =>
_$ConnectionsAndKeywordsFromJson(json);
}
extension ConnectionsAndKeywordsExt on ConnectionsAndKeywords {
List<Connection> get filteredConnections => connections
.where((connection) => {
...connection.chains,
connection.metadata.process,
}.containsAll(keywords))
.toList();
}

Some files were not shown because too many files have changed in this diff Show More