Compare commits

..

42 Commits

Author SHA1 Message Date
chen08209
aa4ffbe4fb Fix fab issues 2024-07-14 01:37:44 +08:00
chen08209
3d25298639 Update version 2024-07-14 00:05:42 +08:00
chen08209
1765576d09 Fix the problem that vpn cannot be started in some cases
Fix the problem that geodata url does not take effect
2024-07-14 00:01:18 +08:00
chen08209
2dd45062f1 Update ua
Fix change outbound mode without check ip issues
2024-07-13 20:43:28 +08:00
chen08209
c6407984ac Separate android ui and vpn 2024-07-13 20:43:21 +08:00
chen08209
53af86238e Fix url validate issues 2
Add android hidden from the recent task

Add geoip file

Support modify geoData URL
2024-07-13 20:43:18 +08:00
chen08209
b20d9edec2 Fix url validate issues
Fix check ip performance problem

Optimize resources page
2024-07-07 13:37:08 +08:00
chen08209
5c3a0c576d Add ua selector
Support modify test url

Optimize android proxy

Fix the error that async proxy provider could not selected the proxy
2024-07-04 09:55:06 +08:00
chen08209
6dcb466fd3 Fix android proxy error 2024-07-01 20:57:24 +08:00
chen08209
acbcec358b Fix submit error 2024-07-01 19:51:11 +08:00
chen08209
a923549ddf Add windows tun
Optimize android proxy

Optimize change profile

Update application ua

Optimize delay test
2024-07-01 19:41:57 +08:00
chen08209
07bd21580b Fix android repeated request notification issues 2024-06-28 21:16:47 +08:00
chen08209
57ceb64a5e Fix memory overflow issues 2024-06-28 07:49:06 +08:00
chen08209
713e83d9d8 Optimize proxies expansion panel 2
Fix android scan qrcode error
2024-06-27 19:39:49 +08:00
chen08209
5e3b0e4929 Optimize proxies expansion panel
Fix text error
2024-06-27 15:54:10 +08:00
chen08209
0389b6eb29 Optimize proxy
Optimize delayed sorting performance

Add expansion panel proxies page

Support to adjust the proxy card size

Support to adjust proxies columns number
2024-06-26 16:04:30 +08:00
chen08209
8f22cbf746 Fix autoRun show issues
Fix Android 10 issues

Optimize ip show
2024-06-23 03:07:52 +08:00
chen08209
1fcc412770 Add intranet IP display
Add connections page

Add search in connections, requests

Add keyword search in connections, requests, logs

Add basic viewing editing capabilities

Optimize update profile
2024-06-22 13:52:20 +08:00
chen08209
afa1b4f424 Update version 2024-06-19 10:12:21 +08:00
chen08209
fa67940ec9 Fix the problem of excessive memory usage in traffic usage.
Add lightBlue theme color

Fix start unable to update profile issues
2024-06-19 10:10:41 +08:00
chen08209
90bb670442 Fix flashback caused by process 2024-06-17 15:49:16 +08:00
chen08209
05abf2d56d Add build version
Optimize quick start

Update system default option
2024-06-16 16:48:52 +08:00
chen08209
658727dd79 Update build.yml 2024-06-16 13:18:55 +08:00
chen08209
f7abf6446c Fix android vpn close issues
Add requests page

Fix checkUpdate dark mode style error

Fix quickStart error open app

Add memory proxies tab index

Support hidden group

Optimize logs
2024-06-16 13:06:34 +08:00
chen08209
5ab4dd0cbd Fix externalController hot load error 2024-06-13 19:22:26 +08:00
chen08209
35f7279fcb Add tcp concurrent switch
Add system proxy switch

Add geodata loader switch

Add external controller switch

Add auto gc on trim memory

Fix android notification error
2024-06-13 17:04:57 +08:00
chen08209
86572cc960 Fix ipv6 error 2024-06-12 19:07:54 +08:00
chen08209
ee22709d49 Fix android udp direct error
Add ipv6 switch

Add access all selected button

Remove android low version splash
2024-06-12 18:29:58 +08:00
chen08209
0a2ad63f38 Update version 2024-06-10 19:11:04 +08:00
chen08209
2ec12c9363 Add allowBypass
Fix Android only pick .text file issues
2024-06-10 19:09:58 +08:00
chen08209
a3c2dc786c Fix search issues 2024-06-09 21:48:17 +08:00
chen08209
7acf9c6db3 Fix LoadBalance, Relay load error 2024-06-09 20:53:36 +08:00
chen08209
8074547fb4 Fix build.yml4 2024-06-09 19:56:51 +08:00
chen08209
8a01e04871 Fix build.yml3 2024-06-09 19:49:51 +08:00
chen08209
7ddcdd9828 Fix build.yml2 2024-06-09 19:49:14 +08:00
chen08209
d89ed076fd Fix build.yml 2024-06-09 19:46:05 +08:00
chen08209
f4c3b06cd5 Add search function at access control
Fix the issues with the profile add button to cover the edit button

Adapt LoadBalance and Relay

Add arm

Fix android notification icon error
2024-06-09 19:25:14 +08:00
chen08209
c65746709d Add one-click update all profiles
Add expire show
2024-06-08 15:43:28 +08:00
chen08209
d2d9bdab02 Temp remove tun mode 2024-06-06 22:58:32 +08:00
chen08209
d0f8444b6d Remove macos in workflow 2024-06-06 22:29:26 +08:00
chen08209
fccabfbe27 Change go version 2024-06-06 22:04:29 +08:00
chen08209
e9bb97c6ce Update Version
Fix tun unable to open
2024-06-06 17:36:49 +08:00
139 changed files with 14440 additions and 3145 deletions

View File

@@ -21,6 +21,26 @@ jobs:
os: macos-13
steps:
- name: Setup Mingw64
if: startsWith(matrix.platform,'windows')
uses: msys2/setup-msys2@v2
with:
msystem: mingw64
install: mingw-w64-x86_64-gcc
update: true
- name: Set Mingw64 Env
if: startsWith(matrix.platform,'windows')
run: |
echo "${{ runner.temp }}\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Check Matrix
run: |
echo "Running on ${{ matrix.os }}"
echo "Arch: ${{ runner.arch }}"
gcc --version
- name: Checkout
uses: actions/checkout@v4
with:
@@ -46,10 +66,10 @@ jobs:
if: startsWith(matrix.platform,'android')
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
- name: Setup Go
uses: actions/setup-go@v5
@@ -82,6 +102,7 @@ jobs:
upload-release:
if: ${{ !contains(github.ref, '+') }}
permissions: write-all
needs: [ build ]
runs-on: ubuntu-latest

2
.gitignore vendored
View File

@@ -31,6 +31,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
/dist/
# Symbolication related
app.*.symbols
@@ -44,6 +45,7 @@ app.*.map.json
/android/app/release
#libclash
/libclash/

2
.gitmodules vendored
View File

@@ -5,4 +5,4 @@
[submodule "plugins/flutter_distributor"]
path = plugins/flutter_distributor
url = git@github.com:chen08209/flutter_distributor.git
branch = main
branch = FlClash

View File

@@ -1,9 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
@@ -17,14 +14,18 @@
<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" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
tools:ignore="SystemPermissionTypo" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config"
android:label="FlClash">
android:extractNativeLibs="true"
android:label="FlClash"
tools:targetApi="n">
<activity
android:name="com.follow.clash.MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -73,7 +74,8 @@
<service
android:name=".services.FlClashTileService"
android:exported="true"
android:icon="@drawable/tile_icon"
android:icon="@drawable/icon"
android:foregroundServiceType="specialUse"
android:label="FlClash"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>

View File

