From 9242e3a681a81502fa9ea83a24014b0ba7ef61f5 Mon Sep 17 00:00:00 2001 From: lucasdonordeste Date: Fri, 8 May 2026 23:41:14 -0300 Subject: [PATCH 1/4] fix: enable Google ML Kit on Apple Silicon iOS 26+ simulators Apple removed Rosetta 2 from the default iOS 26 simulator runtime, which breaks `flutter run` for any project depending on Google ML Kit on Apple Silicon Macs. The published GoogleMLKit/* CocoaPods only ship arm64-iphoneos and x86_64-iphonesimulator slices and pin EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64, so the simulator build no longer finds a matching destination and dies with "Unable to find a destination matching the provided destination specifier". Until Google publishes proper arm64-iphonesimulator slices (https://issuetracker.google.com/issues/178965151), this change ships an opt-in Podfile helper under google_mlkit_commons that: 1. Re-labels the existing arm64 device slice of every ML Kit framework binary as iOS Simulator. Only the 4-byte LC_BUILD_VERSION.platform field is changed (2 -> 7), the same approach the well-known arm64-to-sim tool uses on closed-source SDKs. Idempotent. 2. Strips EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64 from the xcconfigs CocoaPods generates from the pod's user_target_xcconfig, so the user's app target is allowed to build for arm64-iphonesimulator. The example/ios Podfile is wired to call the helper from post_install, which lets the example app build, install and run on an Apple Silicon iOS 26.3 simulator. End-to-end validated: Text Recognition on the Text-From-Widget view returned the exact widget text. Device builds and release builds are not affected. Closes #825 --- packages/example/ios/Podfile | 9 + packages/example/ios/Podfile.lock | 2 +- packages/google_mlkit_commons/README.md | 27 +++ .../ios/scripts/apple_silicon_simulator.rb | 66 ++++++ .../ios/scripts/patch_arm64_simulator.py | 208 ++++++++++++++++++ 5 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb create mode 100644 packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py diff --git a/packages/example/ios/Podfile b/packages/example/ios/Podfile index 9f0d4515..cfb1a912 100644 --- a/packages/example/ios/Podfile +++ b/packages/example/ios/Podfile @@ -24,6 +24,12 @@ end require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) +# Apple Silicon iOS Simulator support for Google ML Kit pods. +# See packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb +# and the iOS section of packages/google_mlkit_commons/README.md +# for the rationale and the upstream Google issue. +require File.expand_path('../../google_mlkit_commons/ios/scripts/apple_silicon_simulator', __dir__) + flutter_ios_podfile_setup target 'Runner' do @@ -56,4 +62,7 @@ post_install do |installer| end end end + + # Lets the example build on Apple Silicon iOS 26+ simulators. + mlkit_apple_silicon_simulator_patch(installer) end diff --git a/packages/example/ios/Podfile.lock b/packages/example/ios/Podfile.lock index 3d814d2b..de28065a 100644 --- a/packages/example/ios/Podfile.lock +++ b/packages/example/ios/Podfile.lock @@ -483,6 +483,6 @@ SPEC CHECKSUMS: PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea -PODFILE CHECKSUM: 810ea711de6d4d578877638350f293e7020676b9 +PODFILE CHECKSUM: e40fd05d6a8f17b70c24ecea80ce69cb70722338 COCOAPODS: 1.16.2 diff --git a/packages/google_mlkit_commons/README.md b/packages/google_mlkit_commons/README.md index 46e77f5a..94bf6aeb 100644 --- a/packages/google_mlkit_commons/README.md +++ b/packages/google_mlkit_commons/README.md @@ -77,6 +77,33 @@ end Notice that the minimum `IPHONEOS_DEPLOYMENT_TARGET` is 15.5, you can set it to something newer but not older. +#### Apple Silicon iOS Simulator (iOS 26+) + +Google's `GoogleMLKit/*` pods only ship `arm64-iphoneos` and `x86_64-iphonesimulator` slices and exclude `arm64` from simulator builds. On Apple Silicon Macs running iOS 26+ simulators (where Rosetta 2 is no longer the default for the iOS Simulator) this makes `flutter run` fail with `Unable to find a destination matching the provided destination specifier`. Issue tracked upstream by Google: https://issuetracker.google.com/issues/178965151. + +Until proper `arm64-iphonesimulator` slices are published, this plugin ships an **opt-in** Podfile helper that re-labels the existing arm64 device slice as iOS Simulator (the same `LC_BUILD_VERSION.platform` swap used by the well-known [`arm64-to-sim`](https://github.com/bogo/arm64-to-sim) tool) and strips the `EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64` from the generated xcconfigs. + +To enable it, add two lines to your iOS `Podfile`: + +```ruby +# Near the top, after `require ... podhelper ...`: +require File.expand_path( + '.symlinks/plugins/google_mlkit_commons/ios/scripts/apple_silicon_simulator', + __dir__, +) + +post_install do |installer| + # ...your existing post_install code... + + # Add this line at the end: + mlkit_apple_silicon_simulator_patch(installer) +end +``` + +Then re-run `pod install`. The example app under `packages/example` is wired up this way. + +The helper only changes vendored binaries inside `Pods/` and the `EXCLUDED_ARCHS` line in pod-generated xcconfigs. Device builds and release builds are unaffected. Remove the two lines to revert. + ### Android - minSdkVersion: 21 diff --git a/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb b/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb new file mode 100644 index 00000000..53f07dd9 --- /dev/null +++ b/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb @@ -0,0 +1,66 @@ +# Helper to make Google ML Kit pods build for Apple Silicon iOS Simulators. +# +# Why this exists +# --------------- +# The frameworks Google publishes under the `GoogleMLKit/*` CocoaPods only ship +# `arm64-iphoneos` and `x86_64-iphonesimulator` slices. Their podspecs set +# `EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64`, which on Apple Silicon Macs +# running iOS 26+ simulators (where Rosetta is not available by default) +# breaks `flutter run` with: +# +# Unable to find a destination matching the provided destination specifier +# +# Until Google publishes proper `arm64-iphonesimulator` slices (tracked in +# https://issuetracker.google.com/issues/178965151), this helper applies a +# well-known workaround at `pod install` time: +# +# 1. Re-labels the `arm64` device slice of every ML Kit framework binary +# as iOS Simulator (only the 4-byte `LC_BUILD_VERSION.platform` field is +# modified — same approach the `arm64-to-sim` tool uses). +# 2. Strips `EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64` from the +# generated xcconfig files so the user's app target is allowed to build +# for `arm64-iphonesimulator`. +# +# This is opt-in. Add a single line inside your existing `post_install` block: +# +# require File.expand_path( +# '.symlinks/plugins/google_mlkit_commons/ios/scripts/apple_silicon_simulator', +# __dir__, +# ) +# post_install do |installer| +# # ...your existing post_install code... +# mlkit_apple_silicon_simulator_patch(installer) +# end +# +# Notes +# ----- +# * Idempotent: running `pod install` multiple times is safe (the patcher +# skips slices that already report platform=iOS Simulator). +# * Affects only the simulator build. Device builds are untouched. +# * Modifies vendored binaries inside `Pods/` only; nothing in your app or in +# pub.dev caches is altered. + +def mlkit_apple_silicon_simulator_patch(installer) + pods_dir = File.expand_path(installer.sandbox.root.to_s) + patcher = File.expand_path('patch_arm64_simulator.py', __dir__) + + framework_dirs = Dir.glob(File.join(pods_dir, '{MLKit*,MLImage*}')) + .select { |d| File.directory?(d) } + unless framework_dirs.empty? + Pod::UI.puts '' + Pod::UI.puts "[ml_kit] Patching #{framework_dirs.size} ML Kit " \ + 'framework(s) for Apple Silicon iOS Simulator...' + unless system('python3', patcher, *framework_dirs) + Pod::UI.warn '[ml_kit] arm64 simulator patcher failed; ' \ + 'simulator build may still require Rosetta.' + end + end + + excluded = 'EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64' + Dir.glob(File.join(pods_dir, 'Target Support Files', '**', '*.xcconfig')) + .each do |xcconfig| + text = File.read(xcconfig) + new_text = text.lines.reject { |l| l.strip == excluded }.join + File.write(xcconfig, new_text) if text != new_text + end +end diff --git a/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py b/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py new file mode 100644 index 00000000..d543ac05 --- /dev/null +++ b/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Re-label the arm64 device slice of Google ML Kit static frameworks as +iOS Simulator, so they link on Apple Silicon iOS 26+ simulators without +requiring Rosetta 2. + +Background +---------- +The frameworks Google ships under the GoogleMLKit/* CocoaPods only contain +two slices: ``arm64`` (built for iOS device, platform=2) and ``x86_64`` +(iOS Simulator, platform=7). Their podspecs therefore set +``EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64`` so Xcode does not try to +link the device slice into a simulator build. + +On Apple Silicon Macs running iOS 26+ simulators, Apple no longer +auto-translates ``x86_64`` simulator binaries through Rosetta 2 by default, +which makes ``flutter run`` fail with:: + + Unable to find a destination matching the provided destination specifier + +Until Google publishes proper ``arm64-iphonesimulator`` slices (tracked in +https://issuetracker.google.com/issues/178965151), this script applies the +same in-place modification that the well-known ``arm64-to-sim`` tool uses: +it walks every ``.o`` member of the arm64 static archive and changes the +``LC_BUILD_VERSION.platform`` field from ``2`` (iOS) to ``7`` +(iOS Simulator). No instructions, symbols or metadata other than the +single 4-byte platform field are touched. + +Usage +----- +:: + + python3 patch_arm64_simulator.py [ ...] + +Each path must be the directory that contains +``Frameworks/.framework/``. + +Idempotent: running it twice is a no-op (after the first pass nothing in +the arm64 slice still claims platform=iOS). +""" + +import os +import struct +import subprocess +import sys +import tempfile + +FAT_MAGIC = 0xCAFEBABE +FAT_MAGIC_64 = 0xCAFEBABF +MH_MAGIC_64 = 0xFEEDFACF +LC_BUILD_VERSION = 0x32 +PLATFORM_IOS = 2 +PLATFORM_IOS_SIMULATOR = 7 +CPU_TYPE_ARM64 = 0x0100000c + + +def _patch_macho_object(buf): + """Patch LC_BUILD_VERSION.platform in a single Mach-O 64 object/dylib. + Returns ``(new_buf, was_patched)``.""" + if len(buf) < 32: + return buf, False + magic = struct.unpack_from('\n': + return 0 + out = bytearray(data[:8]) + pos = 8 + n_patched = 0 + while pos + 60 <= len(data): + header = data[pos:pos + 60] + name = header[:16].rstrip().decode('ascii', errors='replace') + try: + size = int(header[48:58].rstrip().decode('ascii', errors='replace')) + except ValueError: + break + body_start = pos + 60 + body_end = body_start + size + body = data[body_start:body_end] + if name.startswith('#1/'): # BSD long-name extension + try: + name_len = int(name[3:]) + except ValueError: + name_len = 0 + obj_buf = data[body_start + name_len:body_end] + new_obj, patched = _patch_macho_object(obj_buf) + new_body = body[:name_len] + new_obj + else: + new_obj, patched = _patch_macho_object(body) + new_body = new_obj + if patched: + n_patched += 1 + out += header + new_body + pos = body_end + (body_end & 1) # 2-byte alignment + if n_patched > 0: + with open(archive_path, 'wb') as f: + f.write(bytes(out)) + return n_patched + + +def _patch_thin(path): + """Patch a non-fat file: either a Mach-O 64 binary or an ``ar`` archive.""" + with open(path, 'rb') as f: + head = f.read(8) + if head[:8] == b'!\n': + return _patch_static_archive(path) + if len(head) >= 4 and struct.unpack('I', head[:4])[0] + if magic in (FAT_MAGIC, FAT_MAGIC_64): + archs = subprocess.run( + ['lipo', '-archs', fat_path], + capture_output=True, text=True, check=True, + ).stdout.strip().split() + if 'arm64' not in archs: + return 0 + with tempfile.TemporaryDirectory() as td: + arm64_thin = os.path.join(td, 'arm64.bin') + subprocess.run( + ['lipo', fat_path, '-thin', 'arm64', '-output', arm64_thin], + check=True, + ) + n = _patch_thin(arm64_thin) + if n == 0: + return 0 + subprocess.run( + ['lipo', fat_path, '-replace', 'arm64', arm64_thin, + '-output', fat_path], + check=True, + ) + return n + return _patch_thin(fat_path) + + +def _find_framework_binary(pod_dir): + """For ``Pods//``, return ``.framework/``.""" + fw_dir = os.path.join(pod_dir, 'Frameworks') + if not os.path.isdir(fw_dir): + return None + for name in os.listdir(fw_dir): + if name.endswith('.framework'): + base = name[:-len('.framework')] + binary = os.path.join(fw_dir, name, base) + if os.path.isfile(binary): + return binary + return None + + +def main(args): + if not args: + print(__doc__, file=sys.stderr) + return 1 + total = 0 + for path in args: + binary = _find_framework_binary(path) + if not binary: + continue + n = _patch_fat_binary(binary) + if n > 0: + print(f' patched {os.path.basename(binary)}: ' + f'{n} object(s) relabeled to iOS Simulator') + total += n + if total > 0: + print(f'[ml_kit] Total Mach-O objects relabeled: {total}') + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) From 13952f2e60fa097ed266040b1d2aa0729a544f9c Mon Sep 17 00:00:00 2001 From: lucasdonordeste Date: Fri, 8 May 2026 23:48:56 -0300 Subject: [PATCH 2/4] chore: drop redundant comments in arm64 simulator helper The Ruby and Python entry points already have a brief header pointing at the README. Per-function docstrings were restating what the function name already said, and the Podfile inline comments duplicated the helper's self-documenting name. --- packages/example/ios/Podfile | 6 --- packages/example/ios/Podfile.lock | 2 +- .../ios/scripts/apple_silicon_simulator.rb | 45 ++-------------- .../ios/scripts/patch_arm64_simulator.py | 52 +++---------------- 4 files changed, 12 insertions(+), 93 deletions(-) diff --git a/packages/example/ios/Podfile b/packages/example/ios/Podfile index cfb1a912..2f65a326 100644 --- a/packages/example/ios/Podfile +++ b/packages/example/ios/Podfile @@ -23,11 +23,6 @@ def flutter_root end require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -# Apple Silicon iOS Simulator support for Google ML Kit pods. -# See packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb -# and the iOS section of packages/google_mlkit_commons/README.md -# for the rationale and the upstream Google issue. require File.expand_path('../../google_mlkit_commons/ios/scripts/apple_silicon_simulator', __dir__) flutter_ios_podfile_setup @@ -63,6 +58,5 @@ post_install do |installer| end end - # Lets the example build on Apple Silicon iOS 26+ simulators. mlkit_apple_silicon_simulator_patch(installer) end diff --git a/packages/example/ios/Podfile.lock b/packages/example/ios/Podfile.lock index de28065a..c90136b2 100644 --- a/packages/example/ios/Podfile.lock +++ b/packages/example/ios/Podfile.lock @@ -483,6 +483,6 @@ SPEC CHECKSUMS: PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea -PODFILE CHECKSUM: e40fd05d6a8f17b70c24ecea80ce69cb70722338 +PODFILE CHECKSUM: df61d3916884bb4fa8c7ec2fdffdf0dd0d3cec36 COCOAPODS: 1.16.2 diff --git a/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb b/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb index 53f07dd9..c483d5ca 100644 --- a/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb +++ b/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb @@ -1,44 +1,7 @@ -# Helper to make Google ML Kit pods build for Apple Silicon iOS Simulators. -# -# Why this exists -# --------------- -# The frameworks Google publishes under the `GoogleMLKit/*` CocoaPods only ship -# `arm64-iphoneos` and `x86_64-iphonesimulator` slices. Their podspecs set -# `EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64`, which on Apple Silicon Macs -# running iOS 26+ simulators (where Rosetta is not available by default) -# breaks `flutter run` with: -# -# Unable to find a destination matching the provided destination specifier -# -# Until Google publishes proper `arm64-iphonesimulator` slices (tracked in -# https://issuetracker.google.com/issues/178965151), this helper applies a -# well-known workaround at `pod install` time: -# -# 1. Re-labels the `arm64` device slice of every ML Kit framework binary -# as iOS Simulator (only the 4-byte `LC_BUILD_VERSION.platform` field is -# modified — same approach the `arm64-to-sim` tool uses). -# 2. Strips `EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64` from the -# generated xcconfig files so the user's app target is allowed to build -# for `arm64-iphonesimulator`. -# -# This is opt-in. Add a single line inside your existing `post_install` block: -# -# require File.expand_path( -# '.symlinks/plugins/google_mlkit_commons/ios/scripts/apple_silicon_simulator', -# __dir__, -# ) -# post_install do |installer| -# # ...your existing post_install code... -# mlkit_apple_silicon_simulator_patch(installer) -# end -# -# Notes -# ----- -# * Idempotent: running `pod install` multiple times is safe (the patcher -# skips slices that already report platform=iOS Simulator). -# * Affects only the simulator build. Device builds are untouched. -# * Modifies vendored binaries inside `Pods/` only; nothing in your app or in -# pub.dev caches is altered. +# Opt-in Podfile helper that lets Google ML Kit pods build for Apple Silicon +# iOS 26+ simulators. See packages/google_mlkit_commons/README.md (iOS +# section) for rationale and usage. Upstream Google bug: +# https://issuetracker.google.com/issues/178965151 def mlkit_apple_silicon_simulator_patch(installer) pods_dir = File.expand_path(installer.sandbox.root.to_s) diff --git a/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py b/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py index d543ac05..f7507eda 100644 --- a/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py +++ b/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py @@ -1,42 +1,11 @@ #!/usr/bin/env python3 -""" -Re-label the arm64 device slice of Google ML Kit static frameworks as -iOS Simulator, so they link on Apple Silicon iOS 26+ simulators without -requiring Rosetta 2. - -Background ----------- -The frameworks Google ships under the GoogleMLKit/* CocoaPods only contain -two slices: ``arm64`` (built for iOS device, platform=2) and ``x86_64`` -(iOS Simulator, platform=7). Their podspecs therefore set -``EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64`` so Xcode does not try to -link the device slice into a simulator build. - -On Apple Silicon Macs running iOS 26+ simulators, Apple no longer -auto-translates ``x86_64`` simulator binaries through Rosetta 2 by default, -which makes ``flutter run`` fail with:: - - Unable to find a destination matching the provided destination specifier - -Until Google publishes proper ``arm64-iphonesimulator`` slices (tracked in -https://issuetracker.google.com/issues/178965151), this script applies the -same in-place modification that the well-known ``arm64-to-sim`` tool uses: -it walks every ``.o`` member of the arm64 static archive and changes the -``LC_BUILD_VERSION.platform`` field from ``2`` (iOS) to ``7`` -(iOS Simulator). No instructions, symbols or metadata other than the -single 4-byte platform field are touched. - -Usage ------ -:: - - python3 patch_arm64_simulator.py [ ...] - -Each path must be the directory that contains -``Frameworks/.framework/``. - -Idempotent: running it twice is a no-op (after the first pass nothing in -the arm64 slice still claims platform=iOS). +"""Re-label the arm64 device slice of Google ML Kit static frameworks as +iOS Simulator. Walks every .o member of the arm64 archive and flips +LC_BUILD_VERSION.platform from 2 (iOS) to 7 (iOS Simulator); no +instructions or symbols are touched. Same approach as arm64-to-sim. +Idempotent. See packages/google_mlkit_commons/README.md (iOS section). + +Usage: python3 patch_arm64_simulator.py [ ...] """ import os @@ -55,8 +24,6 @@ def _patch_macho_object(buf): - """Patch LC_BUILD_VERSION.platform in a single Mach-O 64 object/dylib. - Returns ``(new_buf, was_patched)``.""" if len(buf) < 32: return buf, False magic = struct.unpack_from('\n': @@ -123,7 +88,6 @@ def _patch_static_archive(archive_path): def _patch_thin(path): - """Patch a non-fat file: either a Mach-O 64 binary or an ``ar`` archive.""" with open(path, 'rb') as f: head = f.read(8) if head[:8] == b'!\n': @@ -140,7 +104,6 @@ def _patch_thin(path): def _patch_fat_binary(fat_path): - """Detect file type and patch the arm64 slice. Returns total patched count.""" with open(fat_path, 'rb') as f: head = f.read(4) if len(head) < 4: @@ -172,7 +135,6 @@ def _patch_fat_binary(fat_path): def _find_framework_binary(pod_dir): - """For ``Pods//``, return ``.framework/``.""" fw_dir = os.path.join(pod_dir, 'Frameworks') if not os.path.isdir(fw_dir): return None From 6ce17e2f52d7da1b0298dfa8a16c6c4df86ae4e7 Mon Sep 17 00:00:00 2001 From: lucasdonordeste Date: Thu, 4 Jun 2026 13:22:03 -0300 Subject: [PATCH 3/4] fix: preserve ar 2-byte alignment padding when rewriting archives Odd-sized archive members are followed by a 1-byte padding so the next header starts on a 2-byte boundary. The rewrite loop skipped this byte on read but never re-emitted it on write, shifting every subsequent header and corrupting the framework binary whenever a member had an odd size. Applies the maintainer/Copilot-suggested fix verbatim. --- .../google_mlkit_commons/ios/scripts/patch_arm64_simulator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py b/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py index f7507eda..7b217c82 100644 --- a/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py +++ b/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py @@ -80,7 +80,9 @@ def _patch_static_archive(archive_path): if patched: n_patched += 1 out += header + new_body - pos = body_end + (body_end & 1) # 2-byte alignment + if size & 1: + out += data[body_end:body_end + 1] + pos = body_end + (size & 1) # 2-byte alignment if n_patched > 0: with open(archive_path, 'wb') as f: f.write(bytes(out)) From 1d2e230371dd9544128f4144de2340ec1fdfb69a Mon Sep 17 00:00:00 2001 From: lucasdonordeste Date: Thu, 4 Jun 2026 14:24:47 -0300 Subject: [PATCH 4/4] feat: auto-toggle ML Kit arm64 slice per build for device and simulator Replace the one-shot pod-install relabel (which broke device builds until manually reverted) with a per-build Xcode script phase that relabels the arm64 slice's LC_BUILD_VERSION.platform to match the build target: iOS Simulator for simulator builds, iOS for device builds. The same pod install now works for both with no manual revert. - patch_arm64_simulator.py: edit the platform field in place via mmap (no lipo subprocess, no temp files, no marker); bidirectional 2<->7; bounds-checked Mach-O walk; fail loud when no framework binaries are found; tagged errors. - apple_silicon_simulator.rb: copy the patcher into Pods, strip the simulator arm64 exclusion via a robust key-based match, and install an always-out-of-date 'set -euo pipefail' build phase on each Pods aggregate target. - README: document the automatic per-build behavior and the full-revert steps. --- packages/google_mlkit_commons/README.md | 4 +- .../ios/scripts/apple_silicon_simulator.rb | 85 ++++-- .../ios/scripts/patch_arm64_simulator.py | 261 +++++++++--------- 3 files changed, 207 insertions(+), 143 deletions(-) diff --git a/packages/google_mlkit_commons/README.md b/packages/google_mlkit_commons/README.md index 94bf6aeb..41eb50be 100644 --- a/packages/google_mlkit_commons/README.md +++ b/packages/google_mlkit_commons/README.md @@ -81,7 +81,7 @@ Notice that the minimum `IPHONEOS_DEPLOYMENT_TARGET` is 15.5, you can set it to Google's `GoogleMLKit/*` pods only ship `arm64-iphoneos` and `x86_64-iphonesimulator` slices and exclude `arm64` from simulator builds. On Apple Silicon Macs running iOS 26+ simulators (where Rosetta 2 is no longer the default for the iOS Simulator) this makes `flutter run` fail with `Unable to find a destination matching the provided destination specifier`. Issue tracked upstream by Google: https://issuetracker.google.com/issues/178965151. -Until proper `arm64-iphonesimulator` slices are published, this plugin ships an **opt-in** Podfile helper that re-labels the existing arm64 device slice as iOS Simulator (the same `LC_BUILD_VERSION.platform` swap used by the well-known [`arm64-to-sim`](https://github.com/bogo/arm64-to-sim) tool) and strips the `EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64` from the generated xcconfigs. +Until proper `arm64-iphonesimulator` slices are published, this plugin ships an **opt-in** Podfile helper that, on every build, relabels the arm64 slice's `LC_BUILD_VERSION.platform` to match the target you are building for — iOS Simulator for simulator builds, iOS for device builds (the same swap used by the well-known [`arm64-to-sim`](https://github.com/bogo/arm64-to-sim) tool). It also strips `EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64` from the generated xcconfigs. To enable it, add two lines to your iOS `Podfile`: @@ -102,7 +102,7 @@ end Then re-run `pod install`. The example app under `packages/example` is wired up this way. -The helper only changes vendored binaries inside `Pods/` and the `EXCLUDED_ARCHS` line in pod-generated xcconfigs. Device builds and release builds are unaffected. Remove the two lines to revert. +Because the relabel runs per build and matches the target automatically, the same `pod install` works for **both** the simulator and physical devices — no manual revert is needed when you switch between them. The helper only touches the arm64 slice of the vendored ML Kit binaries inside `Pods/` and the `EXCLUDED_ARCHS` line in pod-generated xcconfigs. To fully revert, remove the two lines and reinstall the pods with `pod deintegrate && pod install` (a plain `pod install` re-adds the `EXCLUDED_ARCHS` line and removes the build phase, but does not restore the original platform label baked into the cached binaries by the last build). ### Android diff --git a/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb b/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb index c483d5ca..d4e1f4c1 100644 --- a/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb +++ b/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb @@ -1,29 +1,80 @@ -# Opt-in Podfile helper that lets Google ML Kit pods build for Apple Silicon -# iOS 26+ simulators. See packages/google_mlkit_commons/README.md (iOS -# section) for rationale and usage. Upstream Google bug: +# Opt-in Podfile helper that lets Google ML Kit pods build and run on Apple +# Silicon iOS 26+ simulators *and* physical devices from the same Pods install. +# A per-build script phase relabels the arm64 slice to match the target, so no +# manual revert is ever needed. See packages/google_mlkit_commons/README.md +# (iOS section). Upstream Google bug: # https://issuetracker.google.com/issues/178965151 +require 'fileutils' + +MLKIT_STATE_DIR = 'MLKitAppleSiliconSimulator'.freeze +MLKIT_PATCHER = 'patch_arm64_simulator.py'.freeze +MLKIT_PHASE_NAME = '[ML Kit] Relabel arm64 slice for current platform'.freeze +MLKIT_EXCLUDED_RE = /^(\s*EXCLUDED_ARCHS\[sdk=iphonesimulator\*\]\s*=\s*)(.*?)\s*$/ + def mlkit_apple_silicon_simulator_patch(installer) pods_dir = File.expand_path(installer.sandbox.root.to_s) - patcher = File.expand_path('patch_arm64_simulator.py', __dir__) framework_dirs = Dir.glob(File.join(pods_dir, '{MLKit*,MLImage*}')) .select { |d| File.directory?(d) } - unless framework_dirs.empty? - Pod::UI.puts '' - Pod::UI.puts "[ml_kit] Patching #{framework_dirs.size} ML Kit " \ - 'framework(s) for Apple Silicon iOS Simulator...' - unless system('python3', patcher, *framework_dirs) - Pod::UI.warn '[ml_kit] arm64 simulator patcher failed; ' \ - 'simulator build may still require Rosetta.' - end - end + return if framework_dirs.empty? + + _mlkit_copy_patcher(pods_dir) + _mlkit_strip_simulator_arm64_exclusion(pods_dir) + _mlkit_install_build_phase(installer) + installer.pods_project.save + + Pod::UI.puts '' + Pod::UI.puts "[ml_kit] Apple Silicon simulator support enabled for " \ + "#{framework_dirs.size} framework(s) (auto-toggles per build)." +rescue => e + raise "[ml_kit] failed to enable Apple Silicon simulator support: #{e.message}" +end + +def _mlkit_copy_patcher(pods_dir) + state_dir = File.join(pods_dir, MLKIT_STATE_DIR) + FileUtils.rm_rf(state_dir) + FileUtils.mkdir_p(state_dir) + FileUtils.cp(File.expand_path(MLKIT_PATCHER, __dir__), state_dir) +end - excluded = 'EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64' +def _mlkit_strip_simulator_arm64_exclusion(pods_dir) Dir.glob(File.join(pods_dir, 'Target Support Files', '**', '*.xcconfig')) .each do |xcconfig| - text = File.read(xcconfig) - new_text = text.lines.reject { |l| l.strip == excluded }.join - File.write(xcconfig, new_text) if text != new_text + changed = false + new_text = File.read(xcconfig).each_line.map do |line| + match = line.match(MLKIT_EXCLUDED_RE) + next line unless match + + tokens = match[2].split(/\s+/).reject(&:empty?) + next line unless tokens.include?('arm64') + + changed = true + kept = tokens.reject { |t| t == 'arm64' } + kept.empty? ? '' : "#{match[1]}#{kept.join(' ')}\n" + end.join + File.write(xcconfig, new_text) if changed + end +end + +def _mlkit_install_build_phase(installer) + script = <<~SH + set -euo pipefail + : "${PLATFORM_NAME:?PLATFORM_NAME is not set}" + : "${SRCROOT:?SRCROOT is not set}" + /usr/bin/env python3 "${SRCROOT}/#{MLKIT_STATE_DIR}/#{MLKIT_PATCHER}" \\ + --platform "${PLATFORM_NAME}" \\ + --pods-root "${SRCROOT}" + SH + + installer.aggregate_targets.each do |aggregate| + target = installer.pods_project.targets.find { |t| t.name == aggregate.label } + next unless target + + phase = target.shell_script_build_phases.find { |p| p.name == MLKIT_PHASE_NAME } + phase ||= target.new_shell_script_build_phase(MLKIT_PHASE_NAME) + phase.shell_path = '/bin/sh' + phase.shell_script = script + phase.always_out_of_date = '1' if phase.respond_to?(:always_out_of_date=) end end diff --git a/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py b/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py index 7b217c82..c042e1ff 100644 --- a/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py +++ b/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py @@ -1,18 +1,21 @@ #!/usr/bin/env python3 -"""Re-label the arm64 device slice of Google ML Kit static frameworks as -iOS Simulator. Walks every .o member of the arm64 archive and flips -LC_BUILD_VERSION.platform from 2 (iOS) to 7 (iOS Simulator); no -instructions or symbols are touched. Same approach as arm64-to-sim. -Idempotent. See packages/google_mlkit_commons/README.md (iOS section). - -Usage: python3 patch_arm64_simulator.py [ ...] +"""Relabel the arm64 slice of Google ML Kit static frameworks to match the +build's target platform. Flips each arm64 Mach-O object's +LC_BUILD_VERSION.platform between 2 (iOS) and 7 (iOS Simulator) in place, so the +single arm64 slice Google ships works for whichever target is being built; no +instructions, symbols, sizes or offsets change. Idempotent and reversible. See +packages/google_mlkit_commons/README.md (iOS section). + +Run from an Xcode build phase: + patch_arm64_simulator.py --platform "$PLATFORM_NAME" --pods-root "$SRCROOT" """ +import argparse +import glob +import mmap import os import struct -import subprocess import sys -import tempfile FAT_MAGIC = 0xCAFEBABE FAT_MAGIC_64 = 0xCAFEBABF @@ -22,118 +25,110 @@ PLATFORM_IOS_SIMULATOR = 7 CPU_TYPE_ARM64 = 0x0100000c - -def _patch_macho_object(buf): - if len(buf) < 32: - return buf, False - magic = struct.unpack_from(' len(buf): + return False + if struct.unpack_from(' end: + break + cmd, cmdsize = struct.unpack_from(' end: + break if cmd == LC_BUILD_VERSION: - platform = struct.unpack_from('\n': - return 0 - out = bytearray(data[:8]) - pos = 8 - n_patched = 0 - while pos + 60 <= len(data): - header = data[pos:pos + 60] - name = header[:16].rstrip().decode('ascii', errors='replace') + return False + + +def _relabel_archive_region(buf, start, size, target_platform): + """Relabel every arm64 Mach-O member of the ``ar`` archive (or the single + Mach-O object) occupying ``buf[start:start+size]``. Returns the count.""" + region_end = min(start + size, len(buf)) + if buf[start:start + 8] != b'!\n': + return 1 if _relabel_macho(buf, start, target_platform) else 0 + pos = start + 8 + n = 0 + while pos + 60 <= region_end: try: - size = int(header[48:58].rstrip().decode('ascii', errors='replace')) + member_size = int( + bytes(buf[pos + 48:pos + 58]).decode('ascii', 'replace').strip()) except ValueError: break - body_start = pos + 60 - body_end = body_start + size - body = data[body_start:body_end] - if name.startswith('#1/'): # BSD long-name extension + name = bytes(buf[pos:pos + 16]).rstrip() + body = pos + 60 + obj = body + if name.startswith(b'#1/'): # BSD long-name extension try: - name_len = int(name[3:]) + obj = body + int(name[3:]) except ValueError: - name_len = 0 - obj_buf = data[body_start + name_len:body_end] - new_obj, patched = _patch_macho_object(obj_buf) - new_body = body[:name_len] + new_obj - else: - new_obj, patched = _patch_macho_object(body) - new_body = new_obj - if patched: - n_patched += 1 - out += header + new_body - if size & 1: - out += data[body_end:body_end + 1] - pos = body_end + (size & 1) # 2-byte alignment - if n_patched > 0: - with open(archive_path, 'wb') as f: - f.write(bytes(out)) - return n_patched - - -def _patch_thin(path): - with open(path, 'rb') as f: - head = f.read(8) - if head[:8] == b'!\n': - return _patch_static_archive(path) - if len(head) >= 4 and struct.unpack('I', buf, 0)[0] + if fat_magic in (FAT_MAGIC, FAT_MAGIC_64): + nfat = struct.unpack_from('>I', buf, 4)[0] + is64 = fat_magic == FAT_MAGIC_64 + entry = 8 + n = 0 + for _ in range(nfat): + if is64: + if entry + 32 > len(buf): + break + cputype, _cpusub, offset, size = struct.unpack_from('>iIQQ', buf, entry) + entry += 32 + else: + if entry + 20 > len(buf): + break + cputype, _cpusub, offset, size, _align = \ + struct.unpack_from('>iIIII', buf, entry) + entry += 20 + if cputype == CPU_TYPE_ARM64: + n += _relabel_archive_region(buf, offset, size, target_platform) + return n + if struct.unpack_from('\n': + return _relabel_archive_region(buf, 0, len(buf), target_platform) return 0 -def _patch_fat_binary(fat_path): - with open(fat_path, 'rb') as f: - head = f.read(4) - if len(head) < 4: +def _relabel_file(path, target_platform): + if os.path.getsize(path) == 0: return 0 - magic = struct.unpack('>I', head[:4])[0] - if magic in (FAT_MAGIC, FAT_MAGIC_64): - archs = subprocess.run( - ['lipo', '-archs', fat_path], - capture_output=True, text=True, check=True, - ).stdout.strip().split() - if 'arm64' not in archs: - return 0 - with tempfile.TemporaryDirectory() as td: - arm64_thin = os.path.join(td, 'arm64.bin') - subprocess.run( - ['lipo', fat_path, '-thin', 'arm64', '-output', arm64_thin], - check=True, - ) - n = _patch_thin(arm64_thin) - if n == 0: - return 0 - subprocess.run( - ['lipo', fat_path, '-replace', 'arm64', arm64_thin, - '-output', fat_path], - check=True, - ) - return n - return _patch_thin(fat_path) + with open(path, 'r+b') as f: + with mmap.mmap(f.fileno(), 0) as buf: + n = _relabel_buffer(buf, target_platform) + if n: + buf.flush() + return n def _find_framework_binary(pod_dir): @@ -149,24 +144,42 @@ def _find_framework_binary(pod_dir): return None -def main(args): - if not args: - print(__doc__, file=sys.stderr) +def _iter_framework_binaries(pods_root): + for pattern in ('MLKit*', 'MLImage*'): + for pod_dir in sorted(glob.glob(os.path.join(pods_root, pattern))): + if os.path.isdir(pod_dir): + binary = _find_framework_binary(pod_dir) + if binary: + yield binary + + +def main(argv): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--platform', default=os.environ.get('PLATFORM_NAME', '')) + parser.add_argument('--pods-root', required=True) + args = parser.parse_args(argv) + + target_platform = PLATFORM_BY_SDK.get(args.platform) + if target_platform is None: + print(f'[ml_kit] skipping arm64 relabel: platform {args.platform!r} is ' + 'not iphoneos/iphonesimulator', file=sys.stderr) + return 0 + + binaries = list(_iter_framework_binaries(args.pods_root)) + if not binaries: + print('[ml_kit] ERROR: no ML Kit framework binaries found under ' + f'{args.pods_root!r}; arm64 slice not relabeled', file=sys.stderr) return 1 - total = 0 - for path in args: - binary = _find_framework_binary(path) - if not binary: - continue - n = _patch_fat_binary(binary) - if n > 0: - print(f' patched {os.path.basename(binary)}: ' - f'{n} object(s) relabeled to iOS Simulator') - total += n - if total > 0: - print(f'[ml_kit] Total Mach-O objects relabeled: {total}') + + total = sum(_relabel_file(b, target_platform) for b in binaries) + if total: + print(f'[ml_kit] relabeled {total} arm64 object(s) for {args.platform}') return 0 if __name__ == '__main__': - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as exc: # surface a tagged, actionable build-phase error + print(f'[ml_kit] ERROR: arm64 relabel failed: {exc}', file=sys.stderr) + sys.exit(1)