diff --git a/packages/example/ios/Podfile b/packages/example/ios/Podfile index 9f0d4515..2f65a326 100644 --- a/packages/example/ios/Podfile +++ b/packages/example/ios/Podfile @@ -23,6 +23,7 @@ def flutter_root end require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) +require File.expand_path('../../google_mlkit_commons/ios/scripts/apple_silicon_simulator', __dir__) flutter_ios_podfile_setup @@ -56,4 +57,6 @@ post_install do |installer| end end end + + mlkit_apple_silicon_simulator_patch(installer) end diff --git a/packages/example/ios/Podfile.lock b/packages/example/ios/Podfile.lock index 3d814d2b..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: 810ea711de6d4d578877638350f293e7020676b9 +PODFILE CHECKSUM: df61d3916884bb4fa8c7ec2fdffdf0dd0d3cec36 COCOAPODS: 1.16.2 diff --git a/packages/google_mlkit_commons/README.md b/packages/google_mlkit_commons/README.md index 46e77f5a..41eb50be 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, 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`: + +```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. + +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 - 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..d4e1f4c1 --- /dev/null +++ b/packages/google_mlkit_commons/ios/scripts/apple_silicon_simulator.rb @@ -0,0 +1,80 @@ +# 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) + + framework_dirs = Dir.glob(File.join(pods_dir, '{MLKit*,MLImage*}')) + .select { |d| File.directory?(d) } + 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 + +def _mlkit_strip_simulator_arm64_exclusion(pods_dir) + Dir.glob(File.join(pods_dir, 'Target Support Files', '**', '*.xcconfig')) + .each do |xcconfig| + 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 new file mode 100644 index 00000000..c042e1ff --- /dev/null +++ b/packages/google_mlkit_commons/ios/scripts/patch_arm64_simulator.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""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 sys + +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 + +PLATFORM_BY_SDK = { + 'iphoneos': PLATFORM_IOS, + 'iphonesimulator': PLATFORM_IOS_SIMULATOR, +} + + +def _relabel_macho(buf, base, target_platform): + """Flip LC_BUILD_VERSION.platform of the Mach-O object at ``base`` in place. + Returns True only when a byte was actually changed.""" + if base + 32 > len(buf): + return False + if struct.unpack_from(' end: + break + cmd, cmdsize = struct.unpack_from(' end: + break + if cmd == LC_BUILD_VERSION: + if offset + 12 <= end: + platform = struct.unpack_from('\n': + return 1 if _relabel_macho(buf, start, target_platform) else 0 + pos = start + 8 + n = 0 + while pos + 60 <= region_end: + try: + member_size = int( + bytes(buf[pos + 48:pos + 58]).decode('ascii', 'replace').strip()) + except ValueError: + break + name = bytes(buf[pos:pos + 16]).rstrip() + body = pos + 60 + obj = body + if name.startswith(b'#1/'): # BSD long-name extension + try: + obj = body + int(name[3:]) + except ValueError: + obj = body + if _relabel_macho(buf, obj, target_platform): + n += 1 + pos = body + member_size + (member_size & 1) # 2-byte alignment + return n + + +def _relabel_buffer(buf, target_platform): + if len(buf) < 8: + return 0 + fat_magic = struct.unpack_from('>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 _relabel_file(path, target_platform): + if os.path.getsize(path) == 0: + return 0 + 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): + 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 _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 = 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__': + 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)