@@ -1,9 +1,15 @@
package com.follow.clash
import android.content.Context
import androidx.lifecycle.MutableLiveData
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ProxyPlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import java.util.Date
import io.flutter.embedding.engine.dart.DartExecutor
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
enum class RunState {
START,
@@ -11,13 +17,46 @@ enum class RunState {
STOP
}
class GlobalState {
companion object {
val runState: MutableLiveData<RunState> = MutableLiveData<RunState>(RunState.STOP)
var runTime: Date? = null
var flutterEngine: FlutterEngine? = null
fun getCurrentTilePlugin(): TilePlugin? =
flutterEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
object GlobalState {
private val lock = ReentrantLock()
val runState: MutableLiveData<RunState> = MutableLiveData<RunState>(RunState.STOP)
var flutterEngine: FlutterEngine? = null
private var serviceEngine: FlutterEngine? = null
fun getCurrentAppPlugin(): AppPlugin? {
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
return currentEngine?.plugins?.get(AppPlugin::class.java) as AppPlugin?
}
fun getCurrentTitlePlugin(): TilePlugin? {
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
return currentEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
}
fun destroyServiceEngine() {
serviceEngine?.destroy()
serviceEngine = null
}
fun initServiceEngine(context: Context) {
if (serviceEngine != null) return
lock.withLock {
if (serviceEngine != null) return
serviceEngine = FlutterEngine(context)
serviceEngine?.plugins?.add(ProxyPlugin())
serviceEngine?.plugins?.add(AppPlugin())
serviceEngine?.plugins?.add(TilePlugin())
val vpnService = DartExecutor.DartEntrypoint(
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
"vpnService"
)
serviceEngine?.dartExecutor?.executeDartEntrypoint(
vpnService,
)
}
}
}

View File

@@ -10,7 +10,6 @@ import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
GlobalState.flutterEngine?.destroy()
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(AppPlugin())
flutterEngine.plugins.add(ProxyPlugin())

View File

@@ -1,17 +1,18 @@
package com.follow.clash.extensions
import java.net.InetSocketAddress
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.system.OsConstants.IPPROTO_TCP
import android.system.OsConstants.IPPROTO_UDP
import android.util.Base64
import java.net.URL
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.models.Metadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
suspend fun Drawable.getBase64(): String {
@@ -29,3 +30,8 @@ fun Metadata.getProtocol(): Int? {
if (network.startsWith("udp")) return IPPROTO_UDP
return null
}
fun String.getInetSocketAddress(): InetSocketAddress {
val url = URL("https://$this")
return InetSocketAddress(InetAddress.getByName(url.host), url.port)
}

View File

@@ -1,11 +1,15 @@
package com.follow.clash.models
data class Process(
val id: Int,
val metadata: Metadata,
)
data class Metadata(
val network: String,
val sourceIP: String,
val sourcePort: Int,
val destinationIP: String,
val destinationPort: Int,
val remoteDestination: String,
val host: String
)

View File

@@ -10,3 +10,9 @@ data class AccessControl(
val acceptList: List<String>,
val rejectList: List<String>,
)
data class Props(
val accessControl: AccessControl?,
val allowBypass: Boolean?,
val systemProxy: Boolean?,
)

View File

@@ -2,24 +2,27 @@ package com.follow.clash.plugins
import android.Manifest
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat.getSystemService
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.Metadata
import com.follow.clash.models.Package
import com.follow.clash.models.Process
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@@ -27,6 +30,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.InetSocketAddress
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
private var activity: Activity? = null
@@ -44,31 +48,41 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
private val iconMap = mutableMapOf<String, String?>()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
context = flutterPluginBinding.applicationContext;
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
}
private fun tip(message: String?) {
if (toast != null) {
toast!!.cancel()
if(GlobalState.flutterEngine == null){
if (toast != null) {
toast!!.cancel()
}
toast = Toast.makeText(context, message, Toast.LENGTH_SHORT)
toast!!.show()
}
toast = Toast.makeText(context, message, Toast.LENGTH_SHORT)
toast!!.show()
}
@RequiresApi(Build.VERSION_CODES.Q)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"moveTaskToBack" -> {
activity?.moveTaskToBack(true)
result.success(true);
}
"updateExcludeFromRecents" -> {
val value = call.argument<Boolean>("value")
updateExcludeFromRecents(value)
result.success(true);
}
"getPackages" -> {
scope.launch {
result.success(getPackages())
@@ -78,21 +92,34 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
"getPackageIcon" -> {
scope.launch {
val packageName = call.argument<String>("packageName")
if (packageName != null) {
result.success(getPackageIcon(packageName))
} else {
if (packageName == null) {
result.success(null)
return@launch
}
val packageIcon = getPackageIcon(packageName)
packageIcon.let {
if (it != null) {
result.success(it)
return@launch
}
if (iconMap["default"] == null) {
iconMap["default"] =
context?.packageManager?.defaultActivityIcon?.getBase64()
}
result.success(iconMap["default"])
return@launch
}
}
}
"getPackageName" -> {
"resolverProcess" -> {
val data = call.argument<String>("data")
val metadata =
val process =
if (data != null) Gson().fromJson(
data,
Metadata::class.java
Process::class.java
) else null
val metadata = process?.metadata
val protocol = metadata?.getProtocol()
if (protocol == null) {
result.success(null)
@@ -100,17 +127,27 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
scope.launch {
withContext(Dispatchers.Default) {
if (context == null) result.success(null)
val source = InetSocketAddress(metadata.sourceIP, metadata.sourcePort)
val target = InetSocketAddress(
metadata.host.ifEmpty { metadata.destinationIP },
metadata.destinationPort
)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
result.success(null)
return@withContext
}
if (context == null) {
result.success(null)
return@withContext
}
if (connectivity == null) {
connectivity = context!!.getSystemService<ConnectivityManager>()
}
val uid =
connectivity?.getConnectionOwnerUid(protocol, source, target)
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
@@ -125,7 +162,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
val message = call.argument<String>("message")
tip(message)
result.success(true)
}
else -> {
@@ -134,16 +170,39 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
private fun updateExcludeFromRecents(value: Boolean?) {
if (context == null) return
val am = getSystemService(context!!, ActivityManager::class.java)
val task = am?.appTasks?.firstOrNull {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.taskInfo.taskId == activity?.taskId
} else {
it.taskInfo.id == activity?.taskId
}
}
when (value) {
true -> task?.setExcludeFromRecents(value)
false -> task?.setExcludeFromRecents(value)
null -> task?.setExcludeFromRecents(false)
}
}
private suspend fun getPackageIcon(packageName: String): String? {
val packageManager = context?.packageManager
if (iconMap[packageName] == null) {
iconMap[packageName] = packageManager?.getApplicationIcon(packageName)?.getBase64()
iconMap[packageName] = try {
packageManager?.getApplicationIcon(packageName)?.getBase64()
} catch (_: Exception) {
null
}
}
return iconMap[packageName]
}
private suspend fun getPackages(): String {
return withContext(Dispatchers.Default){
return withContext(Dispatchers.Default) {
val packageManager = context?.packageManager
val packages: List<Package>? =
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA)?.filter {
@@ -162,13 +221,16 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
fun requestGc() {
channel.invokeMethod("gc", null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity;
scope = CoroutineScope(Dispatchers.Default)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null;
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
@@ -177,7 +239,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
override fun onDetachedFromActivity() {
channel.invokeMethod("exit", null)
scope.cancel()
activity = null;
activity = null
}
}

View File

@@ -1,7 +1,6 @@
package com.follow.clash.plugins
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ComponentName
import android.content.Context
@@ -9,14 +8,13 @@ import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Build
import android.os.IBinder
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.models.AccessControl
import com.follow.clash.models.Props
import com.follow.clash.services.FlClashVpnService
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity
@@ -25,37 +23,36 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.Date
class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
private lateinit var flutterMethodChannel: MethodChannel
val VPN_PERMISSION_REQUEST_CODE = 1001
val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
private lateinit var flutterMethodChannel: MethodChannel
private var activity: Activity? = null
private var context: Context? = null
private var flClashVpnService: FlClashVpnService? = null
private var isBound = false
private var port: Int? = null
private var accessControl: AccessControl? = null
private lateinit var title: String
private lateinit var content: String
private var port: Int = 7890
private var props: Props? = null
private var isBlockNotification: Boolean = false
private var isStart: Boolean = false
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as FlClashVpnService.LocalBinder
flClashVpnService = binder.getService()
port?.let { startVpn(it) }
isBound = true
if (isStart) {
startVpn()
} else {
flClashVpnService?.initServiceEngine()
}
}
override fun onServiceDisconnected(arg0: ComponentName) {
override fun onServiceDisconnected(arg: ComponentName) {
flClashVpnService = null
isBound = false
}
}
@@ -70,21 +67,29 @@ class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAwar
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
"StartProxy" -> {
port = call.argument<Int>("port")
val args = call.argument<String>("args")
accessControl =
if (args != null) Gson().fromJson(args, AccessControl::class.java) else null
handleStartVpn()
"initService" -> {
isStart = false
initService()
requestNotificationsPermission()
result.success(true)
}
"StopProxy" -> {
"startProxy" -> {
isStart = true
port = call.argument<Int>("port")!!
val args = call.argument<String>("args")
props =
if (args != null) Gson().fromJson(args, Props::class.java) else null
startVpn()
result.success(true)
}
"stopProxy" -> {
stopVpn()
result.success(true)
}
"SetProtect" -> {
"setProtect" -> {
val fd = call.argument<Int>("fd")
if (fd != null) {
flClashVpnService?.protect(fd)
@@ -94,14 +99,10 @@ class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAwar
}
}
"GetRunTimeStamp" -> {
result.success(GlobalState.runTime?.time)
}
"startForeground" -> {
title = call.argument<String>("title") as String
content = call.argument<String>("content") as String
requestNotificationsPermission()
val title = call.argument<String>("title") as String
val content = call.argument<String>("content") as String
startForeground(title, content)
result.success(true)
}
@@ -110,65 +111,41 @@ class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAwar
}
}
private fun handleStartVpn() {
private fun initService() {
val intent = VpnService.prepare(context)
if (intent != null) {
activity?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
} else {
bindService()
if (flClashVpnService != null) {
flClashVpnService!!.initServiceEngine()
} else {
bindService()
}
}
}
private fun startVpn(port: Int) {
if (GlobalState.runState.value == RunState.START) return;
flClashVpnService?.start(port, accessControl)
private fun startVpn() {
if (flClashVpnService == null) {
bindService()
return
}
if (GlobalState.runState.value == RunState.START) return
GlobalState.runState.value = RunState.START
GlobalState.runTime = Date()
startAfter()
flutterMethodChannel.invokeMethod("started", flClashVpnService?.start(port, props))
}
private fun stopVpn() {
if (GlobalState.runState.value == RunState.STOP) return
GlobalState.runState.value = RunState.STOP
flClashVpnService?.stop()
unbindService()
GlobalState.runState.value = RunState.STOP;
GlobalState.runTime = null;
GlobalState.destroyServiceEngine()
}
@SuppressLint("ForegroundServiceType")
private fun startForeground() {
private fun startForeground(title: String, content: String) {
if (GlobalState.runState.value != RunState.START) return
flClashVpnService?.startForeground(title, content)
}
private fun requestNotificationsPermission() {
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
val permission = context?.let {
ContextCompat.checkSelfPermission(
it,
Manifest.permission.POST_NOTIFICATIONS
)
}
if (permission == PackageManager.PERMISSION_GRANTED) {
startForeground()
} else {
activity?.let {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
)
}
}
} else {
startForeground()
}
}
private fun startAfter() {
flutterMethodChannel.invokeMethod("startAfter", flClashVpnService?.fd)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(::onActivityResult)
@@ -183,7 +160,7 @@ class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAwar
stopVpn()
}
}
return true;
return true
}
private fun onRequestPermissionsResultListener(
@@ -192,16 +169,33 @@ class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAwar
grantResults: IntArray
): Boolean {
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startForeground()
}
isBlockNotification = true
}
return true;
return false
}
private fun requestNotificationsPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = context?.let {
ContextCompat.checkSelfPermission(
it,
Manifest.permission.POST_NOTIFICATIONS
)
}
if (permission != PackageManager.PERMISSION_GRANTED) {
if (isBlockNotification) return
if (activity == null) return
ActivityCompat.requestPermissions(
activity!!,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
)
}
}
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null;
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
@@ -209,7 +203,6 @@ class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAwar
}
override fun onDetachedFromActivity() {
stopVpn()
activity = null
}
@@ -217,11 +210,4 @@ class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAwar
val intent = Intent(context, FlClashVpnService::class.java)
context?.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
private fun unbindService() {
if (isBound) {
context?.unbindService(connection)
isBound = false
}
}
}

View File

@@ -1,3 +1,4 @@
package com.follow.clash.plugins
import io.flutter.embedding.engine.plugins.FlutterPlugin

View File

@@ -1,9 +1,9 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
@@ -11,12 +11,6 @@ import androidx.lifecycle.Observer
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.TempActivity
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ProxyPlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
@RequiresApi(Build.VERSION_CODES.N)
@@ -43,54 +37,45 @@ 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)
if (Build.VERSION.SDK_INT >= 34) {
val pendingIntent = PendingIntent.getActivity(
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(pendingIntent)
} else {
startActivityAndCollapse(intent)
}
}
private var flutterEngine: FlutterEngine? = null;
private fun initFlutterEngine() {
flutterEngine = FlutterEngine(this)
flutterEngine?.plugins?.add(ProxyPlugin())
flutterEngine?.plugins?.add(TilePlugin())
flutterEngine?.plugins?.add(AppPlugin())
GlobalState.flutterEngine = flutterEngine
if (flutterEngine?.dartExecutor?.isExecutingDart != true) {
val vpnService = DartExecutor.DartEntrypoint(
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
"vpnService"
)
flutterEngine?.dartExecutor?.executeDartEntrypoint(vpnService)
}
}
override fun onClick() {
super.onClick()
activityTransfer()
val currentTilePlugin = GlobalState.getCurrentTilePlugin()
if (GlobalState.runState.value == RunState.STOP) {
GlobalState.runState.value = RunState.PENDING
if(currentTilePlugin == null){
initFlutterEngine()
}else{
currentTilePlugin.handleStart()
val titlePlugin = GlobalState.getCurrentTitlePlugin()
if (titlePlugin != null) {
titlePlugin.handleStart()
} else {
GlobalState.initServiceEngine(this)
}
} else if(GlobalState.runState.value == RunState.START){
} else if (GlobalState.runState.value == RunState.START) {
GlobalState.runState.value = RunState.PENDING
currentTilePlugin?.handleStop()
GlobalState.getCurrentTitlePlugin()?.handleStop()
}
}

View File

@@ -1,10 +1,9 @@
package com.follow.clash.services
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.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.net.ProxyInfo
@@ -13,21 +12,16 @@ import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
import androidx.core.graphics.drawable.IconCompat
import com.follow.clash.GlobalState
import com.follow.clash.MainActivity
import com.follow.clash.models.AccessControl
import com.follow.clash.R
import com.follow.clash.models.AccessControlMode
import com.follow.clash.models.Props
@SuppressLint("WrongConstant")
class FlClashVpnService : VpnService() {
private val CHANNEL = "FlClash"
var fd: Int? = null
private val notificationId: Int = 1
private val passList = listOf(
@@ -50,16 +44,17 @@ class FlClashVpnService : VpnService() {
"192.168.*"
)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
override fun onCreate() {
super.onCreate()
initServiceEngine()
}
fun start(port: Int, accessControl: AccessControl?) {
fd = with(Builder()) {
fun start(port: Int, props: Props?): Int? {
return with(Builder()) {
addAddress("172.16.0.1", 30)
setMtu(9000)
addRoute("0.0.0.0", 0)
if (accessControl != null) {
props?.accessControl?.let { accessControl ->
when (accessControl.mode) {
AccessControlMode.acceptSelected -> {
(accessControl.acceptList + packageName).forEach {
@@ -80,8 +75,10 @@ class FlClashVpnService : VpnService() {
if (Build.VERSION.SDK_INT >= 29) {
setMetered(false)
}
allowBypass()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (props?.allowBypass == true) {
allowBypass()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && props?.systemProxy == true) {
setHttpProxy(
ProxyInfo.buildDirectProxy(
"127.0.0.1",
@@ -99,11 +96,10 @@ class FlClashVpnService : VpnService() {
stopForeground()
}
private val notificationBuilder by lazy {
private val notificationBuilder: NotificationCompat.Builder by lazy {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
@@ -118,35 +114,44 @@ class FlClashVpnService : VpnService() {
PendingIntent.FLAG_UPDATE_CURRENT
)
}
val icon = IconCompat.createWithResource(this, this.applicationInfo.icon)
with(NotificationCompat.Builder(this, CHANNEL)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setSmallIcon(icon)
}
setSmallIcon(R.drawable.ic_stat_name)
setContentTitle("FlClash")
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
setContentIntent(pendingIntent)
setCategory(NotificationCompat.CATEGORY_SERVICE)
priority = NotificationCompat.PRIORITY_LOW
priority = NotificationCompat.PRIORITY_MIN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
}
setOngoing(true)
setShowWhen(false)
setOnlyAlertOnce(true)
setAutoCancel(true)
}
}
@SuppressLint("ForegroundServiceType", "WrongConstant")
fun initServiceEngine() {
GlobalState.initServiceEngine(this)
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
GlobalState.getCurrentAppPlugin()?.requestGc()
}
fun startForeground(title: String, content: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
var channel = manager?.getNotificationChannel(CHANNEL)
if (channel == null) {
channel =
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
manager?.createNotificationChannel(channel)
}
}
val notification =
notificationBuilder.setContentTitle(title).setContentText(content).build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(notificationId, notification)
@@ -155,7 +160,7 @@ class FlClashVpnService : VpnService() {
private fun stopForeground() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
@@ -170,7 +175,6 @@ class FlClashVpnService : VpnService() {
}
override fun onUnbind(intent: Intent?): Boolean {
GlobalState.getCurrentTilePlugin()?.handleStop();
return super.onUnbind(intent)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -4,7 +4,6 @@
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@mipmap/ic_launcher_foreground</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -4,7 +4,6 @@
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@mipmap/ic_launcher_foreground</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -7,4 +7,8 @@
<certificates src="user" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>

BIN
assets/data/GeoIP.dat Normal file

Binary file not shown.

View File

@@ -2,19 +2,20 @@ package main
import "C"
import (
"github.com/metacubex/mihomo/adapter"
"github.com/metacubex/mihomo/adapter/inbound"
"github.com/metacubex/mihomo/adapter/outboundgroup"
ap "github.com/metacubex/mihomo/adapter/provider"
"github.com/metacubex/mihomo/component/dialer"
"github.com/metacubex/mihomo/component/process"
"github.com/metacubex/mihomo/component/resolver"
"github.com/metacubex/mihomo/config"
"github.com/metacubex/mihomo/constant/provider"
"github.com/metacubex/mihomo/dns"
"github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/hub"
"github.com/metacubex/mihomo/hub/executor"
"github.com/metacubex/mihomo/hub/route"
"github.com/metacubex/mihomo/listener"
"github.com/metacubex/mihomo/log"
"github.com/metacubex/mihomo/tunnel"
"math"
"os"
"os/exec"
"path/filepath"
@@ -60,11 +61,17 @@ type ruleProviderSchema struct {
Interval int `provider:"interval,omitempty"`
}
type ConfigExtendedParams struct {
IsPatch bool `json:"is-patch"`
IsCompatible bool `json:"is-compatible"`
SelectedMap map[string]string `json:"selected-map"`
TestURL *string `json:"test-url"`
}
type GenerateConfigParams struct {
ProfilePath *string `json:"profile-path"`
Config *config.RawConfig `json:"config" `
IsPatch *bool `json:"is-patch"`
IsCompatible *bool `json:"is-compatible"`
ProfilePath *string `json:"profile-path"`
Config config.RawConfig `json:"config" `
Params ConfigExtendedParams `json:"params"`
}
type ChangeProxyParams struct {
@@ -77,20 +84,8 @@ type TestDelayParams struct {
Timeout int64 `json:"timeout"`
}
type Delay struct {
Name string `json:"name"`
Value int32 `json:"value"`
}
type Process struct {
Uid uint32 `json:"uid"`
Network string `json:"network"`
Source string `json:"source"`
Target string `json:"target"`
}
type Now struct {
Name string `json:"name"`
type ProcessMapItem struct {
Id int64 `json:"id"`
Value string `json:"value"`
}
@@ -168,9 +163,9 @@ func getRawConfigWithPath(path *string) *config.RawConfig {
}
}
func decorationConfig(profilePath *string, cfg config.RawConfig, compatible bool) *config.RawConfig {
func decorationConfig(profilePath *string, cfg config.RawConfig) *config.RawConfig {
prof := getRawConfigWithPath(profilePath)
overwriteConfig(prof, cfg, compatible)
overwriteConfig(prof, cfg)
return prof
}
@@ -320,52 +315,58 @@ func generateProxyGroupAndRule(proxyGroup *[]map[string]any, rule *[]string) {
*rule = computedRule
}
func overwriteConfig(targetConfig *config.RawConfig, patchConfig config.RawConfig, compatible bool) {
targetConfig.ExternalController = ""
func overwriteConfig(targetConfig *config.RawConfig, patchConfig config.RawConfig) {
targetConfig.ExternalController = patchConfig.ExternalController
targetConfig.ExternalUI = ""
targetConfig.Interface = ""
targetConfig.ExternalUIURL = ""
targetConfig.GeodataMode = false
//targetConfig.IPv6 = patchConfig.IPv6
targetConfig.TCPConcurrent = patchConfig.TCPConcurrent
targetConfig.UnifiedDelay = patchConfig.UnifiedDelay
//targetConfig.GeodataMode = false
targetConfig.IPv6 = patchConfig.IPv6
targetConfig.LogLevel = patchConfig.LogLevel
targetConfig.Port = 0
targetConfig.SocksPort = 0
//targetConfig.Port = 0
//targetConfig.SocksPort = 0
targetConfig.MixedPort = patchConfig.MixedPort
targetConfig.FindProcessMode = process.FindProcessAlways
targetConfig.FindProcessMode = patchConfig.FindProcessMode
targetConfig.AllowLan = patchConfig.AllowLan
targetConfig.Mode = patchConfig.Mode
targetConfig.Tun.Enable = patchConfig.Tun.Enable
targetConfig.Tun.Device = patchConfig.Tun.Device
targetConfig.Tun.DNSHijack = patchConfig.Tun.DNSHijack
targetConfig.Tun.Stack = patchConfig.Tun.Stack
targetConfig.GeodataLoader = "standard"
targetConfig.GeodataLoader = patchConfig.GeodataLoader
targetConfig.Profile.StoreSelected = false
targetConfig.GeoXUrl = patchConfig.GeoXUrl
targetConfig.GlobalUA = patchConfig.GlobalUA
if targetConfig.DNS.Enable == false {
targetConfig.DNS = patchConfig.DNS
}
if runtime.GOOS == "android" {
targetConfig.DNS.NameServer = append(targetConfig.DNS.NameServer, "dhcp://"+dns.SystemDNSPlaceholder)
} else if runtime.GOOS == "windows" {
targetConfig.DNS.NameServer = append(targetConfig.DNS.NameServer, dns.SystemDNSPlaceholder)
}
if compatible == false {
//if runtime.GOOS == "android" {
// targetConfig.DNS.NameServer = append(targetConfig.DNS.NameServer, "dhcp://"+dns.SystemDNSPlaceholder)
//} else if runtime.GOOS == "windows" {
// targetConfig.DNS.NameServer = append(targetConfig.DNS.NameServer, dns.SystemDNSPlaceholder)
//}
if configParams.IsCompatible == false {
targetConfig.ProxyProvider = make(map[string]map[string]any)
targetConfig.RuleProvider = make(map[string]map[string]any)
generateProxyGroupAndRule(&targetConfig.ProxyGroup, &targetConfig.Rule)
}
}
func patchConfig(general *config.General) {
log.Infoln("[Apply] patch")
route.ReStartServer(general.ExternalController)
listener.SetAllowLan(general.AllowLan)
inbound.SetSkipAuthPrefixes(general.SkipAuthPrefixes)
inbound.SetAllowedIPs(general.LanAllowedIPs)
inbound.SetDisAllowedIPs(general.LanDisAllowedIPs)
listener.SetBindAddress(general.BindAddress)
tunnel.SetSniffing(general.Sniffing)
tunnel.SetFindProcessMode(general.FindProcessMode)
dialer.SetTcpConcurrent(general.TCPConcurrent)
dialer.DefaultInterface.Store(general.Interface)
adapter.UnifiedDelay.Store(general.UnifiedDelay)
listener.ReCreateHTTP(general.Port, tunnel.Tunnel)
listener.ReCreateSocks(general.SocksPort, tunnel.Tunnel)
listener.ReCreateRedir(general.RedirPort, tunnel.Tunnel)
@@ -378,40 +379,52 @@ func patchConfig(general *config.General) {
listener.ReCreateTuic(general.TuicServer, tunnel.Tunnel)
tunnel.SetMode(general.Mode)
log.SetLevel(general.LogLevel)
resolver.DisableIPv6 = !general.IPv6
}
const concurrentCount = math.MaxInt
func hcCompatibleProvider(proxyProviders map[string]provider.ProxyProvider) {
wg := sync.WaitGroup{}
ch := make(chan struct{}, concurrentCount)
for _, proxyProvider := range proxyProviders {
proxyProvider := proxyProvider
if proxyProvider.VehicleType() == provider.Compatible {
log.Infoln("Start initial Compatible provider %s", proxyProvider.Name())
wg.Add(1)
ch <- struct{}{}
go func() {
defer func() { <-ch; wg.Done() }()
if err := proxyProvider.Initial(); err != nil {
log.Errorln("initial Compatible provider %s error: %v", proxyProvider.Name(), err)
}
}()
func patchSelectGroup() {
mapping := configParams.SelectedMap
if mapping == nil {
return
}
for name, proxy := range tunnel.ProxiesWithProviders() {
outbound, ok := proxy.(*adapter.Proxy)
if !ok {
continue
}
selector, ok := outbound.ProxyAdapter.(outboundgroup.SelectAble)
if !ok {
continue
}
selected, exist := mapping[name]
if !exist {
continue
}
selector.ForceSet(selected)
}
}
func applyConfig(isPatch bool) {
var applyLock sync.Mutex
func applyConfig() {
applyLock.Lock()
defer applyLock.Unlock()
cfg, err := config.ParseRawConfig(currentConfig)
if err != nil {
cfg, _ = config.ParseRawConfig(config.DefaultRawConfig())
}
if isPatch {
if configParams.TestURL != nil {
constant.DefaultTestURL = *configParams.TestURL
}
if configParams.IsPatch {
patchConfig(cfg.General)
} else {
executor.ApplyConfig(cfg, true)
hcCompatibleProvider(tunnel.Providers())
runtime.GC()
hub.UltraApplyConfig(cfg, true)
patchSelectGroup()
}
}

View File

@@ -1,6 +1,7 @@
package dart_bridge
/*
#include <stdlib.h>
#include "stdint.h"
#include "include/dart_api_dl.h"
#include "include/dart_api_dl.c"
@@ -24,14 +25,16 @@ func InitDartApi(api unsafe.Pointer) {
}
}
func SendToPort(port int64, msg string) {
func SendToPort(port int64, msg string) bool {
var obj C.Dart_CObject
obj._type = C.Dart_CObject_kString
msgString := C.CString(msg)
defer C.free(unsafe.Pointer(msgString))
ptr := unsafe.Pointer(&obj.value[0])
*(**C.char)(ptr) = msgString
isSuccess := C.GoDart_PostCObject(C.Dart_Port_DL(port), &obj)
if !isSuccess {
fmt.Println("ERROR: post to port ", port, " failed", msg)
return false
}
return true
}

View File

@@ -1,29 +0,0 @@
package dart_bridge
import "encoding/json"
var Port *int64
type MessageType string
const (
Log MessageType = "log"
Tun MessageType = "tun"
Delay MessageType = "delay"
Now MessageType = "now"
Process MessageType = "process"
)
type Message struct {
Type MessageType `json:"type"`
Data interface{} `json:"data"`
}
func (message *Message) Json() string {
data, _ := json.Marshal(message)
return string(data)
}
func SendMessage(message Message) {
SendToPort(*Port, message.Json())
}

View File

@@ -17,6 +17,7 @@ require (
github.com/RyuaNerin/go-krypto v1.2.4 // indirect
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
@@ -30,6 +31,9 @@ require (
github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gaukas/godicttls v0.0.4 // indirect
github.com/go-chi/chi/v5 v5.0.12 // indirect
github.com/go-chi/cors v1.2.1 // indirect
github.com/go-chi/render v1.0.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/gobwas/httphead v0.1.0 // indirect

View File

@@ -9,6 +9,8 @@ github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 h1:cDVUiFo+npB0ZASqnw4
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -46,6 +48,12 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=

View File

@@ -1,5 +1,8 @@
package main
/*
#include <stdlib.h>
*/
import "C"
import (
bridge "core/dart-bridge"
@@ -10,6 +13,7 @@ import (
"github.com/metacubex/mihomo/adapter/provider"
"github.com/metacubex/mihomo/common/structure"
"github.com/metacubex/mihomo/common/utils"
"github.com/metacubex/mihomo/component/geodata"
"github.com/metacubex/mihomo/component/mmdb"
"github.com/metacubex/mihomo/config"
"github.com/metacubex/mihomo/constant"
@@ -28,8 +32,12 @@ import (
var currentConfig = config.DefaultRawConfig()
var configParams = ConfigExtendedParams{}
var isInit = false
var currentProfileName = ""
//export initClash
func initClash(homeDirStr *C.char) bool {
if !isInit {
@@ -60,11 +68,29 @@ func shutdownClash() bool {
return true
}
//export forceGc
func forceGc() {
go func() {
log.Infoln("[APP] request force GC")
runtime.GC()
}()
}
//export setCurrentProfileName
func setCurrentProfileName(s *C.char) {
currentProfileName = C.GoString(s)
}
//export getCurrentProfileName
func getCurrentProfileName() *C.char {
return C.CString(currentProfileName)
}
//export validateConfig
func validateConfig(s *C.char, port C.longlong) {
i := int64(port)
bytes := []byte(C.GoString(s))
go func() {
bytes := []byte(C.GoString(s))
_, err := config.UnmarshalRawConfig(bytes)
if err != nil {
bridge.SendToPort(i, err.Error())
@@ -77,21 +103,18 @@ func validateConfig(s *C.char, port C.longlong) {
//export updateConfig
func updateConfig(s *C.char, port C.longlong) {
i := int64(port)
paramsString := C.GoString(s)
go func() {
paramsString := C.GoString(s)
var params = &GenerateConfigParams{}
err := json.Unmarshal([]byte(paramsString), params)
if err != nil {
bridge.SendToPort(i, err.Error())
return
}
prof := decorationConfig(params.ProfilePath, *params.Config, *params.IsCompatible)
configParams = params.Params
prof := decorationConfig(params.ProfilePath, params.Config)
currentConfig = prof
if *params.IsPatch {
applyConfig(true)
} else {
applyConfig(false)
}
applyConfig()
bridge.SendToPort(i, "")
}()
}
@@ -139,30 +162,32 @@ func getProxies() *C.char {
}
//export changeProxy
func changeProxy(s *C.char) bool {
func changeProxy(s *C.char) {
paramsString := C.GoString(s)
go func() {
paramsString := C.GoString(s)
var params = &ChangeProxyParams{}
err := json.Unmarshal([]byte(paramsString), params)
if err != nil {
log.Infoln("Unmarshal ChangeProxyParams %v", err)
}
groupName := *params.GroupName
proxyName := *params.ProxyName
proxies := tunnel.ProxiesWithProviders()
proxy := proxies[*params.GroupName]
if proxy == nil {
group, ok := proxies[groupName]
if !ok {
return
}
log.Infoln("change proxy %s", proxy.Name())
adapterProxy := proxy.(*adapter.Proxy)
adapterProxy := group.(*adapter.Proxy)
selector, ok := adapterProxy.ProxyAdapter.(*outboundgroup.Selector)
if !ok {
return
}
if err := selector.Set(*params.ProxyName); err != nil {
return
err = selector.Set(proxyName)
if err == nil {
log.Infoln("[Selector] %s selected %s", groupName, proxyName)
}
}()
return true
}
//export getTraffic
@@ -180,11 +205,31 @@ func getTraffic() *C.char {
return C.CString(string(data))
}
//export getTotalTraffic
func getTotalTraffic() *C.char {
up, down := statistic.DefaultManager.Total()
traffic := map[string]int64{
"up": up,
"down": down,
}
data, err := json.Marshal(traffic)
if err != nil {
fmt.Println("Error:", err)
return C.CString("")
}
return C.CString(string(data))
}
//export resetTraffic
func resetTraffic() {
statistic.DefaultManager.ResetStatistic()
}
//export asyncTestDelay
func asyncTestDelay(s *C.char, port C.longlong) {
i := int64(port)
paramsString := C.GoString(s)
go func() {
paramsString := C.GoString(s)
var params = &TestDelayParams{}
err := json.Unmarshal([]byte(paramsString), params)
if err != nil {
@@ -268,7 +313,6 @@ func closeConnections() bool {
//export closeConnection
func closeConnection(id *C.char) bool {
connectionId := C.GoString(id)
err := statistic.DefaultManager.Get(connectionId).Close()
if err != nil {
return false
@@ -279,10 +323,13 @@ func closeConnection(id *C.char) bool {
//export getProviders
func getProviders() *C.char {
data, err := json.Marshal(tunnel.Providers())
var msg *C.char
if err != nil {
return C.CString("")
msg = C.CString("")
return msg
}
return C.CString(string(data))
msg = C.CString(string(data))
return msg
}
//export getProvider
@@ -332,10 +379,9 @@ func getExternalProviders() *C.char {
//export updateExternalProvider
func updateExternalProvider(providerName *C.char, providerType *C.char, port C.longlong) {
i := int64(port)
providerNameString := C.GoString(providerName)
providerTypeString := C.GoString(providerType)
go func() {
providerNameString := C.GoString(providerName)
providerTypeString := C.GoString(providerType)
switch providerTypeString {
case "Proxy":
providers := tunnel.Providers()
@@ -351,34 +397,49 @@ func updateExternalProvider(providerName *C.char, providerType *C.char, port C.l
bridge.SendToPort(i, err.Error())
return
}
case "GeoIp":
case "MMDB":
err := mmdb.DownloadMMDB(constant.Path.Resolve(providerNameString))
if err != nil {
bridge.SendToPort(i, err.Error())
return
}
case "GeoSite":
err := mmdb.DownloadGeoSite(constant.Path.Resolve(providerNameString))
if err != nil {
bridge.SendToPort(i, err.Error())
return
}
case "ASN":
err := mmdb.DownloadASN(constant.Path.Resolve(providerNameString))
if err != nil {
bridge.SendToPort(i, err.Error())
return
}
case "GeoIp":
err := geodata.DownloadGeoIP(constant.Path.Resolve(providerNameString))
if err != nil {
bridge.SendToPort(i, err.Error())
return
}
case "GeoSite":
err := geodata.DownloadGeoSite(constant.Path.Resolve(providerNameString))
if err != nil {
bridge.SendToPort(i, err.Error())
return
}
}
bridge.SendToPort(i, "")
}()
}
//export initNativeApiBridge
func initNativeApiBridge(api unsafe.Pointer, port C.longlong) {
func initNativeApiBridge(api unsafe.Pointer) {
bridge.InitDartApi(api)
}
//export initMessage
func initMessage(port C.longlong) {
i := int64(port)
bridge.Port = &i
Port = i
}
//export freeCString
func freeCString(s *C.char) {
C.free(unsafe.Pointer(s))
}
func init() {
@@ -391,9 +452,21 @@ func init() {
} else {
delayData.Value = int32(delay)
}
bridge.SendMessage(bridge.Message{
Type: bridge.Delay,
SendMessage(Message{
Type: DelayMessage,
Data: delayData,
})
}
statistic.DefaultRequestNotify = func(c statistic.Tracker) {
SendMessage(Message{
Type: RequestMessage,
Data: c,
})
}
executor.DefaultProxyProviderLoadedHook = func(providerName string) {
SendMessage(Message{
Type: LoadedMessage,
Data: providerName,
})
}
}

View File

@@ -2,7 +2,6 @@ package main
import "C"
import (
bridge "core/dart-bridge"
"github.com/metacubex/mihomo/common/observable"
"github.com/metacubex/mihomo/log"
)
@@ -18,11 +17,14 @@ func startLog() {
logSubscriber = log.Subscribe()
go func() {
for logData := range logSubscriber {
message := &bridge.Message{
Type: bridge.Log,
if logData.LogLevel < log.Level() {
continue
}
message := &Message{
Type: LogMessage,
Data: logData,
}
bridge.SendMessage(*message)
SendMessage(*message)
}
}()
}

77
core/message.go Normal file
View File

@@ -0,0 +1,77 @@
package main
import (
bridge "core/dart-bridge"
"encoding/json"
"github.com/metacubex/mihomo/constant"
)
var Port int64
var ServicePort int64
type MessageType string
const (
LogMessage MessageType = "log"
ProtectMessage MessageType = "protect"
DelayMessage MessageType = "delay"
ProcessMessage MessageType = "process"
RequestMessage MessageType = "request"
StartedMessage MessageType = "started"
LoadedMessage MessageType = "loaded"
)
type Delay struct {
Name string `json:"name"`
Value int32 `json:"value"`
}
type Process struct {
Id int64 `json:"id"`
Metadata *constant.Metadata `json:"metadata"`
}
type Message struct {
Type MessageType `json:"type"`
Data interface{} `json:"data"`
}
func (message *Message) Json() (string, error) {
data, err := json.Marshal(message)
return string(data), err
}
func SendMessage(message Message) {
s, err := message.Json()
if err != nil {
return
}
if handler, ok := messageHandlers[message.Type]; ok {
handler(s)
} else {
sendToPort(s)
}
}
var messageHandlers = map[MessageType]func(string) bool{
ProtectMessage: sendToServicePort,
ProcessMessage: sendToServicePort,
StartedMessage: conditionalSend,
LoadedMessage: conditionalSend,
}
func sendToPort(s string) bool {
return bridge.SendToPort(Port, s)
}
func sendToServicePort(s string) bool {
return bridge.SendToPort(ServicePort, s)
}
func conditionalSend(s string) bool {
isSuccess := sendToPort(s)
if !isSuccess {
return sendToServicePort(s)
}
return isSuccess
}

42
core/platform/limit.go Normal file
View File

@@ -0,0 +1,42 @@
//go:build android
package platform
import "syscall"
var nullFd int
var maxFdCount int
func init() {
fd, err := syscall.Open("/dev/null", syscall.O_WRONLY, 0644)
if err != nil {
panic(err.Error())
}
nullFd = fd
var limit syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &limit); err != nil {
maxFdCount = 1024
} else {
maxFdCount = int(limit.Cur)
}
maxFdCount = maxFdCount / 4 * 3
}
func ShouldBlockConnection() bool {
fd, err := syscall.Dup(nullFd)
if err != nil {
return true
}
_ = syscall.Close(fd)
if fd > maxFdCount {
return true
}
return false
}

View File

@@ -1,3 +1,81 @@
//go:build android
package main
import "C"
import (
"encoding/json"
"errors"
"github.com/metacubex/mihomo/component/process"
"github.com/metacubex/mihomo/constant"
"sync"
"sync/atomic"
"time"
)
type ProcessMap struct {
m sync.Map
}
func (cm *ProcessMap) Store(key int64, value string) {
cm.m.Store(key, value)
}
func (cm *ProcessMap) Load(key int64) (string, bool) {
value, ok := cm.m.Load(key)
if !ok || value == nil {
return "", false
}
return value.(string), true
}
var counter int64 = 0
var processMap ProcessMap
func init() {
process.DefaultPackageNameResolver = func(metadata *constant.Metadata) (string, error) {
if metadata == nil {
return "", process.ErrInvalidNetwork
}
id := atomic.AddInt64(&counter, 1)
timeout := time.After(200 * time.Millisecond)
SendMessage(Message{
Type: ProcessMessage,
Data: Process{
Id: id,
Metadata: metadata,
},
})
for {
select {
case <-timeout:
return "", errors.New("package resolver timeout")
default:
value, exists := processMap.Load(id)
if exists {
return value, nil
}
time.Sleep(20 * time.Millisecond)
}
}
}
}
//export setProcessMap
func setProcessMap(s *C.char) {
if s == nil {
return
}
paramsString := C.GoString(s)
go func() {
var processMapItem = &ProcessMapItem{}
err := json.Unmarshal([]byte(paramsString), processMapItem)
if err == nil {
processMap.Store(processMapItem.Id, processMapItem.Value)
}
}()
}

View File

@@ -4,79 +4,157 @@ package main
import "C"
import (
"core/platform"
t "core/tun"
"errors"
"github.com/metacubex/mihomo/component/dialer"
"github.com/metacubex/mihomo/log"
"golang.org/x/sync/semaphore"
"strconv"
"sync"
"sync/atomic"
"syscall"
"time"
)
var tunLock sync.Mutex
var tun *t.Tun
var runTime *time.Time
//export startTUN
func startTUN(fd C.int) bool {
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)
applyConfig(true)
if err != nil {
log.Errorln("startTUN error: %v", err)
tempTun.Close()
return false
}
tempTun.Closer = closer
tun = tempTun
return true
type FdMap struct {
m sync.Map
}
//export updateMarkSocketPort
func updateMarkSocketPort(markSocketPort C.longlong) bool {
tunLock.Lock()
defer tunLock.Unlock()
//if tun != nil {
// tun.MarkSocketPort = int64(markSocketPort)
//}
return true
func (cm *FdMap) Store(key int64) {
cm.m.Store(key, struct{}{})
}
func (cm *FdMap) Load(key int64) bool {
_, ok := cm.m.Load(key)
return ok
}
var fdMap FdMap
//export startTUN
func startTUN(fd C.int, port C.longlong) {
i := int64(port)
ServicePort = i
go func() {
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)
if err != nil {
log.Errorln("startTUN error: %v", err)
tempTun.Close()
}
tempTun.Closer = closer
tun = tempTun
now := time.Now()
runTime = &now
SendMessage(Message{
Type: StartedMessage,
Data: strconv.FormatInt(runTime.UnixMilli(), 10),
})
}()
}
//export getRunTime
func getRunTime() *C.char {
if runTime == nil {
return C.CString("")
}
return C.CString(strconv.FormatInt(runTime.UnixMilli(), 10))
}
//export stopTun
func stopTun() {
tunLock.Lock()
defer tunLock.Unlock()
go func() {
tunLock.Lock()
defer tunLock.Unlock()
if tun != nil {
tun.Close()
applyConfig(true)
tun = nil
}
runTime = nil
if tun != nil {
tun.Close()
tun = nil
}
}()
}
var errBlocked = errors.New("blocked")
//export setFdMap
func setFdMap(fd C.long) {
fdInt := int64(fd)
go func() {
fdMap.Store(fdInt)
}()
}
type Fd struct {
Id int64 `json:"id"`
Value int64 `json:"value"`
}
func markSocket(fd Fd) {
SendMessage(Message{
Type: ProtectMessage,
Data: fd,
})
}
var fdCounter int64 = 0
func init() {
dialer.DefaultSocketHook = func(network, address string, conn syscall.RawConn) error {
if platform.ShouldBlockConnection() {
return errBlocked
}
return conn.Control(func(fd uintptr) {
if tun != nil {
tun.MarkSocket(int(fd))
time.Sleep(time.Millisecond * 100)
if tun == nil {
return
}
fdInt := int64(fd)
timeout := time.After(100 * time.Millisecond)
id := atomic.AddInt64(&fdCounter, 1)
markSocket(Fd{
Id: id,
Value: fdInt,
})
for {
select {
case <-timeout:
return
default:
exists := fdMap.Load(id)
if exists {
return
}
time.Sleep(10 * time.Millisecond)
}
}
})
}

View File

@@ -5,7 +5,6 @@ package tun
import "C"
import (
"context"
bridge "core/dart-bridge"
"encoding/binary"
"github.com/Kr328/tun2socket"
"github.com/Kr328/tun2socket/nat"
@@ -19,7 +18,6 @@ import (
"io"
"net"
"os"
"strconv"
"time"
)
@@ -186,19 +184,3 @@ func Start(fd int, gateway, portal, dns string) (io.Closer, error) {
return stack, nil
}
func (t *Tun) MarkSocket(fd int) {
_ = t.Limit.Acquire(context.Background(), 1)
defer t.Limit.Release(1)
if t.Closed {
return
}
message := &bridge.Message{
Type: bridge.Tun,
Data: strconv.Itoa(fd),
}
bridge.SendMessage(*message)
}

View File

@@ -82,7 +82,11 @@ class ApplicationState extends State<Application> {
super.initState();
globalState.appController = AppController(context);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
globalState.appController.afterInit();
final currentContext = globalState.navigatorKey.currentContext;
if (currentContext != null) {
globalState.appController = AppController(currentContext);
}
await globalState.appController.init();
globalState.appController.initLink();
_updateGroups();
});
@@ -111,7 +115,6 @@ class ApplicationState extends State<Application> {
lightColorScheme: lightDynamic,
darkColorScheme: darkDynamic,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
globalState.appController.updateSystemColorSchemes(systemColorSchemes);
});
@@ -126,7 +129,6 @@ class ApplicationState extends State<Application> {
httpTimeoutDuration,
(timer) async {
await globalState.appController.updateGroups();
globalState.appController.appState.sortNum++;
},
);
}
@@ -160,8 +162,8 @@ class ApplicationState extends State<Application> {
AppLocalizations.delegate.supportedLocales,
themeMode: state.themeMode,
theme: ThemeData(
pageTransitionsTheme: _pageTransitionsTheme,
useMaterial3: true,
pageTransitionsTheme: _pageTransitionsTheme,
colorScheme: _getAppColorScheme(
brightness: Brightness.light,
systemColorSchemes: systemColorSchemes,

View File

@@ -35,7 +35,6 @@ class ClashCore {
clashFFI = ClashFFI(lib);
clashFFI.initNativeApiBridge(
NativeApi.initializeApiDLData,
receiver.sendPort.nativePort,
);
}
@@ -45,10 +44,10 @@ class ClashCore {
}
bool init(String homeDir) {
return clashFFI.initClash(
homeDir.toNativeUtf8().cast(),
) ==
1;
final homeDirChar = homeDir.toNativeUtf8().cast<Char>();
final isInit = clashFFI.initClash(homeDirChar) == 1;
malloc.free(homeDirChar);
return isInit;
}
shutdown() {
@@ -67,10 +66,12 @@ class ClashCore {
receiver.close();
}
});
final dataChar = data.toNativeUtf8().cast<Char>();
clashFFI.validateConfig(
data.toNativeUtf8().cast(),
dataChar,
receiver.sendPort.nativePort,
);
malloc.free(dataChar);
return completer.future;
}
@@ -84,23 +85,51 @@ class ClashCore {
}
});
final params = json.encode(updateConfigParams);
final paramsChar = params.toNativeUtf8().cast<Char>();
clashFFI.updateConfig(
params.toNativeUtf8().cast(),
paramsChar,
receiver.sendPort.nativePort,
);
malloc.free(paramsChar);
return completer.future;
}
initMessage() {
clashFFI.initMessage(
receiver.sendPort.nativePort,
);
}
setProfileName(String profileName) {
final profileNameChar = profileName.toNativeUtf8().cast<Char>();
clashFFI.setCurrentProfileName(
profileNameChar,
);
malloc.free(profileNameChar);
}
getProfileName() {
final currentProfileNameRaw = clashFFI.getCurrentProfileName();
final currentProfileName =
currentProfileNameRaw.cast<Utf8>().toDartString();
clashFFI.freeCString(currentProfileNameRaw);
return currentProfileName;
}
Future<List<Group>> getProxiesGroups() {
final proxiesRaw = clashFFI.getProxies();
final proxiesRawString = proxiesRaw.cast<Utf8>().toDartString();
clashFFI.freeCString(proxiesRaw);
return Isolate.run<List<Group>>(() {
final proxies = json.decode(proxiesRawString);
if (proxiesRawString.isEmpty) return [];
final proxies = (json.decode(proxiesRawString) ?? {}) as Map;
if (proxies.isEmpty) return [];
final groupNames = [
UsedProxy.GLOBAL.name,
...(proxies[UsedProxy.GLOBAL.name]["all"] as List).where((e) {
final proxy = proxies[e];
return GroupTypeExtension.valueList.contains(proxy['type']);
final proxy = proxies[e] ?? {};
return GroupTypeExtension.valueList.contains(proxy['type']) &&
proxy['hidden'] != true;
})
];
final groupsRaw = groupNames.map((groupName) {
@@ -109,6 +138,7 @@ class ClashCore {
.map(
(name) => proxies[name],
)
.where((proxy) => proxy != null)
.toList();
return group;
}).toList();
@@ -120,6 +150,7 @@ class ClashCore {
final externalProvidersRaw = clashFFI.getExternalProviders();
final externalProvidersRawString =
externalProvidersRaw.cast<Utf8>().toDartString();
clashFFI.freeCString(externalProvidersRaw);
return Isolate.run<List<ExternalProvider>>(() {
final externalProviders =
(json.decode(externalProvidersRawString) as List<dynamic>)
@@ -143,17 +174,23 @@ class ClashCore {
receiver.close();
}
});
final providerNameChar = providerName.toNativeUtf8().cast<Char>();
final providerTypeChar = providerType.toNativeUtf8().cast<Char>();
clashFFI.updateExternalProvider(
providerName.toNativeUtf8().cast(),
providerType.toNativeUtf8().cast(),
providerNameChar,
providerTypeChar,
receiver.sendPort.nativePort,
);
malloc.free(providerNameChar);
malloc.free(providerTypeChar);
return completer.future;
}
bool changeProxy(ChangeProxyParams changeProxyParams) {
changeProxy(ChangeProxyParams changeProxyParams) {
final params = json.encode(changeProxyParams);
return clashFFI.changeProxy(params.toNativeUtf8().cast()) == 1;
final paramsChar = params.toNativeUtf8().cast<Char>();
clashFFI.changeProxy(paramsChar);
malloc.free(paramsChar);
}
Future<Delay> getDelay(String proxyName) {
@@ -169,35 +206,55 @@ class ClashCore {
receiver.close();
}
});
final delayParamsChar =
json.encode(delayParams).toNativeUtf8().cast<Char>();
clashFFI.asyncTestDelay(
json.encode(delayParams).toNativeUtf8().cast(),
delayParamsChar,
receiver.sendPort.nativePort,
);
malloc.free(delayParamsChar);
Future.delayed(httpTimeoutDuration + moreDuration, () {
receiver.close();
completer.complete(
Delay(name: proxyName, value: -1),
);
if (!completer.isCompleted) {
completer.complete(
Delay(name: proxyName, value: -1),
);
}
});
return completer.future;
}
clearEffect(String path) {
clashFFI.clearEffect(path.toNativeUtf8().cast());
final pathChar = path.toNativeUtf8().cast<Char>();
clashFFI.clearEffect(pathChar);
malloc.free(pathChar);
}
VersionInfo getVersionInfo() {
final versionInfoRaw = clashFFI.getVersionInfo();
final versionInfo = json.decode(versionInfoRaw.cast<Utf8>().toDartString());
clashFFI.freeCString(versionInfoRaw);
return VersionInfo.fromJson(versionInfo);
}
Traffic getTraffic() {
final trafficRaw = clashFFI.getTraffic();
final trafficMap = json.decode(trafficRaw.cast<Utf8>().toDartString());
clashFFI.freeCString(trafficRaw);
return Traffic.fromMap(trafficMap);
}
Traffic getTotalTraffic() {
final trafficRaw = clashFFI.getTotalTraffic();
final trafficMap = json.decode(trafficRaw.cast<Utf8>().toDartString());
clashFFI.freeCString(trafficRaw);
return Traffic.fromMap(trafficMap);
}
void resetTraffic() {
clashFFI.resetTraffic();
}
void startLog() {
clashFFI.startLog();
}
@@ -206,21 +263,52 @@ class ClashCore {
clashFFI.stopLog();
}
startTun(int fd) {
clashFFI.startTUN(fd);
startTun(int fd, int port) {
if (!Platform.isAndroid) return;
clashFFI.startTUN(fd, port);
}
requestGc() {
clashFFI.forceGc();
}
void stopTun() {
clashFFI.stopTun();
}
void setProcessMap(ProcessMapItem processMapItem) {
final processMapItemChar =
json.encode(processMapItem).toNativeUtf8().cast<Char>();
clashFFI.setProcessMap(processMapItemChar);
malloc.free(processMapItemChar);
}
void setFdMap(int fd) {
clashFFI.setFdMap(fd);
}
DateTime? getRunTime() {
final runTimeRaw = clashFFI.getRunTime();
final runTimeString = runTimeRaw.cast<Utf8>().toDartString();
clashFFI.freeCString(runTimeRaw);
if (runTimeString.isEmpty) return null;
return DateTime.fromMillisecondsSinceEpoch(int.parse(runTimeString));
}
List<Connection> getConnections() {
final connectionsDataRaw = clashFFI.getConnections();
final connectionsData =
json.decode(connectionsDataRaw.cast<Utf8>().toDartString()) as Map;
clashFFI.freeCString(connectionsDataRaw);
final connectionsRaw = connectionsData['connections'] as List? ?? [];
return connectionsRaw.map((e) => Connection.fromJson(e)).toList();
}
closeConnections(String id) {
final idChar = id.toNativeUtf8().cast<Char>();
clashFFI.closeConnection(idChar);
malloc.free(idChar);
}
}
final clashCore = ClashCore();

File diff suppressed because it is too large Load Diff

View File

@@ -7,18 +7,6 @@ import 'package:flutter/foundation.dart';
import 'core.dart';
abstract mixin class ClashMessageListener {
void onLog(Log log) {}
void onTun(String fd) {}
void onDelay(Delay delay) {}
void onProcess(Metadata metadata) {}
void onNow(Now now) {}
}
class ClashMessage {
StreamSubscription? subscription;
@@ -28,23 +16,23 @@ class ClashMessage {
subscription = null;
}
subscription = ClashCore.receiver.listen((message) {
final m = Message.fromJson(json.decode(message));
for (final ClashMessageListener listener in _listeners) {
final m = AppMessage.fromJson(json.decode(message));
for (final AppMessageListener listener in _listeners) {
switch (m.type) {
case MessageType.log:
case AppMessageType.log:
listener.onLog(Log.fromJson(m.data));
break;
case MessageType.tun:
listener.onTun(m.data);
break;
case MessageType.delay:
case AppMessageType.delay:
listener.onDelay(Delay.fromJson(m.data));
break;
case MessageType.process:
listener.onProcess(Metadata.fromJson(m.data));
case AppMessageType.request:
listener.onRequest(Connection.fromJson(m.data));
break;
case MessageType.now:
listener.onNow(Now.fromJson(m.data));
case AppMessageType.started:
listener.onStarted(m.data);
break;
case AppMessageType.loaded:
listener.onLoaded(m.data);
break;
}
}
@@ -53,18 +41,18 @@ class ClashMessage {
static final ClashMessage instance = ClashMessage._();
final ObserverList<ClashMessageListener> _listeners =
ObserverList<ClashMessageListener>();
final ObserverList<AppMessageListener> _listeners =
ObserverList<AppMessageListener>();
bool get hasListeners {
return _listeners.isNotEmpty;
}
void addListener(ClashMessageListener listener) {
void addListener(AppMessageListener listener) {
_listeners.add(listener);
}
void removeListener(ClashMessageListener listener) {
void removeListener(AppMessageListener listener) {
_listeners.remove(listener);
}
}

View File

@@ -17,6 +17,7 @@ class ClashService {
}
const geoFileNameList = [
mmdbFileName,
geoIpFileName,
geoSiteFileName,
asnFileName,
];

View File

@@ -1,14 +1,10 @@
import 'dart:io';
import 'package:fl_clash/clash/clash.dart';
import 'package:fl_clash/plugins/app.dart';
class Android {
init() async {
app?.onExit = () {
clashCore.shutdown();
exit(0);
};
app?.onExit = () {};
}
}

View File

@@ -22,4 +22,6 @@ export 'string.dart';
export 'app_localizations.dart';
export 'function.dart';
export 'package.dart';
export 'measure.dart';
export 'measure.dart';
export 'service.dart';
export 'iterable.dart';

View File

@@ -1,5 +1,6 @@
import 'dart:ui';
import 'package:fl_clash/models/clash_config.dart';
import 'package:flutter/material.dart';
const appName = "FlClash";
@@ -7,10 +8,18 @@ const coreName = "clash.meta";
const packageName = "FlClash";
const httpTimeoutDuration = Duration(milliseconds: 5000);
const moreDuration = Duration(milliseconds: 100);
const animateDuration = Duration(milliseconds: 100);
const defaultUpdateDuration = Duration(days: 1);
const mmdbFileName = "geoip.metadb";
const geoSiteFileName = "GeoSite.dat";
const asnFileName = "ASN.mmdb";
const geoIpFileName = "GeoIP.dat";
const geoSiteFileName = "GeoSite.dat";
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",
"geoip":"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"
};
const profilesDirectoryName = "profiles";
const localhost = "127.0.0.1";
const clashConfigKey = "clash_config";
@@ -18,8 +27,12 @@ const configKey = "config";
const listItemPadding = EdgeInsets.symmetric(horizontal: 16);
const double dialogCommonWidth = 300;
const repository = "chen08209/FlClash";
const defaultExternalController = "127.0.0.1:9090";
const maxMobileWidth = 600;
const maxLaptopWidth = 840;
const geodataLoaderMemconservative = "memconservative";
const geodataLoaderStandard = "standard";
const defaultTestUrl = "https://www.gstatic.com/generate_204";
final filter = ImageFilter.blur(
sigmaX: 5,
sigmaY: 5,

View File

@@ -7,8 +7,12 @@ extension BuildContextExtension on BuildContext {
return findAncestorStateOfType<CommonScaffoldState>();
}
Size get appSize{
return MediaQuery.of(this).size;
}
double get width {
return MediaQuery.of(this).size.width;
return appSize.width;
}
ColorScheme get colorScheme => Theme.of(this).colorScheme;

View File

@@ -35,4 +35,8 @@ extension DateTimeExtension on DateTime {
}
return appLocalizations.just;
}
}
String get show {
return toIso8601String().substring(0, 10);
}
}

13
lib/common/iterable.dart Normal file
View File

@@ -0,0 +1,13 @@
extension IterableExt<T> on Iterable<T> {
Iterable<T> separated(T separator) sync* {
final iterator = this.iterator;
if (!iterator.moveNext()) return;
yield iterator.current;
while (iterator.moveNext()) {
yield separator;
yield iterator.current;
}
}
}

View File

@@ -23,6 +23,7 @@ class Measure {
double? _bodyMediumHeight;
double? _bodySmallHeight;
double? _labelSmallHeight;
double? _labelMediumHeight;
double? _titleLargeHeight;
double? _titleMediumHeight;
@@ -56,6 +57,16 @@ class Measure {
return _labelSmallHeight!;
}
double get labelMediumHeight {
_labelMediumHeight ??= computeTextSize(
Text(
"",
style: context.textTheme.labelMedium,
),
).height;
return _labelMediumHeight!;
}
double get titleLargeHeight {
_titleLargeHeight ??= computeTextSize(
Text(

View File

@@ -29,6 +29,20 @@ class Navigation {
label: "profiles",
fragment: ProfilesFragment(),
),
const NavigationItem(
icon: Icon(Icons.view_timeline),
label: "requests",
fragment: RequestsFragment(),
description: "requestsDesc",
modes: [NavigationItemMode.desktop, NavigationItemMode.more],
),
const NavigationItem(
icon: Icon(Icons.ballot),
label: "connections",
fragment: ConnectionsFragment(),
description: "connectionsDesc",
modes: [NavigationItemMode.desktop, NavigationItemMode.more],
),
const NavigationItem(
icon: Icon(Icons.swap_vert_circle),
label: "resources",

View File

@@ -39,6 +39,9 @@ class Other {
}
final diff = timeStamp / 1000;
final inHours = (diff / 3600).floor();
if (inHours > 99) {
return "99:59:59";
}
final inMinutes = (diff / 60 % 60).floor();
final inSeconds = (diff % 60).floor();
@@ -171,7 +174,7 @@ class Other {
}
List<String> parseReleaseBody(String? body) {
if(body == null) return [];
if (body == null) return [];
const pattern = r'- (.+?)\. \[.+?\]';
final regex = RegExp(pattern);
return regex
@@ -181,7 +184,7 @@ class Other {
.toList();
}
ViewMode getViewMode(double viewWidth){
ViewMode getViewMode(double viewWidth) {
if (viewWidth <= maxMobileWidth) return ViewMode.mobile;
if (viewWidth <= maxLaptopWidth) return ViewMode.laptop;
return ViewMode.desktop;

View File

@@ -1,22 +1,13 @@
import 'dart:async';
import 'dart:io';
import 'package:package_info_plus/package_info_plus.dart';
class AppPackage{
import 'common.dart';
static AppPackage? _instance;
Completer<PackageInfo> packageInfoCompleter = Completer();
AppPackage._internal() {
PackageInfo.fromPlatform().then(
(value) => packageInfoCompleter.complete(value),
);
}
factory AppPackage() {
_instance ??= AppPackage._internal();
return _instance!;
}
extension PackageInfoExtension on PackageInfo {
String get ua => [
"$appName/v$version",
"clash-verge",
"Platform/${Platform.operatingSystem}",
].join(" ");
}
final appPackage = AppPackage();

View File

@@ -8,12 +8,30 @@ import 'constant.dart';
class AppPath {
static AppPath? _instance;
Completer<Directory> applicationSupportDirectoryCompleter = Completer();
Completer<Directory> cacheDir = Completer();
// Future<Directory> _createDesktopCacheDir() async {
// final path = join(dirname(Platform.resolvedExecutable), 'cache');
// final dir = Directory(path);
// if (await dir.exists()) {
// await dir.create(recursive: true);
// }
// return dir;
// }
AppPath._internal() {
getApplicationSupportDirectory().then(
(value) => applicationSupportDirectoryCompleter.complete(value),
);
getApplicationSupportDirectory().then((value) {
cacheDir.complete(value);
});
// if (Platform.isAndroid) {
// getApplicationSupportDirectory().then((value) {
// cacheDir.complete(value);
// });
// } else {
// _createDesktopCacheDir().then((value) {
// cacheDir.complete(value);
// });
// }
}
factory AppPath() {
@@ -22,12 +40,12 @@ class AppPath {
}
Future<String> getHomeDirPath() async {
final directory = await applicationSupportDirectoryCompleter.future;
final directory = await cacheDir.future;
return directory.path;
}
Future<String> getProfilesPath() async {
final directory = await applicationSupportDirectoryCompleter.future;
final directory = await cacheDir.future;
return join(directory.path, profilesDirectoryName);
}

View File

@@ -1,30 +1,22 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:fl_clash/common/common.dart';
import 'package:image_picker/image_picker.dart';
class Picker {
Future<PlatformFile?> pickerConfigFile() async {
FilePickerResult? filePickerResult;
if (Platform.isAndroid) {
filePickerResult = await FilePicker.platform.pickFiles(
withData: true,
type: FileType.custom,
allowedExtensions: ['txt', 'conf'],
);
} else {
filePickerResult = await FilePicker.platform.pickFiles(
withData: true,
type: FileType.custom,
allowedExtensions: ['yaml', 'txt', 'conf'],
);
}
final file = filePickerResult?.files.first;
if (file == null) {
return null;
}
return file;
final filePickerResult = await FilePicker.platform.pickFiles(
withData: true,
allowMultiple: false,
);
return filePickerResult?.files.first;
}
Future<PlatformFile?> pickerGeoDataFile() async {
final filePickerResult = await FilePicker.platform.pickFiles(
withData: true,
allowMultiple: false,
);
return filePickerResult?.files.first;
}
Future<String?> pickerConfigQRCode() async {

View File

@@ -28,20 +28,6 @@ class ProxyManager {
return await (_proxy as Proxy).updateStartTime();
}
Future<bool?> setProtect(int fd) async {
if (_proxy is! Proxy) return null;
return await (_proxy as Proxy).setProtect(fd);
}
Future<bool?> startForeground({
required String title,
required String content,
}) async {
if (_proxy is! Proxy) return null;
return await (_proxy as Proxy)
.startForeground(title: title, content: content);
}
factory ProxyManager() {
_instance ??= ProxyManager._internal();
return _instance!;

View File

@@ -12,20 +12,18 @@ class Request {
bool _isStart = false;
Request() {
_dio = Dio(
BaseOptions(
connectTimeout: httpTimeoutDuration,
sendTimeout: httpTimeoutDuration,
receiveTimeout: httpTimeoutDuration,
headers: {"User-Agent": coreName},
_dio = Dio();
_dio.options = BaseOptions(
headers: {"User-Agent": globalState.appController.clashConfig.globalUa},
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
_syncProxy();
return handler.next(options); // 继续请求
},
),
);
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
_syncProxy();
return handler.next(options); // 继续请求
},
));
}
_syncProxy() {
@@ -37,7 +35,7 @@ class Request {
_dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
if(!_isStart) return client;
if (!_isStart) return client;
client.findProxy = (url) {
return "PROXY localhost:$_port;DIRECT";
};
@@ -56,7 +54,7 @@ class Request {
),
)
.timeout(
httpTimeoutDuration,
httpTimeoutDuration * 2,
);
return response;
}
@@ -71,8 +69,7 @@ class Request {
if (response.statusCode != 200) return null;
final data = response.data as Map<String, dynamic>;
final remoteVersion = data['tag_name'];
final packageInfo = await appPackage.packageInfoCompleter.future;
final version = packageInfo.version;
final version = globalState.packageInfo.version;
final hasUpdate =
other.compareVersions(remoteVersion.replaceAll('v', ''), version) > 0;
if (!hasUpdate) return null;
@@ -86,12 +83,14 @@ class Request {
"https://ipinfo.io/json/": IpInfo.fromIpInfoIoJson,
};
Future<IpInfo?> checkIp() async {
Future<IpInfo?> checkIp(CancelToken? cancelToken) async {
for (final source in _ipInfoSources.entries) {
try {
final response = await _dio.get<Map<String, dynamic>>(
source.key,
);
final response = await _dio
.get<Map<String, dynamic>>(source.key, cancelToken: cancelToken)
.timeout(
httpTimeoutDuration,
);
if (response.statusCode == 200 && response.data != null) {
return source.value(response.data!);
}
@@ -103,4 +102,4 @@ class Request {
}
}
final request = Request();
final request = Request();

110
lib/common/service.dart Normal file
View File

@@ -0,0 +1,110 @@
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart';
typedef CreateServiceNative = IntPtr Function(
IntPtr hSCManager,
Pointer<Utf16> lpServiceName,
Pointer<Utf16> lpDisplayName,
Uint32 dwDesiredAccess,
Uint32 dwServiceType,
Uint32 dwStartType,
Uint32 dwErrorControl,
Pointer<Utf16> lpBinaryPathName,
Pointer<Utf16> lpLoadOrderGroup,
Pointer<Uint32> lpdwTagId,
Pointer<Utf16> lpDependencies,
Pointer<Utf16> lpServiceStartName,
Pointer<Utf16> lpPassword,
);
typedef CreateServiceDart = int Function(
int hSCManager,
Pointer<Utf16> lpServiceName,
Pointer<Utf16> lpDisplayName,
int dwDesiredAccess,
int dwServiceType,
int dwStartType,
int dwErrorControl,
Pointer<Utf16> lpBinaryPathName,
Pointer<Utf16> lpLoadOrderGroup,
Pointer<Uint32> lpdwTagId,
Pointer<Utf16> lpDependencies,
Pointer<Utf16> lpServiceStartName,
Pointer<Utf16> lpPassword,
);
const _SERVICE_ALL_ACCESS = 0xF003F;
const _SERVICE_WIN32_OWN_PROCESS = 0x00000010;
const _SERVICE_AUTO_START = 0x00000002;
const _SERVICE_ERROR_NORMAL = 0x00000001;
typedef GetLastErrorNative = Uint32 Function();
typedef GetLastErrorDart = int Function();
class Service {
static Service? _instance;
late DynamicLibrary _advapi32;
Service._internal() {
_advapi32 = DynamicLibrary.open('advapi32.dll');
}
factory Service() {
_instance ??= Service._internal();
return _instance!;
}
Future<void> createService() async {
final int scManager = OpenSCManager(nullptr, nullptr, _SERVICE_ALL_ACCESS);
if (scManager == 0) return;
final serviceName = 'FlClash Service'.toNativeUtf16();
final displayName = 'FlClash Service'.toNativeUtf16();
final binaryPathName = "C:\\Application\\Clash.Verge_1.6.6_x64_portable\\resources\\clash-verge-service.exe".toNativeUtf16();
final createService =
_advapi32.lookupFunction<CreateServiceNative, CreateServiceDart>(
'CreateServiceW',
);
final getLastError = DynamicLibrary.open('kernel32.dll')
.lookupFunction<GetLastErrorNative, GetLastErrorDart>('GetLastError');
final serviceHandle = createService(
scManager,
serviceName,
displayName,
_SERVICE_ALL_ACCESS,
_SERVICE_WIN32_OWN_PROCESS,
_SERVICE_AUTO_START,
_SERVICE_ERROR_NORMAL,
binaryPathName,
nullptr,
nullptr,
nullptr,
nullptr,
nullptr,
);
print("serviceHandle $serviceHandle");
final errorCode = GetLastError();
print('Error code: $errorCode');
final result = StartService(serviceHandle, 0, nullptr);
if (result == 0) {
print('Failed to start the service.');
} else {
print('Service started successfully.');
}
calloc.free(serviceName);
calloc.free(displayName);
calloc.free(binaryPathName);
}
}
final service = Platform.isWindows ? Service() : null;

View File

@@ -1,10 +1,5 @@
extension StringExtension on String {
bool get isUrl {
try {
Uri.parse(this);
return true;
} catch (e) {
return false;
}
return RegExp(r'^(http|https|ftp)://').hasMatch(this);
}
}

View File

@@ -2,15 +2,13 @@ import 'package:flutter/material.dart';
import 'color.dart';
extension TextStyleExtension on TextStyle {
toLight() {
return copyWith(color: color?.toLight());
}
TextStyle get toLight => copyWith(color: color?.toLight());
toSoftBold() {
return copyWith(fontWeight: FontWeight.w500);
}
TextStyle get toLighter => copyWith(color: color?.toLighter());
toBold() {
return copyWith(fontWeight: FontWeight.bold);
}
}
TextStyle get toSoftBold => copyWith(fontWeight: FontWeight.w500);
TextStyle get toBold => copyWith(fontWeight: FontWeight.bold);
TextStyle get toMinus => copyWith(fontSize: fontSize! - 2);
}

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -16,6 +17,7 @@ class AppController {
late ClashConfig clashConfig;
late Measure measure;
late Function updateClashConfigDebounce;
late Function addCheckIpNumDebounce;
AppController(this.context) {
appState = context.read<AppState>();
@@ -24,12 +26,16 @@ class AppController {
updateClashConfigDebounce = debounce<Function()>(() async {
await updateClashConfig();
});
addCheckIpNumDebounce = debounce(() {
appState.checkIpNum++;
});
measure = Measure.of(context);
}
Future<void> updateSystemProxy(bool isStart) async {
if (isStart) {
await globalState.startSystemProxy(
appState: appState,
config: config,
clashConfig: clashConfig,
);
@@ -41,7 +47,9 @@ class AppController {
];
} else {
await globalState.stopSystemProxy();
clashCore.resetTraffic();
appState.traffics = [];
appState.totalTraffic = Traffic();
appState.runTime = null;
}
}
@@ -62,19 +70,10 @@ class AppController {
updateTraffic() {
globalState.updateTraffic(
config: config,
appState: appState,
);
}
changeProxy() {
globalState.changeProxy(
appState: appState,
config: config,
clashConfig: clashConfig,
);
}
addProfile(Profile profile) async {
config.setProfile(profile);
if (config.currentProfileId != null) return;
@@ -96,13 +95,9 @@ class AppController {
}
}
updateProfile(String id) async {
final profile = config.getCurrentProfileForId(id);
if (profile != null) {
final tempProfile = profile.copyWith();
await tempProfile.update();
config.setProfile(tempProfile);
}
Future<void> updateProfile(Profile profile) async {
await profile.update();
config.setProfile(await profile.update());
}
Future<void> updateClashConfig({bool isPatch = true}) async {
@@ -125,6 +120,14 @@ class AppController {
});
}
Future rawApplyProfile() async {
await globalState.applyProfile(
appState: appState,
config: config,
clashConfig: clashConfig,
);
}
changeProfile(String? value) async {
if (value == config.currentProfileId) return;
config.currentProfileId = value;
@@ -135,16 +138,34 @@ class AppController {
autoUpdateProfiles() async {
for (final profile in config.profiles) {
if (!profile.autoUpdate) return;
if (!profile.autoUpdate) continue;
final isNotNeedUpdate = profile.lastUpdateDate
?.add(
profile.autoUpdateDuration,
)
.isBeforeNow;
if (isNotNeedUpdate == false ||
profile.url == null ||
profile.url!.isEmpty) continue;
await profile.update();
if (isNotNeedUpdate == false || profile.type == ProfileType.file) {
continue;
}
try {
updateProfile(profile);
} catch (e) {
appState.addLog(
Log(
logLevel: LogLevel.info,
payload: e.toString(),
),
);
}
}
}
updateProfiles() async {
for (final profile in config.profiles) {
if (profile.type == ProfileType.file) {
continue;
}
await updateProfile(profile);
}
}
@@ -212,20 +233,21 @@ class AppController {
final tagName = data['tag_name'];
final body = data['body'];
final submits = other.parseReleaseBody(body);
final textTheme = context.textTheme;
globalState.showMessage(
title: appLocalizations.discoverNewVersion,
message: TextSpan(
text: "$tagName \n",
style: context.textTheme.headlineSmall,
style: textTheme.headlineSmall,
children: [
TextSpan(
text: "\n",
style: context.textTheme.bodyMedium,
style: textTheme.bodyMedium,
),
for (final submit in submits)
TextSpan(
text: "- $submit \n",
style: context.textTheme.bodyMedium,
style: textTheme.bodyMedium,
),
],
),
@@ -246,18 +268,38 @@ class AppController {
}
}
afterInit() async {
if (config.autoRun) {
await updateSystemProxy(true);
} else {
await proxyManager.updateStartTime();
await updateSystemProxy(proxyManager.isStart);
}
autoUpdateProfiles();
init() async {
updateLogStatus();
if (!config.silentLaunch) {
window?.show();
}
final commonScaffoldState = globalState.homeScaffoldKey.currentState;
if (commonScaffoldState?.mounted == true) {
await commonScaffoldState?.loadingRun(() async {
await globalState.applyProfile(
appState: appState,
config: config,
clashConfig: clashConfig,
);
}, title: appLocalizations.init);
} else {
await globalState.applyProfile(
appState: appState,
config: config,
clashConfig: clashConfig,
);
}
await afterInit();
}
afterInit() async {
await proxyManager.updateStartTime();
if (proxyManager.isStart) {
await updateSystemProxy(true);
} else {
await updateSystemProxy(config.autoRun);
}
autoUpdateProfiles();
autoCheckUpdate();
}
@@ -320,17 +362,17 @@ class AppController {
}
addProfileFormURL(String url) async {
globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst);
if (globalState.navigatorKey.currentState?.canPop() ?? false) {
globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst);
}
toProfiles();
final commonScaffoldState = globalState.homeScaffoldKey.currentState;
if (commonScaffoldState?.mounted != true) return;
final profile = await commonScaffoldState?.loadingRun<Profile>(
() async {
final profile = Profile(
return await Profile.normal(
url: url,
);
await profile.update();
return profile;
).update();
},
title: "${appLocalizations.add}${appLocalizations.profile}",
);
@@ -353,9 +395,7 @@ class AppController {
if (bytes == null) {
return null;
}
final profile = Profile(label: platformFile?.name);
await profile.saveFile(bytes);
return profile;
return await Profile.normal(label: platformFile?.name).saveFile(bytes);
},
title: "${appLocalizations.add}${appLocalizations.profile}",
);
@@ -370,9 +410,54 @@ class AppController {
addProfileFormURL(url);
}
int get columns =>
globalState.getColumns(appState.viewMode, config.proxiesColumns);
changeColumns() {
config.proxiesColumns = globalState.getColumns(
appState.viewMode,
columns - 1,
);
}
updateViewWidth(double width) {
WidgetsBinding.instance.addPostFrameCallback((_) {
appState.viewWidth = width;
});
}
List<Proxy> _sortOfName(List<Proxy> proxies) {
return List.of(proxies)
..sort(
(a, b) => other.sortByChar(a.name, b.name),
);
}
List<Proxy> _sortOfDelay(List<Proxy> proxies) {
return proxies = List.of(proxies)
..sort(
(a, b) {
final aDelay = appState.getDelay(a.name);
final bDelay = appState.getDelay(b.name);
if (aDelay == null && bDelay == null) {
return 0;
}
if (aDelay == null || aDelay == -1) {
return 1;
}
if (bDelay == null || bDelay == -1) {
return -1;
}
return aDelay.compareTo(bDelay);
},
);
}
List<Proxy> getSortProxies(List<Proxy> proxies) {
return switch (config.proxiesSortType) {
ProxiesSortType.none => proxies,
ProxiesSortType.delay => _sortOfDelay(proxies),
ProxiesSortType.name => _sortOfName(proxies),
};
}
}

View File

@@ -1,6 +1,6 @@
// ignore_for_file: constant_identifier_names
enum GroupType { Selector, URLTest, Fallback }
enum GroupType { Selector, URLTest, Fallback, LoadBalance, Relay }
enum GroupName { GLOBAL, Proxy, Auto, Fallback }
@@ -56,9 +56,32 @@ enum ProfileType { file, url }
enum ResultType { success, error }
enum MessageType { log, tun, delay, process, now }
enum AppMessageType {
log,
delay,
request,
started,
loaded,
}
enum ServiceMessageType {
protect,
process,
started,
loaded,
}
enum FindProcessMode { always, off }
enum RecoveryOption {
all,
onlyProfiles,
}
enum ChipType { action, delete }
enum CommonCardType { plain, filled }
enum ProxiesType { tab, expansion }
enum ProxyCardType { expand, shrink }

View File

@@ -1,7 +1,6 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutFragment extends StatelessWidget {
@@ -49,16 +48,9 @@ class AboutFragment extends StatelessWidget {
appName,
style: Theme.of(context).textTheme.headlineSmall,
),
FutureBuilder<PackageInfo>(
future: appPackage.packageInfoCompleter.future,
builder: (_, package) {
final version = package.data?.version;
if (version == null) return Container();
return Text(
version,
style: Theme.of(context).textTheme.labelLarge,
);
},
Text(
globalState.packageInfo.version,
style: Theme.of(context).textTheme.labelLarge,
)
],
)

View File

@@ -8,6 +8,13 @@ import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
extension AccessControlExtension on AccessControl {
List<String> get currentList => switch (mode) {
AccessControlMode.acceptSelected => acceptList,
AccessControlMode.rejectSelected => rejectList,
};
}
class AccessFragment extends StatefulWidget {
const AccessFragment({super.key});
@@ -28,6 +35,12 @@ class _AccessFragmentState extends State<AccessFragment> {
});
}
@override
void dispose() {
super.dispose();
packagesListenable.dispose();
}
Widget _buildAppProxyModePopup() {
final items = [
CommonPopupMenuItem(
@@ -83,134 +96,61 @@ class _AccessFragmentState extends State<AccessFragment> {
);
}
Widget _buildSelectedAllButton({
required bool isSelectedAll,
required List<String> allValueList,
}) {
return Builder(
builder: (context) {
final tooltip = isSelectedAll
? appLocalizations.cancelSelectAll
: appLocalizations.selectAll;
return IconButton(
tooltip: tooltip,
onPressed: () {
final config = globalState.appController.config;
final isAccept =
config.accessControl.mode == AccessControlMode.acceptSelected;
if (isSelectedAll) {
config.accessControl = switch (isAccept) {
true => config.accessControl.copyWith(
acceptList: [],
),
false => config.accessControl.copyWith(
rejectList: [],
),
};
} else {
config.accessControl = switch (isAccept) {
true => config.accessControl.copyWith(
acceptList: allValueList,
),
false => config.accessControl.copyWith(
rejectList: allValueList,
),
};
}
},
icon: isSelectedAll
? const Icon(Icons.deselect)
: const Icon(Icons.select_all),
);
Widget _buildSearchButton(List<Package> packages) {
return IconButton(
tooltip: appLocalizations.search,
onPressed: () {
showSearch(
context: context,
delegate: AccessControlSearchDelegate(
packages: packages,
),
).then((_) => {setState(() {})});
},
icon: const Icon(Icons.search),
);
}
Widget _actionHeader({
Widget _buildSelectedAllButton({
required bool isAccessControl,
required List<String> valueList,
required String describe,
required List<String> packageNameList,
required bool isSelectedAll,
required List<String> allValueList,
}) {
final tooltip = isSelectedAll
? appLocalizations.cancelSelectAll
: appLocalizations.selectAll;
return AbsorbPointer(
absorbing: !isAccessControl,
child: Padding(
padding: const EdgeInsets.only(
top: 4,
bottom: 4,
left: 16,
right: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: IntrinsicHeight(
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Flexible(
child: Text(
appLocalizations.selected,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color:
Theme.of(context).colorScheme.primary,
),
),
),
const Flexible(
child: SizedBox(
width: 8,
),
),
Flexible(
child: Text(
"${valueList.length}",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color:
Theme.of(context).colorScheme.primary,
),
),
),
],
),
),
Flexible(
child: Text(describe),
)
],
child: FloatingActionButton(
tooltip: tooltip,
onPressed: () {
final config = globalState.appController.config;
final isAccept =
config.accessControl.mode == AccessControlMode.acceptSelected;
if (isSelectedAll) {
config.accessControl = switch (isAccept) {
true => config.accessControl.copyWith(
acceptList: [],
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: _buildSelectedAllButton(
isSelectedAll: const ListEquality<String>()
.equals(valueList, packageNameList),
allValueList: packageNameList,
),
false => config.accessControl.copyWith(
rejectList: [],
),
Flexible(child: _buildFilterSystemAppButton()),
Flexible(child: _buildAppProxyModePopup()),
],
),
],
),
};
} else {
config.accessControl = switch (isAccept) {
true => config.accessControl.copyWith(
acceptList: allValueList,
),
false => config.accessControl.copyWith(
rejectList: allValueList,
),
};
}
},
child: isSelectedAll
? const Icon(Icons.deselect)
: const Icon(Icons.select_all),
),
);
}
@@ -252,14 +192,11 @@ class _AccessFragmentState extends State<AccessFragment> {
accessControlMode == AccessControlMode.acceptSelected
? acceptPackages
: rejectPackages;
final currentList =
accessControlMode == AccessControlMode.acceptSelected
? accessControl.acceptList
: accessControl.rejectList;
final currentList = accessControl.currentList;
final currentPackages = isFilterSystemApp
? packages
.where((element) => element.isSystem == false)
.toList()
.where((element) => element.isSystem == false)
.toList()
: packages;
final packageNameList =
currentPackages.map((e) => e.packageName).toList();
@@ -270,59 +207,139 @@ class _AccessFragmentState extends State<AccessFragment> {
: appLocalizations.accessControlNotAllowDesc;
return DisabledMask(
status: !isAccessControl,
child: Column(
children: [
_actionHeader(
child: FloatLayout(
floatingWidget: FloatWrapper(
child: _buildSelectedAllButton(
isAccessControl: isAccessControl,
valueList: valueList,
describe: describe,
packageNameList: packageNameList,
isSelectedAll: valueList.length == packageNameList.length,
allValueList: packageNameList,
),
Expanded(
flex: 1,
child: FadeBox(
key: const Key("fade_box"),
child: currentPackages.isEmpty
? const Center(
child: CircularProgressIndicator(),
)
: ListView.builder(
itemCount: currentPackages.length,
itemBuilder: (_, index) {
final package = currentPackages[index];
return PackageListItem(
key: Key(package.packageName),
package: package,
value:
valueList.contains(package.packageName),
isActive: isAccessControl,
onChanged: (value) {
if (value == true) {
valueList.add(package.packageName);
} else {
valueList.remove(package.packageName);
}
final config =
globalState.appController.config;
if (accessControlMode ==
AccessControlMode.acceptSelected) {
config.accessControl =
config.accessControl.copyWith(
acceptList: valueList,
);
} else {
config.accessControl =
config.accessControl.copyWith(
rejectList: valueList,
);
}
},
);
},
),
child: Column(
children: [
AbsorbPointer(
absorbing: !isAccessControl,
child: Padding(
padding: const EdgeInsets.only(
top: 4,
bottom: 4,
left: 16,
right: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: IntrinsicHeight(
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Flexible(
child: Text(
appLocalizations.selected,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.primary,
),
),
),
const Flexible(
child: SizedBox(
width: 8,
),
),
Flexible(
child: Text(
"${valueList.length}",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.primary,
),
),
),
],
),
),
Flexible(
child: Text(describe),
)
],
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: _buildSearchButton(currentPackages)),
Flexible(child: _buildFilterSystemAppButton()),
Flexible(child: _buildAppProxyModePopup()),
],
),
],
),
),
),
),
],
Expanded(
flex: 1,
child: FadeBox(
key: const Key("fade_box"),
child: currentPackages.isEmpty
? const Center(
child: CircularProgressIndicator(),
)
: ListView.builder(
itemCount: currentPackages.length,
itemBuilder: (_, index) {
final package = currentPackages[index];
return PackageListItem(
key: Key(package.packageName),
package: package,
value:
valueList.contains(package.packageName),
isActive: isAccessControl,
onChanged: (value) {
if (value == true) {
valueList.add(package.packageName);
} else {
valueList.remove(package.packageName);
}
final config =
globalState.appController.config;
if (accessControlMode ==
AccessControlMode.acceptSelected) {
config.accessControl =
config.accessControl.copyWith(
acceptList: valueList,
);
} else {
config.accessControl =
config.accessControl.copyWith(
rejectList: valueList,
);
}
},
);
},
),
),
),
],
),
),
);
},
@@ -429,3 +446,111 @@ class PackageListItem extends StatelessWidget {
);
}
}
class AccessControlSearchDelegate extends SearchDelegate {
final List<Package> packages;
AccessControlSearchDelegate({
required this.packages,
});
List<Package> get _results {
final lowQuery = query.toLowerCase();
return packages
.where(
(package) =>
package.label.toLowerCase().contains(lowQuery) ||
package.packageName.contains(lowQuery),
)
.toList();
}
@override
List<Widget>? buildActions(BuildContext context) {
return [
IconButton(
onPressed: () {
if (query.isEmpty) {
close(context, null);
return;
}
query = '';
},
icon: const Icon(Icons.clear),
),
const SizedBox(
width: 8,
)
];
}
@override
Widget? buildLeading(BuildContext context) {
return IconButton(
onPressed: () {
close(context, null);
},
icon: const Icon(Icons.arrow_back),
);
}
Widget _packageList(List<Package> packages) {
return Selector<Config, PackageListSelectorState>(
selector: (_, config) => PackageListSelectorState(
accessControl: config.accessControl,
isAccessControl: config.isAccessControl,
),
builder: (context, state, __) {
final accessControl = state.accessControl;
final isAccessControl = state.isAccessControl;
final accessControlMode = accessControl.mode;
final currentList = accessControl.currentList;
final packageNameList =
this.packages.map((e) => e.packageName).toList();
final valueList = currentList.intersection(packageNameList);
return DisabledMask(
status: !isAccessControl,
child: ListView.builder(
itemCount: packages.length,
itemBuilder: (_, index) {
final package = packages[index];
return PackageListItem(
key: Key(package.packageName),
package: package,
value: valueList.contains(package.packageName),
isActive: isAccessControl,
onChanged: (value) {
if (value == true) {
valueList.add(package.packageName);
} else {
valueList.remove(package.packageName);
}
final config = globalState.appController.config;
if (accessControlMode == AccessControlMode.acceptSelected) {
config.accessControl = config.accessControl.copyWith(
acceptList: valueList,
);
} else {
config.accessControl = config.accessControl.copyWith(
rejectList: valueList,
);
}
},
);
},
),
);
},
);
}
@override
Widget buildResults(BuildContext context) {
return buildSuggestions(context);
}
@override
Widget buildSuggestions(BuildContext context) {
return _packageList(_results);
}
}

View File

@@ -89,6 +89,24 @@ class ApplicationSettingFragment extends StatelessWidget {
);
},
),
if (Platform.isAndroid)
Selector<Config, bool>(
selector: (_, config) => config.isExclude,
builder: (_, isExclude, child) {
return ListItem.switchItem(
leading: const Icon(Icons.visibility_off),
title: Text(appLocalizations.exclude),
subtitle: Text(appLocalizations.excludeDesc),
delegate: SwitchDelegate(
value: isExclude,
onChanged: (value) {
final config = context.read<Config>();
config.isExclude = value;
},
),
);
},
),
if (Platform.isAndroid)
Selector<Config, bool>(
selector: (_, config) => config.isAnimateToPage,

View File

@@ -6,7 +6,6 @@ import 'package:fl_clash/models/dav.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/fade_box.dart';
import 'package:fl_clash/widgets/list.dart';
import 'package:fl_clash/widgets/section.dart';
import 'package:fl_clash/widgets/text.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -34,9 +33,9 @@ class _BackupAndRecoveryState extends State<BackupAndRecovery> {
final res = await commonScaffoldState?.loadingRun<bool>(() async {
return await _client?.backup();
});
if(res != true) return;
if (res != true) return;
globalState.showMessage(
title: appLocalizations.recovery,
title: appLocalizations.backup,
message: TextSpan(text: appLocalizations.backupSuccess),
);
}
@@ -46,7 +45,7 @@ class _BackupAndRecoveryState extends State<BackupAndRecovery> {
final res = await commonScaffoldState?.loadingRun<bool>(() async {
return await _client?.recovery(recoveryOption: recoveryOption);
});
if(res != true) return;
if (res != true) return;
globalState.showMessage(
title: appLocalizations.recovery,
message: TextSpan(text: appLocalizations.recoverySuccess),
@@ -69,26 +68,22 @@ class _BackupAndRecoveryState extends State<BackupAndRecovery> {
if (dav == null) {
return ListView(
children: [
Section(
ListHeader(
title: appLocalizations.account,
child: Builder(
builder: (_) {
return ListItem(
leading: const Icon(Icons.account_box),
title: Text(appLocalizations.noInfo),
subtitle: Text(appLocalizations.pleaseBindWebDAV),
trailing: FilledButton.tonal(
onPressed: () {
_showAddWebDAV(dav);
},
child: Text(
appLocalizations.bind,
),
),
);
),
ListItem(
leading: const Icon(Icons.account_box),
title: Text(appLocalizations.noInfo),
subtitle: Text(appLocalizations.pleaseBindWebDAV),
trailing: FilledButton.tonal(
onPressed: () {
_showAddWebDAV(dav);
},
child: Text(
appLocalizations.bind,
),
),
)
),
],
);
}
@@ -96,62 +91,60 @@ class _BackupAndRecoveryState extends State<BackupAndRecovery> {
final pingFuture = _client!.pingCompleter.future;
return ListView(
children: [
Section(
title: appLocalizations.account,
child: ListItem(
leading: const Icon(Icons.account_box),
title: TooltipText(
text: Text(
dav.user,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
ListHeader(title: appLocalizations.account),
ListItem(
leading: const Icon(Icons.account_box),
title: TooltipText(
text: Text(
dav.user,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(appLocalizations.connectivity),
FutureBuilder<bool>(
future: pingFuture,
builder: (_, snapshot) {
return Center(
child: FadeBox(
key: const Key("fade_box_1"),
child: snapshot.connectionState ==
ConnectionState.waiting
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1,
),
)
: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: snapshot.data == true
? Colors.green
: Colors.red,
),
width: 12,
height: 12,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(appLocalizations.connectivity),
FutureBuilder<bool>(
future: pingFuture,
builder: (_, snapshot) {
return Center(
child: FadeBox(
key: const Key("fade_box_1"),
child: snapshot.connectionState ==
ConnectionState.waiting
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1,
),
),
);
},
),
],
),
)
: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: snapshot.data == true
? Colors.green
: Colors.red,
),
width: 12,
height: 12,
),
),
);
},
),
],
),
trailing: FilledButton.tonal(
onPressed: () {
_showAddWebDAV(dav);
},
child: Text(
appLocalizations.edit,
),
),
trailing: FilledButton.tonal(
onPressed: () {
_showAddWebDAV(dav);
},
child: Text(
appLocalizations.edit,
),
),
),
@@ -161,22 +154,21 @@ class _BackupAndRecoveryState extends State<BackupAndRecovery> {
return FadeBox(
key: const Key("fade_box_2"),
child: snapshot.data == true
? Section(
title: appLocalizations.backupAndRecovery,
child: Column(
children: [
ListItem(
onTab: _backup,
title: Text(appLocalizations.backup),
subtitle: Text(appLocalizations.backupDesc),
),
ListItem(
onTab: _handleRecovery,
title: Text(appLocalizations.recovery),
subtitle: Text(appLocalizations.recoveryDesc),
),
],
),
? Column(
children: [
ListHeader(
title: appLocalizations.backupAndRecovery),
ListItem(
onTab: _backup,
title: Text(appLocalizations.backup),
subtitle: Text(appLocalizations.backupDesc),
),
ListItem(
onTab: _handleRecovery,
title: Text(appLocalizations.recovery),
subtitle: Text(appLocalizations.recoveryDesc),
),
],
)
: Container(),
);
@@ -228,6 +220,12 @@ class _WebDAVFormDialogState extends State<WebDAVFormDialog> {
Navigator.pop(context);
}
@override
void dispose() {
super.dispose();
_obscureController.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
@@ -37,141 +39,409 @@ class _ConfigFragmentState extends State<ConfigFragment> {
}
}
_updateLoglevel(LogLevel? logLevel) {
if (logLevel == null ||
logLevel == globalState.appController.clashConfig.logLevel) return;
globalState.appController.clashConfig.logLevel = logLevel;
globalState.appController.updateClashConfigDebounce();
_showLogLevelDialog(LogLevel value) {
globalState.showCommonDialog(
child: AlertDialog(
title: Text(appLocalizations.logLevel),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 16,
),
content: SizedBox(
width: 250,
child: Wrap(
children: [
for (final logLevel in LogLevel.values)
ListItem.radio(
delegate: RadioDelegate<LogLevel>(
value: logLevel,
groupValue: value,
onChanged: (LogLevel? value) {
if (value == null) {
return;
}
final appController = globalState.appController;
appController.clashConfig.logLevel = value;
appController.updateClashConfigDebounce();
Navigator.of(context).pop();
},
),
title: Text(logLevel.name),
)
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
List<Widget> items = [
Selector<ClashConfig, int>(
selector: (_, clashConfig) => clashConfig.mixedPort,
builder: (_, mixedPort, __) {
return ListItem(
onTab: () {
_modifyMixedPort(mixedPort);
_showUaDialog(String? value) {
const uas = [
null,
"clash-verge/v1.6.6",
"ClashforWindows/0.19.23",
];
globalState.showCommonDialog(
child: AlertDialog(
title: const Text("UA"),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 16,
),
content: SizedBox(
width: 250,
child: Wrap(
children: [
for (final ua in uas)
ListItem.radio(
delegate: RadioDelegate<String?>(
value: ua,
groupValue: value,
onChanged: (String? value) {
final appController = globalState.appController;
appController.clashConfig.globalRealUa = value;
appController.updateClashConfigDebounce();
Navigator.of(context).pop();
},
),
title: Text(ua ?? appLocalizations.defaultText),
)
],
),
),
),
);
}
_modifyTestUrl(String testUrl) async {
final newTestUrl = await globalState.showCommonDialog<String>(
child: TestUrlFormDialog(
testUrl: testUrl,
),
);
if (newTestUrl != null && newTestUrl != testUrl && mounted) {
try {
if (!newTestUrl.isUrl) {
throw "Invalid url";
}
globalState.appController.config.testUrl = newTestUrl;
globalState.appController.updateClashConfigDebounce();
} catch (e) {
globalState.showMessage(
title: appLocalizations.testUrl,
message: TextSpan(
text: e.toString(),
),
);
}
}
}
List<Widget> _buildAppSection() {
return generateSection(
title: appLocalizations.app,
items: [
if (Platform.isAndroid)
Selector<Config, bool>(
selector: (_, config) => config.allowBypass,
builder: (_, allowBypass, __) {
return ListItem.switchItem(
leading: const Icon(Icons.arrow_forward_outlined),
title: Text(appLocalizations.allowBypass),
subtitle: Text(appLocalizations.allowBypassDesc),
delegate: SwitchDelegate(
value: allowBypass,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.config.allowBypass = value;
},
),
);
},
padding: const EdgeInsets.symmetric(horizontal: 16,vertical: 4),
leading: const Icon(Icons.adjust),
title: Text(appLocalizations.proxyPort),
trailing: FilledButton.tonal(
onPressed: () {
),
if (Platform.isAndroid)
Selector<Config, bool>(
selector: (_, config) => config.systemProxy,
builder: (_, systemProxy, __) {
return ListItem.switchItem(
leading: const Icon(Icons.settings_ethernet),
title: Text(appLocalizations.systemProxy),
subtitle: Text(appLocalizations.systemProxyDesc),
delegate: SwitchDelegate(
value: systemProxy,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.config.systemProxy = value;
},
),
);
},
),
Selector<Config, bool>(
selector: (_, config) => config.isCompatible,
builder: (_, isCompatible, __) {
return ListItem.switchItem(
leading: const Icon(Icons.expand_outlined),
title: Text(appLocalizations.compatible),
subtitle: Text(appLocalizations.compatibleDesc),
delegate: SwitchDelegate(
value: isCompatible,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.config.isCompatible = value;
await appController.applyProfile();
},
),
);
},
),
],
);
}
List<Widget> _buildGeneralSection() {
return generateSection(
title: appLocalizations.general,
items: [
Selector<ClashConfig, LogLevel>(
selector: (_, clashConfig) => clashConfig.logLevel,
builder: (_, value, __) {
return ListItem(
leading: const Icon(Icons.info_outline),
title: Text(appLocalizations.logLevel),
subtitle: Text(value.name),
onTab: () {
_showLogLevelDialog(value);
},
);
},
),
Selector<ClashConfig, String?>(
selector: (_, clashConfig) => clashConfig.globalRealUa,
builder: (_, value, __) {
return ListItem(
leading: const Icon(Icons.computer_outlined),
title: const Text("UA"),
subtitle: Text(value ?? appLocalizations.defaultText),
onTab: () {
_showUaDialog(value);
},
);
},
),
Selector<Config, String>(
selector: (_, config) => config.testUrl,
builder: (_, value, __) {
return ListItem(
leading: const Icon(Icons.timeline),
title: Text(appLocalizations.testUrl),
subtitle: Text(value),
onTab: () {
_modifyTestUrl(value);
},
);
},
),
Selector<ClashConfig, int>(
selector: (_, clashConfig) => clashConfig.mixedPort,
builder: (_, mixedPort, __) {
return ListItem(
onTab: () {
_modifyMixedPort(mixedPort);
},
child: Text(
"$mixedPort",
leading: const Icon(Icons.adjust_outlined),
title: Text(appLocalizations.proxyPort),
subtitle: Text(appLocalizations.proxyPortDesc),
trailing: FilledButton.tonal(
onPressed: () {
_modifyMixedPort(mixedPort);
},
child: Text(
"$mixedPort",
),
),
),
);
},
),
Selector<ClashConfig, bool>(
selector: (_, clashConfig) => clashConfig.allowLan,
builder: (_, allowLan, __) {
return ListItem.switchItem(
leading: const Icon(Icons.device_hub),
title: Text(appLocalizations.allowLan),
subtitle: Text(appLocalizations.allowLanDesc),
delegate: SwitchDelegate(
value: allowLan,
onChanged: (bool value) async {
final clashConfig = context.read<ClashConfig>();
clashConfig.allowLan = value;
globalState.appController.updateClashConfigDebounce();
},
),
);
},
),
if (system.isDesktop)
);
},
),
Selector<ClashConfig, bool>(
selector: (_, clashConfig) => clashConfig.tun.enable,
builder: (_, tunEnable, __) {
selector: (_, clashConfig) => clashConfig.ipv6,
builder: (_, ipv6, __) {
return ListItem.switchItem(
leading: const Icon(Icons.support),
title: Text(appLocalizations.tun),
subtitle: Text(appLocalizations.tunDesc),
leading: const Icon(Icons.water_outlined),
title: const Text("IPv6"),
subtitle: Text(appLocalizations.ipv6Desc),
delegate: SwitchDelegate(
value: tunEnable,
value: ipv6,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.clashConfig.ipv6 = value;
appController.updateClashConfigDebounce();
},
),
);
},
),
Selector<ClashConfig, bool>(
selector: (_, clashConfig) => clashConfig.allowLan,
builder: (_, allowLan, __) {
return ListItem.switchItem(
leading: const Icon(Icons.device_hub),
title: Text(appLocalizations.allowLan),
subtitle: Text(appLocalizations.allowLanDesc),
delegate: SwitchDelegate(
value: allowLan,
onChanged: (bool value) async {
final clashConfig = context.read<ClashConfig>();
clashConfig.tun = Tun(enable: value);
clashConfig.allowLan = value;
globalState.appController.updateClashConfigDebounce();
},
),
);
},
),
Selector<Config, bool>(
selector: (_, config) => config.isCompatible,
builder: (_, isCompatible, __) {
return ListItem.switchItem(
leading: const Icon(Icons.expand),
title: Text(appLocalizations.compatible),
subtitle: Text(appLocalizations.compatibleDesc),
delegate: SwitchDelegate(
value: isCompatible,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.config.isCompatible = value;
await appController.updateClashConfig(isPatch: false);
await appController.updateGroups();
appController.changeProxy();
},
),
);
},
),
Padding(
padding: kMaterialListPadding,
child: Selector<ClashConfig, LogLevel>(
selector: (_, clashConfig) => clashConfig.logLevel,
builder: (_, value, __) {
return ListItem(
leading: const Icon(Icons.feedback),
title: Text(appLocalizations.logLevel),
trailing: SizedBox(
height: 48,
child: DropdownMenu<LogLevel>(
width: 124,
inputDecorationTheme: const InputDecorationTheme(
filled: true,
contentPadding: EdgeInsets.symmetric(
vertical: 5,
horizontal: 16,
),
),
initialSelection: value,
dropdownMenuEntries: [
for (final logLevel in LogLevel.values)
DropdownMenuEntry<LogLevel>(
value: logLevel,
label: logLevel.name,
)
],
onSelected: _updateLoglevel,
),
Selector<ClashConfig, bool>(
selector: (_, clashConfig) => clashConfig.unifiedDelay,
builder: (_, unifiedDelay, __) {
return ListItem.switchItem(
leading: const Icon(Icons.compress_outlined),
title: Text(appLocalizations.unifiedDelay),
subtitle: Text(appLocalizations.unifiedDelayDesc),
delegate: SwitchDelegate(
value: unifiedDelay,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.clashConfig.unifiedDelay = value;
appController.updateClashConfigDebounce();
},
),
);
},
),
),
Selector<ClashConfig, bool>(
selector: (_, clashConfig) =>
clashConfig.findProcessMode == FindProcessMode.always,
builder: (_, findProcess, __) {
return ListItem.switchItem(
leading: const Icon(Icons.polymer_outlined),
title: Text(appLocalizations.findProcessMode),
subtitle: Text(appLocalizations.findProcessModeDesc),
delegate: SwitchDelegate(
value: findProcess,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.clashConfig.findProcessMode =
value ? FindProcessMode.always : FindProcessMode.off;
appController.updateClashConfigDebounce();
},
),
);
},
),
Selector<ClashConfig, bool>(
selector: (_, clashConfig) => clashConfig.tcpConcurrent,
builder: (_, tcpConcurrent, __) {
return ListItem.switchItem(
leading: const Icon(Icons.double_arrow_outlined),
title: Text(appLocalizations.tcpConcurrent),
subtitle: Text(appLocalizations.tcpConcurrentDesc),
delegate: SwitchDelegate(
value: tcpConcurrent,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.clashConfig.tcpConcurrent = value;
appController.updateClashConfigDebounce();
},
),
);
},
),
Selector<ClashConfig, bool>(
selector: (_, clashConfig) =>
clashConfig.geodataLoader == geodataLoaderMemconservative,
builder: (_, memconservative, __) {
return ListItem.switchItem(
leading: const Icon(Icons.memory),
title: Text(appLocalizations.geodataLoader),
subtitle: Text(appLocalizations.geodataLoaderDesc),
delegate: SwitchDelegate(
value: memconservative,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.clashConfig.geodataLoader = value
? geodataLoaderMemconservative
: geodataLoaderStandard;
appController.updateClashConfigDebounce();
},
),
);
},
),
Selector<ClashConfig, bool>(
selector: (_, clashConfig) =>
clashConfig.externalController.isNotEmpty,
builder: (_, hasExternalController, __) {
return ListItem.switchItem(
leading: const Icon(Icons.api_outlined),
title: Text(appLocalizations.externalController),
subtitle: Text(appLocalizations.externalControllerDesc),
delegate: SwitchDelegate(
value: hasExternalController,
onChanged: (bool value) async {
final appController = globalState.appController;
appController.clashConfig.externalController =
value ? defaultExternalController : '';
appController.updateClashConfigDebounce();
},
),
);
},
),
],
);
}
List<Widget> _buildMoreSection() {
return generateSection(
title: appLocalizations.more,
items: [
if (system.isDesktop)
Selector<ClashConfig, bool>(
selector: (_, clashConfig) => clashConfig.tun.enable,
builder: (_, tunEnable, __) {
return ListItem.switchItem(
leading: const Icon(Icons.important_devices_outlined),
title: Text(appLocalizations.tun),
subtitle: Text(appLocalizations.tunDesc),
delegate: SwitchDelegate(
value: tunEnable,
onChanged: (bool value) async {
final clashConfig = context.read<ClashConfig>();
clashConfig.tun = Tun(enable: value);
globalState.appController.updateClashConfigDebounce();
},
),
);
},
),
],
);
}
@override
Widget build(BuildContext context) {
List<Widget> items = [
..._buildAppSection(),
..._buildGeneralSection(),
..._buildMoreSection(),
];
return ListView.separated(
return ListView.builder(
padding: const EdgeInsets.only(bottom: 32),
itemBuilder: (_, index) {
return Container(
alignment: Alignment.center,
child: items[index],
);
},
separatorBuilder: (_, __) {
return const Divider(
height: 0,
);
},
itemCount: items.length,
);
}
@@ -195,7 +465,7 @@ class _MixedPortFormDialogState extends State<MixedPortFormDialog> {
portController = TextEditingController(text: "${widget.mixedPort}");
}
_handleAddProfileFormURL() async {
_handleUpdate() async {
final port = portController.value.text;
if (port.isEmpty) return;
Navigator.of(context).pop<String>(port);
@@ -221,7 +491,64 @@ class _MixedPortFormDialogState extends State<MixedPortFormDialog> {
),
actions: [
TextButton(
onPressed: _handleAddProfileFormURL,
onPressed: _handleUpdate,
child: Text(appLocalizations.submit),
)
],
);
}
}
class TestUrlFormDialog extends StatefulWidget {
final String testUrl;
const TestUrlFormDialog({
super.key,
required this.testUrl,
});
@override
State<TestUrlFormDialog> createState() => _TestUrlFormDialogState();
}
class _TestUrlFormDialogState extends State<TestUrlFormDialog> {
late TextEditingController testUrlController;
@override
void initState() {
super.initState();
testUrlController = TextEditingController(text: widget.testUrl);
}
_handleUpdate() async {
final testUrl = testUrlController.value.text;
if (testUrl.isEmpty) return;
Navigator.of(context).pop<String>(testUrl);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(appLocalizations.testUrl),
content: SizedBox(
width: 300,
child: Wrap(
runSpacing: 16,
children: [
TextField(
maxLines: 5,
minLines: 1,
controller: testUrlController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(
onPressed: _handleUpdate,
child: Text(appLocalizations.submit),
)
],

View File

@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:io';
import 'package:fl_clash/clash/core.dart';
import 'package:fl_clash/clash/clash.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/plugins/app.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ConnectionsFragment extends StatefulWidget {
const ConnectionsFragment({super.key});
@@ -15,124 +18,417 @@ class ConnectionsFragment extends StatefulWidget {
}
class _ConnectionsFragmentState extends State<ConnectionsFragment> {
final connectionsNotifier = ValueNotifier<List<Connection>>([]);
Map<String, String?> idPackageNameMap = {};
final connectionsNotifier =
ValueNotifier<ConnectionsAndKeywords>(const ConnectionsAndKeywords());
final ScrollController _scrollController = ScrollController(
keepScrollOffset: false,
);
Timer? timer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_getConnections();
WidgetsBinding.instance.addPostFrameCallback((_) {
connectionsNotifier.value = connectionsNotifier.value
.copyWith(connections: clashCore.getConnections());
if (timer != null) {
timer?.cancel();
timer = null;
}
timer = Timer.periodic(const Duration(seconds: 3), (timer) {
if (mounted) {
_getConnections();
}
});
timer = Timer.periodic(
const Duration(seconds: 1),
(timer) {
connectionsNotifier.value = connectionsNotifier.value
.copyWith(connections: clashCore.getConnections());
},
);
});
}
_getConnections() {
connectionsNotifier.value = clashCore
.getConnections();
_initActions() {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
commonScaffoldState?.actions = [
IconButton(
onPressed: () {
showSearch(
context: context,
delegate: ConnectionsSearchDelegate(
state: connectionsNotifier.value,
),
);
},
icon: const Icon(Icons.search),
),
const SizedBox(
width: 8,
)
];
},
);
}
_addKeyword(String keyword) {
final isContains = connectionsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(connectionsNotifier.value.keywords)
..add(keyword);
connectionsNotifier.value = connectionsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = connectionsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(connectionsNotifier.value.keywords)
..remove(keyword);
connectionsNotifier.value = connectionsNotifier.value.copyWith(
keywords: keywords,
);
}
_handleBlockConnection(String id) {
clashCore.closeConnections(id);
connectionsNotifier.value = connectionsNotifier.value
.copyWith(connections: clashCore.getConnections());
}
@override
void dispose() {
super.dispose();
timer?.cancel();
connectionsNotifier.dispose();
_scrollController.dispose();
timer = null;
}
Future<ImageProvider?> _getPackageIconWithConnection(
Connection connection) async {
final uid = connection.metadata.uid;
// if(globalState.packageNameMap[uid] == null){
// globalState.packageNameMap[uid] = await app?.getPackageName(connection.metadata);
// }
final packageName = globalState.packageNameMap[uid];
if(packageName == null) return null;
return await app?.getPackageIcon(packageName);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<Connection>>(
valueListenable: connectionsNotifier,
builder: (_, List<Connection> connections, __) {
if (connections.isEmpty) {
return const NullStatus(
label: "未开启代理,或者没有连接数据",
);
return Selector<AppState, bool?>(
selector: (_, appState) =>
appState.currentLabel == 'connections' ||
appState.viewMode == ViewMode.mobile &&
appState.currentLabel == "tools",
builder: (_, isCurrent, child) {
if (isCurrent == null || isCurrent) {
_initActions();
}
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (_, index) {
final connection = connections[index];
return ListTile(
titleAlignment: ListTileTitleAlignment.top,
leading: Container(
margin: const EdgeInsets.only(top: 4),
width: 48,
height: 48,
child: FutureBuilder<ImageProvider?>(
future: _getPackageIconWithConnection(connection),
builder: (_, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Container();
} else {
return Image(
image: snapshot.data!,
gaplessPlayback: true,
width: 48,
height: 48,
);
}
return child!;
},
child: ValueListenableBuilder<ConnectionsAndKeywords>(
valueListenable: connectionsNotifier,
builder: (_, state, __) {
var connections = state.filteredConnections;
if (connections.isEmpty) {
return NullStatus(
label: appLocalizations.nullConnectionsDesc,
);
}
connections = connections.reversed.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 6,
spacing: 6,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
controller: _scrollController,
itemBuilder: (_, index) {
final connection = connections[index];
return ConnectionItem(
key: Key(connection.id),
connection: connection,
onClick: _addKeyword,
onBlock: _handleBlockConnection,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: connections.length,
),
)
],
);
},
),
);
}
}
class ConnectionItem extends StatelessWidget {
final Connection connection;
final Function(String)? onClick;
final Function(String)? onBlock;
const ConnectionItem({
super.key,
required this.connection,
this.onClick,
this.onBlock,
});
Future<ImageProvider?> _getPackageIcon(Connection connection) async {
return await app?.getPackageIcon(connection.metadata.process);
}
String _getRequestText(Metadata metadata) {
var text = "${metadata.network}://";
final ips = [
metadata.host,
metadata.destinationIP,
].where((ip) => ip.isNotEmpty);
text += ips.join("/");
text += ":${metadata.destinationPort}";
return text;
}
String _getSourceText(Connection connection) {
final metadata = connection.metadata;
if (metadata.process.isEmpty) {
return connection.start.lastUpdateTimeDesc;
}
return "${metadata.process} · ${connection.start.lastUpdateTimeDesc}";
}
@override
Widget build(BuildContext context) {
return ListItem(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
tileTitleAlignment: ListTileTitleAlignment.titleHeight,
leading: Platform.isAndroid
? Container(
margin: const EdgeInsets.only(top: 4),
width: 48,
height: 48,
child: FutureBuilder<ImageProvider?>(
future: _getPackageIcon(connection),
builder: (_, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Container();
} else {
return Image(
image: snapshot.data!,
gaplessPlayback: true,
width: 48,
height: 48,
);
}
},
),
)
: null,
title: Text(
_getRequestText(connection.metadata),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 8,
),
Text(
_getSourceText(connection),
),
const SizedBox(
height: 8,
),
Wrap(
runSpacing: 6,
spacing: 6,
children: [
for (final chain in connection.chains)
CommonChip(
label: chain,
onPressed: () {
if (onClick == null) return;
onClick!(chain);
},
),
],
),
],
),
trailing: IconButton(
icon: const Icon(Icons.block),
onPressed: () {
if (onBlock == null) return;
onBlock!(connection.id);
},
),
);
}
}
class ConnectionsSearchDelegate extends SearchDelegate {
ValueNotifier<ConnectionsAndKeywords> connectionsNotifier;
ConnectionsSearchDelegate({
required ConnectionsAndKeywords state,
}) : connectionsNotifier = ValueNotifier<ConnectionsAndKeywords>(state);
get state => connectionsNotifier.value;
List<Connection> get _results {
final lowerQuery = query.toLowerCase().trim();
return connectionsNotifier.value.filteredConnections.where((request) {
final lowerNetwork = request.metadata.network.toLowerCase();
final lowerHost = request.metadata.host.toLowerCase();
final lowerDestinationIP = request.metadata.destinationIP.toLowerCase();
final lowerProcess = request.metadata.process.toLowerCase();
final lowerChains = request.chains.join("").toLowerCase();
return lowerNetwork.contains(lowerQuery) ||
lowerHost.contains(lowerQuery) ||
lowerDestinationIP.contains(lowerQuery) ||
lowerProcess.contains(lowerQuery) ||
lowerChains.contains(lowerQuery);
}).toList();
}
_addKeyword(String keyword) {
final isContains = connectionsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(connectionsNotifier.value.keywords)
..add(keyword);
connectionsNotifier.value = connectionsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = connectionsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(connectionsNotifier.value.keywords)
..remove(keyword);
connectionsNotifier.value = connectionsNotifier.value.copyWith(
keywords: keywords,
);
}
_handleBlockConnection(String id) {
clashCore.closeConnections(id);
connectionsNotifier.value = connectionsNotifier.value
.copyWith(connections: clashCore.getConnections());
}
@override
List<Widget>? buildActions(BuildContext context) {
return [
IconButton(
onPressed: () {
if (query.isEmpty) {
close(context, null);
return;
}
query = '';
},
icon: const Icon(Icons.clear),
),
const SizedBox(
width: 8,
)
];
}
@override
Widget? buildLeading(BuildContext context) {
return IconButton(
onPressed: () {
close(context, null);
},
icon: const Icon(Icons.arrow_back),
);
}
@override
Widget buildResults(BuildContext context) {
return buildSuggestions(context);
}
@override
void dispose() {
connectionsNotifier.dispose();
super.dispose();
}
@override
Widget buildSuggestions(BuildContext context) {
return ValueListenableBuilder(
valueListenable: connectionsNotifier,
builder: (_, __, ___) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 6,
spacing: 6,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
contentPadding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(connection.metadata.host.isNotEmpty
? connection.metadata.host
: connection.metadata.destinationIP),
Padding(
padding: const EdgeInsets.only(
top: 12,
),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (final chain in connection.chains)
CommonChip(
label: chain,
),
],
),
),
],
Expanded(
child: ListView.separated(
itemBuilder: (_, index) {
final connection = _results[index];
return ConnectionItem(
key: Key(connection.id),
connection: connection,
onClick: _addKeyword,
onBlock: _handleBlockConnection,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: _results.length,
),
trailing: IconButton(
icon: const Icon(Icons.block),
onPressed: () {},
),
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: connections.length,
)
],
);
},
);

View File

@@ -13,6 +13,7 @@ class CoreInfo extends StatelessWidget {
selector: (_, appState) => appState.versionInfo,
builder: (_, versionInfo, __) {
return CommonCard(
onPressed: () {},
info: Info(
label: appLocalizations.coreInfo,
iconData: Icons.memory,
@@ -31,7 +32,7 @@ class CoreInfo extends StatelessWidget {
style: context
.textTheme
.titleMedium
?.toSoftBold(),
?.toSoftBold,
),
),
const SizedBox(
@@ -44,7 +45,7 @@ class CoreInfo extends StatelessWidget {
style: context
.textTheme
.titleLarge
?.toSoftBold(),
?.toSoftBold,
),
),
],

View File

@@ -1,11 +1,11 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/fragments/dashboard/intranet_ip.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/material.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:provider/provider.dart';
import 'network_detection.dart';
import 'core_info.dart';
import 'outbound_mode.dart';
import 'start_button.dart';
import 'network_speed.dart';
@@ -56,7 +56,7 @@ class _DashboardFragmentState extends State<DashboardFragment> {
),
GridItem(
crossAxisCellCount: isDesktop ? 4 : 6,
child: const CoreInfo(),
child: const IntranetIP(),
),
],
);

View File

@@ -0,0 +1,92 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
class IntranetIP extends StatefulWidget {
const IntranetIP({super.key});
@override
State<IntranetIP> createState() => _IntranetIPState();
}
class _IntranetIPState extends State<IntranetIP> {
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;
}
}
}
return null;
}
@override
void dispose() {
super.dispose();
ipNotifier.dispose();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
ipNotifier.value = await getLocalIpAddress() ?? "";
});
}
@override
Widget build(BuildContext context) {
return CommonCard(
info: Info(
label: appLocalizations.intranetIP,
iconData: Icons.devices,
),
onPressed: (){
},
child: Container(
padding: const EdgeInsets.all(16).copyWith(top: 0),
height: globalState.appController.measure.titleLargeHeight + 24 - 2,
child: ValueListenableBuilder(
valueListenable: ipNotifier,
builder: (_, value, __) {
return FadeBox(
child: value.isNotEmpty
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
flex: 1,
child: TooltipText(
text: Text(
value,
style: context.textTheme.titleLarge?.toSoftBold.toMinus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
)
: const Padding(
padding: EdgeInsets.all(2),
child: AspectRatio(
aspectRatio: 1,
child: CircularProgressIndicator(),
),
),
);
},
),
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:country_flags/country_flags.dart';
import 'package:dio/dio.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
@@ -16,41 +17,61 @@ class NetworkDetection extends StatefulWidget {
class _NetworkDetectionState extends State<NetworkDetection> {
final ipInfoNotifier = ValueNotifier<IpInfo?>(null);
final timeoutNotifier = ValueNotifier<bool>(false);
bool? _preIsStart;
CancelToken? cancelToken;
Function? _checkIpDebounce;
_checkIp(
bool isInit,
bool isStart,
) async {
if (!isInit) return;
timeoutNotifier.value = false;
if (_preIsStart == false && _preIsStart == isStart) return;
if (cancelToken != null) {
cancelToken!.cancel();
cancelToken = null;
}
ipInfoNotifier.value = null;
final ipInfo = await request.checkIp();
final ipInfo = await request.checkIp(cancelToken);
if (ipInfo == null) {
timeoutNotifier.value = true;
return;
} else {
timeoutNotifier.value = false;
}
ipInfoNotifier.value = await request.checkIp();
_preIsStart = isStart;
ipInfoNotifier.value = ipInfo;
}
_checkIpContainer(Widget child) {
_checkIpDebounce = debounce(_checkIp);
return Selector2<AppState, Config, CheckIpSelectorState>(
selector: (_, appState, config) {
return CheckIpSelectorState(
isInit: appState.isInit,
selectedMap: appState.selectedMap,
isStart: appState.isStart,
checkIpNum: appState.checkIpNum,
);
},
builder: (_, state, __) {
_checkIp(
state.isInit,
);
if (_checkIpDebounce != null) {
_checkIpDebounce!([state.isInit, state.isStart]);
}
return child;
},
child: child,
);
}
@override
void dispose() {
super.dispose();
ipInfoNotifier.dispose();
timeoutNotifier.dispose();
}
@override
Widget build(BuildContext context) {
return _checkIpContainer(
@@ -58,6 +79,7 @@ class _NetworkDetectionState extends State<NetworkDetection> {
valueListenable: ipInfoNotifier,
builder: (_, ipInfo, __) {
return CommonCard(
onPressed: () {},
child: Column(
children: [
Flexible(
@@ -82,15 +104,30 @@ class _NetworkDetectionState extends State<NetworkDetection> {
width: 24,
height: 24,
)
: TooltipText(
text: Text(
appLocalizations.checking,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium,
),
: ValueListenableBuilder(
valueListenable: timeoutNotifier,
builder: (_, timeout, __) {
if (timeout) {
return Text(
appLocalizations.checkError,
style: Theme.of(context)
.textTheme
.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
return TooltipText(
text: Text(
appLocalizations.checking,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium,
),
);
},
),
),
),
@@ -99,8 +136,9 @@ class _NetworkDetectionState extends State<NetworkDetection> {
),
),
Container(
height:
globalState.appController.measure.titleLargeHeight + 24,
height: globalState.appController.measure.titleLargeHeight +
24 -
2,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.all(16).copyWith(top: 0),
child: FadeBox(
@@ -115,7 +153,7 @@ class _NetworkDetectionState extends State<NetworkDetection> {
text: Text(
ipInfo.ip,
style: context.textTheme.titleLarge
?.toSoftBold(),
?.toSoftBold.toMinus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -126,17 +164,23 @@ class _NetworkDetectionState extends State<NetworkDetection> {
: ValueListenableBuilder(
valueListenable: timeoutNotifier,
builder: (_, timeout, __) {
if(timeout){
if (timeout) {
return Text(
appLocalizations.ipCheckError,
style: context.textTheme.bodyMedium
?.toSoftBold(),
"timeout",
style: context.textTheme.titleLarge
?.copyWith(color: Colors.red)
.toSoftBold
.toMinus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
return const SizedBox(
child: CircularProgressIndicator(),
return Container(
padding: const EdgeInsets.all(2),
child: const AspectRatio(
aspectRatio: 1,
child: CircularProgressIndicator(),
),
);
},
),

View File

@@ -21,26 +21,17 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
.asMap()
.map(
(index, e) => MapEntry(
index,
Point(
(index + initPoints.length).toDouble(),
e.speed.toDouble(),
),
),
)
index,
Point(
(index + initPoints.length).toDouble(),
e.speed.toDouble(),
),
),
)
.values
.toList();
var pointsRaw = [...initPoints, ...trafficPoints];
List<Point> points;
if (pointsRaw.length > 60) {
points = pointsRaw
.getRange(pointsRaw.length - 61, pointsRaw.length - 1)
.toList();
} else {
points = pointsRaw;
}
return points;
return [...initPoints, ...trafficPoints];
}
Traffic _getLastTraffic(List<Traffic> traffics) {
@@ -53,12 +44,11 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
required IconData iconData,
required TrafficValue value,
}) {
final showValue = value.showValue;
final showUnit = "${value.showUnit}/s";
final titleLargeSoftBold =
Theme.of(context).textTheme.titleLarge?.toSoftBold();
final bodyMedium = Theme.of(context).textTheme.bodySmall?.toLight();
Theme.of(context).textTheme.titleLarge?.toSoftBold;
final bodyMedium = Theme.of(context).textTheme.bodySmall?.toLight;
final valueText = Text(
showValue,
style: titleLargeSoftBold,
@@ -85,7 +75,7 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
Flexible(
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.toSoftBold(),
style: Theme.of(context).textTheme.titleSmall?.toSoftBold,
),
),
],
@@ -121,7 +111,8 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
@override
Widget build(BuildContext context) {
return CommonCard(
info: Info(
onPressed: () {},
info: Info(
label: appLocalizations.networkSpeed,
iconData: Icons.speed,
),
@@ -172,4 +163,4 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
),
);
}
}
}

View File

@@ -13,18 +13,10 @@ class OutboundMode extends StatelessWidget {
_changeMode(BuildContext context, Mode? value) async {
final appController = globalState.appController;
final clashConfig = appController.clashConfig;
final config = appController.config;
if (value == null || clashConfig.mode == value) return;
clashConfig.mode = value;
await appController.updateClashConfig();
if (!config.isCompatible) {
final proxySelected = config.currentSelectedMap[GroupName.Proxy.name];
final globalSelected = config.currentSelectedMap[GroupName.GLOBAL.name];
if (proxySelected != null && globalSelected == null) {
config.updateCurrentSelectedMap(GroupName.GLOBAL.name, proxySelected);
}
}
appController.changeProxy();
appController.addCheckIpNumDebounce();
}
@override
@@ -33,6 +25,7 @@ class OutboundMode extends StatelessWidget {
selector: (_, clashConfig) => clashConfig.mode,
builder: (_, mode, __) {
return CommonCard(
onPressed: () {},
info: Info(
label: appLocalizations.outboundMode,
iconData: Icons.call_split,
@@ -63,11 +56,8 @@ class OutboundMode extends StatelessWidget {
),
title: Text(
Intl.message(item.name),
style: Theme
.of(context)
.textTheme
.titleMedium
?.toSoftBold(),
style:
Theme.of(context).textTheme.titleMedium?.toSoftBold,
),
),
],

View File

@@ -13,19 +13,17 @@ class StartButton extends StatefulWidget {
class _StartButtonState extends State<StartButton>
with SingleTickerProviderStateMixin {
bool isStart = false;
bool isInit = false;
late AnimationController _controller;
bool isStart = false;
@override
void initState() {
isStart = globalState.appController.appState.isStart;
super.initState();
_controller = AnimationController(
vsync: this,
value: isStart ? 1 : 0,
value: 0,
duration: const Duration(milliseconds: 200),
);
super.initState();
}
@override
@@ -35,9 +33,12 @@ class _StartButtonState extends State<StartButton>
}
handleSwitchStart() {
isStart = !isStart;
updateController();
updateSystemProxy();
final appController = globalState.appController;
if (isStart == appController.appState.isStart) {
isStart = !isStart;
updateController();
appController.updateSystemProxy(isStart);
}
}
updateController() {
@@ -48,11 +49,18 @@ class _StartButtonState extends State<StartButton>
}
}
updateSystemProxy() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final appController = globalState.appController;
await appController.updateSystemProxy(isStart);
});
Widget _updateControllerContainer(Widget child) {
return Selector<AppState, bool>(
selector: (_, appState) => appState.isStart,
builder: (_, isStart, child) {
if(isStart != this.isStart){
this.isStart = isStart;
updateController();
}
return child!;
},
child: child,
);
}
@override
@@ -72,8 +80,7 @@ class _StartButtonState extends State<StartButton>
other.getTimeDifference(
DateTime.now(),
),
style:
Theme.of(context).textTheme.titleMedium?.toSoftBold(),
style: Theme.of(context).textTheme.titleMedium?.toSoftBold,
),
)
.width +
@@ -119,24 +126,14 @@ class _StartButtonState extends State<StartButton>
child: child,
);
},
child: Selector<AppState, bool>(
selector: (_, appState) => appState.runTime != null,
builder: (_, isRun, child) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isStart != isRun) {
isStart = isRun;
updateController();
}
});
return child!;
},
child: Selector<AppState, int?>(
child: _updateControllerContainer(
Selector<AppState, int?>(
selector: (_, appState) => appState.runTime,
builder: (_, int? value, __) {
final text = other.getTimeText(value);
return Text(
text,
style: Theme.of(context).textTheme.titleMedium?.toSoftBold(),
style: Theme.of(context).textTheme.titleMedium?.toSoftBold,
);
},
),

View File

@@ -42,7 +42,7 @@ class TrafficUsage extends StatelessWidget {
),
Text(
trafficValue.showUnit,
style: context.textTheme.labelMedium?.toLight(),
style: context.textTheme.labelMedium?.toLight,
),
],
);
@@ -51,25 +51,16 @@ class TrafficUsage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CommonCard(
onPressed: () {},
info: Info(
label: appLocalizations.trafficUsage,
iconData: Icons.data_saver_off,
),
child: Selector<AppState, List<Traffic>>(
selector: (_, appState) => appState.traffics,
builder: (_, traffics, __) {
final trafficTotal = traffics.isNotEmpty
? traffics.reduce(
(value, element) {
return Traffic(
up: element.up.value + value.up.value,
down: element.down.value + value.down.value,
);
},
)
: Traffic();
final upTrafficValue = trafficTotal.up;
final downTrafficValue = trafficTotal.down;
child: Selector<AppState, Traffic>(
selector: (_, appState) => appState.totalTraffic,
builder: (_, totalTraffic, __) {
final upTotalTrafficValue = totalTraffic.up;
final downTotalTrafficValue = totalTraffic.down;
return Padding(
padding: const EdgeInsets.all(16).copyWith(top: 0),
child: Column(
@@ -80,7 +71,7 @@ class TrafficUsage extends StatelessWidget {
child: getTrafficDataItem(
context,
Icons.arrow_upward,
upTrafficValue,
upTotalTrafficValue,
),
),
const SizedBox(
@@ -91,7 +82,7 @@ class TrafficUsage extends StatelessWidget {
child: getTrafficDataItem(
context,
Icons.arrow_downward,
downTrafficValue,
downTotalTrafficValue,
),
),
],

View File

@@ -9,4 +9,5 @@ export 'config.dart';
export 'application_setting.dart';
export 'about.dart';
export 'backup_and_recovery.dart';
export 'resources.dart';
export 'resources.dart';
export 'requests.dart';

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/state.dart';
@@ -16,20 +17,32 @@ class LogsFragment extends StatefulWidget {
}
class _LogsFragmentState extends State<LogsFragment> {
final logsNotifier = ValueNotifier<List<Log>>([]);
final logsNotifier = ValueNotifier<LogsAndKeywords>(const LogsAndKeywords());
final scrollController = ScrollController(
keepScrollOffset: false,
);
List<GlobalObjectKey<_LogItemState>> keys = [];
Timer? timer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
logsNotifier.value = context.read<AppState>().logs;
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = globalState.appController.appState;
logsNotifier.value = logsNotifier.value.copyWith(logs: appState.logs);
if (timer != null) {
timer?.cancel();
timer = null;
}
timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
logsNotifier.value = globalState.appController.appState.logs;
final logs = appState.logs;
if (!const ListEquality<Log>().equals(
logsNotifier.value.logs,
logs,
)) {
logsNotifier.value = logsNotifier.value.copyWith(logs: logs);
}
});
});
}
@@ -38,6 +51,8 @@ class _LogsFragmentState extends State<LogsFragment> {
void dispose() {
super.dispose();
timer?.cancel();
logsNotifier.dispose();
scrollController.dispose();
timer = null;
}
@@ -51,42 +66,36 @@ class _LogsFragmentState extends State<LogsFragment> {
showSearch(
context: context,
delegate: LogsSearchDelegate(
logs: logsNotifier.value.reversed.toList(),
logs: logsNotifier.value,
),
);
},
icon: const Icon(Icons.search),
),
const SizedBox(
width: 8,
)
];
});
}
_buildList() {
return ValueListenableBuilder<List<Log>>(
valueListenable: logsNotifier,
builder: (_, List<Log> logs, __) {
if (logs.isEmpty) {
return NullStatus(
label: appLocalizations.nullLogsDesc,
);
}
logs = logs.reversed.toList();
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: logs.length,
itemBuilder: (BuildContext context, int index) {
final log = logs[index];
return LogItem(
log: log,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
);
},
_addKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)
..add(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)
..remove(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
}
@@ -103,21 +112,88 @@ class _LogsFragmentState extends State<LogsFragment> {
}
return child!;
},
child: _buildList(),
child: ValueListenableBuilder<LogsAndKeywords>(
valueListenable: logsNotifier,
builder: (_, state, __) {
var logs = state.filteredLogs;
if (logs.isEmpty) {
return NullStatus(
label: appLocalizations.nullLogsDesc,
);
}
logs = logs.reversed.toList();
keys = logs
.map((log) => GlobalObjectKey<_LogItemState>(log.dateTime))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
controller: scrollController,
itemBuilder: (_, index) {
final log = logs[index];
return LogItem(
key: Key(log.dateTime.toString()),
log: log,
onClick: _addKeyword,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: logs.length,
),
)
],
);
},
),
);
}
}
class LogsSearchDelegate extends SearchDelegate {
List<Log> logs = [];
ValueNotifier<LogsAndKeywords> logsNotifier;
LogsSearchDelegate({
required this.logs,
});
required LogsAndKeywords logs,
}) : logsNotifier = ValueNotifier(logs);
@override
void dispose() {
super.dispose();
logsNotifier.dispose();
}
get state => logsNotifier.value;
List<Log> get _results {
final lowQuery = query.toLowerCase();
return logs
return logsNotifier.value.filteredLogs
.where(
(log) =>
(log.payload?.toLowerCase().contains(lowQuery) ?? false) ||
@@ -139,6 +215,9 @@ class LogsSearchDelegate extends SearchDelegate {
},
icon: const Icon(Icons.clear),
),
const SizedBox(
width: 8,
)
];
}
@@ -157,58 +236,120 @@ class LogsSearchDelegate extends SearchDelegate {
return buildSuggestions(context);
}
_addKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)..add(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)..remove(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
}
@override
Widget buildSuggestions(BuildContext context) {
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _results.length,
itemBuilder: (BuildContext context, int index) {
final log = _results[index];
return LogItem(
key: ValueKey(log.dateTime),
log: log,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
return ValueListenableBuilder(
valueListenable: logsNotifier,
builder: (_, __, ___) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 6,
spacing: 6,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
itemBuilder: (_, index) {
final log = _results[index];
return LogItem(
key: Key(log.dateTime.toString()),
log: log,
onClick: (value) {
_addKeyword(value);
},
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: _results.length,
),
)
],
);
},
);
}
}
class LogItem extends StatelessWidget {
class LogItem extends StatefulWidget {
final Log log;
final Function(String)? onClick;
const LogItem({
super.key,
required this.log,
this.onClick,
});
@override
State<LogItem> createState() => _LogItemState();
}
class _LogItemState extends State<LogItem> {
@override
Widget build(BuildContext context) {
return ListTile(
final log = widget.log;
return ListItem(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
title: SelectableText(log.payload ?? ''),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
top: 8,
),
child: SelectableText(
"${log.dateTime}",
style: context.textTheme.bodySmall
?.copyWith(color: context.colorScheme.primary),
),
SelectableText(
"${log.dateTime}",
style: context.textTheme.bodySmall
?.copyWith(color: context.colorScheme.primary),
),
const SizedBox(height: 8,),
Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(
vertical: 8,
),
child: CommonChip(
onPressed: () {
if (widget.onClick == null) return;
widget.onClick!(log.logLevel.name);
},
label: log.logLevel.name,
),
),

View File

@@ -25,7 +25,9 @@ class AddProfile extends StatelessWidget {
final url = await Navigator.of(context)
.push<String>(MaterialPageRoute(builder: (_) => const ScanPage()));
if (url != null) {
_handleAddProfileFormURL(url);
WidgetsBinding.instance.addPostFrameCallback((_){
_handleAddProfileFormURL(url);
});
}
}
@@ -91,6 +93,8 @@ class _URLFormDialogState extends State<URLFormDialog> {
runSpacing: 16,
children: [
TextField(
maxLines: 5,
minLines: 1,
controller: urlController,
decoration: InputDecoration(
border: const OutlineInputBorder(),

View File

@@ -1,4 +1,5 @@
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/widgets.dart';
@@ -40,19 +41,26 @@ class _EditProfileState extends State<EditProfile> {
_handleConfirm() {
if (!_formKey.currentState!.validate()) return;
final config = widget.context.read<Config>();
final hasUpdate = urlController.text.isNotEmpty && widget.profile.url != urlController.text;
widget.profile.url = urlController.text;
widget.profile.label = labelController.text;
widget.profile.autoUpdate = autoUpdate;
widget.profile.autoUpdateDuration =
Duration(minutes: int.parse(autoUpdateDurationController.text));
config.setProfile(widget.profile);
final profile = widget.profile.copyWith(
url: urlController.text,
label: labelController.text,
autoUpdate: autoUpdate,
autoUpdateDuration: Duration(
minutes: int.parse(
autoUpdateDurationController.text,
),
),
);
final hasUpdate = widget.profile.url != profile.url;
config.setProfile(profile);
if (hasUpdate) {
widget.context.findAncestorStateOfType<CommonScaffoldState>()?.loadingRun(
() => globalState.appController.updateProfile(
widget.profile.id,
),
);
globalState.homeScaffoldKey.currentState?.loadingRun(
() async {
if (hasUpdate) {
await globalState.appController.updateProfile(profile);
}
},
);
}
Navigator.of(context).pop();
}
@@ -82,12 +90,12 @@ class _EditProfileState extends State<EditProfile> {
},
),
),
if (widget.profile.url != null && widget.profile.url!.isNotEmpty == true)...[
if (widget.profile.type == ProfileType.url) ...[
ListItem(
title: TextFormField(
controller: urlController,
maxLines: 5,
minLines: 1,
maxLines: 2,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: appLocalizations.url,
@@ -151,6 +159,7 @@ class _EditProfileState extends State<EditProfile> {
vertical: 16,
),
child: ListView.separated(
primary: true,
itemBuilder: (_, index) {
return items[index];
},

View File

@@ -1,5 +1,6 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/fragments/profiles/edit_profile.dart';
import 'package:fl_clash/fragments/profiles/view_profile.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/state.dart';
@@ -15,6 +16,7 @@ enum ProfileActions {
edit,
update,
delete,
view,
}
class ProfilesFragment extends StatefulWidget {
@@ -25,15 +27,10 @@ class ProfilesFragment extends StatefulWidget {
}
class _ProfilesFragmentState extends State<ProfilesFragment> {
_handleDeleteProfile(String id) async {
globalState.appController.deleteProfile(id);
}
final hasPadding = ValueNotifier<bool>(false);
Function? applyConfigDebounce;
_handleUpdateProfile(String id) async {
context.findAncestorStateOfType<CommonScaffoldState>()?.loadingRun(
() => globalState.appController.updateProfile(id),
);
}
List<GlobalObjectKey<_ProfileItemState>> profileItemKeys = [];
_handleShowAddExtendPage() {
showExtendPage(
@@ -45,76 +42,6 @@ class _ProfilesFragmentState extends State<ProfilesFragment> {
);
}
_handleShowEditExtendPage(Profile profile) {
showExtendPage(
context,
body: EditProfile(
profile: profile.copyWith(),
context: context,
),
title: "${appLocalizations.edit}${appLocalizations.profile}",
);
}
_buildGrid({
required ProfilesSelectorState state,
int crossAxisCount = 1,
}) {
return SingleChildScrollView(
padding: crossAxisCount > 1
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero,
child: Grid.baseGap(
crossAxisCount: crossAxisCount,
children: [
for (final profile in state.profiles)
GridItem(
child: ProfileItem(
profile: profile,
commonPopupMenu: CommonPopupMenu<ProfileActions>(
items: [
CommonPopupMenuItem(
action: ProfileActions.edit,
label: appLocalizations.edit,
iconData: Icons.edit,
),
if (profile.url != null)
CommonPopupMenuItem(
action: ProfileActions.update,
label: appLocalizations.update,
iconData: Icons.sync,
),
CommonPopupMenuItem(
action: ProfileActions.delete,
label: appLocalizations.delete,
iconData: Icons.delete,
),
],
onSelected: (ProfileActions? action) async {
switch (action) {
case ProfileActions.edit:
_handleShowEditExtendPage(profile);
break;
case ProfileActions.delete:
_handleDeleteProfile(profile.id);
break;
case ProfileActions.update:
_handleUpdateProfile(profile.id);
break;
case null:
break;
}
},
),
groupValue: state.currentProfileId,
onChanged: globalState.appController.changeProfile,
),
),
],
),
);
}
_getColumns(ViewMode viewMode) {
switch (viewMode) {
case ViewMode.mobile:
@@ -126,128 +53,444 @@ class _ProfilesFragmentState extends State<ProfilesFragment> {
}
}
_updateProfiles() async {
final updateProfiles = profileItemKeys.map<Future>(
(key) async => await key.currentState?.updateProfile(false));
await Future.wait(updateProfiles);
}
_initScaffoldState() {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
if (!context.mounted) return;
commonScaffoldState?.actions = [
IconButton(
onPressed: () {
_updateProfiles();
},
icon: const Icon(Icons.sync),
),
const SizedBox(
width: 8,
)
];
},
);
}
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
hasPadding.dispose();
}
_changeProfile(String? id) async {
final appController = globalState.appController;
final config = appController.config;
if (id == config.currentProfileId) return;
config.currentProfileId = id;
applyConfigDebounce ??= debounce<Function()>(() async {
await appController.applyProfile();
appController.appState.delayMap = {};
appController.saveConfigPreferences();
});
applyConfigDebounce!();
}
@override
Widget build(BuildContext context) {
return FloatLayout(
floatingWidget: Container(
margin: const EdgeInsets.all(kFloatingActionButtonMargin),
floatingWidget: FloatWrapper(
child: FloatingActionButton(
heroTag: null,
onPressed: _handleShowAddExtendPage,
child: const Icon(Icons.add),
child: const Icon(
Icons.add,
),
),
),
child: Selector2<AppState, Config, ProfilesSelectorState>(
selector: (_, appState, config) => ProfilesSelectorState(
profiles: config.profiles,
currentProfileId: config.currentProfileId,
viewMode: appState.viewMode,
),
builder: (context, state, child) {
if (state.profiles.isEmpty) {
return NullStatus(
label: appLocalizations.nullProfileDesc,
);
child: Selector<AppState, bool>(
selector: (_, appState) => appState.currentLabel == 'profiles',
builder: (_, isCurrent, child) {
if (isCurrent) {
_initScaffoldState();
}
return Align(
alignment: Alignment.topCenter,
child: _buildGrid(
state: state,
crossAxisCount: _getColumns(state.viewMode),
),
);
return child!;
},
child: Selector2<AppState, Config, ProfilesSelectorState>(
selector: (_, appState, config) => ProfilesSelectorState(
profiles: config.profiles,
currentProfileId: config.currentProfileId,
viewMode: appState.viewMode,
),
builder: (context, state, child) {
if (state.profiles.isEmpty) {
return NullStatus(
label: appLocalizations.nullProfileDesc,
);
}
profileItemKeys = state.profiles
.map(
(profile) => GlobalObjectKey<_ProfileItemState>(profile.id))
.toList();
final columns = _getColumns(state.viewMode);
return Align(
alignment: Alignment.topCenter,
child: NotificationListener<ScrollNotification>(
onNotification: (scrollNotification) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
hasPadding.value =
scrollNotification.metrics.maxScrollExtent > 0;
},
);
return true;
},
child: ValueListenableBuilder(
valueListenable: hasPadding,
builder: (_, hasPadding, __) {
return SingleChildScrollView(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 16 + (hasPadding ? 56 : 0),
),
child: Grid(
mainAxisSpacing: 16,
crossAxisSpacing: 16,
crossAxisCount: columns,
children: [
for (int i = 0; i < state.profiles.length; i++)
GridItem(
child: ProfileItem(
key: profileItemKeys[i],
profile: state.profiles[i],
groupValue: state.currentProfileId,
onChanged: _changeProfile,
),
),
],
),
);
},
),
),
);
},
),
),
);
}
}
class ProfileItem extends StatelessWidget {
class ProfileItem extends StatefulWidget {
final Profile profile;
final String? groupValue;
final CommonPopupMenu commonPopupMenu;
final void Function(String? value) onChanged;
const ProfileItem({
super.key,
required this.profile,
required this.commonPopupMenu,
required this.groupValue,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
String useShow;
String totalShow;
double progress;
final userInfo = profile.userInfo;
if (userInfo == null) {
useShow = "Infinite";
totalShow = "Infinite";
progress = 1;
} else {
final use = userInfo.upload + userInfo.download;
final total = userInfo.total;
useShow = TrafficValue(value: use).show;
totalShow = TrafficValue(value: total).show;
progress = total == 0 ? 0.0 : use / total;
State<ProfileItem> createState() => _ProfileItemState();
}
class _ProfileItemState extends State<ProfileItem> {
final isUpdating = ValueNotifier<bool>(false);
_handleDeleteProfile() async {
globalState.appController.deleteProfile(widget.profile.id);
}
_handleUpdateProfile() async {
await globalState.safeRun<void>(updateProfile);
}
Future updateProfile([isSingle = true]) async {
isUpdating.value = true;
try {
final appController = globalState.appController;
await appController.updateProfile(widget.profile);
if (widget.profile.id == appController.config.currentProfile?.id &&
!appController.appState.isStart) {
globalState.appController.rawApplyProfile();
}
} catch (e) {
isUpdating.value = false;
if (!isSingle) {
return e.toString();
} else {
rethrow;
}
}
return ListItem.radio(
horizontalTitleGap: 16,
delegate: RadioDelegate<String?>(
value: profile.id,
groupValue: groupValue,
onChanged: onChanged,
isUpdating.value = false;
return null;
}
_handleShowEditExtendPage() {
showExtendPage(
context,
body: EditProfile(
profile: widget.profile,
context: context,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
trailing: commonPopupMenu,
title: Column(
mainAxisSize: MainAxisSize.min,
title: "${appLocalizations.edit}${appLocalizations.profile}",
);
}
_handleViewProfile() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ViewProfile(
profile: widget.profile,
),
),
);
}
_buildTitle(Profile profile) {
final textTheme = context.textTheme;
return Container(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
profile.label ?? profile.id,
style: textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
profile.lastUpdateDate?.lastUpdateTimeDesc ?? '',
style: textTheme.labelMedium?.toLight,
),
],
),
Builder(builder: (context) {
final userInfo = profile.userInfo ?? const UserInfo();
final use = userInfo.upload + userInfo.download;
final total = userInfo.total;
final useShow = TrafficValue(value: use).show;
final totalShow = TrafficValue(value: total).show;
final progress = total == 0 ? 0.0 : use / total;
final expireShow = userInfo.expire == 0
? appLocalizations.infiniteTime
: DateTime.fromMillisecondsSinceEpoch(userInfo.expire * 1000)
.show;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
profile.label ?? profile.id,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
Container(
margin: const EdgeInsets.symmetric(
vertical: 8,
),
child: LinearProgressIndicator(
minHeight: 6,
value: progress,
),
),
Flexible(
child: Text(
profile.lastUpdateDate?.lastUpdateTimeDesc ?? '',
style: Theme.of(context).textTheme.labelMedium?.toLight(),
),
Text(
"$useShow / $totalShow",
style: textTheme.labelMedium?.toLight,
),
const SizedBox(
height: 2,
),
Row(
children: [
Text(
appLocalizations.expirationTime,
style: textTheme.labelMedium?.toLighter,
),
const SizedBox(
width: 4,
),
Text(
expireShow,
style: textTheme.labelMedium?.toLighter,
),
],
)
],
),
),
Flexible(
child: Container(
margin: const EdgeInsets.symmetric(
vertical: 8,
),
child: LinearProgressIndicator(
minHeight: 6,
value: progress,
),
),
),
Flexible(
child: Text(
"$useShow / $totalShow",
style: Theme.of(context).textTheme.labelMedium?.toLight(),
),
),
);
// final child = switch (userInfo != null) {
// true => () {
// final use = userInfo!.upload + userInfo.download;
// final total = userInfo.total;
// final useShow = TrafficValue(value: use).show;
// final totalShow = TrafficValue(value: total).show;
// final progress = total == 0 ? 0.0 : use / total;
// final expireShow = userInfo.expire == 0
// ? appLocalizations.infiniteTime
// : DateTime.fromMillisecondsSinceEpoch(
// userInfo.expire * 1000)
// .show;
// return Column(
// mainAxisSize: MainAxisSize.min,
// crossAxisAlignment: CrossAxisAlignment.start,
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Container(
// margin: const EdgeInsets.symmetric(
// vertical: 8,
// ),
// child: LinearProgressIndicator(
// minHeight: 6,
// value: progress,
// ),
// ),
// Text(
// "$useShow / $totalShow",
// style: textTheme.labelMedium?.toLight(),
// ),
// const SizedBox(
// height: 2,
// ),
// Row(
// children: [
// Text(
// appLocalizations.expirationTime,
// style: textTheme.labelMedium?.toLighter(),
// ),
// const SizedBox(
// width: 4,
// ),
// Text(
// expireShow,
// style: textTheme.labelMedium?.toLighter(),
// ),
// ],
// )
// ],
// );
// }(),
// false => Column(
// children: [
// Padding(
// padding: const EdgeInsets.only(top: 8),
// child: CommonChip(
// onPressed: _handleViewProfile,
// avatar: const Icon(Icons.remove_red_eye),
// label: appLocalizations.view,
// ),
// ),
// ],
// ),
// };
// final measure = globalState.appController.measure;
// final height = 6 + 8 * 2 + 2 + measure.labelMediumHeight * 2;
// return SizedBox(
// height: height,
// child: child,
// );
}),
],
),
);
}
@override
void dispose() {
isUpdating.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final profile = widget.profile;
final groupValue = widget.groupValue;
final onChanged = widget.onChanged;
return CommonCard(
child: ListItem.radio(
key: Key(profile.id),
horizontalTitleGap: 16,
delegate: RadioDelegate<String?>(
value: profile.id,
groupValue: groupValue,
onChanged: onChanged,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
trailing: SizedBox(
height: 48,
width: 48,
child: ValueListenableBuilder(
valueListenable: isUpdating,
builder: (_, isUpdating, ___) {
return FadeBox(
child: isUpdating
? const Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
)
: CommonPopupMenu<ProfileActions>(
items: [
CommonPopupMenuItem(
action: ProfileActions.edit,
label: appLocalizations.edit,
iconData: Icons.edit,
),
if (profile.type == ProfileType.url)
CommonPopupMenuItem(
action: ProfileActions.update,
label: appLocalizations.update,
iconData: Icons.sync,
),
CommonPopupMenuItem(
action: ProfileActions.view,
label: appLocalizations.view,
iconData: Icons.visibility,
),
CommonPopupMenuItem(
action: ProfileActions.delete,
label: appLocalizations.delete,
iconData: Icons.delete,
),
],
onSelected: (ProfileActions? action) async {
switch (action) {
case ProfileActions.edit:
_handleShowEditExtendPage();
break;
case ProfileActions.delete:
_handleDeleteProfile();
break;
case ProfileActions.update:
_handleUpdateProfile();
break;
case ProfileActions.view:
_handleViewProfile();
break;
case null:
break;
}
},
));
},
),
),
title: _buildTitle(profile),
tileTitleAlignment: ListTileTitleAlignment.titleHeight,
),
);
}
}

View File

@@ -0,0 +1,207 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:flutter/material.dart';
import 'package:re_editor/re_editor.dart';
import 'package:re_highlight/languages/yaml.dart';
import 'package:re_highlight/styles/intellij-light.dart';
class ViewProfile extends StatefulWidget {
final Profile profile;
const ViewProfile({
super.key,
required this.profile,
});
@override
State<ViewProfile> createState() => _ViewProfileState();
}
class _ViewProfileState extends State<ViewProfile> {
bool readOnly = true;
CodeLineEditingController? controller;
final contentNotifier = ValueNotifier<String>("");
final key = GlobalKey<CommonScaffoldState>();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
final profilePath = await appPath.getProfilePath(widget.profile.id);
if (profilePath == null) {
return;
}
final file = File(profilePath);
final text = await file.readAsString();
contentNotifier.value = text;
});
}
@override
void dispose() {
super.dispose();
contentNotifier.dispose();
controller?.dispose();
}
Profile get profile => widget.profile;
_handleChangeReadOnly() async {
if (readOnly == true) {
setState(() {
readOnly = false;
});
} else {
final text = controller?.text;
if (text == null || text == contentNotifier.value) {
setState(() {
readOnly = true;
});
return;
}
contentNotifier.value = text;
final newProfile = await key.currentState?.loadingRun<Profile>(() async {
return await profile.saveFileWithString(text);
});
if (newProfile == null) return;
globalState.appController.config.setProfile(newProfile);
setState(() {
readOnly = true;
});
}
}
@override
Widget build(BuildContext context) {
return CommonScaffold(
key: key,
actions: [
IconButton(
onPressed: controller?.undo,
icon: const Icon(Icons.undo),
),
IconButton(
onPressed: controller?.redo,
icon: const Icon(Icons.redo),
),
if (!widget.profile.realAutoUpdate)
IconButton(
onPressed: _handleChangeReadOnly,
icon: readOnly ? const Icon(Icons.edit) : const Icon(Icons.save),
),
const SizedBox(
width: 8,
)
],
body: ValueListenableBuilder(
valueListenable: contentNotifier,
builder: (_, value, __) {
if (value.isEmpty) return Container();
controller = CodeLineEditingController.fromText(value);
return CodeEditor(
autofocus: false,
readOnly: readOnly,
scrollbarBuilder: (context, child, details) {
return Scrollbar(
controller: details.controller,
thickness: 8,
radius: const Radius.circular(2),
interactive: true,
child: child,
);
},
showCursorWhenReadOnly: false,
controller: controller,
toolbarController:
!readOnly ? const ContextMenuControllerImpl() : null,
shortcutsActivatorsBuilder:
const DefaultCodeShortcutsActivatorsBuilder(),
indicatorBuilder:
(context, editingController, chunkController, notifier) {
return Row(
children: [
DefaultCodeLineNumber(
controller: editingController,
notifier: notifier,
),
DefaultCodeChunkIndicator(
width: 20,
controller: chunkController,
notifier: notifier,
)
],
);
},
style: CodeEditorStyle(
fontSize: 14,
codeTheme: CodeHighlightTheme(
languages: {
'yaml': CodeHighlightThemeMode(
mode: langYaml,
)
},
theme: intellijLightTheme,
),
),
);
},
),
title: widget.profile.label ?? widget.profile.id,
);
}
}
class ContextMenuItemWidget extends PopupMenuItem<void> {
ContextMenuItemWidget({
super.key,
required String text,
required VoidCallback super.onTap,
}) : super(child: Text(text));
}
class ContextMenuControllerImpl implements SelectionToolbarController {
const ContextMenuControllerImpl();
@override
void hide(BuildContext context) {}
@override
void show({
required BuildContext context,
required CodeLineEditingController controller,
required TextSelectionToolbarAnchors anchors,
Rect? renderRect,
required LayerLink layerLink,
required ValueNotifier<bool> visibility,
}) {
if (controller.selectedText.isEmpty) {
return;
}
showMenu(
context: context,
position: RelativeRect.fromSize(
(anchors.secondaryAnchor ?? anchors.primaryAnchor) &
const Size(150, double.infinity),
MediaQuery.of(context).size,
),
items: [
ContextMenuItemWidget(
text: appLocalizations.cut,
onTap: controller.cut,
),
ContextMenuItemWidget(
text: appLocalizations.copy,
onTap: controller.copy,
),
ContextMenuItemWidget(
text: appLocalizations.paste,
onTap: controller.paste,
),
],
);
}
}

File diff suppressed because it is too large Load Diff

418
lib/fragments/requests.dart Normal file
View File

@@ -0,0 +1,418 @@
import 'dart:async';
import 'dart:io';
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/plugins/app.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class RequestsFragment extends StatefulWidget {
const RequestsFragment({super.key});
@override
State<RequestsFragment> createState() => _RequestsFragmentState();
}
class _RequestsFragmentState extends State<RequestsFragment> {
final requestsNotifier =
ValueNotifier<ConnectionsAndKeywords>(const ConnectionsAndKeywords());
final ScrollController _scrollController = ScrollController(
keepScrollOffset: false,
);
Timer? timer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = globalState.appController.appState;
requestsNotifier.value =
requestsNotifier.value.copyWith(connections: appState.requests);
if (timer != null) {
timer?.cancel();
timer = null;
}
timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
final requests = appState.requests;
if (!const ListEquality<Connection>().equals(
requestsNotifier.value.connections,
requests,
)) {
requestsNotifier.value =
requestsNotifier.value.copyWith(connections: requests);
}
});
});
}
_initActions() {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
commonScaffoldState?.actions = [
IconButton(
onPressed: () {
showSearch(
context: context,
delegate: RequestsSearchDelegate(
state: requestsNotifier.value,
),
);
},
icon: const Icon(Icons.search),
),
const SizedBox(
width: 8,
)
];
},
);
}
_addKeyword(String keyword) {
final isContains = requestsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(requestsNotifier.value.keywords)
..add(keyword);
requestsNotifier.value = requestsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = requestsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(requestsNotifier.value.keywords)
..remove(keyword);
requestsNotifier.value = requestsNotifier.value.copyWith(
keywords: keywords,
);
}
@override
void dispose() {
super.dispose();
timer?.cancel();
_scrollController.dispose();
timer = null;
}
@override
Widget build(BuildContext context) {
return Selector<AppState, bool?>(
selector: (_, appState) =>
appState.currentLabel == 'requests' ||
appState.viewMode == ViewMode.mobile &&
appState.currentLabel == "tools",
builder: (_, isCurrent, child) {
if (isCurrent == null || isCurrent) {
_initActions();
}
return child!;
},
child: ValueListenableBuilder<ConnectionsAndKeywords>(
valueListenable: requestsNotifier,
builder: (_, state, __) {
var connections = state.filteredConnections;
if (connections.isEmpty) {
return NullStatus(
label: appLocalizations.nullRequestsDesc,
);
}
connections = connections.reversed.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 6,
spacing: 6,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
controller: _scrollController,
itemBuilder: (_, index) {
final connection = connections[index];
return RequestItem(
key: Key(connection.id),
connection: connection,
onClick: _addKeyword,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: connections.length,
),
)
],
);
},
),
);
}
}
class RequestItem extends StatelessWidget {
final Connection connection;
final Function(String)? onClick;
const RequestItem({
super.key,
required this.connection,
this.onClick,
});
Future<ImageProvider?> _getPackageIcon(Connection connection) async {
return await app?.getPackageIcon(connection.metadata.process);
}
String _getRequestText(Metadata metadata) {
var text = "${metadata.network}://";
final ips = [
metadata.host,
metadata.destinationIP,
].where((ip) => ip.isNotEmpty);
text += ips.join("/");
text += ":${metadata.destinationPort}";
return text;
}
String _getSourceText(Connection connection) {
final metadata = connection.metadata;
if (metadata.process.isEmpty) {
return connection.start.lastUpdateTimeDesc;
}
return "${metadata.process} · ${connection.start.lastUpdateTimeDesc}";
}
@override
Widget build(BuildContext context) {
return ListItem(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
tileTitleAlignment: ListTileTitleAlignment.titleHeight,
leading: Platform.isAndroid
? Container(
margin: const EdgeInsets.only(top: 4),
width: 48,
height: 48,
child: FutureBuilder<ImageProvider?>(
future: _getPackageIcon(connection),
builder: (_, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Container();
} else {
return Image(
image: snapshot.data!,
gaplessPlayback: true,
width: 48,
height: 48,
);
}
},
),
)
: null,
title: Text(
_getRequestText(connection.metadata),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 8,
),
Text(
_getSourceText(connection),
),
const SizedBox(
height: 8,
),
Wrap(
runSpacing: 6,
spacing: 6,
children: [
for (final chain in connection.chains)
CommonChip(
label: chain,
onPressed: () {
if (onClick == null) return;
onClick!(chain);
},
),
],
),
],
),
);
}
}
class RequestsSearchDelegate extends SearchDelegate {
ValueNotifier<ConnectionsAndKeywords> requestsNotifier;
RequestsSearchDelegate({
required ConnectionsAndKeywords state,
}) : requestsNotifier = ValueNotifier<ConnectionsAndKeywords>(state);
get state => requestsNotifier.value;
List<Connection> get _results {
final lowerQuery = query.toLowerCase().trim();
return requestsNotifier.value.filteredConnections.where((request) {
final lowerNetwork = request.metadata.network.toLowerCase();
final lowerHost = request.metadata.host.toLowerCase();
final lowerDestinationIP = request.metadata.destinationIP.toLowerCase();
final lowerProcess = request.metadata.process.toLowerCase();
final lowerChains = request.chains.join("").toLowerCase();
return lowerNetwork.contains(lowerQuery) ||
lowerHost.contains(lowerQuery) ||
lowerDestinationIP.contains(lowerQuery) ||
lowerProcess.contains(lowerQuery) ||
lowerChains.contains(lowerQuery);
}).toList();
}
_addKeyword(String keyword) {
final isContains = requestsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(requestsNotifier.value.keywords)
..add(keyword);
requestsNotifier.value = requestsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = requestsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(requestsNotifier.value.keywords)
..remove(keyword);
requestsNotifier.value = requestsNotifier.value.copyWith(
keywords: keywords,
);
}
@override
List<Widget>? buildActions(BuildContext context) {
return [
IconButton(
onPressed: () {
if (query.isEmpty) {
close(context, null);
return;
}
query = '';
},
icon: const Icon(Icons.clear),
),
const SizedBox(
width: 8,
)
];
}
@override
Widget? buildLeading(BuildContext context) {
return IconButton(
onPressed: () {
close(context, null);
},
icon: const Icon(Icons.arrow_back),
);
}
@override
Widget buildResults(BuildContext context) {
return buildSuggestions(context);
}
@override
void dispose() {
requestsNotifier.dispose();
super.dispose();
}
@override
Widget buildSuggestions(BuildContext context) {
return ValueListenableBuilder(
valueListenable: requestsNotifier,
builder: (_, __, ___) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 6,
spacing: 6,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
itemBuilder: (_, index) {
final connection = _results[index];
return RequestItem(
key: Key(connection.id),
connection: connection,
onClick: (value) {
_addKeyword(value);
},
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: _results.length,
),
)
],
);
},
);
}
}

View File

@@ -2,22 +2,37 @@ import 'dart:io';
import 'package:fl_clash/clash/clash.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/ffi.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:path/path.dart' hide context;
import 'package:provider/provider.dart';
@immutable
class GeoItem {
final String label;
final String key;
final String fileName;
const GeoItem({
required this.label,
required this.key,
required this.fileName,
});
}
@immutable
class FileInfo {
final String size;
final DateTime lastModified;
const FileInfo({
required this.size,
required this.lastModified,
});
}
class Resources extends StatefulWidget {
const Resources({super.key});
@@ -26,147 +41,473 @@ class Resources extends StatefulWidget {
}
class _ResourcesState extends State<Resources> {
_updateExternalProvider(
String providerName,
String providerType,
) async {
final commonScaffoldState = context.commonScaffoldState;
await commonScaffoldState?.loadingRun(() async {
final message = await clashCore.updateExternalProvider(
providerName: providerName,
providerType: providerType,
);
if (message.isNotEmpty) throw message;
List<ExternalProvider> externalProviders = [];
List<GlobalObjectKey<_ProviderItemState>> providerItemKeys = [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncExternalProviders();
});
setState(() {});
}
Future<DateTime> _getGeoFileLastModified(String fileName) async {
final homePath = await appPath.getHomeDirPath();
return await File(join(homePath, fileName)).lastModified();
_syncExternalProviders() async {
externalProviders = await clashCore.getExternalProviders();
if (mounted) {
setState(() {});
}
}
Widget _buildExternalProviderSection() {
return FutureBuilder<List<ExternalProvider>>(
future: () async {
await Future.delayed(const Duration(milliseconds: 200));
return await clashCore.getExternalProviders();
}(),
builder: (_, snapshot) {
return Center(
child: FadeBox(
key: const Key("external_providers"),
child: snapshot.data == null || snapshot.data!.isEmpty
? Container()
: Section(
title: appLocalizations.externalResources,
child: Column(
children: [
for (final externalProvider in snapshot.data!)
ListItem(
title: Text(externalProvider.name),
subtitle: Text(
"${externalProvider.type} (${externalProvider.vehicleType})",
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
externalProvider.updateAt.lastUpdateTimeDesc,
style: context.textTheme.bodyMedium,
),
const Padding(
padding: EdgeInsets.only(left: 12,right: 4),
child: VerticalDivider(
endIndent: 2,
width: 4,
indent: 2,
),
),
externalProvider.vehicleType == "HTTP"
? IconButton(
icon: const Icon(Icons.sync),
onPressed: () {
_updateExternalProvider(
externalProvider.name,
externalProvider.type,
);
},
)
: Container(),
],
),
)
],
),
),
),
);
},
_updateProviders() async {
final updateProviders = providerItemKeys.map<Future>(
(key) async => await key.currentState?.updateProvider(false),
);
await Future.wait(updateProviders);
_syncExternalProviders();
}
Widget _buildGeoDataSection() {
List<Widget> _buildExternalProviderSection() {
List<GlobalObjectKey<_ProviderItemState>> keys = [];
final res = generateInfoSection(
info: Info(
iconData: Icons.source,
label: appLocalizations.externalResources,
),
actions: [
IconButton.filledTonal(
onPressed: () {
_updateProviders();
},
padding: const EdgeInsets.all(4),
iconSize: 20,
icon: const Icon(
Icons.sync,
),
)
],
items: externalProviders.map(
(externalProvider) {
final key =
GlobalObjectKey<_ProviderItemState>(externalProvider.name);
keys.add(key);
return ProviderItem(
key: key,
provider: externalProvider,
onUpdated: () {
_syncExternalProviders();
},
);
},
),
);
providerItemKeys = keys;
return res;
}
List<Widget> _buildGeoDataSection() {
const geoItems = <GeoItem>[
GeoItem(label: "GeoIp", fileName: mmdbFileName),
GeoItem(label: "GeoSite", fileName: geoSiteFileName),
GeoItem(label: "ASN", fileName: asnFileName),
GeoItem(
label: "GeoIp",
fileName: geoIpFileName,
key: "geoip",
),
GeoItem(label: "GeoSite", fileName: geoSiteFileName, key: "geosite"),
GeoItem(
label: "MMDB",
fileName: mmdbFileName,
key: "mmdb",
),
GeoItem(label: "ASN", fileName: asnFileName, key: "asn"),
];
return Section(
title: appLocalizations.geoData,
child: Column(
children: [
for (final geoItem in geoItems)
ListItem(
title: Text(geoItem.label),
subtitle: FutureBuilder<DateTime>(
future: () async {
await Future.delayed(const Duration(milliseconds: 200));
return await _getGeoFileLastModified(geoItem.fileName);
}(),
builder: (_, snapshot) {
return Container(
alignment: Alignment.centerLeft,
height: 24,
child: FadeBox(
key: Key("fade_box_${geoItem.label}"),
child: snapshot.data == null
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(
snapshot.data!.lastUpdateTimeDesc,
),
),
);
},
),
trailing: IconButton(
icon: const Icon(Icons.sync),
onPressed: () {
_updateExternalProvider(
geoItem.fileName,
geoItem.label,
);
},
),
),
],
return generateInfoSection(
info: Info(
iconData: Icons.storage,
label: appLocalizations.geoData,
),
items: geoItems.map(
(geoItem) => GeoDataListItem(
geoItem: geoItem,
),
),
);
}
@override
Widget build(BuildContext context) {
return ListView(
children: [
_buildGeoDataSection(),
_buildExternalProviderSection(),
return generateListView(
[
..._buildGeoDataSection(),
..._buildExternalProviderSection(),
],
);
}
}
class GeoDataListItem extends StatefulWidget {
final GeoItem geoItem;
const GeoDataListItem({
super.key,
required this.geoItem,
});
@override
State<GeoDataListItem> createState() => _GeoDataListItemState();
}
class _GeoDataListItemState extends State<GeoDataListItem> {
final isUpdating = ValueNotifier<bool>(false);
GeoItem get geoItem => widget.geoItem;
_updateUrl(String url) async {
final newUrl = await globalState.showCommonDialog<String>(
child: UpdateGeoUrlFormDialog(
title: geoItem.label,
url: url,
),
);
if (newUrl != null && newUrl != url && mounted) {
try {
if (!newUrl.isUrl) {
throw "Invalid url";
}
final appController = globalState.appController;
appController.clashConfig.geoXUrl =
Map.from(appController.clashConfig.geoXUrl)..[geoItem.key] = newUrl;
appController.updateClashConfigDebounce();
} catch (e) {
globalState.showMessage(
title: geoItem.label,
message: TextSpan(
text: e.toString(),
),
);
}
}
}
Future<FileInfo> _getGeoFileLastModified(String fileName) async {
final homePath = await appPath.getHomeDirPath();
final file = File(join(homePath, fileName));
final lastModified = await file.lastModified();
final size = await file.length();
return FileInfo(
size: TrafficValue(value: size).show,
lastModified: lastModified,
);
}
// _uploadGeoFile(String fileName) async {
// final res = await picker.pickerGeoDataFile();
// if (res == null || res.bytes == null) return;
// final homePath = await appPath.getHomeDirPath();
// final file = File(join(homePath, fileName));
// await file.writeAsBytes(
// res.bytes!,
// flush: true,
// );
// setState(() {});
// }
String _buildFileInfoDesc(FileInfo fileInfo) {
return "${fileInfo.size} · ${fileInfo.lastModified.lastUpdateTimeDesc}";
}
Widget _buildSubtitle(String url) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 4,
),
FutureBuilder<FileInfo>(
future: _getGeoFileLastModified(geoItem.fileName),
builder: (_, snapshot) {
return SizedBox(
height: 24,
child: FadeBox(
key: Key("fade_box_${geoItem.label}"),
child: snapshot.data == null
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(
_buildFileInfoDesc(snapshot.data!),
),
),
);
},
),
Text(
url,
style: context.textTheme.bodyMedium?.toLight,
),
const SizedBox(
height: 8,
),
const SizedBox(
height: 8,
),
Wrap(
runSpacing: 6,
spacing: 12,
children: [
CommonChip(
avatar: const Icon(Icons.edit),
label: appLocalizations.edit,
onPressed: () {
_updateUrl(url);
},
),
CommonChip(
avatar: const Icon(Icons.sync),
label: appLocalizations.sync,
onPressed: () {
_handleUpdateGeoDataItem();
},
),
],
),
],
);
}
_handleUpdateGeoDataItem() async {
await globalState.safeRun<void>(updateGeoDateItem);
setState(() {});
}
updateGeoDateItem() async {
isUpdating.value = true;
try {
final message = await clashCore.updateExternalProvider(
providerName: geoItem.fileName,
providerType: geoItem.label,
);
if (message.isNotEmpty) throw message;
} catch (e) {
isUpdating.value = false;
rethrow;
}
isUpdating.value = false;
return null;
}
@override
void dispose() {
super.dispose();
isUpdating.dispose();
}
@override
Widget build(BuildContext context) {
return ListItem(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
title: Text(geoItem.label),
subtitle: Selector<ClashConfig, String>(
selector: (_, clashConfig) => clashConfig.geoXUrl[geoItem.key]!,
builder: (_, value, __) {
return _buildSubtitle(value);
},
),
trailing: SizedBox(
height: 48,
width: 48,
child: ValueListenableBuilder(
valueListenable: isUpdating,
builder: (_, isUpdating, ___) {
return FadeBox(
child: isUpdating
? const Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
)
: const SizedBox(),
);
},
),
),
);
}
}
class ProviderItem extends StatefulWidget {
final ExternalProvider provider;
final Function onUpdated;
const ProviderItem({
super.key,
required this.provider,
required this.onUpdated,
});
@override
State<ProviderItem> createState() => _ProviderItemState();
}
class _ProviderItemState extends State<ProviderItem> {
final isUpdating = ValueNotifier<bool>(false);
ExternalProvider get provider => widget.provider;
_handleUpdateProfile() async {
await globalState.safeRun<void>(updateProvider);
widget.onUpdated();
}
updateProvider([isSingle = true]) async {
if (provider.vehicleType != "HTTP") return;
isUpdating.value = true;
try {
final message = await clashCore.updateExternalProvider(
providerName: provider.name,
providerType: provider.type,
);
if (message.isNotEmpty) throw message;
} catch (e) {
isUpdating.value = false;
if (!isSingle) {
return e.toString();
} else {
rethrow;
}
}
isUpdating.value = false;
return null;
}
String _buildProviderDesc() {
return "${provider.type} (${provider.vehicleType}) · ${provider.updateAt.lastUpdateTimeDesc}";
}
@override
void dispose() {
super.dispose();
isUpdating.dispose();
}
Widget _buildSubtitle() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 4,
),
Text(
_buildProviderDesc(),
),
if (provider.vehicleType == "HTTP") ...[
const SizedBox(
height: 8,
),
CommonChip(
avatar: const Icon(Icons.sync),
label: appLocalizations.sync,
onPressed: () {
_handleUpdateProfile();
},
),
],
],
);
}
@override
Widget build(BuildContext context) {
return ListItem(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
title: Text(provider.name),
subtitle: _buildSubtitle(),
trailing: SizedBox(
height: 48,
width: 48,
child: ValueListenableBuilder(
valueListenable: isUpdating,
builder: (_, isUpdating, ___) {
return FadeBox(
child: isUpdating
? const Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
)
: const SizedBox(),
);
},
),
),
);
}
}
class UpdateGeoUrlFormDialog extends StatefulWidget {
final String title;
final String url;
const UpdateGeoUrlFormDialog({
super.key,
required this.title,
required this.url,
});
@override
State<UpdateGeoUrlFormDialog> createState() => _UpdateGeoUrlFormDialogState();
}
class _UpdateGeoUrlFormDialogState extends State<UpdateGeoUrlFormDialog> {
late TextEditingController urlController;
@override
void initState() {
super.initState();
urlController = TextEditingController(text: widget.url);
}
_handleUpdate() async {
final url = urlController.value.text;
if (url.isEmpty) return;
Navigator.of(context).pop<String>(url);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: 300,
child: Wrap(
runSpacing: 16,
children: [
TextField(
maxLines: 5,
minLines: 1,
controller: urlController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(
onPressed: _handleUpdate,
child: Text(appLocalizations.submit),
)
],
);
}
}

View File

@@ -111,9 +111,10 @@ class ThemeFragment extends StatelessWidget {
null,
defaultPrimaryColor,
Colors.pinkAccent,
Colors.lightBlue,
Colors.greenAccent,
Colors.yellowAccent,
Colors.purple
Colors.purple,
];
return Column(
children: [

View File

@@ -57,137 +57,120 @@ class _ToolboxFragmentState extends State<ToolsFragment> {
return Intl.message(locale.toString());
}
Widget _getOtherList() {
List<Widget> items = [
ListItem.open(
leading: const Icon(Icons.info),
title: Text(appLocalizations.about),
delegate: OpenDelegate(
title: appLocalizations.about,
widget: const AboutFragment(),
List<Widget> _getOtherList() {
return generateSection(
title: appLocalizations.other,
items: [
ListItem.open(
leading: const Icon(Icons.info),
title: Text(appLocalizations.about),
delegate: OpenDelegate(
title: appLocalizations.about,
widget: const AboutFragment(),
),
),
),
];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final item in items) ...[
item,
if (item != items.last)
const Divider(
height: 0,
),
]
],
);
}
Widget _getSettingList() {
List<Widget> items = [
Selector<Config, String?>(
selector: (_, config) => config.locale,
builder: (_, localeString, __) {
final subTitle = localeString ?? appLocalizations.defaultText;
final currentLocale = other.getLocaleForString(localeString);
return ListTile(
leading: const Icon(Icons.language_outlined),
title: Text(appLocalizations.language),
subtitle: Text(Intl.message(subTitle)),
onTap: () {
globalState.showCommonDialog(
child: AlertDialog(
title: Text(appLocalizations.language),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 16,
),
content: SizedBox(
width: 250,
child: Wrap(
children: [
for (final locale in [
null,
...AppLocalizations.delegate.supportedLocales
])
ListItem.radio(
delegate: RadioDelegate<Locale?>(
value: locale,
groupValue: currentLocale,
onChanged: (Locale? value) {
final config = context.read<Config>();
config.locale = value?.toString();
Navigator.of(context).pop();
},
),
title: Text(_getLocaleString(locale)),
)
],
List<Widget> _getSettingList() {
return generateSection(
title: appLocalizations.settings,
items: [
Selector<Config, String?>(
selector: (_, config) => config.locale,
builder: (_, localeString, __) {
final subTitle = localeString ?? appLocalizations.defaultText;
final currentLocale = other.getLocaleForString(localeString);
return ListTile(
leading: const Icon(Icons.language_outlined),
title: Text(appLocalizations.language),
subtitle: Text(Intl.message(subTitle)),
onTap: () {
globalState.showCommonDialog(
child: AlertDialog(
title: Text(appLocalizations.language),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 16,
),
content: SizedBox(
width: 250,
child: Wrap(
children: [
for (final locale in [
null,
...AppLocalizations.delegate.supportedLocales
])
ListItem.radio(
delegate: RadioDelegate<Locale?>(
value: locale,
groupValue: currentLocale,
onChanged: (Locale? value) {
final config = context.read<Config>();
config.locale = value?.toString();
Navigator.of(context).pop();
},
),
title: Text(_getLocaleString(locale)),
)
],
),
),
),
),
);
},
);
},
),
ListItem.open(
leading: const Icon(Icons.style),
title: Text(appLocalizations.theme),
subtitle: Text(appLocalizations.themeDesc),
delegate: OpenDelegate(
title: appLocalizations.theme,
widget: const ThemeFragment(),
extendPageWidth: 360,
);
},
);
},
),
),
ListItem.open(
leading: const Icon(Icons.cloud_sync),
title: Text(appLocalizations.backupAndRecovery),
subtitle: Text(appLocalizations.backupAndRecoveryDesc),
delegate: OpenDelegate(
title: appLocalizations.backupAndRecovery,
widget: const BackupAndRecovery(),
),
),
if (Platform.isAndroid)
ListItem.open(
leading: const Icon(Icons.view_list),
title: Text(appLocalizations.accessControl),
subtitle: Text(appLocalizations.accessControlDesc),
leading: const Icon(Icons.style),
title: Text(appLocalizations.theme),
subtitle: Text(appLocalizations.themeDesc),
delegate: OpenDelegate(
title: appLocalizations.appAccessControl,
widget: const AccessFragment(),
title: appLocalizations.theme,
widget: const ThemeFragment(),
extendPageWidth: 360,
),
),
ListItem.open(
leading: const Icon(Icons.edit),
title: Text(appLocalizations.override),
subtitle: Text(appLocalizations.overrideDesc),
delegate: OpenDelegate(
title: appLocalizations.override,
widget: const ConfigFragment(),
ListItem.open(
leading: const Icon(Icons.cloud_sync),
title: Text(appLocalizations.backupAndRecovery),
subtitle: Text(appLocalizations.backupAndRecoveryDesc),
delegate: OpenDelegate(
title: appLocalizations.backupAndRecovery,
widget: const BackupAndRecovery(),
),
),
),
ListItem.open(
leading: const Icon(Icons.settings_applications),
title: Text(appLocalizations.application),
subtitle: Text(appLocalizations.applicationDesc),
delegate: OpenDelegate(
title: appLocalizations.application,
widget: const ApplicationSettingFragment(),
),
),
];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final item in items) ...[
item,
if (item != items.last)
const Divider(
height: 0,
if (Platform.isAndroid)
ListItem.open(
leading: const Icon(Icons.view_list),
title: Text(appLocalizations.accessControl),
subtitle: Text(appLocalizations.accessControlDesc),
delegate: OpenDelegate(
title: appLocalizations.appAccessControl,
widget: const AccessFragment(),
),
]
),
ListItem.open(
leading: const Icon(Icons.edit),
title: Text(appLocalizations.override),
subtitle: Text(appLocalizations.overrideDesc),
delegate: OpenDelegate(
title: appLocalizations.override,
widget: const ConfigFragment(),
extendPageWidth: 360,
),
),
ListItem.open(
leading: const Icon(Icons.settings_applications),
title: Text(appLocalizations.application),
subtitle: Text(appLocalizations.applicationDesc),
delegate: OpenDelegate(
title: appLocalizations.application,
widget: const ApplicationSettingFragment(),
),
),
],
);
}
@@ -215,20 +198,16 @@ class _ToolboxFragmentState extends State<ToolsFragment> {
if (state.navigationItems.isEmpty) {
return Container();
}
return Section(
title: appLocalizations.more,
child: _buildNavigationMenu(state.navigationItems),
return Column(
children: [
ListHeader(title: appLocalizations.more),
_buildNavigationMenu(state.navigationItems)
],
);
},
),
Section(
title: appLocalizations.settings,
child: _getSettingList(),
),
Section(
title: appLocalizations.other,
child: _getOtherList(),
),
..._getSettingList(),
..._getOtherList(),
];
return ListView.builder(
itemCount: items.length,

View File

@@ -37,7 +37,7 @@
"overrideDesc": "Override Proxy related config",
"allowLan": "AllowLan",
"allowLanDesc": "Allow access proxy through the LAN",
"tun": "Tun mode",
"tun": "TUN mode",
"tunDesc": "only effective in administrator mode",
"minimizeOnExit": "Minimize on exit",
"minimizeOnExitDesc": "Modify the default system exit event",
@@ -111,6 +111,7 @@
"noMoreInfoDesc": "No more info",
"profileParseErrorDesc": "profile parse error",
"proxyPort": "ProxyPort",
"proxyPortDesc": "Set the Clash listening port",
"port": "Port",
"logLevel": "LogLevel",
"show": "Show",
@@ -159,5 +160,40 @@
"externalResources": "External resources",
"checking": "Checking...",
"country": "Country",
"ipCheckError": "Ip check timeout"
"checkError": "Check error",
"search": "Search",
"allowBypass": "Allow applications to bypass VPN",
"allowBypassDesc": "Some apps can bypass VPN when turned on",
"externalController": "ExternalController",
"externalControllerDesc": "Once enabled, the Clash kernel can be controlled on port 9090",
"ipv6Desc": "When turned on it will be able to receive IPv6 traffic",
"app": "App",
"general": "General",
"systemProxyDesc": "Attach HTTP proxy to VpnService",
"unifiedDelay": "Unified delay",
"unifiedDelayDesc": "Remove extra delays such as handshaking",
"tcpConcurrent": "TCP concurrent",
"tcpConcurrentDesc": "Enabling it will allow TCP concurrency",
"geodataLoader": "Geo Low Memory Mode",
"geodataLoaderDesc": "Enabling will use the Geo low memory loader",
"requests": "Requests",
"requestsDesc": "View recently requested data",
"findProcessMode": "Find process",
"findProcessModeDesc": "There is a risk of flashback after opening",
"init": "Init",
"infiniteTime": "Long term effective",
"expirationTime": "Expiration time",
"connections": "Connections",
"connectionsDesc": "View current connection",
"nullRequestsDesc": "No requests",
"nullConnectionsDesc": "No connections",
"intranetIP": "Intranet IP",
"view": "View",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"testUrl": "Test url",
"sync": "Sync",
"exclude": "Hidden from recent tasks",
"excludeDesc": "When the app is in the background, the app is hidden from the recent task"
}

View File

@@ -37,7 +37,7 @@
"overrideDesc": "覆写代理相关配置",
"allowLan": "局域网代理",
"allowLanDesc": "允许通过局域网访问代理",
"tun": "Tun模式",
"tun": "TUN模式",
"tunDesc": "仅在管理员模式生效",
"minimizeOnExit": "退出时最小化",
"minimizeOnExitDesc": "修改系统默认退出事件",
@@ -111,6 +111,7 @@
"noMoreInfoDesc": "暂无更多信息",
"profileParseErrorDesc": "配置文件解析错误",
"proxyPort": "代理端口",
"proxyPortDesc": "设置Clash监听端口",
"port": "端口",
"logLevel": "日志等级",
"show": "显示",
@@ -159,5 +160,40 @@
"externalResources": "外部资源",
"checking": "检测中...",
"country": "区域",
"ipCheckError": "Ip检测超时"
"checkError": "检测失败",
"search": "搜索",
"allowBypass": "允许应用绕过VPN",
"allowBypassDesc": "开启后部分应用可绕过VPN",
"externalController": "外部控制器",
"externalControllerDesc": "开启后将可以通过9090端口控制Clash内核",
"ipv6Desc": "开启后将可以接收IPv6流量",
"app": "应用",
"general": "基础",
"systemProxyDesc": "为VpnService附加HTTP代理",
"unifiedDelay": "统一延迟",
"unifiedDelayDesc": "去除握手等额外延迟",
"tcpConcurrent": "TCP并发",
"tcpConcurrentDesc": "开启后允许TCP并发",
"geodataLoader": "Geo低内存模式",
"geodataLoaderDesc": "开启将使用Geo低内存加载器",
"requests": "请求",
"requestsDesc": "查看最近请求数据",
"findProcessMode": "查找进程",
"findProcessModeDesc": "开启后存在闪退风险",
"init": "初始化",
"infiniteTime": "长期有效",
"expirationTime": "到期时间",
"connections": "连接",
"connectionsDesc": "查看当前连接",
"nullRequestsDesc": "暂无请求",
"nullConnectionsDesc": "暂无连接",
"intranetIP": "内网 IP",
"view": "查看",
"cut": "剪切",
"copy": "复制",
"paste": "粘贴",
"testUrl": "测速链接",
"sync": "同步",
"exclude": "从最近任务中隐藏",
"excludeDesc": "应用在后台时,从最近任务中隐藏应用"
}

View File

@@ -40,9 +40,14 @@ class MessageLookup extends MessageLookupByLibrary {
"addressTip": MessageLookupByLibrary.simpleMessage(
"Please enter a valid WebDAV address"),
"ago": MessageLookupByLibrary.simpleMessage(" Ago"),
"allowBypass": MessageLookupByLibrary.simpleMessage(
"Allow applications to bypass VPN"),
"allowBypassDesc": MessageLookupByLibrary.simpleMessage(
"Some apps can bypass VPN when turned on"),
"allowLan": MessageLookupByLibrary.simpleMessage("AllowLan"),
"allowLanDesc": MessageLookupByLibrary.simpleMessage(
"Allow access proxy through the LAN"),
"app": MessageLookupByLibrary.simpleMessage("App"),
"appAccessControl":
MessageLookupByLibrary.simpleMessage("App access control"),
"application": MessageLookupByLibrary.simpleMessage("Application"),
@@ -76,6 +81,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Cancel filter system app"),
"cancelSelectAll":
MessageLookupByLibrary.simpleMessage("Cancel select all"),
"checkError": MessageLookupByLibrary.simpleMessage("Check error"),
"checkUpdate":
MessageLookupByLibrary.simpleMessage("Check for updates"),
"checkUpdateError": MessageLookupByLibrary.simpleMessage(
@@ -86,11 +92,16 @@ class MessageLookup extends MessageLookupByLibrary {
"compatibleDesc": MessageLookupByLibrary.simpleMessage(
"Opening it will lose part of its application ability and gain the support of full amount of Clash."),
"confirm": MessageLookupByLibrary.simpleMessage("Confirm"),
"connections": MessageLookupByLibrary.simpleMessage("Connections"),
"connectionsDesc":
MessageLookupByLibrary.simpleMessage("View current connection"),
"connectivity": MessageLookupByLibrary.simpleMessage("Connectivity"),
"copy": MessageLookupByLibrary.simpleMessage("Copy"),
"core": MessageLookupByLibrary.simpleMessage("Core"),
"coreInfo": MessageLookupByLibrary.simpleMessage("Core info"),
"country": MessageLookupByLibrary.simpleMessage("Country"),
"create": MessageLookupByLibrary.simpleMessage("Create"),
"cut": MessageLookupByLibrary.simpleMessage("Cut"),
"dark": MessageLookupByLibrary.simpleMessage("Dark"),
"dashboard": MessageLookupByLibrary.simpleMessage("Dashboard"),
"days": MessageLookupByLibrary.simpleMessage("Days"),
@@ -110,7 +121,17 @@ class MessageLookup extends MessageLookupByLibrary {
"download": MessageLookupByLibrary.simpleMessage("Download"),
"edit": MessageLookupByLibrary.simpleMessage("Edit"),
"en": MessageLookupByLibrary.simpleMessage("English"),
"exclude":
MessageLookupByLibrary.simpleMessage("Hidden from recent tasks"),
"excludeDesc": MessageLookupByLibrary.simpleMessage(
"When the app is in the background, the app is hidden from the recent task"),
"exit": MessageLookupByLibrary.simpleMessage("Exit"),
"expirationTime":
MessageLookupByLibrary.simpleMessage("Expiration time"),
"externalController":
MessageLookupByLibrary.simpleMessage("ExternalController"),
"externalControllerDesc": MessageLookupByLibrary.simpleMessage(
"Once enabled, the Clash kernel can be controlled on port 9090"),
"externalResources":
MessageLookupByLibrary.simpleMessage("External resources"),
"file": MessageLookupByLibrary.simpleMessage("File"),
@@ -118,14 +139,26 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Directly upload profile"),
"filterSystemApp":
MessageLookupByLibrary.simpleMessage("Filter system app"),
"findProcessMode": MessageLookupByLibrary.simpleMessage("Find process"),
"findProcessModeDesc": MessageLookupByLibrary.simpleMessage(
"There is a risk of flashback after opening"),
"general": MessageLookupByLibrary.simpleMessage("General"),
"geoData": MessageLookupByLibrary.simpleMessage("GeoData"),
"geodataLoader":
MessageLookupByLibrary.simpleMessage("Geo Low Memory Mode"),
"geodataLoaderDesc": MessageLookupByLibrary.simpleMessage(
"Enabling will use the Geo low memory loader"),
"global": MessageLookupByLibrary.simpleMessage("Global"),
"goDownload": MessageLookupByLibrary.simpleMessage("Go to download"),
"hours": MessageLookupByLibrary.simpleMessage("Hours"),
"importFromURL":
MessageLookupByLibrary.simpleMessage("Import from URL"),
"ipCheckError":
MessageLookupByLibrary.simpleMessage("Ip check timeout"),
"infiniteTime":
MessageLookupByLibrary.simpleMessage("Long term effective"),
"init": MessageLookupByLibrary.simpleMessage("Init"),
"intranetIP": MessageLookupByLibrary.simpleMessage("Intranet IP"),
"ipv6Desc": MessageLookupByLibrary.simpleMessage(
"When turned on it will be able to receive IPv6 traffic"),
"just": MessageLookupByLibrary.simpleMessage("Just"),
"language": MessageLookupByLibrary.simpleMessage("Language"),
"light": MessageLookupByLibrary.simpleMessage("Light"),
@@ -154,11 +187,14 @@ class MessageLookup extends MessageLookupByLibrary {
"Please create a profile or add a valid profile"),
"notSelectedTip": MessageLookupByLibrary.simpleMessage(
"The current proxy group cannot be selected."),
"nullConnectionsDesc":
MessageLookupByLibrary.simpleMessage("No connections"),
"nullCoreInfoDesc":
MessageLookupByLibrary.simpleMessage("Unable to obtain core info"),
"nullLogsDesc": MessageLookupByLibrary.simpleMessage("No logs"),
"nullProfileDesc": MessageLookupByLibrary.simpleMessage(
"No profile, Please add a profile"),
"nullRequestsDesc": MessageLookupByLibrary.simpleMessage("No requests"),
"other": MessageLookupByLibrary.simpleMessage("Other"),
"outboundMode": MessageLookupByLibrary.simpleMessage("Outbound mode"),
"override": MessageLookupByLibrary.simpleMessage("Override"),
@@ -167,6 +203,7 @@ class MessageLookup extends MessageLookupByLibrary {
"password": MessageLookupByLibrary.simpleMessage("Password"),
"passwordTip":
MessageLookupByLibrary.simpleMessage("Password cannot be empty"),
"paste": MessageLookupByLibrary.simpleMessage("Paste"),
"pleaseBindWebDAV":
MessageLookupByLibrary.simpleMessage("Please bind WebDAV"),
"pleaseUploadFile":
@@ -194,6 +231,8 @@ class MessageLookup extends MessageLookupByLibrary {
"project": MessageLookupByLibrary.simpleMessage("Project"),
"proxies": MessageLookupByLibrary.simpleMessage("Proxies"),
"proxyPort": MessageLookupByLibrary.simpleMessage("ProxyPort"),
"proxyPortDesc": MessageLookupByLibrary.simpleMessage(
"Set the Clash listening port"),
"qrcode": MessageLookupByLibrary.simpleMessage("QR code"),
"qrcodeDesc": MessageLookupByLibrary.simpleMessage(
"Scan QR code to obtain profile"),
@@ -206,11 +245,15 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Only recovery profiles"),
"recoverySuccess":
MessageLookupByLibrary.simpleMessage("Recovery success"),
"requests": MessageLookupByLibrary.simpleMessage("Requests"),
"requestsDesc": MessageLookupByLibrary.simpleMessage(
"View recently requested data"),
"resources": MessageLookupByLibrary.simpleMessage("Resources"),
"resourcesDesc": MessageLookupByLibrary.simpleMessage(
"External resource related info"),
"rule": MessageLookupByLibrary.simpleMessage("Rule"),
"save": MessageLookupByLibrary.simpleMessage("Save"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"selectAll": MessageLookupByLibrary.simpleMessage("Select all"),
"selected": MessageLookupByLibrary.simpleMessage("Selected"),
"settings": MessageLookupByLibrary.simpleMessage("Settings"),
@@ -221,10 +264,17 @@ class MessageLookup extends MessageLookupByLibrary {
"startVpn": MessageLookupByLibrary.simpleMessage("Staring VPN..."),
"stopVpn": MessageLookupByLibrary.simpleMessage("Stopping VPN..."),
"submit": MessageLookupByLibrary.simpleMessage("Submit"),
"sync": MessageLookupByLibrary.simpleMessage("Sync"),
"systemProxy": MessageLookupByLibrary.simpleMessage("SystemProxy"),
"systemProxyDesc": MessageLookupByLibrary.simpleMessage(
"Attach HTTP proxy to VpnService"),
"tabAnimation": MessageLookupByLibrary.simpleMessage("Tab animation"),
"tabAnimationDesc": MessageLookupByLibrary.simpleMessage(
"When enabled, the home tab will add a toggle animation"),
"tcpConcurrent": MessageLookupByLibrary.simpleMessage("TCP concurrent"),
"tcpConcurrentDesc": MessageLookupByLibrary.simpleMessage(
"Enabling it will allow TCP concurrency"),
"testUrl": MessageLookupByLibrary.simpleMessage("Test url"),
"theme": MessageLookupByLibrary.simpleMessage("Theme"),
"themeColor": MessageLookupByLibrary.simpleMessage("Theme color"),
"themeDesc": MessageLookupByLibrary.simpleMessage(
@@ -233,18 +283,22 @@ class MessageLookup extends MessageLookupByLibrary {
"tip": MessageLookupByLibrary.simpleMessage("tip"),
"tools": MessageLookupByLibrary.simpleMessage("Tools"),
"trafficUsage": MessageLookupByLibrary.simpleMessage("Traffic usage"),
"tun": MessageLookupByLibrary.simpleMessage("Tun mode"),
"tun": MessageLookupByLibrary.simpleMessage("TUN mode"),
"tunDesc": MessageLookupByLibrary.simpleMessage(
"only effective in administrator mode"),
"unableToUpdateCurrentProfileDesc":
MessageLookupByLibrary.simpleMessage(
"unable to update current profile"),
"unifiedDelay": MessageLookupByLibrary.simpleMessage("Unified delay"),
"unifiedDelayDesc": MessageLookupByLibrary.simpleMessage(
"Remove extra delays such as handshaking"),
"unknown": MessageLookupByLibrary.simpleMessage("Unknown"),
"update": MessageLookupByLibrary.simpleMessage("Update"),
"upload": MessageLookupByLibrary.simpleMessage("Upload"),
"url": MessageLookupByLibrary.simpleMessage("URL"),
"urlDesc":
MessageLookupByLibrary.simpleMessage("Obtain profile through URL"),
"view": MessageLookupByLibrary.simpleMessage("View"),
"webDAVConfiguration":
MessageLookupByLibrary.simpleMessage("WebDAV configuration"),
"whitelistMode": MessageLookupByLibrary.simpleMessage("Whitelist mode"),

View File

@@ -36,8 +36,12 @@ class MessageLookup extends MessageLookupByLibrary {
"addressHelp": MessageLookupByLibrary.simpleMessage("WebDAV服务器地址"),
"addressTip": MessageLookupByLibrary.simpleMessage("请输入有效的WebDAV地址"),
"ago": MessageLookupByLibrary.simpleMessage(""),
"allowBypass": MessageLookupByLibrary.simpleMessage("允许应用绕过VPN"),
"allowBypassDesc":
MessageLookupByLibrary.simpleMessage("开启后部分应用可绕过VPN"),
"allowLan": MessageLookupByLibrary.simpleMessage("局域网代理"),
"allowLanDesc": MessageLookupByLibrary.simpleMessage("允许通过局域网访问代理"),
"app": MessageLookupByLibrary.simpleMessage("应用"),
"appAccessControl": MessageLookupByLibrary.simpleMessage("应用访问控制"),
"application": MessageLookupByLibrary.simpleMessage("应用程序"),
"applicationDesc": MessageLookupByLibrary.simpleMessage("修改应用程序相关设置"),
@@ -63,6 +67,7 @@ class MessageLookup extends MessageLookupByLibrary {
"cancelFilterSystemApp":
MessageLookupByLibrary.simpleMessage("取消过滤系统应用"),
"cancelSelectAll": MessageLookupByLibrary.simpleMessage("取消全选"),
"checkError": MessageLookupByLibrary.simpleMessage("检测失败"),
"checkUpdate": MessageLookupByLibrary.simpleMessage("检查更新"),
"checkUpdateError": MessageLookupByLibrary.simpleMessage("当前应用已经是最新版了"),
"checking": MessageLookupByLibrary.simpleMessage("检测中..."),
@@ -70,11 +75,15 @@ class MessageLookup extends MessageLookupByLibrary {
"compatibleDesc":
MessageLookupByLibrary.simpleMessage("开启将失去部分应用能力获得全量的Clash的支持"),
"confirm": MessageLookupByLibrary.simpleMessage("确定"),
"connections": MessageLookupByLibrary.simpleMessage("连接"),
"connectionsDesc": MessageLookupByLibrary.simpleMessage("查看当前连接"),
"connectivity": MessageLookupByLibrary.simpleMessage("连通性:"),
"copy": MessageLookupByLibrary.simpleMessage("复制"),
"core": MessageLookupByLibrary.simpleMessage("内核"),
"coreInfo": MessageLookupByLibrary.simpleMessage("内核信息"),
"country": MessageLookupByLibrary.simpleMessage("区域"),
"create": MessageLookupByLibrary.simpleMessage("创建"),
"cut": MessageLookupByLibrary.simpleMessage("剪切"),
"dark": MessageLookupByLibrary.simpleMessage("深色"),
"dashboard": MessageLookupByLibrary.simpleMessage("仪表盘"),
"days": MessageLookupByLibrary.simpleMessage(""),
@@ -91,17 +100,34 @@ class MessageLookup extends MessageLookupByLibrary {
"download": MessageLookupByLibrary.simpleMessage("下载"),
"edit": MessageLookupByLibrary.simpleMessage("编辑"),
"en": MessageLookupByLibrary.simpleMessage("英语"),
"exclude": MessageLookupByLibrary.simpleMessage("从最近任务中隐藏"),
"excludeDesc":
MessageLookupByLibrary.simpleMessage("应用在后台时,从最近任务中隐藏应用"),
"exit": MessageLookupByLibrary.simpleMessage("退出"),
"expirationTime": MessageLookupByLibrary.simpleMessage("到期时间"),
"externalController": MessageLookupByLibrary.simpleMessage("外部控制器"),
"externalControllerDesc":
MessageLookupByLibrary.simpleMessage("开启后将可以通过9090端口控制Clash内核"),
"externalResources": MessageLookupByLibrary.simpleMessage("外部资源"),
"file": MessageLookupByLibrary.simpleMessage("文件"),
"fileDesc": MessageLookupByLibrary.simpleMessage("直接上传配置文件"),
"filterSystemApp": MessageLookupByLibrary.simpleMessage("过滤系统应用"),
"findProcessMode": MessageLookupByLibrary.simpleMessage("查找进程"),
"findProcessModeDesc":
MessageLookupByLibrary.simpleMessage("开启后存在闪退风险"),
"general": MessageLookupByLibrary.simpleMessage("基础"),
"geoData": MessageLookupByLibrary.simpleMessage("地理数据"),
"geodataLoader": MessageLookupByLibrary.simpleMessage("Geo低内存模式"),
"geodataLoaderDesc":
MessageLookupByLibrary.simpleMessage("开启将使用Geo低内存加载器"),
"global": MessageLookupByLibrary.simpleMessage("全局"),
"goDownload": MessageLookupByLibrary.simpleMessage("前往下载"),
"hours": MessageLookupByLibrary.simpleMessage("小时"),
"importFromURL": MessageLookupByLibrary.simpleMessage("从URL导入"),
"ipCheckError": MessageLookupByLibrary.simpleMessage("Ip检测超时"),
"infiniteTime": MessageLookupByLibrary.simpleMessage("长期有效"),
"init": MessageLookupByLibrary.simpleMessage("初始化"),
"intranetIP": MessageLookupByLibrary.simpleMessage("内网 IP"),
"ipv6Desc": MessageLookupByLibrary.simpleMessage("开启后将可以接收IPv6流量"),
"just": MessageLookupByLibrary.simpleMessage("刚刚"),
"language": MessageLookupByLibrary.simpleMessage("语言"),
"light": MessageLookupByLibrary.simpleMessage("浅色"),
@@ -126,16 +152,19 @@ class MessageLookup extends MessageLookupByLibrary {
"noProxyDesc":
MessageLookupByLibrary.simpleMessage("请创建配置文件或者添加有效配置文件"),
"notSelectedTip": MessageLookupByLibrary.simpleMessage("当前代理组无法选中"),
"nullConnectionsDesc": MessageLookupByLibrary.simpleMessage("暂无连接"),
"nullCoreInfoDesc": MessageLookupByLibrary.simpleMessage("无法获取内核信息"),
"nullLogsDesc": MessageLookupByLibrary.simpleMessage("暂无日志"),
"nullProfileDesc":
MessageLookupByLibrary.simpleMessage("没有配置文件,请先添加配置文件"),
"nullRequestsDesc": MessageLookupByLibrary.simpleMessage("暂无请求"),
"other": MessageLookupByLibrary.simpleMessage("其他"),
"outboundMode": MessageLookupByLibrary.simpleMessage("出站模式"),
"override": MessageLookupByLibrary.simpleMessage("覆写"),
"overrideDesc": MessageLookupByLibrary.simpleMessage("覆写代理相关配置"),
"password": MessageLookupByLibrary.simpleMessage("密码"),
"passwordTip": MessageLookupByLibrary.simpleMessage("密码不能为空"),
"paste": MessageLookupByLibrary.simpleMessage("粘贴"),
"pleaseBindWebDAV": MessageLookupByLibrary.simpleMessage("请绑定WebDAV"),
"pleaseUploadFile": MessageLookupByLibrary.simpleMessage("请上传文件"),
"pleaseUploadValidQrcode":
@@ -159,6 +188,7 @@ class MessageLookup extends MessageLookupByLibrary {
"project": MessageLookupByLibrary.simpleMessage("项目"),
"proxies": MessageLookupByLibrary.simpleMessage("代理"),
"proxyPort": MessageLookupByLibrary.simpleMessage("代理端口"),
"proxyPortDesc": MessageLookupByLibrary.simpleMessage("设置Clash监听端口"),
"qrcode": MessageLookupByLibrary.simpleMessage("二维码"),
"qrcodeDesc": MessageLookupByLibrary.simpleMessage("扫描二维码获取配置文件"),
"recovery": MessageLookupByLibrary.simpleMessage("恢复"),
@@ -166,10 +196,13 @@ class MessageLookup extends MessageLookupByLibrary {
"recoveryDesc": MessageLookupByLibrary.simpleMessage("从WebDAV恢复数据"),
"recoveryProfiles": MessageLookupByLibrary.simpleMessage("仅恢复配置文件"),
"recoverySuccess": MessageLookupByLibrary.simpleMessage("恢复成功"),
"requests": MessageLookupByLibrary.simpleMessage("请求"),
"requestsDesc": MessageLookupByLibrary.simpleMessage("查看最近请求数据"),
"resources": MessageLookupByLibrary.simpleMessage("资源"),
"resourcesDesc": MessageLookupByLibrary.simpleMessage("外部资源相关信息"),
"rule": MessageLookupByLibrary.simpleMessage("规则"),
"save": MessageLookupByLibrary.simpleMessage("保存"),
"search": MessageLookupByLibrary.simpleMessage("搜索"),
"selectAll": MessageLookupByLibrary.simpleMessage("全选"),
"selected": MessageLookupByLibrary.simpleMessage("已选择"),
"settings": MessageLookupByLibrary.simpleMessage("设置"),
@@ -179,10 +212,16 @@ class MessageLookup extends MessageLookupByLibrary {
"startVpn": MessageLookupByLibrary.simpleMessage("正在启动VPN..."),
"stopVpn": MessageLookupByLibrary.simpleMessage("正在停止VPN..."),
"submit": MessageLookupByLibrary.simpleMessage("提交"),
"sync": MessageLookupByLibrary.simpleMessage("同步"),
"systemProxy": MessageLookupByLibrary.simpleMessage("系统代理"),
"systemProxyDesc":
MessageLookupByLibrary.simpleMessage("为VpnService附加HTTP代理"),
"tabAnimation": MessageLookupByLibrary.simpleMessage("选项卡动画"),
"tabAnimationDesc":
MessageLookupByLibrary.simpleMessage("开启后,主页选项卡将添加切换动画"),
"tcpConcurrent": MessageLookupByLibrary.simpleMessage("TCP并发"),
"tcpConcurrentDesc": MessageLookupByLibrary.simpleMessage("开启后允许TCP并发"),
"testUrl": MessageLookupByLibrary.simpleMessage("测速链接"),
"theme": MessageLookupByLibrary.simpleMessage("主题"),
"themeColor": MessageLookupByLibrary.simpleMessage("主题色彩"),
"themeDesc": MessageLookupByLibrary.simpleMessage("设置深色模式,调整色彩"),
@@ -190,15 +229,18 @@ class MessageLookup extends MessageLookupByLibrary {
"tip": MessageLookupByLibrary.simpleMessage("提示"),
"tools": MessageLookupByLibrary.simpleMessage("工具"),
"trafficUsage": MessageLookupByLibrary.simpleMessage("流量统计"),
"tun": MessageLookupByLibrary.simpleMessage("Tun模式"),
"tun": MessageLookupByLibrary.simpleMessage("TUN模式"),
"tunDesc": MessageLookupByLibrary.simpleMessage("仅在管理员模式生效"),
"unableToUpdateCurrentProfileDesc":
MessageLookupByLibrary.simpleMessage("无法更新当前配置文件"),
"unifiedDelay": MessageLookupByLibrary.simpleMessage("统一延迟"),
"unifiedDelayDesc": MessageLookupByLibrary.simpleMessage("去除握手等额外延迟"),
"unknown": MessageLookupByLibrary.simpleMessage("未知"),
"update": MessageLookupByLibrary.simpleMessage("更新"),
"upload": MessageLookupByLibrary.simpleMessage("上传"),
"url": MessageLookupByLibrary.simpleMessage("URL"),
"urlDesc": MessageLookupByLibrary.simpleMessage("直接上传配置文件"),
"view": MessageLookupByLibrary.simpleMessage("查看"),
"webDAVConfiguration": MessageLookupByLibrary.simpleMessage("WebDAV配置"),
"whitelistMode": MessageLookupByLibrary.simpleMessage("白名单模式"),
"years": MessageLookupByLibrary.simpleMessage(""),

View File

@@ -430,10 +430,10 @@ class AppLocalizations {
);
}
/// `Tun mode`
/// `TUN mode`
String get tun {
return Intl.message(
'Tun mode',
'TUN mode',
name: 'tun',
desc: '',
args: [],
@@ -1170,6 +1170,16 @@ class AppLocalizations {
);
}
/// `Set the Clash listening port`
String get proxyPortDesc {
return Intl.message(
'Set the Clash listening port',
name: 'proxyPortDesc',
desc: '',
args: [],
);
}
/// `Port`
String get port {
return Intl.message(
@@ -1650,11 +1660,361 @@ class AppLocalizations {
);
}
/// `Ip check timeout`
String get ipCheckError {
/// `Check error`
String get checkError {
return Intl.message(
'Ip check timeout',
name: 'ipCheckError',
'Check error',
name: 'checkError',
desc: '',
args: [],
);
}
/// `Search`
String get search {
return Intl.message(
'Search',
name: 'search',
desc: '',
args: [],
);
}
/// `Allow applications to bypass VPN`
String get allowBypass {
return Intl.message(
'Allow applications to bypass VPN',
name: 'allowBypass',
desc: '',
args: [],
);
}
/// `Some apps can bypass VPN when turned on`
String get allowBypassDesc {
return Intl.message(
'Some apps can bypass VPN when turned on',
name: 'allowBypassDesc',
desc: '',
args: [],
);
}
/// `ExternalController`
String get externalController {
return Intl.message(
'ExternalController',
name: 'externalController',
desc: '',
args: [],
);
}
/// `Once enabled, the Clash kernel can be controlled on port 9090`
String get externalControllerDesc {
return Intl.message(
'Once enabled, the Clash kernel can be controlled on port 9090',
name: 'externalControllerDesc',
desc: '',
args: [],
);
}
/// `When turned on it will be able to receive IPv6 traffic`
String get ipv6Desc {
return Intl.message(
'When turned on it will be able to receive IPv6 traffic',
name: 'ipv6Desc',
desc: '',
args: [],
);
}
/// `App`
String get app {
return Intl.message(
'App',
name: 'app',
desc: '',
args: [],
);
}
/// `General`
String get general {
return Intl.message(
'General',
name: 'general',
desc: '',
args: [],
);
}
/// `Attach HTTP proxy to VpnService`
String get systemProxyDesc {
return Intl.message(
'Attach HTTP proxy to VpnService',
name: 'systemProxyDesc',
desc: '',
args: [],
);
}
/// `Unified delay`
String get unifiedDelay {
return Intl.message(
'Unified delay',
name: 'unifiedDelay',
desc: '',
args: [],
);
}
/// `Remove extra delays such as handshaking`
String get unifiedDelayDesc {
return Intl.message(
'Remove extra delays such as handshaking',
name: 'unifiedDelayDesc',
desc: '',
args: [],
);
}
/// `TCP concurrent`
String get tcpConcurrent {
return Intl.message(
'TCP concurrent',
name: 'tcpConcurrent',
desc: '',
args: [],
);
}
/// `Enabling it will allow TCP concurrency`
String get tcpConcurrentDesc {
return Intl.message(
'Enabling it will allow TCP concurrency',
name: 'tcpConcurrentDesc',
desc: '',
args: [],
);
}
/// `Geo Low Memory Mode`
String get geodataLoader {
return Intl.message(
'Geo Low Memory Mode',
name: 'geodataLoader',
desc: '',
args: [],
);
}
/// `Enabling will use the Geo low memory loader`
String get geodataLoaderDesc {
return Intl.message(
'Enabling will use the Geo low memory loader',
name: 'geodataLoaderDesc',
desc: '',
args: [],
);
}
/// `Requests`
String get requests {
return Intl.message(
'Requests',
name: 'requests',
desc: '',
args: [],
);
}
/// `View recently requested data`
String get requestsDesc {
return Intl.message(
'View recently requested data',
name: 'requestsDesc',
desc: '',
args: [],
);
}
/// `Find process`
String get findProcessMode {
return Intl.message(
'Find process',
name: 'findProcessMode',
desc: '',
args: [],
);
}
/// `There is a risk of flashback after opening`
String get findProcessModeDesc {
return Intl.message(
'There is a risk of flashback after opening',
name: 'findProcessModeDesc',
desc: '',
args: [],
);
}
/// `Init`
String get init {
return Intl.message(
'Init',
name: 'init',
desc: '',
args: [],
);
}
/// `Long term effective`
String get infiniteTime {
return Intl.message(
'Long term effective',
name: 'infiniteTime',
desc: '',
args: [],
);
}
/// `Expiration time`
String get expirationTime {
return Intl.message(
'Expiration time',
name: 'expirationTime',
desc: '',
args: [],
);
}
/// `Connections`
String get connections {
return Intl.message(
'Connections',
name: 'connections',
desc: '',
args: [],
);
}
/// `View current connection`
String get connectionsDesc {
return Intl.message(
'View current connection',
name: 'connectionsDesc',
desc: '',
args: [],
);
}
/// `No requests`
String get nullRequestsDesc {
return Intl.message(
'No requests',
name: 'nullRequestsDesc',
desc: '',
args: [],
);
}
/// `No connections`
String get nullConnectionsDesc {
return Intl.message(
'No connections',
name: 'nullConnectionsDesc',
desc: '',
args: [],
);
}
/// `Intranet IP`
String get intranetIP {
return Intl.message(
'Intranet IP',
name: 'intranetIP',
desc: '',
args: [],
);
}
/// `View`
String get view {
return Intl.message(
'View',
name: 'view',
desc: '',
args: [],
);
}
/// `Cut`
String get cut {
return Intl.message(
'Cut',
name: 'cut',
desc: '',
args: [],
);
}
/// `Copy`
String get copy {
return Intl.message(
'Copy',
name: 'copy',
desc: '',
args: [],
);
}
/// `Paste`
String get paste {
return Intl.message(
'Paste',
name: 'paste',
desc: '',
args: [],
);
}
/// `Test url`
String get testUrl {
return Intl.message(
'Test url',
name: 'testUrl',
desc: '',
args: [],
);
}
/// `Sync`
String get sync {
return Intl.message(
'Sync',
name: 'sync',
desc: '',
args: [],
);
}
/// `Hidden from recent tasks`
String get exclude {
return Intl.message(
'Hidden from recent tasks',
name: 'exclude',
desc: '',
args: [],
);
}
/// `When the app is in the background, the app is hidden from the recent task`
String get excludeDesc {
return Intl.message(
'When the app is in the background, the app is hidden from the recent task',
name: 'excludeDesc',
desc: '',
args: [],
);

View File

@@ -3,9 +3,11 @@ import 'dart:io';
import 'package:fl_clash/clash/clash.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/plugins/proxy.dart';
import 'package:fl_clash/plugins/tile.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'application.dart';
import 'l10n/l10n.dart';
import 'models/models.dart';
@@ -15,6 +17,8 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await android?.init();
await window?.init();
clashCore.initMessage();
globalState.packageInfo = await PackageInfo.fromPlatform();
final config = await preferences.getConfig() ?? Config();
final clashConfig = await preferences.getClashConfig() ?? ClashConfig();
final appState = AppState(
@@ -22,12 +26,11 @@ Future<void> main() async {
isCompatible: config.isCompatible,
selectedMap: config.currentSelectedMap,
);
await globalState.init(
appState: appState,
config: config,
clashConfig: clashConfig,
appState.navigationItems = navigation.getItems(
openLogs: config.openLogs,
hasProxies: false,
);
globalState.updateNavigationItems(
await globalState.init(
appState: appState,
config: config,
clashConfig: clashConfig,
@@ -43,6 +46,7 @@ Future<void> main() async {
@pragma('vm:entry-point')
Future<void> vpnService() async {
WidgetsFlutterBinding.ensureInitialized();
globalState.isVpnService = true;
final config = await preferences.getConfig() ?? Config();
final clashConfig = await preferences.getClashConfig() ?? ClashConfig();
final appState = AppState(
@@ -50,61 +54,112 @@ Future<void> vpnService() async {
isCompatible: config.isCompatible,
selectedMap: config.currentSelectedMap,
);
clashMessage.addListener(ClashMessageListenerWithVpn(onTun: (String fd) {
proxyManager.setProtect(
int.parse(fd),
);
}));
await globalState.init(
appState: appState,
config: config,
clashConfig: clashConfig,
);
proxy?.setServiceMessageHandler(
ServiceMessageHandler(
onProtect: (Fd fd) async {
await proxy?.setProtect(fd.value);
clashCore.setFdMap(fd.id);
},
onProcess: (Process process) async {
print(process);
var packageName = await app?.resolverProcess(process);
clashCore.setProcessMap(
ProcessMapItem(
id: process.id,
value: packageName ?? "",
),
);
},
onStarted: (String runTime) {
globalState.applyProfile(
appState: appState,
config: config,
clashConfig: clashConfig,
);
},
onLoaded: (String groupName) {
final currentSelectedMap = config.currentSelectedMap;
final proxyName = currentSelectedMap[groupName];
if (proxyName == null) return;
clashCore.changeProxy(
ChangeProxyParams(
groupName: groupName,
proxyName: proxyName,
),
);
},
),
);
final appLocalizations = await AppLocalizations.load(
other.getLocaleForString(config.locale) ??
WidgetsBinding.instance.platformDispatcher.locale,
);
await app?.tip(appLocalizations.startVpn);
await globalState.startSystemProxy(
appState: appState,
config: config,
clashConfig: clashConfig,
);
handleStart() async {
await app?.tip(appLocalizations.startVpn);
await globalState.startSystemProxy(
config: config,
clashConfig: clashConfig,
);
globalState.updateTraffic(config: config);
globalState.updateFunctionLists = [
() {
globalState.updateTraffic(config: config);
}
];
}
tile?.addListener(
TileListenerWithVpn(
onStop: () async {
await app?.tip(appLocalizations.stopVpn);
await globalState.stopSystemProxy();
exit(0);
},
),
);
if (appState.isInit) {
handleStart();
tile?.addListener(
TileListenerWithVpn(
onStop: () async {
await app?.tip(appLocalizations.stopVpn);
await globalState.stopSystemProxy();
clashCore.shutdown();
exit(0);
},
),
);
}
globalState.updateTraffic();
globalState.updateFunctionLists = [
() {
globalState.updateTraffic();
}
];
}
class ClashMessageListenerWithVpn with ClashMessageListener {
final Function(String fd) _onTun;
@immutable
class ServiceMessageHandler with ServiceMessageListener {
final Function(Fd fd) _onProtect;
final Function(Process process) _onProcess;
final Function(String runTime) _onStarted;
final Function(String groupName) _onLoaded;
ClashMessageListenerWithVpn({
required Function(String fd) onTun,
}) : _onTun = onTun;
const ServiceMessageHandler({
required Function(Fd fd) onProtect,
required Function(Process process) onProcess,
required Function(String runTime) onStarted,
required Function(String groupName) onLoaded,
}) : _onProtect = onProtect,
_onProcess = onProcess,
_onStarted = onStarted,
_onLoaded = onLoaded;
@override
void onTun(String fd) {
_onTun(fd);
onProtect(Fd fd) {
_onProtect(fd);
}
@override
onProcess(Process process) {
_onProcess(process);
}
@override
onStarted(String runTime) {
_onStarted(runTime);
}
@override
onLoaded(String groupName) {
_onLoaded(groupName);
}
}

View File

@@ -1,7 +1,10 @@
import 'dart:io';
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 'ffi.dart';
import 'log.dart';
import 'navigation.dart';
@@ -19,6 +22,7 @@ class AppState with ChangeNotifier {
bool _isInit;
VersionInfo? _versionInfo;
List<Traffic> _traffics;
Traffic _totalTraffic;
List<Log> _logs;
String _currentLabel;
SystemColorSchemes _systemColorSchemes;
@@ -29,6 +33,8 @@ class AppState with ChangeNotifier {
bool _isCompatible;
List<Group> _groups;
double _viewWidth;
List<Connection> _requests;
num _checkIpNum;
AppState({
required Mode mode,
@@ -42,7 +48,10 @@ class AppState with ChangeNotifier {
_viewWidth = 0,
_selectedMap = selectedMap,
_sortNum = 0,
_checkIpNum = 0,
_requests = [],
_mode = mode,
_totalTraffic = Traffic(),
_delayMap = {},
_groups = [],
_isCompatible = isCompatible,
@@ -107,7 +116,7 @@ class AppState with ChangeNotifier {
} else {
final index = groups.indexWhere((element) => element.name == proxyName);
if (index == -1) return type;
return "$type(${groups[index].now})";
return "$type(${groups[index].now ?? '*'})";
}
}
@@ -116,9 +125,10 @@ class AppState with ChangeNotifier {
final index = groups.indexWhere((element) => element.name == proxyName);
if (index == -1) return proxyName;
final group = groups[index];
return getRealProxyName(selectedMap.containsKey(proxyName)
? selectedMap[proxyName]
: group.now);
return getRealProxyName((selectedMap.containsKey(proxyName)
? selectedMap[proxyName]
: group.now)) ??
proxyName;
}
String? get showProxyName {
@@ -152,8 +162,39 @@ class AppState with ChangeNotifier {
}
}
addTraffic(Traffic value) {
_traffics = List.from(_traffics)..add(value);
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) {
if (_requests != value) {
_requests = value;
notifyListeners();
}
}
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();
}
@@ -167,9 +208,10 @@ class AppState with ChangeNotifier {
}
addLog(Log log) {
_logs.add(log);
if (_logs.length > 60) {
_logs = _logs.sublist(_logs.length - 60);
_logs = List.from(_logs)..add(log);
final maxLength = Platform.isAndroid ? 1000 : 60;
if (_logs.length > maxLength) {
_logs = _logs.sublist(_logs.length - maxLength);
}
notifyListeners();
}
@@ -201,6 +243,15 @@ class AppState with ChangeNotifier {
}
}
num get checkIpNum => _checkIpNum;
set checkIpNum(num value) {
if (_checkIpNum != value) {
_checkIpNum = value;
notifyListeners();
}
}
Mode get mode => _mode;
set mode(Mode value) {

View File

@@ -1,7 +1,10 @@
// ignore_for_file: invalid_annotation_target
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -104,33 +107,43 @@ class Dns {
}
}
typedef GeoXMap = Map<String, String>;
@JsonSerializable()
class ClashConfig extends ChangeNotifier {
int _mixedPort;
bool _allowLan;
Mode _mode;
bool _ipv6;
String _geodataLoader;
LogLevel _logLevel;
String _externalController;
Mode _mode;
FindProcessMode _findProcessMode;
bool _unifiedDelay;
bool _tcpConcurrent;
Tun _tun;
Dns _dns;
GeoXMap _geoXUrl;
List<String> _rules;
String? _globalRealUa;
ClashConfig({
int? mixedPort,
Mode? mode,
bool? allowLan,
LogLevel? logLevel,
Tun? tun,
Dns? dns,
List<String>? rules,
}) : _mixedPort = mixedPort ?? 7890,
_mode = mode ?? Mode.rule,
_allowLan = allowLan ?? false,
_logLevel = logLevel ?? LogLevel.info,
_tun = tun ?? const Tun(),
_dns = dns ?? Dns(),
_rules = rules ?? [];
ClashConfig()
: _mixedPort = 7890,
_mode = Mode.rule,
_ipv6 = false,
_findProcessMode = FindProcessMode.off,
_allowLan = false,
_tcpConcurrent = false,
_logLevel = LogLevel.info,
_tun = const Tun(),
_unifiedDelay = false,
_geodataLoader = geodataLoaderMemconservative,
_externalController = '',
_dns = Dns(),
_geoXUrl = defaultGeoXMap,
_rules = [];
@JsonKey(name: "mixed-port")
@JsonKey(name: "mixed-port", defaultValue: 7890)
int get mixedPort => _mixedPort;
set mixedPort(int value) {
@@ -140,6 +153,7 @@ class ClashConfig extends ChangeNotifier {
}
}
@JsonKey(defaultValue: Mode.rule)
Mode get mode => _mode;
set mode(Mode value) {
@@ -149,6 +163,16 @@ class ClashConfig extends ChangeNotifier {
}
}
@JsonKey(name: "find-process-mode", defaultValue: FindProcessMode.off)
FindProcessMode get findProcessMode => _findProcessMode;
set findProcessMode(FindProcessMode value) {
if (_findProcessMode != value) {
_findProcessMode = value;
notifyListeners();
}
}
@JsonKey(name: "allow-lan")
bool get allowLan => _allowLan;
@@ -159,7 +183,7 @@ class ClashConfig extends ChangeNotifier {
}
}
@JsonKey(name: "log-level")
@JsonKey(name: "log-level", defaultValue: LogLevel.info)
LogLevel get logLevel => _logLevel;
set logLevel(LogLevel value) {
@@ -169,7 +193,62 @@ class ClashConfig extends ChangeNotifier {
}
}
Tun get tun => _tun;
@JsonKey(name: "external-controller", defaultValue: '')
String get externalController => _externalController;
set externalController(String value) {
if (_externalController != value) {
_externalController = value;
notifyListeners();
}
}
@JsonKey(defaultValue: false)
bool get ipv6 => _ipv6;
set ipv6(bool value) {
if (_ipv6 != value) {
_ipv6 = value;
notifyListeners();
}
}
@JsonKey(name: "geodata-loader", defaultValue: geodataLoaderMemconservative)
String get geodataLoader => _geodataLoader;
set geodataLoader(String value) {
if (_geodataLoader != value) {
_geodataLoader = value;
notifyListeners();
}
}
@JsonKey(name: "unified-delay", defaultValue: false)
bool get unifiedDelay => _unifiedDelay;
set unifiedDelay(bool value) {
if (_unifiedDelay != value) {
_unifiedDelay = value;
notifyListeners();
}
}
@JsonKey(name: "tcp-concurrent", defaultValue: false)
bool get tcpConcurrent => _tcpConcurrent;
set tcpConcurrent(bool value) {
if (_tcpConcurrent != value) {
_tcpConcurrent = value;
notifyListeners();
}
}
Tun get tun {
if (Platform.isAndroid) {
return _tun.copyWith(enable: false);
}
return _tun;
}
set tun(Tun value) {
if (_tun != value) {
@@ -196,6 +275,35 @@ class ClashConfig extends ChangeNotifier {
}
}
@JsonKey(name: "global-ua", defaultValue: null)
String get globalUa {
if (_globalRealUa == null) {
return globalState.packageInfo.ua;
} else {
return _globalRealUa!;
}
}
@JsonKey(name: "global-real-ua", defaultValue: null)
String? get globalRealUa => _globalRealUa;
set globalRealUa(String? value) {
if (_globalRealUa != value) {
_globalRealUa = value;
notifyListeners();
}
}
@JsonKey(name: "geox-url", defaultValue: defaultGeoXMap)
GeoXMap get geoXUrl => _geoXUrl;
set geoXUrl(GeoXMap value) {
if (!const MapEquality<String, String>().equals(value, _geoXUrl)) {
_geoXUrl = value;
notifyListeners();
}
}
update([ClashConfig? clashConfig]) {
if (clashConfig != null) {
_mixedPort = clashConfig._mixedPort;
@@ -205,6 +313,7 @@ class ClashConfig extends ChangeNotifier {
_tun = clashConfig._tun;
_dns = clashConfig._dns;
_rules = clashConfig._rules;
_globalRealUa = clashConfig.globalRealUa;
}
notifyListeners();
}
@@ -216,20 +325,4 @@ class ClashConfig extends ChangeNotifier {
factory ClashConfig.fromJson(Map<String, dynamic> json) {
return _$ClashConfigFromJson(json);
}
ClashConfig copyWith({Tun? tun}) {
return ClashConfig(
mixedPort: mixedPort,
mode: mode,
logLevel: logLevel,
tun: tun ?? this.tun,
dns: dns,
allowLan: allowLan,
);
}
@override
String toString() {
return 'ClashConfig{_mixedPort: $_mixedPort, _allowLan: $_allowLan, _mode: $_mode, _logLevel: $_logLevel, _tun: $_tun, _dns: $_dns, _rules: $_rules}';
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -8,6 +9,7 @@ import '../common/common.dart';
import 'models.dart';
part 'generated/config.g.dart';
part 'generated/config.freezed.dart';
@freezed
@@ -23,6 +25,17 @@ class AccessControl with _$AccessControl {
_$AccessControlFromJson(json);
}
@freezed
class Props with _$Props {
const factory Props({
AccessControl? accessControl,
bool? allowBypass,
bool? systemProxy,
}) = _Props;
factory Props.fromJson(Map<String, Object?> json) => _$PropsFromJson(json);
}
@JsonSerializable()
class Config extends ChangeNotifier {
List<Profile> _profiles;
@@ -41,7 +54,14 @@ class Config extends ChangeNotifier {
AccessControl _accessControl;
bool _isAnimateToPage;
bool _autoCheckUpdate;
bool _allowBypass;
bool _systemProxy;
bool _isExclude;
DAV? _dav;
ProxiesType _proxiesType;
ProxyCardType _proxyCardType;
int _proxiesColumns;
String _testUrl;
Config()
: _profiles = [],
@@ -56,8 +76,15 @@ class Config extends ChangeNotifier {
_isMinimizeOnExit = true,
_isAccessControl = false,
_autoCheckUpdate = true,
_systemProxy = true,
_testUrl = defaultTestUrl,
_accessControl = const AccessControl(),
_isAnimateToPage = true;
_isAnimateToPage = true,
_allowBypass = true,
_isExclude = false,
_proxyCardType = ProxyCardType.expand,
_proxiesType = ProxiesType.tab,
_proxiesColumns = 2;
deleteProfileById(String id) {
_profiles = profiles.where((element) => element.id != id).toList();
@@ -127,10 +154,35 @@ class Config extends ChangeNotifier {
}
Profile? get currentProfile {
try {
return profiles.firstWhere((element) => element.id == _currentProfileId);
} catch (_) {
return null;
final index =
profiles.indexWhere((profile) => profile.id == _currentProfileId);
return index == -1 ? null : profiles[index];
}
String? get currentGroupName => currentProfile?.currentGroupName;
Set<String> get currentUnfoldSet => currentProfile?.unfoldSet ?? {};
updateCurrentUnfoldSet(Set<String> value) {
if (!const SetEquality<String>().equals(currentUnfoldSet, value)) {
_setProfile(
currentProfile!.copyWith(
unfoldSet: value,
),
);
notifyListeners();
}
}
updateCurrentGroupName(String groupName) {
if (currentProfile != null &&
currentProfile!.currentGroupName != groupName) {
_setProfile(
currentProfile!.copyWith(
currentGroupName: groupName,
),
);
notifyListeners();
}
}
@@ -139,9 +191,16 @@ class Config extends ChangeNotifier {
}
updateCurrentSelectedMap(String groupName, String proxyName) {
if (currentProfile?.selectedMap[groupName] != proxyName) {
currentProfile?.selectedMap = Map.from(currentProfile?.selectedMap ?? {})
..[groupName] = proxyName;
if (currentProfile != null &&
currentProfile!.selectedMap[groupName] != proxyName) {
final SelectedMap selectedMap = Map.from(
currentProfile?.selectedMap ?? {},
)..[groupName] = proxyName;
_setProfile(
currentProfile!.copyWith(
selectedMap: selectedMap,
),
);
notifyListeners();
}
}
@@ -305,8 +364,84 @@ class Config extends ChangeNotifier {
}
}
update(
[Config? config, RecoveryOption recoveryOptions = RecoveryOption.all]) {
@JsonKey(defaultValue: true)
bool get allowBypass {
return _allowBypass;
}
set allowBypass(bool value) {
if (_allowBypass != value) {
_allowBypass = value;
notifyListeners();
}
}
@JsonKey(defaultValue: true)
bool get systemProxy {
return _systemProxy;
}
set systemProxy(bool value) {
if (_systemProxy != value) {
_systemProxy = value;
notifyListeners();
}
}
@JsonKey(defaultValue: ProxiesType.tab)
ProxiesType get proxiesType => _proxiesType;
set proxiesType(ProxiesType value) {
if (_proxiesType != value) {
_proxiesType = value;
notifyListeners();
}
}
@JsonKey(defaultValue: ProxyCardType.expand)
ProxyCardType get proxyCardType => _proxyCardType;
set proxyCardType(ProxyCardType value) {
if (_proxyCardType != value) {
_proxyCardType = value;
notifyListeners();
}
}
@JsonKey(defaultValue: 2)
int get proxiesColumns => _proxiesColumns;
set proxiesColumns(int value) {
if (_proxiesColumns != value) {
_proxiesColumns = value;
notifyListeners();
}
}
@JsonKey(name: "test-url", defaultValue: defaultTestUrl)
String get testUrl => _testUrl;
set testUrl(String value) {
if (_testUrl != value) {
_testUrl = value;
notifyListeners();
}
}
@JsonKey(defaultValue: false)
bool get isExclude => _isExclude;
set isExclude(bool value) {
if (_isExclude != value) {
_isExclude = value;
notifyListeners();
}
}
update([
Config? config,
RecoveryOption recoveryOptions = RecoveryOption.all,
]) {
if (config != null) {
_profiles = config._profiles;
for (final profile in config._profiles) {
@@ -322,9 +457,11 @@ class Config extends ChangeNotifier {
_autoLaunch = config._autoLaunch;
_silentLaunch = config._silentLaunch;
_autoRun = config._autoRun;
_proxiesType = config._proxiesType;
_openLog = config._openLog;
_themeMode = config._themeMode;
_locale = config._locale;
_allowBypass = config._allowBypass;
_primaryColor = config._primaryColor;
_proxiesSortType = config._proxiesSortType;
_isMinimizeOnExit = config._isMinimizeOnExit;
@@ -333,6 +470,7 @@ class Config extends ChangeNotifier {
_isAnimateToPage = config._isAnimateToPage;
_autoCheckUpdate = config._autoCheckUpdate;
_dav = config._dav;
_testUrl = config.testUrl;
}
notifyListeners();
}

View File

@@ -14,6 +14,7 @@ class Metadata with _$Metadata {
required String destinationIP,
required String destinationPort,
required String host,
required String process,
required String remoteDestination,
}) = _Metadata;
@@ -22,7 +23,7 @@ class Metadata with _$Metadata {
}
@freezed
class Connection with _$Connection{
class Connection with _$Connection {
const factory Connection({
required String id,
num? upload,
@@ -35,3 +36,19 @@ class Connection with _$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)=> Set.from(connection.chains).containsAll(keywords)).toList();
}

View File

@@ -2,19 +2,33 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/clash_config.dart';
import 'package:fl_clash/models/connection.dart';
import 'package:fl_clash/models/models.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'generated/ffi.g.dart';
part 'generated/ffi.freezed.dart';
@freezed
class ConfigExtendedParams with _$ConfigExtendedParams {
const factory ConfigExtendedParams({
@JsonKey(name: "is-patch") required bool isPatch,
@JsonKey(name: "is-compatible") required bool isCompatible,
@JsonKey(name: "selected-map") required SelectedMap selectedMap,
@JsonKey(name: "test-url") required String testUrl,
}) = _ConfigExtendedParams;
factory ConfigExtendedParams.fromJson(Map<String, Object?> json) =>
_$ConfigExtendedParamsFromJson(json);
}
@freezed
class UpdateConfigParams with _$UpdateConfigParams {
const factory UpdateConfigParams({
@JsonKey(name: "profile-path") String? profilePath,
required ClashConfig config,
@JsonKey(name: "is-patch") required bool isPatch,
@JsonKey(name: "is-compatible") required bool isCompatible,
required ConfigExtendedParams params,
}) = _UpdateConfigParams;
factory UpdateConfigParams.fromJson(Map<String, Object?> json) =>
@@ -33,14 +47,25 @@ class ChangeProxyParams with _$ChangeProxyParams {
}
@freezed
class Message with _$Message {
const factory Message({
required MessageType type,
class AppMessage with _$AppMessage {
const factory AppMessage({
required AppMessageType type,
dynamic data,
}) = _Message;
}) = _AppMessage;
factory Message.fromJson(Map<String, Object?> json) =>
_$MessageFromJson(json);
factory AppMessage.fromJson(Map<String, Object?> json) =>
_$AppMessageFromJson(json);
}
@freezed
class ServiceMessage with _$ServiceMessage {
const factory ServiceMessage({
required ServiceMessageType type,
dynamic data,
}) = _ServiceMessage;
factory ServiceMessage.fromJson(Map<String, Object?> json) =>
_$ServiceMessageFromJson(json);
}
@freezed
@@ -66,16 +91,35 @@ class Now with _$Now {
@freezed
class Process with _$Process {
const factory Process({
required int uid,
required String network,
required String source,
required String target,
required int id,
required Metadata metadata,
}) = _Process;
factory Process.fromJson(Map<String, Object?> json) =>
_$ProcessFromJson(json);
}
@freezed
class Fd with _$Fd {
const factory Fd({
required int id,
required int value,
}) = _Fd;
factory Fd.fromJson(Map<String, Object?> json) => _$FdFromJson(json);
}
@freezed
class ProcessMapItem with _$ProcessMapItem {
const factory ProcessMapItem({
required int id,
required String value,
}) = _ProcessMapItem;
factory ProcessMapItem.fromJson(Map<String, Object?> json) =>
_$ProcessMapItemFromJson(json);
}
@freezed
class ExternalProvider with _$ExternalProvider {
const factory ExternalProvider({
@@ -88,3 +132,27 @@ class ExternalProvider with _$ExternalProvider {
factory ExternalProvider.fromJson(Map<String, Object?> json) =>
_$ExternalProviderFromJson(json);
}
abstract mixin class AppMessageListener {
void onLog(Log log) {}
void onDelay(Delay delay) {}
void onRequest(Connection connection) {}
void onStarted(String runTime) {}
void onLoaded(String groupName) {}
}
abstract mixin class ServiceMessageListener {
onProtect(Fd fd) {}
onProcess(Process process) {}
onStarted(String runTime) {}
onLoaded(String groupName) {}
}

View File

@@ -135,7 +135,7 @@ class _$TunImpl implements _Tun {
const _$TunImpl(
{this.enable = false,
this.device = appName,
this.stack = TunStack.mixed,
this.stack = TunStack.gvisor,
@JsonKey(name: "dns-hijack")
final List<String> dnsHijack = const ["any:53"]})
: _dnsHijack = dnsHijack;

View File

@@ -35,30 +35,55 @@ Map<String, dynamic> _$DnsToJson(Dns instance) => <String, dynamic>{
'fake-ip-filter': instance.fakeIpFilter,
};
ClashConfig _$ClashConfigFromJson(Map<String, dynamic> json) => ClashConfig(
mixedPort: (json['mixed-port'] as num?)?.toInt(),
mode: $enumDecodeNullable(_$ModeEnumMap, json['mode']),
allowLan: json['allow-lan'] as bool?,
logLevel: $enumDecodeNullable(_$LogLevelEnumMap, json['log-level']),
tun: json['tun'] == null
? null
: Tun.fromJson(json['tun'] as Map<String, dynamic>),
dns: json['dns'] == null
? null
: Dns.fromJson(json['dns'] as Map<String, dynamic>),
rules:
(json['rules'] as List<dynamic>?)?.map((e) => e as String).toList(),
);
ClashConfig _$ClashConfigFromJson(Map<String, dynamic> json) => ClashConfig()
..mixedPort = (json['mixed-port'] as num?)?.toInt() ?? 7890
..mode = $enumDecodeNullable(_$ModeEnumMap, json['mode']) ?? Mode.rule
..findProcessMode = $enumDecodeNullable(
_$FindProcessModeEnumMap, json['find-process-mode']) ??
FindProcessMode.off
..allowLan = json['allow-lan'] as bool
..logLevel =
$enumDecodeNullable(_$LogLevelEnumMap, json['log-level']) ?? LogLevel.info
..externalController = json['external-controller'] as String? ?? ''
..ipv6 = json['ipv6'] as bool? ?? false
..geodataLoader = json['geodata-loader'] as String? ?? 'memconservative'
..unifiedDelay = json['unified-delay'] as bool? ?? false
..tcpConcurrent = json['tcp-concurrent'] as bool? ?? false
..tun = Tun.fromJson(json['tun'] as Map<String, dynamic>)
..dns = Dns.fromJson(json['dns'] as Map<String, dynamic>)
..rules = (json['rules'] as List<dynamic>).map((e) => e as String).toList()
..globalRealUa = json['global-real-ua'] as String?
..geoXUrl = (json['geox-url'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
{
'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',
'geoip':
'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'
};
Map<String, dynamic> _$ClashConfigToJson(ClashConfig instance) =>
<String, dynamic>{
'mixed-port': instance.mixedPort,
'mode': _$ModeEnumMap[instance.mode]!,
'find-process-mode': _$FindProcessModeEnumMap[instance.findProcessMode]!,
'allow-lan': instance.allowLan,
'log-level': _$LogLevelEnumMap[instance.logLevel]!,
'external-controller': instance.externalController,
'ipv6': instance.ipv6,
'geodata-loader': instance.geodataLoader,
'unified-delay': instance.unifiedDelay,
'tcp-concurrent': instance.tcpConcurrent,
'tun': instance.tun,
'dns': instance.dns,
'rules': instance.rules,
'global-real-ua': instance.globalRealUa,
'geox-url': instance.geoXUrl,
};
const _$ModeEnumMap = {
@@ -67,6 +92,11 @@ const _$ModeEnumMap = {
Mode.direct: 'direct',
};
const _$FindProcessModeEnumMap = {
FindProcessMode.always: 'always',
FindProcessMode.off: 'off',
};
const _$LogLevelEnumMap = {
LogLevel.debug: 'debug',
LogLevel.info: 'info',
@@ -79,7 +109,7 @@ _$TunImpl _$$TunImplFromJson(Map<String, dynamic> json) => _$TunImpl(
enable: json['enable'] as bool? ?? false,
device: json['device'] as String? ?? appName,
stack: $enumDecodeNullable(_$TunStackEnumMap, json['stack']) ??
TunStack.mixed,
TunStack.gvisor,
dnsHijack: (json['dns-hijack'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??

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