From b9764c86fe13fd0750fe5f02045fbd1d8ee6939e Mon Sep 17 00:00:00 2001 From: Alex Li Date: Fri, 17 Jul 2026 11:57:11 +0800 Subject: [PATCH] fix(macOS): offload compression off the AppKit main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlutterMethodChannel dispatches handle(_:result:) on the platform thread (main/UI on macOS). Decode, CGContext resize+rotate, CGImageDestination encode and disk writes all ran inline on that thread, so compressing anything nontrivial stalled the Flutter engine and froze the UI. Move the switch body onto a userInitiated global queue and marshal every FlutterResult back to main — the Android threadPool + main-Looper Handler and iOS global-queue handlers already worked this way; macOS now matches. Also close two reply-path gaps so every branch of handle(...) resolves `result`: showLog previously never called result(...) (Android returns 1, iOS returns @1), leaving the Dart future pending; and the [weak self]/nil branch on the background queue now replies with an error rather than silently swallowing the call. Ignore SwiftPM's .build/ output under macos/flutter_image_compress_macos/ so it doesn't leak into git status after `swift build` or IDE indexing. --- .../macos/.gitignore | 3 +- .../FlutterImageCompressMacosPlugin.swift | 58 ++++++++++++++----- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/packages/flutter_image_compress_macos/macos/.gitignore b/packages/flutter_image_compress_macos/macos/.gitignore index 37b06bf5..e6a34d66 100644 --- a/packages/flutter_image_compress_macos/macos/.gitignore +++ b/packages/flutter_image_compress_macos/macos/.gitignore @@ -1 +1,2 @@ -Flutter/ \ No newline at end of file +Flutter/ +.build/ diff --git a/packages/flutter_image_compress_macos/macos/flutter_image_compress_macos/Sources/flutter_image_compress_macos/FlutterImageCompressMacosPlugin.swift b/packages/flutter_image_compress_macos/macos/flutter_image_compress_macos/Sources/flutter_image_compress_macos/FlutterImageCompressMacosPlugin.swift index b964d7a1..43ac5a13 100644 --- a/packages/flutter_image_compress_macos/macos/flutter_image_compress_macos/Sources/flutter_image_compress_macos/FlutterImageCompressMacosPlugin.swift +++ b/packages/flutter_image_compress_macos/macos/flutter_image_compress_macos/Sources/flutter_image_compress_macos/FlutterImageCompressMacosPlugin.swift @@ -138,21 +138,51 @@ public class FlutterImageCompressMacosPlugin: NSObject, FlutterPlugin { let method = call.method let args = call.arguments - switch method { - case "showLog": + // showLog is a fast setter — reply on the calling thread and skip the + // background hop. Every branch of handle(...) must resolve `result`, + // otherwise the awaiting Dart future hangs forever. + if method == "showLog" { Logger.showLog(show: args as! Bool) - case "compressAndGetFile": - let dstPath = (args as! Dictionary)["targetPath"] as! String - handleResult(args, result)?.compressToPath(result, dstPath) - break - case "compressWithFile": - handleResult(args, result)?.compressToBytes(result) - break - case "compressWithList": - handleResult(args, result)?.compressToBytes(result) - break - default: - result(FlutterMethodNotImplemented) + result(1) + return + } + + // Compression (decode / CGContext resize+rotate / CGImageDestination + // encode / disk write) is CPU- and I/O-heavy. FlutterMethodChannel + // dispatches on the AppKit main thread, so running the work inline + // stalls Flutter's UI. Offload to a background queue and marshal the + // FlutterResult back to the platform thread — same pattern as the + // Android threadPool + main-Looper Handler and iOS global-queue + // handlers. + let mainResult: FlutterResult = { value in + if Thread.isMainThread { + result(value) + } else { + DispatchQueue.main.async { result(value) } + } + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { + // Plugin was detached before we got scheduled. Reply anyway so the + // Dart future doesn't hang; the engine tolerates result(...) on a + // torn-down channel. + mainResult(FlutterError(code: "plugin_detached", + message: "Plugin was detached before compression could start", + details: nil)) + return + } + switch method { + case "compressAndGetFile": + let dstPath = (args as! Dictionary)["targetPath"] as! String + self.handleResult(args, mainResult)?.compressToPath(mainResult, dstPath) + case "compressWithFile": + self.handleResult(args, mainResult)?.compressToBytes(mainResult) + case "compressWithList": + self.handleResult(args, mainResult)?.compressToBytes(mainResult) + default: + mainResult(FlutterMethodNotImplemented) + } } } }