Skip to content

Commit be7035c

Browse files
fix: enable Google ML Kit on Apple Silicon iOS 26+ simulators (#862)
* 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 * 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. * 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. * 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.
1 parent 3d9d81e commit be7035c

5 files changed

Lines changed: 296 additions & 1 deletion

File tree

packages/example/ios/Podfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def flutter_root
2323
end
2424

2525
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
26+
require File.expand_path('../../google_mlkit_commons/ios/scripts/apple_silicon_simulator', __dir__)
2627

2728
flutter_ios_podfile_setup
2829

@@ -56,4 +57,6 @@ post_install do |installer|
5657
end
5758
end
5859
end
60+
61+
mlkit_apple_silicon_simulator_patch(installer)
5962
end

packages/example/ios/Podfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,6 @@ SPEC CHECKSUMS:
483483
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
484484
SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea
485485

486-
PODFILE CHECKSUM: 810ea711de6d4d578877638350f293e7020676b9
486+
PODFILE CHECKSUM: df61d3916884bb4fa8c7ec2fdffdf0dd0d3cec36
487487

488488
COCOAPODS: 1.16.2

packages/google_mlkit_commons/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,33 @@ end
7777

7878
Notice that the minimum `IPHONEOS_DEPLOYMENT_TARGET` is 15.5, you can set it to something newer but not older.
7979

80+
#### Apple Silicon iOS Simulator (iOS 26+)
81+
82+
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.
83+
84+
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.
85+
86+
To enable it, add two lines to your iOS `Podfile`:
87+
88+
```ruby
89+
# Near the top, after `require ... podhelper ...`:
90+
require File.expand_path(
91+
'.symlinks/plugins/google_mlkit_commons/ios/scripts/apple_silicon_simulator',
92+
__dir__,
93+
)
94+
95+
post_install do |installer|
96+
# ...your existing post_install code...
97+
98+
# Add this line at the end:
99+
mlkit_apple_silicon_simulator_patch(installer)
100+
end
101+
```
102+
103+
Then re-run `pod install`. The example app under `packages/example` is wired up this way.
104+
105+
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).
106+
80107
### Android
81108

