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