pod-binary-pack 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,713 @@
1
+ # frozen_string_literal: true
2
+ require 'yaml'
3
+ require 'json'
4
+ require 'tempfile'
5
+ require 'shellwords'
6
+ require_relative 'repo_xcode'
7
+ require_relative 'repo_module_source'
8
+ require_relative 'build_item'
9
+ require_relative 'util/file_helper'
10
+ require_relative 'hook_manager'
11
+
12
+ module Build
13
+ class RepoXcodePackage < RepoXCode
14
+
15
+ # cocoapods-yaml 插件的依赖文件命名
16
+ LOCAL_DEPENDENCIES_FILE = 'LocalDependencies.yaml'
17
+ TARGET_DEPENDENCIES_GLOB = '*Dependencies.yaml'
18
+
19
+ # 当前编译模式
20
+ attr_accessor :debug_mode
21
+
22
+ # 真机产物路径 在编译完成前产物为空
23
+ attr_accessor :real_device_archive_path
24
+
25
+ # 模拟器产物路径 在编译完成前产物为空
26
+ attr_accessor :simulator_archive_path
27
+
28
+ # @return [Array] resource_bundles的路径(在编译完成前为空)
29
+ attr_accessor :resource_bundles_paths
30
+ # @return [Array] resource的路径(在编译完成前为空)
31
+ attr_accessor :resource_paths
32
+
33
+ def initialize(path, ssh_url, branch, xcworkspace, scheme, **signing_options)
34
+ super(path, ssh_url, branch, xcworkspace, scheme, **signing_options)
35
+ @debug_mode = false
36
+ @resource_bundles_paths = Array.new
37
+ @resource_paths = Array.new
38
+ end
39
+
40
+ # 打包前准备工程内的 pod yaml 文件
41
+ # 打包工程即壳工程本身,<Target>Dependencies.yaml 已在工程内,
42
+ # 这里只保留扩展点(如三方库特殊替换)
43
+ def prepare_yaml_files
44
+ hooker = HookManager.instance
45
+ hooker.trigger_hook(Status::XCODE_PACKAGE_COPY_YAML, self.path)
46
+ end
47
+
48
+ # 打包期临时桩化 main.m 后执行块,结束后必定还原。
49
+ #
50
+ # 背景: TRIM_UNUSED_POD 裁剪时 cocoapods-yaml 会扫描壳工程自有源码的 #import <组件/...>
51
+ # 来决定保留哪些 pod;而壳工程 main.m 引用了 BSLaunch 等业务组件。
52
+ # 为让裁剪只安装目标组件(及其依赖)并保证 archive 编译通过,
53
+ # pod update 前把 main.m 替换为仅依赖 UIKit 的最小 AppDelegate(同旧打包工程的做法)。
54
+ def with_build_stub
55
+ stub_main_m
56
+ yield
57
+ ensure
58
+ restore_main_m
59
+ end
60
+
61
+ # 生成本地依赖覆盖文件(打包期临时文件,结束后删除,不提交)
62
+ def generate_local_yaml(repo_module_source)
63
+ local_yaml_path = File.join(self.path, LOCAL_DEPENDENCIES_FILE)
64
+ pod_name = repo_module_source.pod_name
65
+
66
+ dependencies_content = {}
67
+ dependencies_content["TRIM_UNUSED_POD"] = true
68
+ dependencies_content["PODS"] = []
69
+
70
+ # 先从壳工程 target 依赖文件里找配置(subspecs/configurations),找不到则生成最小条目
71
+ pod_items = get_pod_in_self(pod_name)
72
+ if pod_items.any?
73
+ # 优先root声明(携带subspecs/configurations),cocoapods-yaml合并时按root覆盖
74
+ root_item = pod_items.find { |item| item["pod"] == pod_name } || pod_items.first
75
+ dependency = create_local_yaml_pod(pod_name, repo_module_source)
76
+
77
+ # 多subspec组件整包全量构建:以podspec中声明的全部subspec为准(保证每个模块自身的
78
+ # 三方依赖在archive时都存在),不再只取壳工程声明的子集;无subspec时沿用壳工程配置。
79
+ # 互斥subspec组件(如FMDB的standard/standalone/SQLCipher是不同sqlite后端,全量合并
80
+ # 编译必冲突)可通过 build.yml components.<组件>.subspecs 显式指定集成集合
81
+ explicit_subspecs = repo_module_source.respond_to?(:build_subspecs) ? repo_module_source.build_subspecs : nil
82
+ build_subspecs = repo_module_source.respond_to?(:resolved_build_subspecs) ? repo_module_source.resolved_build_subspecs : []
83
+ if build_subspecs.any?
84
+ dependency["subspecs"] = build_subspecs
85
+ if explicit_subspecs
86
+ Build.log.detail("组件#{pod_name}按配置集成#{build_subspecs.size}个subspec: #{build_subspecs.join('、')}")
87
+ else
88
+ Build.log.detail("组件#{pod_name}含#{build_subspecs.size}个subspec,打包集成全部: #{build_subspecs.join('、')}")
89
+ end
90
+ elsif root_item["subspecs"]
91
+ dependency["subspecs"] = root_item["subspecs"]
92
+ end
93
+ dependency["configurations"] = root_item["configurations"] if root_item["configurations"]
94
+ dependencies_content["PODS"] << dependency
95
+
96
+ # 更新下编译模式
97
+ __update_debug_mode(root_item)
98
+ else
99
+ dependencies_content["PODS"] << create_local_yaml_pod(pod_name, repo_module_source)
100
+ end
101
+
102
+ File.open(local_yaml_path, 'w') { |file|
103
+ file.write(dependencies_content.to_yaml)
104
+ }
105
+
106
+ end
107
+
108
+ # 壳工程所有 target 依赖文件的绝对路径(排除本地覆盖文件)
109
+ def target_dependency_paths
110
+ Dir.glob(File.join(self.path, TARGET_DEPENDENCIES_GLOB))
111
+ .reject { |p| File.basename(p) == LOCAL_DEPENDENCIES_FILE }
112
+ .sort
113
+ end
114
+
115
+ # 从所有 target 依赖文件中读取某个pod的声明(root或subspec)
116
+ def get_pod_in_self(pod_name)
117
+ target_dependency_paths.each do |yaml_path|
118
+ result = get_pod_in_yaml(pod_name, yaml_path)
119
+ return result if result.any?
120
+ end
121
+ []
122
+ end
123
+
124
+ def get_pod_in_yaml(pod_name, yaml_path)
125
+ result = []
126
+
127
+ # 壳工程未提供该文件时视为无配置,由调用方走默认生成分支
128
+ return result unless File.exist?(yaml_path)
129
+
130
+ yaml_content = load_dependency_yaml(yaml_path)
131
+ pods_array = (yaml_content.is_a?(Hash) && yaml_content["PODS"]) ? yaml_content["PODS"] : []
132
+
133
+ pods_array.each { |item|
134
+ next unless item["pod"]
135
+ if item["pod"] == pod_name
136
+ result << item
137
+ elsif item["pod"].include?("#{pod_name}/") #子仓库形式
138
+ result << item
139
+ end
140
+ }
141
+
142
+ result
143
+ end
144
+
145
+ # 读取依赖yaml(支持锚点变量),兼容无Psych别名参数的环境
146
+ def load_dependency_yaml(yaml_path)
147
+ YAML.load_file(yaml_path, aliases: true)
148
+ rescue ArgumentError
149
+ YAML.load_file(yaml_path)
150
+ end
151
+
152
+ # 在所有target依赖文件中定位声明了某pod(root或subspec)的文件
153
+ # @return [String, nil] 文件绝对路径
154
+ def locate_pod_dependency_file(pod_name)
155
+ target_dependency_paths.find do |path|
156
+ File.read(path) =~ /^- pod:\s+#{Regexp.escape(pod_name)}(\/|\s|$)/
157
+ end
158
+ end
159
+
160
+ # 主App target对应的依赖文件路径
161
+ def main_dependency_file
162
+ File.join(self.path, "#{application_target.name}Dependencies.yaml")
163
+ end
164
+
165
+ # 读取target依赖文件的SOURCES数组(有序),支持锚点
166
+ def dependency_sources(yaml_path)
167
+ content = load_dependency_yaml(yaml_path)
168
+ content.is_a?(Hash) ? Array(content["SOURCES"]).map(&:to_s) : []
169
+ end
170
+
171
+ # 外科式修改:在不整体重写yaml(保护锚点变量/注释/空行)的前提下,
172
+ # 更新指定pod块内的字段。block从 "- pod: NAME" 行开始,到缩进层级回到0之前
173
+ def edit_pod_block(text, pod_name)
174
+ lines = text.lines
175
+ start_idx = lines.index { |line| line =~ /^- pod:\s+#{Regexp.escape(pod_name)}\s*$/ }
176
+ return text unless start_idx
177
+
178
+ end_idx = start_idx
179
+ ((start_idx + 1)...lines.size).each do |idx|
180
+ break unless lines[idx] =~ /^\s+\S/
181
+ end_idx = idx
182
+ end
183
+
184
+ block = lines[start_idx..end_idx]
185
+ new_block = yield(block)
186
+ (lines[0...start_idx] + Array(new_block) + lines[(end_idx + 1)..-1]).join
187
+ end
188
+
189
+ # 设置pod块内的2空格缩进字段(不存在则插入到pod行之后)
190
+ def set_block_field(block_lines, field, value)
191
+ idx = block_lines.index { |line| line =~ /^ #{Regexp.escape(field)}:/ }
192
+ if idx
193
+ block_lines[idx] = " #{field}: #{value}\n"
194
+ else
195
+ block_lines.insert(1, " #{field}: #{value}\n")
196
+ end
197
+ block_lines
198
+ end
199
+
200
+ # 删除pod块内的2空格缩进字段
201
+ def remove_block_field(block_lines, field)
202
+ block_lines.reject { |line| line =~ /^ #{Regexp.escape(field)}:/ }
203
+ end
204
+
205
+ # 在文件末尾追加一个pod条目(自动补空行)
206
+ def append_pod_item(text, lines)
207
+ text = text.end_with?("\n") ? text : "#{text}\n"
208
+ text += "\n" unless text.end_with?("\n\n")
209
+ text + lines
210
+ end
211
+
212
+ def create_local_yaml_pod(pod_name, repo_module_source)
213
+ module_source_path = repo_module_source.path
214
+ dependency = {}
215
+ dependency["pod"] = pod_name
216
+ if repo_module_source.respond_to?(:podspec_at_repo_root?) && !repo_module_source.podspec_at_repo_root?
217
+ # podspec在子目录(或一仓多组件)时,:path 无法在根目录找到podspec,
218
+ # 显式指向 update_podspec 生成的 json 文件,CocoaPods 以其所在目录为文件根
219
+ dependency["podspec"] = repo_module_source.podspec_json_path
220
+ else
221
+ dependency["path"] = module_source_path
222
+ end
223
+ # 本地覆盖文件默认inhibit_warnings=false,打包场景显式true避免源码警告干扰archive
224
+ dependency["inhibit_warnings"] = true
225
+
226
+ dependency
227
+
228
+ end
229
+
230
+ # 二进制pod散装头文件兜底:
231
+ # 源码pod时代 Pods/<组件>/ 下有散装.h,部分组件podspec用相对HEADER_SEARCH_PATHS
232
+ # (如FMDB的SQLCipher subspec指向"SQLCipher"目录)查找这些文件;组件二进制化后该目录
233
+ # 只剩.xcframework,同名头文件查找会穿透到系统SDK(如sqlite3.h),缺少SQLCipher的
234
+ # codec API声明(sqlite3_key/sqlite3_rekey)导致编译失败。
235
+ # 这里把xcframework内slice的Headers软链回Pods/<组件>/,恢复与源码pod一致的查找布局;
236
+ # 已存在同名散装文件的pod不受影响(源码pod场景),重复执行幂等
237
+ def restore_vendored_pod_headers
238
+ pods_dir = File.join(self.path, 'Pods')
239
+ return unless File.directory?(pods_dir)
240
+
241
+ linked_pods = []
242
+ Dir.children(pods_dir).sort.each do |name|
243
+ pod_dir = File.join(pods_dir, name)
244
+ next unless File.directory?(pod_dir)
245
+
246
+ headers_dirs = Dir.glob(File.join(pod_dir, '*.xcframework', 'ios-*', '*.framework', 'Headers'))
247
+ next if headers_dirs.empty?
248
+
249
+ linked = 0
250
+ headers_dirs.each do |headers_dir|
251
+ Dir.children(headers_dir).sort.each do |header|
252
+ next unless header.end_with?('.h')
253
+ link_path = File.join(pod_dir, header)
254
+ next if File.exist?(link_path)
255
+ FileUtils.ln_s(File.expand_path(File.join(headers_dir, header)), link_path)
256
+ linked += 1
257
+ end
258
+ end
259
+ linked_pods << "#{name}(#{linked}个)" if linked > 0
260
+ end
261
+
262
+ unless linked_pods.empty?
263
+ Build.log.detail("-> 已为二进制pod补齐散装头文件软链: #{linked_pods.join('、')}")
264
+ end
265
+ end
266
+
267
+ public
268
+ def archive(repo_module_source)
269
+
270
+ # 二进制pod散装头文件兜底(详见方法注释)
271
+ restore_vendored_pod_headers
272
+
273
+ # 检测组件自带的预编译库是否缺 arm64 模拟器 slice,模拟器 archive 时需据此排除 arm64
274
+ prepare_simulator_arch_compat(repo_module_source)
275
+
276
+ # 即时不构建二进制,也要保证真机能够编译成功
277
+ do_archive(false)
278
+
279
+ if repo_module_source.build_item.simulator
280
+ do_archive(true)
281
+ else
282
+ Build.log.info("不构建模拟器")
283
+ end
284
+
285
+ # 打包完成 更新属性
286
+ update_resource_bundles_paths(repo_module_source)
287
+ update_resource_paths(repo_module_source)
288
+
289
+ end
290
+
291
+ public
292
+ # 校验组件与壳工程的最低iOS版本(只读,不修改任何工程配置)。
293
+ # CocoaPods对组件target的deployment target取 max(壳工程platform, podspec声明):
294
+ # - 组件要求 <= 壳工程:产物以壳工程版本为准
295
+ # - 组件要求 > 壳工程:组件target自动按更高版本编译,pod update不会失败(仅兼容性warning),
296
+ # 链接进壳工程也是warning不阻断,无需为单个组件改动公共壳工程
297
+ def check_package_version(repo_module_source)
298
+ component_version = repo_module_source.ios_version.to_s.strip
299
+ shell_version = shell_deployment_target.to_s.strip
300
+
301
+ # 壳工程版本读取失败属于异常降级,需要警告提示;podspec未声明平台属正常可空,仅普通提示
302
+ if shell_version.empty?
303
+ Build.log.warn("读取壳工程最低iOS版本失败,跳过组件与壳工程的版本融合判断(不影响本次打包)")
304
+ return
305
+ end
306
+ if component_version.empty?
307
+ Build.log.detail("组件podspec未声明最低iOS版本,壳工程为#{shell_version},跳过版本融合判断")
308
+ return
309
+ end
310
+
311
+ effective = Gem::Version.new(component_version) >= Gem::Version.new(shell_version) ? component_version : shell_version
312
+ Build.log.detail("最低iOS版本:组件podspec #{component_version} | 壳工程 #{shell_version} | 组件产物实际按 #{effective} 编译")
313
+
314
+ if Gem::Version.new(component_version) > Gem::Version.new(shell_version)
315
+ Build.log.warn("组件最低iOS版本(#{component_version})高于壳工程(#{shell_version}):无需修改壳工程,CocoaPods会自动按#{component_version}编译该组件,pod update与链接仅产生兼容性警告、不阻断打包;但该组件在iOS#{shell_version}~#{component_version}系统上运行存在兼容风险,请知悉")
316
+ end
317
+ end
318
+
319
+ def git_clone(only_head)
320
+ super
321
+
322
+ # clone 后提前校验壳工程存在且 xcodeproj 可正常解析
323
+ xcodeproj_path = "#{repo_path}/#{scheme}.xcodeproj"
324
+ Xcodeproj::Project.open(xcodeproj_path)
325
+ end
326
+
327
+ private
328
+ # 桩化用的最小 main.m:仅依赖系统 UIKit,不引用任何业务组件
329
+ STUB_MAIN_M = <<~OBJC.freeze
330
+ //
331
+ // main.m
332
+ // ⚠️ 本文件由 pod-binary-pack 在组件打包期间临时替换,打包结束后会自动还原,请勿手动修改
333
+ //
334
+
335
+ #import <UIKit/UIKit.h>
336
+
337
+ @interface PodBinaryPackBuildAppDelegate : UIResponder <UIApplicationDelegate>
338
+ @property (nonatomic, strong) UIWindow *window;
339
+ @end
340
+
341
+ @implementation PodBinaryPackBuildAppDelegate
342
+
343
+ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
344
+ self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
345
+ UIViewController *viewController = [[UIViewController alloc] init];
346
+ viewController.view.backgroundColor = [UIColor whiteColor];
347
+ self.window.rootViewController = viewController;
348
+ [self.window makeKeyAndVisible];
349
+ return YES;
350
+ }
351
+
352
+ @end
353
+
354
+ int main(int argc, char * argv[]) {
355
+ @autoreleasepool {
356
+ return UIApplicationMain(argc, argv, nil, NSStringFromClass([PodBinaryPackBuildAppDelegate class]));
357
+ }
358
+ }
359
+ OBJC
360
+
361
+ # 壳工程 application target(主App target),解析结果在本次构建内缓存
362
+ # @return [Xcodeproj::Project::Object::PBXNativeTarget, nil]
363
+ def application_target
364
+ @application_target ||= begin
365
+ xcodeproj_path = "#{repo_path}/#{scheme}.xcodeproj"
366
+ project = Xcodeproj::Project.open(xcodeproj_path)
367
+ project.targets.find do |t|
368
+ t.product_type.to_s.end_with?('application') &&
369
+ (t.name == scheme || project.targets.one? { |x| x.product_type.to_s.end_with?('application') })
370
+ end
371
+ end
372
+ end
373
+
374
+ # 读取壳工程 application target 的最低iOS部署版本 IPHONEOS_DEPLOYMENT_TARGET
375
+ # @return [String, nil] 如 "15.0",读取失败或target不存在返回nil(由调用方决定如何提示)
376
+ def shell_deployment_target
377
+ application_target&.common_resolved_build_setting('IPHONEOS_DEPLOYMENT_TARGET')
378
+ rescue => exception
379
+ Build.log.detail("读取壳工程IPHONEOS_DEPLOYMENT_TARGET异常:#{exception.message}")
380
+ nil
381
+ end
382
+
383
+ # 定位 application target 编译源中的 main.m
384
+ # @return [Pathname, nil]
385
+ def find_main_m
386
+ return nil unless application_target
387
+
388
+ ref = application_target.source_build_phase.files_references.find do |f|
389
+ f.path && File.basename(f.path) == 'main.m'
390
+ end
391
+ ref&.real_path
392
+ end
393
+
394
+ def stub_main_m
395
+ main_path = find_main_m
396
+ unless main_path && File.exist?(main_path)
397
+ Build.log.warn('未在壳工程 application target 中找到 main.m,跳过 main.m 桩化(若编译报组件找不到请检查工程结构)')
398
+ @stub_main_m_info = nil
399
+ return
400
+ end
401
+
402
+ original = File.read(main_path)
403
+ if original.include?('PodBinaryPackBuildAppDelegate')
404
+ @stub_main_m_info = nil
405
+ return
406
+ end
407
+
408
+ @stub_main_m_info = [main_path.to_s, original]
409
+ File.write(main_path, STUB_MAIN_M)
410
+ Build.log.detail("已临时桩化 main.m(仅保留 UIKit 最小 AppDelegate): #{main_path}")
411
+ end
412
+
413
+ def restore_main_m
414
+ return unless @stub_main_m_info
415
+
416
+ main_path, original = @stub_main_m_info
417
+ File.write(main_path, original)
418
+ @stub_main_m_info = nil
419
+ Build.log.detail("已还原 main.m 原始内容: #{main_path}")
420
+ end
421
+
422
+ private
423
+ # 更新编译模式(有configurations声明时按声明判定Debug/Release,
424
+ # 无声明时保持初始默认false即Release)
425
+ def __update_debug_mode(pod_item)
426
+
427
+ configurations = pod_item["configurations"]
428
+ unless configurations
429
+ return
430
+ end
431
+
432
+ if configurations.class == Array
433
+ res = true
434
+ configurations.each { |config|
435
+ # 数组里只要有release 就认为是release包
436
+ if config.to_s.downcase == "release"
437
+ res = false
438
+ break
439
+ end
440
+ }
441
+ @debug_mode = res
442
+ elsif configurations.class == String
443
+ @debug_mode = true
444
+ # 字符串只要包含release 就认为是release
445
+ if configurations.downcase.include?("release")
446
+ @debug_mode = false
447
+ end
448
+ end
449
+ end
450
+
451
+ # 模拟器架构兼容检测:除组件源码自带的预编译库外,还要扫描 Pods 下所有依赖
452
+ # (二进制发布的组件以 xcframework 安装)。任一缺 arm64-simulator slice(但有 x86_64 兜底)时,
453
+ # 模拟器构建需排除 arm64 只出 x86_64。典型场景:
454
+ # - YYImage WebP subspec 内置 2016 年版 fat 静态库,arm64 只有真机 slice
455
+ # - YBImageBrowser 依赖的 YYImage 二进制 xcframework 只有 ios-arm64 + ios-x86_64-simulator,
456
+ # Apple Silicon 模拟器同时构建 arm64+x86_64 时 xcodebuild 报 "Unable to find matching slice"
457
+ def prepare_simulator_arch_compat(repo_module_source)
458
+ @simulator_exclude_arm64 = false
459
+ # 同步到build_item供各仓库提交信息追加 [裁剪架构] 关键字
460
+ repo_module_source.build_item.simulator_exclude_arm64 = false
461
+ return unless repo_module_source.build_item.simulator
462
+
463
+ # 组件源码自身的 fat framework/静态库(仅检测当前podspec仍声明的vendored库:
464
+ # 按配置剪掉subspec后,如YYImage去掉WebP,源码里残留的vendored库已不参与编译,
465
+ # 不应再命中,否则模拟器产物会被误判继续排除arm64)
466
+ allowed = declared_vendored_binary_paths(repo_module_source)
467
+ incompatible = detect_device_only_arm64_binaries(repo_module_source.path, allowed_paths: allowed)
468
+ incompatible.map! { |relative| "组件源码/#{relative}" }
469
+
470
+ # Pods 下依赖的 xcframework / fat framework(二进制发布的组件)
471
+ incompatible.concat(detect_pods_device_only_arm64_binaries)
472
+
473
+ return if incompatible.empty?
474
+
475
+ @simulator_exclude_arm64 = true
476
+ # 同步到build_item供各仓库提交信息追加 [裁剪架构] 关键字
477
+ repo_module_source.build_item.simulator_exclude_arm64 = true
478
+ Build.log.warn('检测到组件或其依赖缺少 arm64 模拟器 slice,模拟器产物将只构建 x86_64:')
479
+ incompatible.each { |name| Build.log.warn(" #{name}") }
480
+ Build.log.warn('Apple Silicon Mac 上运行该模拟器产物需使用 Rosetta 模式的模拟器;真机产物不受影响')
481
+ end
482
+
483
+ # @return [Array<String>, nil] 当前podspec(可能已按配置剪裁subspec)声明的vendored
484
+ # 预编译库绝对路径集合;podspec内容不可用时返回nil(调用方回退为全量扫描)。
485
+ # spec未声明任何vendored库时返回空数组(源码内不存在参与编译的预编译二进制)
486
+ def declared_vendored_binary_paths(repo_module_source)
487
+ content = repo_module_source.respond_to?(:podspec_content) ? repo_module_source.podspec_content : nil
488
+ return nil unless content.is_a?(Hash)
489
+
490
+ specs = repo_module_source.respond_to?(:collect_specs) ? repo_module_source.collect_specs(content) : [content]
491
+ entries = specs.flat_map do |spec|
492
+ [spec["vendored_frameworks"], spec["vendored_libraries"],
493
+ spec["ios"].is_a?(Hash) ? spec["ios"]["vendored_frameworks"] : nil,
494
+ spec["ios"].is_a?(Hash) ? spec["ios"]["vendored_libraries"] : nil]
495
+ end.flatten.compact.map(&:to_s)
496
+ return [] if entries.empty?
497
+
498
+ root = repo_module_source.component_root_dir
499
+ entries.flat_map { |entry| Dir.glob(File.join(root, entry)) }
500
+ .select { |path| File.exist?(path) }
501
+ .map { |path| File.expand_path(path) }
502
+ .uniq
503
+ end
504
+
505
+ # @return [Array<String>] 仅含真机 arm64 slice(且提供 x86_64 兜底)的预编译二进制相对路径。
506
+ # allowed_paths 非 nil 时仅保留声明在vendored库内的二进制(按目录前缀匹配)
507
+ def detect_device_only_arm64_binaries(source_path, allowed_paths: nil)
508
+ result = vendored_binaries(source_path).select do |bin|
509
+ archs = `lipo -archs #{Shellwords.escape(bin)} 2>/dev/null`.split
510
+ next false unless archs.include?('arm64')
511
+ next false unless archs.include?('x86_64')
512
+
513
+ !arm64_slice_supports_simulator?(bin)
514
+ end
515
+ unless allowed_paths.nil?
516
+ result = result.select do |bin|
517
+ expanded = File.expand_path(bin)
518
+ allowed_paths.any? { |path| expanded == path || expanded.start_with?(path + File::SEPARATOR) }
519
+ end
520
+ end
521
+ result.map { |bin| bin.sub("#{source_path}/", '') }
522
+ end
523
+
524
+ # 扫描 Pods 目录下各依赖安装的 xcframework / fat framework,
525
+ # 返回缺 arm64-simulator slice 但有 x86_64-simulator 兜底的框架名(供排除 arm64)
526
+ def detect_pods_device_only_arm64_binaries
527
+ pods_root = File.join(self.path, 'Pods')
528
+ return [] unless Dir.exist?(pods_root)
529
+
530
+ incompatible = []
531
+
532
+ # 1) xcframework:解析 Info.plist,缺 arm64-simulator 但有 x86_64-simulator 时回退 x86_64
533
+ Dir.glob(File.join(pods_root, '*', '*.xcframework')).sort.each do |xcframework|
534
+ next unless xcframework_can_fallback_x86_64?(xcframework)
535
+
536
+ incompatible << File.basename(xcframework, '.xcframework')
537
+ end
538
+
539
+ # 2) 老式 fat framework/静态库:按 arm64 slice 平台标记判定
540
+ incompatible.concat(
541
+ detect_device_only_arm64_binaries(pods_root)
542
+ .map { |relative| relative.split(File::SEPARATOR).first }
543
+ )
544
+
545
+ incompatible.uniq
546
+ end
547
+
548
+ # xcframework 模拟器 slice 支持的架构数组;非数组结构时返回 nil
549
+ def xcframework_simulator_archs(xcframework_path)
550
+ plist_path = File.join(xcframework_path, 'Info.plist')
551
+ return nil unless File.exist?(plist_path)
552
+
553
+ # 用系统 plutil 转 JSON 解析,避免依赖 CFPropertyList/Xcodeproj::Plist 的具体实现
554
+ json = `plutil -convert json -o - #{Shellwords.escape(plist_path)} 2>/dev/null`
555
+ plist = JSON.parse(json) rescue nil
556
+ libraries = plist.is_a?(Hash) ? plist['AvailableLibraries'] : nil
557
+ return nil unless libraries.is_a?(Array)
558
+
559
+ libraries.select { |lib| lib['SupportedPlatformVariant'] == 'simulator' }
560
+ .flat_map { |lib| Array(lib['SupportedArchitectures']) }
561
+ end
562
+
563
+ # xcframework 可安全回退为 x86_64-only 模拟器:有模拟器 slice、缺 arm64、但提供 x86_64。
564
+ # 完全没有模拟器 slice 的库不纳入(那是另一类配置问题,交由链接器报原错)
565
+ def xcframework_can_fallback_x86_64?(xcframework_path)
566
+ archs = xcframework_simulator_archs(xcframework_path)
567
+ return false if archs.nil? || archs.empty?
568
+
569
+ !archs.include?('arm64') && archs.include?('x86_64')
570
+ end
571
+
572
+ # 组件源码内的预编译二进制:*.framework/<框架名> 与顶层静态库 *.a;
573
+ # .xcframework 内部已按平台分 slice 由 ld 自动选择,跳过不检测
574
+ def vendored_binaries(source_path)
575
+ result = []
576
+ Dir.glob(File.join(source_path, '**', '*.framework')).sort.each do |framework|
577
+ next if framework.include?('.xcframework/')
578
+
579
+ binary = File.join(framework, File.basename(framework, '.framework'))
580
+ result << binary if File.file?(binary)
581
+ end
582
+ result.concat(Dir.glob(File.join(source_path, '**', '*.a')).reject { |path| path.include?('.xcframework/') }.sort)
583
+ # lipo 对非 Mach-O/非归档文件返回非 0,据此过滤掉 Headers 等同名项
584
+ result.uniq.select do |bin|
585
+ system("lipo -info #{Shellwords.escape(bin)}", out: File::NULL, err: File::NULL)
586
+ end
587
+ end
588
+
589
+ # arm64 slice 是否支持模拟器:现代 arm64-simulator 目标必定带
590
+ # LC_BUILD_VERSION 且 platform 为 7(iOSSimulator);其余情况(LC_VERSION_MIN_IPHONEOS 或 platform=iOS)均为真机
591
+ def arm64_slice_supports_simulator?(binary)
592
+ Tempfile.create(['arm64_slice', '.a']) do |tmp|
593
+ tmp.close
594
+ unless system("lipo #{Shellwords.escape(binary)} -thin arm64 -output #{Shellwords.escape(tmp.path)}",
595
+ out: File::NULL, err: File::NULL)
596
+ return false
597
+ end
598
+ load_info = `otool -l #{Shellwords.escape(tmp.path)} 2>/dev/null`
599
+ load_info.include?('LC_BUILD_VERSION') && load_info.match?(/\bplatform\s+7\b/)
600
+ end
601
+ end
602
+
603
+ private
604
+ # 打包
605
+ def do_archive(simulator = false)
606
+
607
+ current = Dir.pwd
608
+ Dir.chdir(self.path)
609
+
610
+ product_dir = simulator ? "ios_simulator" : "ios"
611
+ archive_path = "#{self.path}/build/#{product_dir}"
612
+ debug_mode_str = debug_mode ? "Debug" : "Release"
613
+ # generic/platform=iOS Simulator 通用模拟器目标默认按 ARCHS_STANDARD 同时产出
614
+ # arm64(Apple Silicon模拟器) + x86_64(Intel模拟器),合并后 slice 目录为 ios-arm64_x86_64-simulator,
615
+ # 可同时兼容两类Mac。如只需arm64可显式追加 ARCHS=arm64,不要再用已废弃的 VALID_ARCHS
616
+ destination = simulator ? "iOS Simulator" : "iOS"
617
+
618
+ # 记录路径
619
+ if simulator
620
+ @simulator_archive_path = "#{archive_path}.xcarchive"
621
+ else
622
+ @real_device_archive_path = "#{archive_path}.xcarchive"
623
+ end
624
+
625
+ command = "xcodebuild archive"
626
+ command += " -workspace \"#{self.xcworkspace}\""
627
+ command += " -scheme \"#{self.scheme}\""
628
+ command += " -configuration #{debug_mode_str}"
629
+ command += " -destination \"generic/platform=#{destination}\""
630
+
631
+ if code_signing
632
+ # 启用签名: 需要 Xcode 已登录对应团队账号并能自动获取描述文件
633
+ command += " -allowProvisioningUpdates -allowProvisioningDeviceRegistration"
634
+ command += " DEVELOPMENT_TEAM=#{development_team}" unless development_team.to_s.strip.empty?
635
+ else
636
+ # 不签名: 本工具 archive 仅为提取组件 .framework,产物由 App 正式打包时统一签名,
637
+ # 因此构建机无需任何证书/Apple ID/描述文件
638
+ command += " CODE_SIGN_IDENTITY=\"\""
639
+ command += " CODE_SIGNING_REQUIRED=NO"
640
+ command += " CODE_SIGNING_ALLOWED=NO"
641
+ end
642
+
643
+ command += " -archivePath \"#{archive_path}\""
644
+ command += " SKIP_INSTALL=NO"
645
+ command += " BUILD_LIBRARY_FOR_DISTRIBUTION=YES"
646
+ # 组件自带仅真机 arm64 的预编译库(老 fat framework/静态库)时,模拟器无法产出 arm64 slice,
647
+ # 强制只构建 x86_64(Apple Silicon 以 Rosetta 运行模拟器),否则链接报
648
+ # "building for iOS-simulator but linking object built for iOS"
649
+ if simulator && @simulator_exclude_arm64
650
+ command += " EXCLUDED_ARCHS=arm64"
651
+ end
652
+ # 默认静默减少日志,--verbose 时输出完整编译日志便于排查失败原因
653
+ command += " -quiet" unless Build.log.debug_enabled?
654
+
655
+ build_type_string = simulator ? "模拟器" : "真机"
656
+ Build.log.step("开始编译构建#{build_type_string}")
657
+ Build.log.debug("#{build_type_string}构建命令: #{command}")
658
+
659
+ res = system command
660
+
661
+ unless res == true
662
+ Build.log.error("编译失败(#{build_type_string}),构建命令: #{command}")
663
+ error = "编译失败(#{build_type_string})"
664
+ pusher.push("❌ 编译失败", error)
665
+ Process.exit(-1)
666
+ end
667
+ Build.log.detail("#{build_type_string}构建完成")
668
+
669
+ Dir.chdir(current)
670
+ end
671
+
672
+ private
673
+ def update_resource_bundles_paths(repo_module_source)
674
+ applications_path = "#{self.real_device_archive_path}/Products/Applications"
675
+
676
+ # 如果只构建模拟器的话 就没有真机的产物, 就从模拟器里找
677
+ unless Dir.exist?applications_path
678
+ applications_path = "#{self.simulator_archive_path}/Products/Applications"
679
+ end
680
+
681
+ # 汇总root及所有subspec的resource_bundles(多subspec整包构建)
682
+ bundle_names = repo_module_source.all_resource_bundle_names
683
+
684
+ return if bundle_names.empty?
685
+
686
+ # 获取bundle的路径
687
+ bundle_path_array = Array.new
688
+ bundle_names.each { |bundle_name|
689
+ bundle = "#{bundle_name}.bundle"
690
+ FileHelper.recursion_find(applications_path, bundle, bundle_path_array)
691
+ }
692
+ @resource_bundles_paths = bundle_path_array
693
+ end
694
+
695
+ def update_resource_paths(repo_module_source)
696
+ # 汇总root及所有subspec的resources glob(多subspec整包构建)
697
+ resources_array = repo_module_source.all_resource_globs
698
+
699
+ return if resources_array.empty?
700
+
701
+ search_root = repo_module_source.component_root_dir
702
+ # 遍历配置的资源文件(多glob重叠命中时去重)
703
+ resources_array.each { |resource|
704
+ # 原始资源文件路径(podspec所在目录为根),可能是通配符
705
+ Dir["#{search_root}/#{resource}"].select{|real_path|
706
+ @resource_paths << real_path
707
+ }
708
+ }
709
+ @resource_paths.uniq!
710
+ end
711
+
712
+ end
713
+ end