82109
- minSdkVersion: 21
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Opt-in Podfile helper that lets Google ML Kit pods build and run on Apple
2+
# Silicon iOS 26+ simulators *and* physical devices from the same Pods install.
3+
# A per-build script phase relabels the arm64 slice to match the target, so no
4+
# manual revert is ever needed. See packages/google_mlkit_commons/README.md
5+
# (iOS section). Upstream Google bug:
6+
# https://issuetracker.google.com/issues/178965151
7+
8+
require 'fileutils'
9+
10+
MLKIT_STATE_DIR = 'MLKitAppleSiliconSimulator'.freeze
11+
MLKIT_PATCHER = 'patch_arm64_simulator.py'.freeze
12+
MLKIT_PHASE_NAME = '[ML Kit] Relabel arm64 slice for current platform'.freeze
13+
MLKIT_EXCLUDED_RE = /^(\s*EXCLUDED_ARCHS\[sdk=iphonesimulator\*\]\s*=\s*)(.*?)\s*$/
14+
15+
def mlkit_apple_silicon_simulator_patch(installer)
16+
pods_dir = File.expand_path(installer.sandbox.root.to_s)
17+
18+
framework_dirs = Dir.glob(File.join(pods_dir, '{MLKit*,MLImage*}'))
19+
.select { |d| File.directory?(d) }
20+
return if framework_dirs.empty?
21+
22+
_mlkit_copy_patcher(pods_dir)
23+
_mlkit_strip_simulator_arm64_exclusion(pods_dir)
24+
_mlkit_install_build_phase(installer)
25+
installer.pods_project.save
26+
27+
Pod::UI.puts ''
28+
Pod::UI.puts "[ml_kit] Apple Silicon simulator support enabled for " \
29+
"#{framework_dirs.size} framework(s) (auto-toggles per build)."
30+
rescue => e
31+
raise "[ml_kit] failed to enable Apple Silicon simulator support: #{e.message}"
32+
end
33+
34+
def _mlkit_copy_patcher(pods_dir)
35+
state_dir = File.join(pods_dir, MLKIT_STATE_DIR)
36+
FileUtils.rm_rf(state_dir)
37+
FileUtils.mkdir_p(state_dir)
38+
FileUtils.cp(File.expand_path(MLKIT_PATCHER, __dir__), state_dir)
39+
end
40+
41+
def _mlkit_strip_simulator_arm64_exclusion(pods_dir)
42+
Dir.glob(File.join(pods_dir, 'Target Support Files', '**', '*.xcconfig'))
43+
.each do |xcconfig|
44+
changed = false
45+
new_text = File.read(xcconfig).each_line.map do |line|
46+
match = line.match(MLKIT_EXCLUDED_RE)
47+
next line unless match
48+
49+
tokens = match[2].split(/\s+/).reject(&:empty?)
50+
next line unless tokens.include?('arm64')
51+
52+
changed = true
53+
kept = tokens.reject { |t| t == 'arm64' }
54+
kept.empty? ? '' : "#{match[1]}#{kept.join(' ')}\n"
55+
end.join
56+
File.write(xcconfig, new_text) if changed
57+
end
58+
end
59+
60+
def _mlkit_install_build_phase(installer)
61+
script = <<~SH
62+
set -euo pipefail
63+
: "${PLATFORM_NAME:?PLATFORM_NAME is not set}"
64+
: "${SRCROOT:?SRCROOT is not set}"
65+
/usr/bin/env python3 "${SRCROOT}/#{MLKIT_STATE_DIR}/#{MLKIT_PATCHER}" \\
66+
--platform "${PLATFORM_NAME}" \\
67+
--pods-root "${SRCROOT}"
68+
SH
69+
70+
installer.aggregate_targets.each do |aggregate|
71+
target = installer.pods_project.targets.find { |t| t.name == aggregate.label }
72+
next unless target
73+
74+
phase = target.shell_script_build_phases.find { |p| p.name == MLKIT_PHASE_NAME }
75+
phase ||= target.new_shell_script_build_phase(MLKIT_PHASE_NAME)
76+
phase.shell_path = '/bin/sh'
77+
phase.shell_script = script
78+
phase.always_out_of_date = '1' if phase.respond_to?(:always_out_of_date=)
79+
end
80+
end
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env python3
2+
"""Relabel the arm64 slice of Google ML Kit static frameworks to match the
3+
build's target platform. Flips each arm64 Mach-O object's
4+
LC_BUILD_VERSION.platform between 2 (iOS) and 7 (iOS Simulator) in place, so the
5+
single arm64 slice Google ships works for whichever target is being built; no
6+
instructions, symbols, sizes or offsets change. Idempotent and reversible. See
7+
packages/google_mlkit_commons/README.md (iOS section).
8+
9+
Run from an Xcode build phase:
10+
patch_arm64_simulator.py --platform "$PLATFORM_NAME" --pods-root "$SRCROOT"
11+
"""
12+
13+
import argparse
14+
import glob
15+
import mmap
16+
import os
17+
import struct
18+
import sys
19+
20+
FAT_MAGIC = 0xCAFEBABE
21+
FAT_MAGIC_64 = 0xCAFEBABF
22+
MH_MAGIC_64 = 0xFEEDFACF
23+
LC_BUILD_VERSION = 0x32
24+
PLATFORM_IOS = 2
25+
PLATFORM_IOS_SIMULATOR = 7
26+
CPU_TYPE_ARM64 = 0x0100000c
27+
28+
PLATFORM_BY_SDK = {
29+
'iphoneos': PLATFORM_IOS,
30+
'iphonesimulator': PLATFORM_IOS_SIMULATOR,
31+
}
32+
33+
34+
def _relabel_macho(buf, base, target_platform):
35+
"""Flip LC_BUILD_VERSION.platform of the Mach-O object at ``base`` in place.
36+
Returns True only when a byte was actually changed."""
37+
if base + 32 > len(buf):
38+
return False
39+
if struct.unpack_from('<I', buf, base)[0] != MH_MAGIC_64:
40+
return False
41+
cputype, _cpusub, _filetype, ncmds, sizeofcmds, _flags, _reserved = \
42+
struct.unpack_from('<iIIIIII', buf, base + 4)
43+
if cputype != CPU_TYPE_ARM64:
44+
return False
45+
offset = base + 32
46+
end = min(base + 32 + sizeofcmds, len(buf))
47+
for _ in range(ncmds):
48+
if offset + 8 > end:
49+
break
50+
cmd, cmdsize = struct.unpack_from('<II', buf, offset)
51+
if cmdsize < 8 or offset + cmdsize > end:
52+
break
53+
if cmd == LC_BUILD_VERSION:
54+
if offset + 12 <= end:
55+
platform = struct.unpack_from('<I', buf, offset + 8)[0]
56+
if platform in (PLATFORM_IOS, PLATFORM_IOS_SIMULATOR) \
57+
and platform != target_platform:
58+
struct.pack_into('<I', buf, offset + 8, target_platform)
59+
return True
60+
return False
61+
offset += cmdsize
62+
return False
63+
64+
65+
def _relabel_archive_region(buf, start, size, target_platform):
66+
"""Relabel every arm64 Mach-O member of the ``ar`` archive (or the single
67+
Mach-O object) occupying ``buf[start:start+size]``. Returns the count."""
68+
region_end = min(start + size, len(buf))
69+
if buf[start:start + 8] != b'!<arch>\n':
70+
return 1 if _relabel_macho(buf, start, target_platform) else 0
71+
pos = start + 8
72+
n = 0
73+
while pos + 60 <= region_end:
74+
try:
75+
member_size = int(
76+
bytes(buf[pos + 48:pos + 58]).decode('ascii', 'replace').strip())
77+
except ValueError:
78+
break
79+
name = bytes(buf[pos:pos + 16]).rstrip()
80+
body = pos + 60
81+
obj = body
82+
if name.startswith(b'#1/'): # BSD long-name extension
83+
try:
84+
obj = body + int(name[3:])
85+
except ValueError:
86+
obj = body
87+
if _relabel_macho(buf, obj, target_platform):
88+
n += 1
89+
pos = body + member_size + (member_size & 1) # 2-byte alignment
90+
return n
91+
92+
93+
def _relabel_buffer(buf, target_platform):
94+
if len(buf) < 8:
95+
return 0
96+
fat_magic = struct.unpack_from('>I', buf, 0)[0]
97+
if fat_magic in (FAT_MAGIC, FAT_MAGIC_64):
98+
nfat = struct.unpack_from('>I', buf, 4)[0]
99+
is64 = fat_magic == FAT_MAGIC_64
100+
entry = 8
101+
n = 0
102+
for _ in range(nfat):
103+
if is64:
104+
if entry + 32 > len(buf):
105+
break
106+
cputype, _cpusub, offset, size = struct.unpack_from('>iIQQ', buf, entry)
107+
entry += 32
108+
else:
109+
if entry + 20 > len(buf):
110+
break
111+
cputype, _cpusub, offset, size, _align = \
112+
struct.unpack_from('>iIIII', buf, entry)
113+
entry += 20
114+
if cputype == CPU_TYPE_ARM64:
115+
n += _relabel_archive_region(buf, offset, size, target_platform)
116+
return n
117+
if struct.unpack_from('<I', buf, 0)[0] == MH_MAGIC_64 \
118+
or buf[0:8] == b'!<arch>\n':
119+
return _relabel_archive_region(buf, 0, len(buf), target_platform)
120+
return 0
121+
122+
123+
def _relabel_file(path, target_platform):
124+
if os.path.getsize(path) == 0:
125+
return 0
126+
with open(path, 'r+b') as f:
127+
with mmap.mmap(f.fileno(), 0) as buf:
128+
n = _relabel_buffer(buf, target_platform)
129+
if n:
130+
buf.flush()
131+
return n
132+
133+
134+
def _find_framework_binary(pod_dir):
135+
fw_dir = os.path.join(pod_dir, 'Frameworks')
136+
if not os.path.isdir(fw_dir):
137+
return None
138+
for name in os.listdir(fw_dir):
139+
if name.endswith('.framework'):
140+
base = name[:-len('.framework')]
141+
binary = os.path.join(fw_dir, name, base)
142+
if os.path.isfile(binary):
143+
return binary
144+
return None
145+
146+
147+
def _iter_framework_binaries(pods_root):
148+
for pattern in ('MLKit*', 'MLImage*'):
149+
for pod_dir in sorted(glob.glob(os.path.join(pods_root, pattern))):
150+
if os.path.isdir(pod_dir):
151+
binary = _find_framework_binary(pod_dir)
152+
if binary:
153+
yield binary
154+
155+
156+
def main(argv):
157+
parser = argparse.ArgumentParser(description=__doc__)
158+
parser.add_argument('--platform', default=os.environ.get('PLATFORM_NAME', ''))
159+
parser.add_argument('--pods-root', required=True)
160+
args = parser.parse_args(argv)
161+
162+
target_platform = PLATFORM_BY_SDK.get(args.platform)
163+
if target_platform is None:
164+
print(f'[ml_kit] skipping arm64 relabel: platform {args.platform!r} is '
165+
'not iphoneos/iphonesimulator', file=sys.stderr)
166+
return 0
167+
168+
binaries = list(_iter_framework_binaries(args.pods_root))
169+
if not binaries:
170+
print('[ml_kit] ERROR: no ML Kit framework binaries found under '
171+
f'{args.pods_root!r}; arm64 slice not relabeled', file=sys.stderr)
172+
return 1
173+
174+
total = sum(_relabel_file(b, target_platform) for b in binaries)
175+
if total:
176+
print(f'[ml_kit] relabeled {total} arm64 object(s) for {args.platform}')
177+
return 0
178+
179+
180+
if __name__ == '__main__':
181+
try:
182+
sys.exit(main(sys.argv[1:]))
183+
except Exception as exc: # surface a tagged, actionable build-phase error
184+
print(f'[ml_kit] ERROR: arm64 relabel failed: {exc}', file=sys.stderr)
185+
sys.exit(1)

0 commit comments

Comments
 (0)