diff --git a/.agents/flutter-upgrade.md b/.agents/flutter-upgrade.md
new file mode 100644
index 00000000..dfe4cb75
--- /dev/null
+++ b/.agents/flutter-upgrade.md
@@ -0,0 +1,31 @@
+# Flutter Upgrade Checklist
+
+Read this before changing `FLUTTER_VERSION` in `.github/workflows/build.yaml` or `.fvmrc`. Each entry is a
+workaround tied to a Flutter bug: what it does, where it lives, and what has to be true before it can go. Remove an
+entry together with its workaround; add one whenever a new workaround is pinned to the SDK.
+
+## Stable cadence
+
+Flutter ships a stable release about once a quarter, three minor versions apart (3.41 in February, 3.44 in May, 3.47
+in August 2026). The two minors in between (3.48, 3.49) are beta only, so a fix that lands on `main` reaches stable
+in the next multiple, 3.50, expected around mid-November 2026. Hotfix releases (3.47.x) carry only cherry-picks
+listed in the SDK's `CHANGELOG.md`.
+
+## Impeller disabled on Intel macOS release builds
+
+- **Bug:** [flutter/flutter#191538](https://github.com/flutter/flutter/issues/191538). From 3.47, when Impeller
+ became the default desktop renderer, Intel (x86_64) Macs flicker across the whole window while scrolling or
+ animating, and text can render garbled. Apple Silicon is not affected.
+- **Fix upstream:** landed on `main` before 2026-08-25 and verified on `3.48.0-1.0.pre-404`; it is not in any
+ 3.47.x hotfix (checked through 3.47.5).
+- **Workaround:** the release job for `macos-15-intel` (`arch: amd64`) runs
+ `plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist` before `dart setup.dart`, so only the Intel
+ DMG falls back to Skia. The checked-in `Info.plist` does not set the key, so local builds and the arm64 DMG keep
+ Impeller. The macOS embedder reads the key at launch in `FlutterDartProject.mm` (`enableImpeller`), release
+ builds included.
+- **Side effect:** Skia on Intel renders blur, shadows and text slightly differently; judge Intel-only visual reports
+ against Skia, not Impeller.
+- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains the fix (3.50 or later, or a 3.47.x whose
+ changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and
+ dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
+ switch to the discrete GPU and hide the flicker.
diff --git a/.github/homebrew_cask_template.rb b/.github/homebrew_cask_template.rb
index 3a9a493a..cfe4a17f 100644
--- a/.github/homebrew_cask_template.rb
+++ b/.github/homebrew_cask_template.rb
@@ -23,9 +23,11 @@ cask "flclash" do
app "FlClash.app"
- postflight do
- system_command "xattr",
- args: ["-rd", "com.apple.quarantine", "#{appdir}/FlClash.app"]
+ postflight_steps do
+ run "/usr/bin/xattr",
+ args: ["-rd", "com.apple.quarantine", "{{appdir}}/FlClash.app"],
+ writable_paths: ["FlClash.app"],
+ writable_base: :appdir
end
uninstall quit: "com.follow.clash"
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index a644e2da..04389fec 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -6,6 +6,7 @@ on:
- '**'
tags:
- 'v*'
+ workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -13,19 +14,23 @@ concurrency:
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
- FLUTTER_VERSION: '3.47.1'
+ FLUTTER_VERSION: '3.47.4'
GO_VERSION: '1.26.4'
NDK_VERSION: r28c
jobs:
dart:
name: Dart
+ # A private copy pays for its Actions minutes, so it runs the checks only when
+ # started by hand.
+ if: &checks github.event_name == 'workflow_dispatch' || !github.event.repository.private
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
+ submodules: true
- name: Test commit message hook
run: bash tool/check_commit_msg_test.sh
@@ -33,6 +38,12 @@ jobs:
- name: Test comment density hook
run: bash tool/check_comment_density_test.sh
+ - name: Setup Rust cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: plugins/rust_api/rust
+ cache-directories: .dart_tool/hooks_runner
+
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
@@ -49,9 +60,9 @@ jobs:
- name: Analyze
run: flutter analyze --no-fatal-infos
- - name: Skip native build hooks
+ - name: Skip the Go core build hook
run: |
- yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
+ yq -i '.hooks.user_defines.setup.build_assets = false' pubspec.yaml
- name: Verify changelog
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
@@ -65,6 +76,7 @@ jobs:
plugins:
name: Plugins
+ if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -87,8 +99,75 @@ jobs:
dart analyze
dart test
+ desktop:
+ name: Desktop ${{ matrix.platform }}
+ if: *checks
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux
+ os: ubuntu-latest
+ - platform: windows
+ os: windows-2022
+ - platform: macos
+ os: macos-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ cache-dependency-path: core/go.sum
+
+ - name: Setup Rust cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ key: desktop-${{ matrix.platform }}
+ workspaces: |
+ services/helper
+ plugins/rust_api/rust
+ cache-directories: .dart_tool/hooks_runner
+
+ - name: Setup Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ cache: true
+
+ - name: Install Linux build dependencies
+ if: matrix.platform == 'linux'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
+
+ - name: Setup Xcode
+ if: matrix.platform == 'macos'
+ run: |
+ sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
+ xcodebuild -version
+
+ - name: Enable git long paths (Windows)
+ if: matrix.platform == 'windows'
+ run: git config --global core.longpaths true
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ # Compiles the desktop runners and the native plugin code that only the
+ # tagged release matrix built before; the bundle itself is discarded.
+ - name: Build debug bundle
+ run: flutter build ${{ matrix.platform }} --debug
+
go:
name: Go core
+ if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -140,6 +219,7 @@ jobs:
android:
name: Android unit tests
+ if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -190,6 +270,7 @@ jobs:
rust:
name: Rust
+ if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -214,6 +295,7 @@ jobs:
windows-helper-test:
name: Windows helper test
+ if: *checks
runs-on: windows-2022
steps:
- name: Checkout
@@ -231,7 +313,8 @@ jobs:
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
build:
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+ # Releases publish where the release links point: the public repository.
+ if: github.repository == 'chen08209/FlClash' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [ dart, plugins, go, android, rust, windows-helper-test ]
runs-on: ${{ matrix.os }}
strategy:
@@ -334,6 +417,11 @@ jobs:
flutter --version
flutter pub get
+ # See .agents/flutter-upgrade.md before bumping FLUTTER_VERSION.
+ - name: Disable Impeller on Intel macOS
+ if: matrix.platform == 'macos' && matrix.arch == 'amd64'
+ run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
+
- name: Setup
env:
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
diff --git a/.run/main_safe_mode.run.xml b/.run/main_safe_mode.run.xml
new file mode 100644
index 00000000..26cbd74f
--- /dev/null
+++ b/.run/main_safe_mode.run.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/.zed/debug.json b/.zed/debug.json
new file mode 100644
index 00000000..41633910
--- /dev/null
+++ b/.zed/debug.json
@@ -0,0 +1,24 @@
+[
+ {
+ "label": "Flutter App (With Env)",
+ "adapter": "Dart",
+ "type": "flutter",
+ "request": "launch",
+ "program": "lib/main.dart",
+ "cwd": "$ZED_WORKTREE_ROOT",
+ "toolArgs": ["--dart-define-from-file", "env.json"],
+ },
+ {
+ "label": "Flutter App (Safe Mode)",
+ "adapter": "Dart",
+ "type": "flutter",
+ "request": "launch",
+ "program": "lib/main.dart",
+ "cwd": "$ZED_WORKTREE_ROOT",
+ "toolArgs": [
+ "--dart-define-from-file",
+ "env.json",
+ "--dart-define=SAFE_MODE=true",
+ ],
+ },
+]
diff --git a/.zed/tasks.json b/.zed/tasks.json
new file mode 100644
index 00000000..5340b1b8
--- /dev/null
+++ b/.zed/tasks.json
@@ -0,0 +1,21 @@
+[
+ {
+ "label": "flutter run (With Env)",
+ "command": "flutter",
+ "args": ["run", "--dart-define-from-file", "env.json"],
+ "cwd": "$ZED_WORKTREE_ROOT",
+ "use_new_terminal": true,
+ },
+ {
+ "label": "flutter run (Safe Mode)",
+ "command": "flutter",
+ "args": [
+ "run",
+ "--dart-define-from-file",
+ "env.json",
+ "--dart-define=SAFE_MODE=true",
+ ],
+ "cwd": "$ZED_WORKTREE_ROOT",
+ "use_new_terminal": true,
+ },
+]
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 40799875..54f13556 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,3 +1,4 @@
+import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
@@ -79,6 +80,10 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
+
+ configure {
+ nativeSymbolUploadEnabled = hasReleaseSigning
+ }
}
}
@@ -98,6 +103,13 @@ flutter {
source = "../.."
}
+// The Crashlytics plugin finalizes R8 with the mapping upload but leaves the native symbol upload to the caller.
+if (hasReleaseSigning) {
+ tasks.matching { it.name == "assembleRelease" || it.name == "bundleRelease" }.configureEach {
+ finalizedBy("uploadCrashlyticsSymbolFileRelease")
+ }
+}
+
dependencies {
implementation(project(":service"))
implementation(project(":common"))
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 260cee7f..9b302fb2 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -27,6 +27,7 @@
+
Unit)
+ fun requestLocalNetworkPermission(callback: (Boolean) -> Unit)
+
fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit)
fun cancelVpnPreparation(callback: (Boolean) -> Unit)
@@ -100,6 +108,13 @@ internal object AndroidServiceStateHost : ServiceStateHost {
override fun isVpnPermissionGranted(): Boolean =
VpnService.prepare(GlobalState.application) == null
+ override fun isLocalNetworkPermissionGranted(): Boolean =
+ sdkInt < Build.VERSION_CODES.CINNAMON_BUN ||
+ ContextCompat.checkSelfPermission(
+ GlobalState.application,
+ Manifest.permission.ACCESS_LOCAL_NETWORK,
+ ) == PackageManager.PERMISSION_GRANTED
+
override fun tile(): TileGateway? = flutterEngine?.plugin()?.let { plugin ->
object : TileGateway {
override fun handleStart() = plugin.handleStart()
@@ -113,6 +128,9 @@ internal object AndroidServiceStateHost : ServiceStateHost {
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
plugin.requestNotificationPermission(callback)
+ override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) =
+ plugin.requestLocalNetworkPermission(callback)
+
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) =
plugin.prepareVpn(enable, callback)
diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt
index 07ac6739..d5bb601b 100644
--- a/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt
+++ b/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt
@@ -136,7 +136,13 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
}
val app = host.app()
if (app != null) {
- app.requestNotificationPermission(launchRequest)
+ app.requestNotificationPermission { shouldStart ->
+ if (shouldStart) {
+ app.requestLocalNetworkPermission { launchRequest(true) }
+ } else {
+ launchRequest(false)
+ }
+ }
} else {
launchRequest(true)
}
@@ -246,7 +252,7 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
return@transition true
}
mutableRunState.value = RunState.STARTING
- val startedAtMillis = host.startService(options)
+ val startedAtMillis = host.startService(withLocalNetworkFallback(options))
if (startedAtMillis == 0L) {
mutableRunState.value = RunState.STOPPED
fail(request)
@@ -260,6 +266,17 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
}
}
+ // system/mixed hand TCP to the kernel via the tun subnet, which Android 17 gates as local network.
+ private fun withLocalNetworkFallback(options: VpnOptions): VpnOptions {
+ if (!options.enable || options.stack !in KERNEL_TCP_STACKS ||
+ host.isLocalNetworkPermissionGranted()
+ ) {
+ return options
+ }
+ host.showToast(sharedState.localNetworkTip)
+ return options.copy(stack = FALLBACK_STACK)
+ }
+
private suspend fun reconcileStopped() = transitionLock.withLock {
if (isRunningRequested() || runTimeMillis == 0L) {
return@withLock
@@ -322,6 +339,9 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
}
internal companion object {
+ val KERNEL_TCP_STACKS = setOf("system", "mixed")
+ const val FALLBACK_STACK = "gvisor"
+
/**
* The Core init payload. The key spelling is a cross-language contract with the Go wrapper,
* not an implementation detail.
diff --git a/android/app/src/main/kotlin/com/follow/clash/models/State.kt b/android/app/src/main/kotlin/com/follow/clash/models/State.kt
index ac784e34..a1e8c03b 100644
--- a/android/app/src/main/kotlin/com/follow/clash/models/State.kt
+++ b/android/app/src/main/kotlin/com/follow/clash/models/State.kt
@@ -6,6 +6,8 @@ import com.google.gson.annotations.SerializedName
data class SharedState(
val startTip: String = "Starting VPN...",
val stopTip: String = "Stopping VPN...",
+ val localNetworkTip: String =
+ "Local network permission denied: using the gvisor stack, LAN is unreachable.",
val crashlytics: Boolean = true,
val currentProfileName: String = "FlClash",
val stopText: String = "Stop",
diff --git a/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt b/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt
index 2964c5bf..910a0e46 100644
--- a/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt
+++ b/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt
@@ -68,8 +68,14 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
private val requestInstalledAppsCallback = PendingCallback()
+ private val requestLocalNetworkCallback = PendingCallback()
+
private var isRequestingNotificationPermission = false
+ private var isRequestingLocalNetworkPermission = false
+
+ private var skipLocalNetworkPermissionRequest = false
+
private val gson = Gson()
private val packageResolver by lazy {
@@ -309,6 +315,42 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
requestNotificationCallback.resolve(shouldStart)
}
+ fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) = onMainThread {
+ requestLocalNetworkCallback.replace(callback, supersededValue = false)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || hasLocalNetworkPermission()) {
+ invokeRequestLocalNetworkCallback(true)
+ return@onMainThread
+ }
+ if (skipLocalNetworkPermissionRequest) {
+ invokeRequestLocalNetworkCallback(false)
+ return@onMainThread
+ }
+ if (isRequestingLocalNetworkPermission) {
+ return@onMainThread
+ }
+ val activity = activity
+ if (activity == null) {
+ invokeRequestLocalNetworkCallback(false)
+ return@onMainThread
+ }
+ isRequestingLocalNetworkPermission = true
+ ActivityCompat.requestPermissions(
+ activity,
+ arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK),
+ LOCAL_NETWORK_PERMISSION_REQUEST_CODE,
+ )
+ }
+
+ private fun hasLocalNetworkPermission(): Boolean = ContextCompat.checkSelfPermission(
+ GlobalState.application,
+ Manifest.permission.ACCESS_LOCAL_NETWORK,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ private fun invokeRequestLocalNetworkCallback(granted: Boolean) {
+ isRequestingLocalNetworkPermission = false
+ requestLocalNetworkCallback.resolve(granted)
+ }
+
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
requestInstalledAppsCallback.replace(callback, supersededValue = false)
val activity = activity
@@ -390,6 +432,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
scope.cancel()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
+ invokeRequestLocalNetworkCallback(false)
invokeRequestInstalledAppsCallback(false)
}
@@ -426,6 +469,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
detachFromActivity()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
+ invokeRequestLocalNetworkCallback(false)
invokeRequestInstalledAppsCallback(false)
}
@@ -456,6 +500,15 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
true
}
+ LOCAL_NETWORK_PERMISSION_REQUEST_CODE -> {
+ skipLocalNetworkPermissionRequest = true
+ invokeRequestLocalNetworkCallback(
+ grantResults.isNotEmpty() &&
+ grantResults[0] == PackageManager.PERMISSION_GRANTED,
+ )
+ true
+ }
+
else -> false
}
@@ -463,5 +516,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
const val INSTALLED_APPS_PERMISSION_REQUEST_CODE = 1003
+ const val LOCAL_NETWORK_PERMISSION_REQUEST_CODE = 1004
}
}
diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts
index fee242d2..476929d4 100644
--- a/android/core/build.gradle.kts
+++ b/android/core/build.gradle.kts
@@ -33,6 +33,13 @@ android {
}
}
+ // :app strips what it packages; Crashlytics symbolication needs the unstripped libraries to reach its merge.
+ packaging {
+ jniLibs {
+ keepDebugSymbols += "**/*.so"
+ }
+ }
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index d6eaaf7b..c7294771 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -1,16 +1,16 @@
[versions]
-firebaseBom = "34.15.0"
-minSdk = "23"
-targetSdk = "36"
-compileSdk = "36"
+firebaseBom = "34.19.0"
+minSdk = "24"
+targetSdk = "37"
+compileSdk = "37"
ndkVersion = "28.2.13676358"
-coreKtx = "1.17.0"
-annotationJvm = "1.9.1"
-coreSplashscreen = "1.0.1"
-gson = "2.13.1"
-smaliDexlib2 = "3.0.9"
+coreKtx = "1.19.1"
+annotationJvm = "1.11.0"
+coreSplashscreen = "1.2.0"
+gson = "2.14.0"
+smaliDexlib2 = "3.0.10"
junit = "4.13.2"
-kotlinxCoroutines = "1.10.2"
+kotlinxCoroutines = "1.11.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index c1501a7e..cbe42241 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index 145cd6f8..c4f8f103 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -19,10 +19,10 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
- id("com.android.application") version "9.0.1" apply false
- id("org.jetbrains.kotlin.android") version "2.4.0" apply false
+ id("com.android.application") version "9.4.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.4.20" apply false
id("com.google.gms.google-services") version "4.5.0" apply false
- id("com.google.firebase.crashlytics") version "3.0.7" apply false
+ id("com.google.firebase.crashlytics") version "3.0.8" apply false
}
include(":app")
diff --git a/android/tests/app/ServiceStateMachineTest.kt b/android/tests/app/ServiceStateMachineTest.kt
index fb3509a8..0430f165 100644
--- a/android/tests/app/ServiceStateMachineTest.kt
+++ b/android/tests/app/ServiceStateMachineTest.kt
@@ -18,7 +18,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
-private fun vpnOptions(enable: Boolean = true) = VpnOptions(
+private fun vpnOptions(enable: Boolean = true, stack: String = "gvisor") = VpnOptions(
enable = enable,
port = 7890,
ipv6 = false,
@@ -32,12 +32,12 @@ private fun vpnOptions(enable: Boolean = true) = VpnOptions(
allowBypass = false,
systemProxy = true,
bypassDomain = emptyList(),
- stack = "gvisor",
+ stack = stack,
routeAddress = emptyList(),
)
-private fun configuredState(enable: Boolean = true) = SharedState(
- vpnOptions = vpnOptions(enable),
+private fun configuredState(enable: Boolean = true, stack: String = "gvisor") = SharedState(
+ vpnOptions = vpnOptions(enable, stack),
setupParams = SetupParams(testUrl = "https://example.com", selectedMap = emptyMap()),
)
@@ -56,17 +56,24 @@ private class FakeTile : TileGateway {
private class FakeApp(
private val notificationGranted: Boolean = true,
+ private val localNetworkGranted: Boolean = true,
private val vpnGranted: Boolean = true,
private val holdVpnPreparation: Boolean = false,
) : AppGateway {
var beforeVpnPrepared: (() -> Unit)? = null
var cancelledPreparations = 0
+ var localNetworkRequests = 0
private var heldCallback: ((Boolean) -> Unit)? = null
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
callback(notificationGranted)
+ override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) {
+ localNetworkRequests++
+ callback(localNetworkGranted)
+ }
+
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) {
beforeVpnPrepared?.invoke()
if (holdVpnPreparation) {
@@ -89,10 +96,12 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
var setupResult: Result = Result.success("")
var startResult = 1_700_000_000_000L
var vpnPermissionGranted = true
+ var localNetworkPermissionGranted = true
var vpnServiceActive = true
var tile: TileGateway? = null
var app: AppGateway? = null
var beforeStartService: (() -> Unit)? = null
+ var lastStartOptions: VpnOptions? = null
override var runTimeMillis = 0L
override val homeDirPath = "/data/user/0/com.follow.clash/files"
@@ -128,6 +137,8 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
override fun isVpnPermissionGranted(): Boolean = vpnPermissionGranted
+ override fun isLocalNetworkPermissionGranted(): Boolean = localNetworkPermissionGranted
+
override fun tile(): TileGateway? = tile
override fun app(): AppGateway? = app
@@ -141,6 +152,7 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
override suspend fun startService(options: VpnOptions): Long {
startCalls++
+ lastStartOptions = options
beforeStartService?.invoke()
runTimeMillis = startResult
return startResult
@@ -279,6 +291,57 @@ class ServiceStateMachineTest {
assertEquals(1, host.startCalls)
}
+ /** Android 17 drops the kernel hop system/mixed rely on until the permission is granted. */
+ @Test
+ fun `a denied local network permission falls back to the gvisor stack`() = runTest {
+ val host = FakeHost(backgroundScope)
+ host.localNetworkPermissionGranted = false
+ val machine = ServiceStateMachine(host)
+ machine.syncSharedState(configuredState(stack = "system"))
+
+ assertTrue(machine.requestStart().await())
+ assertEquals("gvisor", host.lastStartOptions?.stack)
+ assertTrue(host.toasts.contains(configuredState().localNetworkTip))
+ }
+
+ @Test
+ fun `a granted local network permission keeps the chosen stack`() = runTest {
+ val host = FakeHost(backgroundScope)
+ val machine = ServiceStateMachine(host)
+ machine.syncSharedState(configuredState(stack = "mixed"))
+
+ assertTrue(machine.requestStart().await())
+ assertEquals("mixed", host.lastStartOptions?.stack)
+ assertFalse(host.toasts.contains(configuredState().localNetworkTip))
+ }
+
+ @Test
+ fun `a proxy-only start keeps its stack without the local network permission`() = runTest {
+ val host = FakeHost(backgroundScope)
+ host.localNetworkPermissionGranted = false
+ val machine = ServiceStateMachine(host)
+ machine.syncSharedState(configuredState(enable = false, stack = "system"))
+
+ assertTrue(machine.requestStart().await())
+ assertEquals("system", host.lastStartOptions?.stack)
+ assertFalse(host.toasts.contains(configuredState().localNetworkTip))
+ }
+
+ @Test
+ fun `the local network prompt is asked once and never blocks the start`() = runTest {
+ val host = FakeHost(backgroundScope)
+ val app = FakeApp(localNetworkGranted = false)
+ host.app = app
+ host.localNetworkPermissionGranted = false
+ val machine = ServiceStateMachine(host)
+ machine.syncSharedState(configuredState(stack = "system"))
+
+ assertTrue(machine.requestStart().await())
+ assertEquals(1, app.localNetworkRequests)
+ assertEquals(1, host.startCalls)
+ assertEquals("gvisor", host.lastStartOptions?.stack)
+ }
+
@Test
fun `a stop request drives the service to STOPPED`() = runTest {
val host = FakeHost(backgroundScope)
diff --git a/lib/common/constant.dart b/lib/common/constant.dart
index 61b91ea3..39f15dbd 100644
--- a/lib/common/constant.dart
+++ b/lib/common/constant.dart
@@ -38,6 +38,8 @@ const sheetAppBarHeight = 68.0;
const watchExecution = false;
+const safeModeBuild = bool.fromEnvironment('SAFE_MODE');
+
String _randomPipeId() {
final random = Random.secure();
return List.generate(
diff --git a/plugins/rust_api/rust/rust-toolchain.toml b/plugins/rust_api/rust/rust-toolchain.toml
index 5a64f72a..503b1297 100644
--- a/plugins/rust_api/rust/rust-toolchain.toml
+++ b/plugins/rust_api/rust/rust-toolchain.toml
@@ -1,6 +1,6 @@
[toolchain]
# native_toolchain_rust refuses a floating channel; bump this deliberately.
-channel = "1.95.0"
+channel = "1.98.1"
targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
diff --git a/plugins/setup/setup_hooks/lib/src/go_builder.dart b/plugins/setup/setup_hooks/lib/src/go_builder.dart
index f5f1305c..a633d806 100644
--- a/plugins/setup/setup_hooks/lib/src/go_builder.dart
+++ b/plugins/setup/setup_hooks/lib/src/go_builder.dart
@@ -124,9 +124,18 @@ class GoBuilder {
return env;
}
+ // AGP strips the copy it packages into the APK, and Crashlytics reads the
+ // unstripped one from the merged native libs, so Android keeps its symbols.
+ String _ldflags(Target target) => target.isLib
+ ? config.goLdflags
+ .split(' ')
+ .where((flag) => flag != '-s' && flag != '-w')
+ .join(' ')
+ : config.goLdflags;
+
List _buildArguments(Target target, {String? outFile}) => [
'build',
- '-ldflags=${config.goLdflags}',
+ '-ldflags=${_ldflags(target)}',
'-tags=${config.tags}',
if (target.isLib) '-buildmode=c-shared',
if (outFile != null) ...['-o', outFile],
diff --git a/pubspec.lock b/pubspec.lock
index 42613c12..877e924c 100755
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -285,10 +285,10 @@ packages:
dependency: transitive
description:
name: cupertino_ui
- sha256: "7ed8ce4159d342eec4c65f4ea6eec57adaf9365404378541f38efc1da20a5b3d"
+ sha256: c23816577a1280cff409e989541767887011f8f1d8c1863a794ed308af46f2a9
url: "https://pub.dev"
source: hosted
- version: "1.0.0"
+ version: "1.1.1"
dart_style:
dependency: transitive
description:
@@ -305,14 +305,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.14"
- defer_pointer:
- dependency: "direct main"
- description:
- name: defer_pointer
- sha256: d69e6f8c1d0f052d2616cc1db3782e0ea73f42e4c6f6122fd1a548dfe79faf02
- url: "https://pub.dev"
- source: hosted
- version: "0.0.2"
device_info_plus:
dependency: "direct main"
description:
@@ -543,10 +535,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
- sha256: "56e81e662e6e54e59b231da57e90ac0d06ac67d8e3f2273c129a37ae6282b40c"
+ sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3"
url: "https://pub.dev"
source: hosted
- version: "3.4.2"
+ version: "3.4.3"
flutter_rust_bridge:
dependency: transitive
description:
@@ -906,10 +898,10 @@ packages:
dependency: "direct main"
description:
name: material_ui
- sha256: "4f3f38b9953df0a87d6bf5f21880029f77c47048487d5339410c39936be4683b"
+ sha256: "134ab2f0843545c4e661e4ba2753fe1bcf0753cb690d5a9ee4870638a40411e9"
url: "https://pub.dev"
source: hosted
- version: "1.0.1"
+ version: "1.4.0"
meta:
dependency: transitive
description:
@@ -1185,34 +1177,34 @@ packages:
dependency: "direct main"
description:
name: riverpod
- sha256: "84351b3472a447f7b89f961f95c73287009d58c59d8cc1267425d995f78e50f1"
+ sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a"
url: "https://pub.dev"
source: hosted
- version: "3.4.2"
+ version: "3.4.3"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
- sha256: "46a8dd0461080fde8f886f65f87580758dae8495e352f9ea6a09953abc3df7bd"
+ sha256: bb13cb3985815840cc103eda1027158358c5fdc00f308fc6f76574d0594107af
url: "https://pub.dev"
source: hosted
- version: "1.0.0-dev.11"
+ version: "1.0.0-dev.12"
riverpod_annotation:
dependency: "direct main"
description:
name: riverpod_annotation
- sha256: dd043c75eb3cea2654f5dca51234413ac04bd092ccc73e747fd73cae37ac5360
+ sha256: "322e56ad198928ef26ba240f85421bfff2493bd65665c887b1299e6386b6206d"
url: "https://pub.dev"
source: hosted
- version: "4.0.6"
+ version: "4.0.7"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
- sha256: "2e66b5691cf5d50d6a409904b0477e869e0ecbdd5ada1535d263a95d44439e75"
+ sha256: c94dcb8b60ff4805d2bb8cacca969c48237bcb563e2ab0b5afb50204470c7636
url: "https://pub.dev"
source: hosted
- version: "4.0.8"
+ version: "4.0.9"
rust_api:
dependency: "direct main"
description:
@@ -1419,10 +1411,10 @@ packages:
dependency: transitive
description:
name: sqflite
- sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
+ sha256: "54ad23e2a79c846b596b6c56cdd9c867ea58c288258402316b602f78df0cbfe0"
url: "https://pub.dev"
source: hosted
- version: "2.4.3"
+ version: "2.4.4"
sqflite_android:
dependency: transitive
description:
@@ -1832,5 +1824,5 @@ packages:
source: git
version: "2.1.0"
sdks:
- dart: ">=3.13.0 <4.0.0"
- flutter: ">=3.44.0"
+ dart: ">=3.13.2 <4.0.0"
+ flutter: ">=3.47.2"
diff --git a/pubspec.yaml b/pubspec.yaml
index c3ced6e2..a06e1be8 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: fl_clash
description: A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
publish_to: 'none'
-version: 0.8.98+2026091401
+version: 0.8.99+2026092701
environment:
sdk: '>=3.10.0 <4.0.0'
@@ -15,7 +15,7 @@ hooks:
dependencies:
flutter:
sdk: flutter
- material_ui: ^1.0.1
+ material_ui: ^1.4.0
intl: any
path_provider: ^2.1.5
path: ^1.9.0
@@ -59,10 +59,9 @@ dependencies:
device_info_plus: ^13.2.0
connectivity_plus: 7.2.0
screen_retriever: ^0.2.1
- defer_pointer: ^0.0.2
- flutter_riverpod: ^3.4.2
+ flutter_riverpod: ^3.4.3
riverpod_annotation: ^4.0.6
- riverpod: ^3.4.2
+ riverpod: ^3.4.3
material_color_utilities: ^0.13.0
material_new_shapes: ^1.0.0
flutter_svg: ^2.1.0
diff --git a/test/tool/changelog_render_test.dart b/test/tool/changelog_render_test.dart
index f31aa893..d4809bc3 100644
--- a/test/tool/changelog_render_test.dart
+++ b/test/tool/changelog_render_test.dart
@@ -236,21 +236,40 @@ void main() {
);
});
+ ChangelogVersion longVersion() => version(
+ groups: [
+ entryGroup(ChangelogType.feat, [
+ for (var index = 0; index < 200; index++)
+ ('hash$index', null, 'A reasonably long changelog entry & more'),
+ ]),
+ ],
+ );
+
test('truncates past the caption limit and links out', () {
final text = renderTelegram(
- version(
- groups: [
- entryGroup(ChangelogType.feat, [
- for (var index = 0; index < 200; index++)
- ('hash$index', null, 'A reasonably long changelog entry'),
- ]),
- ],
- ),
+ longVersion(),
moreUrl: 'https://example.com',
);
- expect(text.length, lessThan(telegramLimit + 40));
- expect(text, endsWith('https://example.com'));
+ expect(text.length, lessThan(telegramLimit + 200));
+ expect(text, endsWith('…\nhttps://example.com'));
+ });
+
+ test('counts visible text, not markup, against the limit', () {
+ final text = renderTelegram(longVersion());
+ final visible = text
+ .replaceAll(RegExp('<[^>]*>'), '')
+ .replaceAll('&', '&');
+
+ expect(visible.length, greaterThan(telegramLimit - 60));
+ expect(visible.length, lessThanOrEqualTo(telegramLimit + 3));
+ });
+
+ test('ends on an ellipsis alone without a page to link to', () {
+ expect(
+ renderTelegram(longVersion()),
+ endsWith('• A reasonably long changelog entry & more\n\n…'),
+ );
});
});
diff --git a/tool/changelog.dart b/tool/changelog.dart
index ea7a4857..8e04cf46 100644
--- a/tool/changelog.dart
+++ b/tool/changelog.dart
@@ -123,9 +123,13 @@ int _render(String root, ArgResults command) {
final output = switch (rest.first) {
'release' => renderRelease(version),
+ // Only stable builds pass --tag, and only they get a release page; a
+ // prerelease caption already links its workflow run.
'telegram' => renderTelegram(
version,
- moreUrl: 'https://github.com/$_repository/releases',
+ moreUrl: tag == null
+ ? null
+ : 'https://github.com/$_repository/releases/tag/$tag',
),
_ => _fail('Unknown render target: ${rest.first}'),
};
diff --git a/tool/src/changelog/render.dart b/tool/src/changelog/render.dart
index 0858e672..5d553ca2 100644
--- a/tool/src/changelog/render.dart
+++ b/tool/src/changelog/render.dart
@@ -98,7 +98,7 @@ String escapeTelegramHtml(String value) => value
/// Telegram captions are capped at 1024 characters, so the text is truncated
/// with a pointer to the full notes instead of failing the upload.
-String renderTelegram(ChangelogVersion version, {required String moreUrl}) {
+String renderTelegram(ChangelogVersion version, {String? moreUrl}) {
final buffer = StringBuffer();
if (version.isEmpty) {
buffer.writeln('• ${escapeTelegramHtml(emptyVersionNote)}');
@@ -111,21 +111,27 @@ String renderTelegram(ChangelogVersion version, {required String moreUrl}) {
buffer.writeln();
}
final text = buffer.toString().trimRight();
- if (text.length <= telegramLimit) {
+ if (_telegramVisibleLength(text) <= telegramLimit) {
return text;
}
- final cut = text.substring(0, telegramLimit);
- final lastBreak = cut.lastIndexOf('\n');
- // Cut on a line break so the cut never lands inside a tag; the trailing
- // pattern covers the one case that has no break to cut on, where the cut can
- // still land halfway through an entity.
- final kept = (lastBreak < 0 ? cut : cut.substring(0, lastBreak)).replaceAll(
- RegExp(r'&[A-Za-z]*$|<[^>]*$'),
- '',
- );
- return '$kept\n\n…\n$moreUrl';
+ final kept = StringBuffer();
+ var length = 0;
+ for (final line in text.split('\n')) {
+ length += _telegramVisibleLength(line) + 1;
+ if (length > telegramLimit) {
+ break;
+ }
+ kept.writeln(line);
+ }
+ final more = moreUrl == null ? '' : '\n$moreUrl';
+ return '${kept.toString().trimRight()}\n\n…$more';
}
+int _telegramVisibleLength(String markup) => markup
+ .replaceAll(RegExp('<[^>]*>'), '')
+ .replaceAll(RegExp('&[a-z]+;'), '&')
+ .length;
+
/// Version headings stay the only `##` level; groups are bold lines because a
/// release carries a handful of entries and an `###` per group outweighs them.
String _heading(ChangelogVersion version) => version.date.isEmpty