Module: CocoaPodsLocalOverride

Defined in:
lib/cocoapods_plugin.rb

Constant Summary collapse

CONFIG_FILE_NAME =

默认配置文件名(放在 Podfile 同级目录)

'Podfile.local'
ENV_DISABLE_KEY =

禁用插件的环境变量名

'COCOAPODS_LOCAL_OVERRIDE_DISABLE'
ENV_CONFIG_KEY =

自定义配置文件路径的环境变量名

'COCOAPODS_LOCAL_OVERRIDE_CONFIG'
PLUGIN_ROOT =

插件自身的根目录(gem 安装根路径),用于定位模板文件

File.expand_path('..', __dir__).freeze
EXAMPLE_FILE =

模板文件:当 Podfile.local 不存在时自动从该文件复制创建

'Podfile.local.example'
C_RESET =

===========================================================================

终端 ANSI 颜色常量

颜色体系: C_RESET / C_DIM — 重置与弱化(箭头、箭头两边的空格等辅助元素) C_CYAN — 主要信息展示色(pod 路径/版本号/分支名等值) C_GREEN — 成功信息([+] 前缀、配置创建等正面状态) C_YELLOW — 警告信息([x] 前缀、路径不存在等异常状态) C_MAGENTA — 未使用(保留) C_RED — 未使用(保留) LABEL_COLORS — 标签专用色:蓝绿色系渐变,视觉统一但不突兀

"\e[0m"
C_DIM =

重置所有样式

"\e[2m"
C_CYAN =

暗淡/弱化效果(用于箭头 → 等辅助元素)

"\e[36m"
C_GREEN =

青色(用于显示的路径/版本/分支名等值)

"\e[32m"
C_YELLOW =

绿色([+] 成功前缀)

"\e[33m"
C_MAGENTA =

黄色([x] 警告前缀)

"\e[35m"
C_RED =

品红(保留未使用)

"\e[31m"
COLORS =

pod 值的颜色映射(用于显示的路径/版本号/分支名等)

{
  local:   C_CYAN,
  version: C_GREEN,
  branch:  C_MAGENTA
}.freeze
LABEL_COLORS =

标签颜色映射([ LOCAL ] / [ VERSION ] / [ BRANCH ] 颜色) 使用 256 色调色板,蓝绿色系渐变:绿→青→蓝,同色系但有区分度

{
  local:   "\e[38;5;120m",  # 春绿
  version: "\e[38;5;80m",   # 青蓝
  branch:  "\e[38;5;39m"    # 湖蓝
}.freeze
LABELS =

标签文字映射

{
  local:   '[ LOCAL ]',
  version: '[ VERSION ]',
  branch:  '[ BRANCH ]'
}.freeze

Class Method Summary collapse

Class Method Details

.add_local_config_to_project(generated_projects) ⇒ Object

将 Podfile.local 添加到 Pods.xcodeproj 的主 Group 中,并移至首位

Parameters:

  • generated_projects (Array<Xcodeproj::Project>)

    CocoaPods 生成的工程列表



460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
# File 'lib/cocoapods_plugin.rb', line 460

def self.add_local_config_to_project(generated_projects)
  config_path = find_config_path
  return unless File.exist?(config_path)

  # 遍历所有生成的工程(通常只有 Pods.xcodeproj 一个)
  Array(generated_projects).each do |project|
    next unless project

    filename = File.basename(config_path)

    # 检查是否已经添加过(避免重复添加)
    already_added = project.files.any? do |f|
      f.path == filename || f.path == config_path ||
        (f.real_path.to_s == File.expand_path(config_path) rescue false)
    end
    next if already_added

    # 作为文件引用添加到工程
    project.add_podfile(config_path)

    # 将 Podfile.local 移到第一个位置(排在 Podfile 前面)
    main_group = project.main_group
    local_ref = main_group.children.find { |f| f.path == filename }

    if local_ref
      idx = main_group.children.index(local_ref)
      if idx && idx > 0
        # 移除后插入到首位
        main_group.children.delete_at(idx)
        main_group.children.insert(0, local_ref)
      end
    end

    project.save
    Pod::UI.puts "#{C_GREEN}[+] Added #{filename} to Pods project#{C_RESET}"
  end
end

.all_config_empty?Boolean

检查所有配置项是否均为空(包括 nil 和空 Hash) 用于快速判断是否需要安装 monkey-patch,避免无配置时浪费性能

Returns:

  • (Boolean)


386
387
388
389
# File 'lib/cocoapods_plugin.rb', line 386

def self.all_config_empty?
  cfg = load_config
  cfg.values.all? { |v| v.nil? || v.empty? }
end

.apply_podfile_hook!Object

拦截 Podfile#pod 方法:根据 Podfile.local 配置改写 pod 声明 在模块底部自动调用(加载即生效)



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
# File 'lib/cocoapods_plugin.rb', line 272

def self.apply_podfile_hook!
  return if @podfile_hook_applied

  # 预加载配置以触发 @max_name_len 计算
  load_config

  # 如果被禁用或配置为空,不安装钩子
  if disabled? || all_config_empty?
    @podfile_hook_applied = true
    return
  end

  # 打开 Pod::Podfile 的单例类(class_eval),注入自定义 pod 方法
  Pod::Podfile.class_eval do
    # 幂等检查:如果已经 alias 过则跳过
    unless method_defined?(:local_override_original_pod)
      # 1. 保存原始 pod 方法
      alias_method :local_override_original_pod, :pod

      # 2. 定义新的 pod 方法,加入覆盖逻辑
      define_method :pod do |*args|
        pod_name = args[0].to_s

        # 获取当前配置(每次调用都实时获取,但不触发重复解析)
        local_pods       = CocoaPodsLocalOverride.local_pods
        replace_versions = CocoaPodsLocalOverride.replace_versions
        replace_branches = CocoaPodsLocalOverride.replace_branches

        # 按优先级匹配:local > version > branch > 原始调用
        if local_pods && local_pods.key?(pod_name)
          handle_local(pod_name, local_pods[pod_name], args)
        elsif replace_versions && replace_versions.key?(pod_name)
          handle_version(pod_name, replace_versions[pod_name])
        elsif replace_branches && replace_branches.key?(pod_name)
          handle_branch(pod_name, replace_branches[pod_name], args)
        else
          # 未命中任何覆盖,调用原始方法
          local_override_original_pod(*args)
        end
      end

      # =====================================================================
      # 私有辅助方法(注入 Pod::Podfile 实例)
      # =====================================================================

      private

      # 处理 local 类型覆盖:将 pod 指向本地绝对路径
      # 路径不存在时回退到远程版本,避免构建中断
      define_method :handle_local do |name, dev_path, original_args|
        # 相对路径 → 基于 Podfile 目录计算绝对路径
        abs_path = resolve_absolute_path(dev_path)

        if File.directory?(abs_path)
          # 打印覆盖信息,格式: [ LOCAL ] PodName → ../relative/path
          Pod::UI.puts CocoaPodsLocalOverride.format_pod_line(:local, name, dev_path)
          # 使用 :path 选项指向本地目录,:inhibit_warnings => false 保留编译警告
          local_override_original_pod(name, :path => abs_path, :inhibit_warnings => false)
        else
          Pod::UI.warn "#{CocoaPodsLocalOverride::C_YELLOW}[x] path not found: #{abs_path}, use remote for #{name}#{CocoaPodsLocalOverride::C_RESET}"
          local_override_original_pod(*original_args)
        end
      end

      # 处理 version 类型覆盖:将 pod 锁定到指定版本号
      define_method :handle_version do |name, new_version|
        Pod::UI.puts CocoaPodsLocalOverride.format_pod_line(:version, name, new_version)
        # 直接传递版本号字符串作为第二个位置参数
        local_override_original_pod(name, new_version, :inhibit_warnings => false)
      end

      # 处理 branch 类型覆盖:将 pod 的 :branch 选项替换为目标分支
      # 前提:原始声明中第二个参数必须是 Hash(如 pod 'X', :git => '...', :branch => '...')
      define_method :handle_branch do |name, new_branch, args|
        Pod::UI.puts CocoaPodsLocalOverride.format_pod_line(:branch, name, new_branch)

        args_copy = args.dup
        if args_copy[1].is_a?(Hash)
          # 合并 :branch key,覆盖原始值
          args_copy[1] = args_copy[1].merge(:branch => new_branch)
        else
          # 第二个参数不是 Hash 时无法安全替换,跳过并警告
          Pod::UI.warn "#{CocoaPodsLocalOverride::C_YELLOW}[x] #{name}: 2nd arg not Hash, branch replace skipped#{CocoaPodsLocalOverride::C_RESET}"
        end
        local_override_original_pod(*args_copy)
      end

      # 将相对路径转换为基于 Podfile 所在目录的绝对路径
      # 已是绝对路径则原样返回
      define_method :resolve_absolute_path do |rel_path|
        p = Pathname.new(rel_path)
        return rel_path if p.absolute?

        # 基准目录:Podfile 所在目录,兜底为当前工作目录
        base = begin
                 File.dirname(Pod::Config.instance.podfile_path)
               rescue StandardError
                 Dir.pwd
               end
        File.expand_path(rel_path, base)
      end
    end
  end

  @podfile_hook_applied = true
end

.apply_pods_project_hook!Object

安装 Pods 工程钩子:拦截 create_and_save_projects 以添加 Podfile.local



437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
# File 'lib/cocoapods_plugin.rb', line 437

def self.apply_pods_project_hook!
  return if @pods_project_hook_applied
  # 触发配置加载(可能在 Podfile 不存在时触发自动创建)
  load_config
  @pods_project_hook_applied = true

  Pod::Installer.class_eval do
    unless method_defined?(:local_override_original_create_and_save_projects)
      alias_method :local_override_original_create_and_save_projects, :create_and_save_projects

      # 在原始方法执行完后,额外将 Podfile.local 添加到生成的 Pods 工程中
      define_method :create_and_save_projects do |*args|
        local_override_original_create_and_save_projects(*args)

        CocoaPodsLocalOverride.add_local_config_to_project(@generated_projects)
      end
    end
  end
end

.apply_transitive_dependency_hook!Object

安装传递依赖注入钩子:在 resolve_dependencies 执行前注入



620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
# File 'lib/cocoapods_plugin.rb', line 620

def self.apply_transitive_dependency_hook!
  return if @transitive_dependency_hook_applied
  load_config

  # 禁用或没有本地 pod 时跳过
  if disabled? || local_pods.nil? || local_pods.empty?
    @transitive_dependency_hook_applied = true
    return
  end

  Pod::Installer.class_eval do
    unless method_defined?(:local_override_original_resolve_deps)
      alias_method :local_override_original_resolve_deps, :resolve_dependencies

      # 在依赖解析之前注入传递依赖,确保本地版本优先被解析
      define_method :resolve_dependencies do
        CocoaPodsLocalOverride.inject_transitive_local_pods(podfile)
        local_override_original_resolve_deps
      end
    end
  end

  @transitive_dependency_hook_applied = true
end

.disabled?Boolean

检查插件是否被环境变量禁用 首次调用时读取 COCOAPODS_LOCAL_OVERRIDE_DISABLE,结果缓存到 @disabled。 支持的值: 1, true, yes(不区分大小写)

Returns:

  • (Boolean)

    true 表示已禁用



114
115
116
117
118
119
120
121
122
123
124
# File 'lib/cocoapods_plugin.rb', line 114

def self.disabled?
  return @disabled unless @disabled.nil?

  val = ENV[ENV_DISABLE_KEY]
  # 仅当环境变量值为 1 / true / yes 时才算禁用
  @disabled = %w[1 true yes].include?(val.to_s.downcase)
  if @disabled
    Pod::UI.puts "#{C_DIM}[i] cocoapods-local-override disabled by #{ENV_DISABLE_KEY}=#{val}#{C_RESET}"
  end
  @disabled
end

.ensure_config_file(config_path) ⇒ Object

如果 Podfile.local 不存在,从 gem 内置的 Podfile.local.example 模板 复制一份到 Podfile 同级目录,方便开发者快速上手。

Parameters:

  • config_path (String)

    目标配置文件路径



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/cocoapods_plugin.rb', line 160

def self.ensure_config_file(config_path)
  return if File.exist?(config_path)

  example_path = File.join(PLUGIN_ROOT, EXAMPLE_FILE)

  unless File.exist?(example_path)
    Pod::UI.warn "#{C_YELLOW}[x] Template #{EXAMPLE_FILE} not found in gem#{C_RESET}"
    return
  end

  begin
    # 确保目标目录存在
    FileUtils.mkdir_p(File.dirname(config_path))
    # 复制模板文件(copy_file 保留原始文件权限)
    FileUtils.copy_file(example_path, config_path)
    Pod::UI.puts "#{C_GREEN}[+] Created #{File.basename(config_path)} from #{EXAMPLE_FILE}#{C_RESET}"
  rescue StandardError => e
    Pod::UI.warn "#{C_YELLOW}[x] Failed to create #{config_path}: #{e.message}#{C_RESET}"
  end
end

.find_config_pathString

定位配置文件路径,优先级:

  1. 环境变量 COCOAPODS_LOCAL_OVERRIDE_CONFIG 指定的路径
  2. 默认:Podfile 所在目录/Podfile.local
  3. 兜底:当前工作目录/Podfile.local

Returns:

  • (String)

    配置文件绝对路径



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/cocoapods_plugin.rb', line 131

def self.find_config_path
  # 优先使用环境变量指定的自定义路径
  custom = ENV[ENV_CONFIG_KEY]
  unless custom.to_s.strip.empty?
    custom = custom.strip
    if File.exist?(custom)
      return custom
    else
      Pod::UI.warn "#{C_YELLOW}[x] #{ENV_CONFIG_KEY}=#{custom} not found, fallback to default#{C_RESET}"
    end
  end

  # 默认:从 Podfile 所在目录拼接 Podfile.local
  podfile = begin
              Pod::Config.instance.podfile_path
            rescue StandardError
              nil
            end
  base = podfile ? File.dirname(podfile) : Dir.pwd
  File.join(base, CONFIG_FILE_NAME)
end

.format_pod_line(category, name, value, via: nil) ⇒ String

格式化输出一行 pod 覆盖信息,格式示例: [ LOCAL ] BatchGoodsManager → ../BusinessLayer/BatchGoodsManager [ VERSION ] MyPod → 1.2.3 [ BRANCH ] DeliverGoods → develop-9.6.2

Parameters:

  • category (Symbol)

    :local / :version / :branch

  • name (String)

    pod 名称(自动对齐到 @max_name_len)

  • value (String)

    显示的值(路径、版本号或分支名)

  • via (String, nil) (defaults to: nil)

    可选,传递依赖的上级 pod 名称,输出为 (via xxx)

Returns:

  • (String)

    格式化后的 ANSI 颜色字符串



401
402
403
404
405
406
407
408
409
410
411
412
# File 'lib/cocoapods_plugin.rb', line 401

def self.format_pod_line(category, name, value, via: nil)
  label  = LABELS[category]
  if via
    via_visible = " (via #{via})"
    name_str    = "#{name}#{C_DIM}#{via_visible}#{C_RESET}"
    pad_len     = [@max_name_len - name.length - via_visible.length, 0].max
    padded      = "#{name_str}#{' ' * pad_len}"
  else
    padded = name.ljust(@max_name_len)
  end
  "#{LABEL_COLORS[category]}#{label}#{C_RESET} #{padded} #{C_DIM}#{C_RESET}  #{C_CYAN}#{value}#{C_RESET}"
end

.inject_transitive_local_pods(podfile) ⇒ Object

BFS 遍历依赖树,将未声明但已在 local_pods 中的传递依赖自动注入

Parameters:

  • podfile (Pod::Podfile)

    当前 Podfile 实例



543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
# File 'lib/cocoapods_plugin.rb', line 543

def self.inject_transitive_local_pods(podfile)
  lp = local_pods
  return if lp.nil? || lp.empty?

  # 确定 Podfile 所在目录(用于计算相对路径的绝对路径)
  podfile_dir = begin
                  File.dirname(podfile.defined_in_file || Dir.pwd)
                rescue StandardError
                  Dir.pwd
                end

  # 收集 Podfile 中已显式声明的所有 pod 名称
  declared_names = Set.new
  podfile.target_definitions.each_value do |td|
    td.non_inherited_dependencies.each { |dep| declared_names.add(dep.name) }
  end

  # BFS 初始化:起始队列 = 已声明且同时存在于 local_pods 中的 pod
  visit_queue = declared_names.select { |n| lp.key?(n) }
  visited = Set.new(visit_queue)  # 已访问集合,防止循环依赖

  # 获取根 target definition(Pods),后续注入都在这里进行
  root_td = podfile.target_definitions['Pods'] || podfile.target_definitions.values.first
  return unless root_td

  # BFS 主循环
  while (name = visit_queue.shift)
    # 计算本地路径的绝对路径
    abs_path = File.expand_path(lp[name], podfile_dir)
    podspec_path = resolve_podspec_path(abs_path, name)

    unless podspec_path
      Pod::UI.warn "#{C_YELLOW}[x] #{name}: no podspec found in #{abs_path}#{C_RESET}"
      next
    end

    # 解析 podspec 获取依赖列表
    begin
      spec = Pod::Specification.from_file(podspec_path)
    rescue StandardError => e
      Pod::UI.warn "#{C_YELLOW}[x] #{name}: failed to parse #{podspec_path}#{e.message}#{C_RESET}"
      next
    end

    # 遍历每个依赖
    spec.dependencies.each do |dep|
      # 兼容不同的依赖表示:Pod::Dependency 或 [name, requirement] 数组
      dep_name = dep.respond_to?(:name) ? dep.name : dep.first
      dep_name = dep_name.to_s

      # 跳过不在 local_pods 中的依赖
      next unless lp.key?(dep_name)
      # 跳过已在 Podfile 中显式声明的依赖
      next if declared_names.include?(dep_name)
      # 跳过已访问过的依赖(防止循环)
      next if visited.include?(dep_name)

      dep_rel_path = lp[dep_name]
      dep_abs_path = File.expand_path(dep_rel_path, podfile_dir)

      unless File.directory?(dep_abs_path)
        Pod::UI.warn "#{C_YELLOW}[x] #{dep_name} (dep of #{name}): path not found #{dep_abs_path}#{C_RESET}"
        next
      end

      # 注入到根 target definition 中
      root_td.store_pod(dep_name, :path => dep_abs_path, :inhibit_warnings => false)
      declared_names.add(dep_name)   # 标记为已声明,防止后续重复注入
      visited.add(dep_name)          # 标记为已访问
      visit_queue.push(dep_name)     # 加入队列继续 BFS

      Pod::UI.puts CocoaPodsLocalOverride.format_pod_line(:local, dep_name, dep_rel_path, via: name)
    end
  end
end

.load_configHash

加载并解析 Podfile.local(YAML 格式),结果缓存到 @config。 典型配置结构:

local_pods:        { PodName: "../relative/path" }
replace_versions:  { PodName: "1.2.3" }
replace_branches:  { PodName: "develop-x.y.z" }

Returns:

  • (Hash)

    解析后的配置 Hash,含 local_pods / replace_versions / replace_branches 三个 key



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/cocoapods_plugin.rb', line 192

def self.load_config
  return @config if @config_loaded

  @config_loaded = true

  # 环境变量禁用时直接返回空配置,不执行后续逻辑
  if disabled?
    @config = {}
    return @config
  end

  config_path = find_config_path

  # 文件不存在则自动创建模板
  ensure_config_file(config_path)

  # 创建后仍可能为空(如环境变量指定了不存在的文件)
  unless File.exist?(config_path)
    @config = {}
    return @config
  end

  # 解析 YAML,使用 safe_load(Psych 默认行为)
  raw = YAML.load_file(config_path)
  @config = {
    'local_pods'       => normalize_hash(raw&.dig('local_pods')),
    'replace_versions' => normalize_hash(raw&.dig('replace_versions')),
    'replace_branches' => normalize_hash(raw&.dig('replace_branches'))
  }

  # 计算所有 pod 名称的最大长度,用于输出时的列对齐
  all_names = @config.values.flat_map(&:keys)
  @max_name_len = all_names.map(&:length).max || 0
  @config
rescue Psych::SyntaxError => e
  Pod::UI.warn "#{C_YELLOW}[x] YAML parse error in #{File.basename(config_path)}: #{e.message}#{C_RESET}"
  @config = {}
rescue StandardError => e
  Pod::UI.warn "#{C_YELLOW}[x] Failed to load #{File.basename(config_path)}: #{e.message}#{C_RESET}"
  @config = {}
end

.local_podsHash

Returns 本地 pod 映射表 => relative_path.

Returns:

  • (Hash)

    本地 pod 映射表 => relative_path



239
240
241
# File 'lib/cocoapods_plugin.rb', line 239

def self.local_pods
  load_config['local_pods']
end

.normalize_hash(raw) ⇒ Hash{String => String}

标准化 YAML 中的 Hash 值:

  1. 所有 key 转为 String 并 strip 空白
  2. 所有 value 转为 String 并 strip 空白
  3. 跳过空 key
  4. nil 或非 Hash 输入返回空 {}

Parameters:

  • raw (Object)

    YAML 解析后的原始值

Returns:

  • (Hash{String => String})


421
422
423
424
425
426
# File 'lib/cocoapods_plugin.rb', line 421

def self.normalize_hash(raw)
  return {} if raw.nil? || !raw.is_a?(Hash)
  raw.each_with_object({}) do |(k, v), h|
    h[k.to_s.strip] = v.to_s.strip unless k.to_s.strip.empty?
  end
end

.replace_branchesHash

Returns 分支替换映射表 => branch_name.

Returns:

  • (Hash)

    分支替换映射表 => branch_name



249
250
251
# File 'lib/cocoapods_plugin.rb', line 249

def self.replace_branches
  load_config['replace_branches']
end

.replace_versionsHash

Returns 版本替换映射表 => version_string.

Returns:

  • (Hash)

    版本替换映射表 => version_string



244
245
246
# File 'lib/cocoapods_plugin.rb', line 244

def self.replace_versions
  load_config['replace_versions']
end

.resolve_podspec_path(local_path, pod_name = nil) ⇒ String?

从本地 pod 目录中查找 podspec 文件 优先按 pod 名称查找(Name.podspec / Name.podspec.json), 回退到扫描目录下任意 podspec。

Parameters:

  • local_path (String)

    pod 本地目录绝对路径

  • pod_name (String) (defaults to: nil)

    pod 名称(可选,用于精确匹配)

Returns:

  • (String, nil)

    podspec 文件路径,未找到返回 nil



524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
# File 'lib/cocoapods_plugin.rb', line 524

def self.resolve_podspec_path(local_path, pod_name = nil)
  return nil unless File.directory?(local_path)

  # 优先按 pod 名称查找(更精确)
  if pod_name && !pod_name.strip.empty?
    [File.join(local_path, "#{pod_name}.podspec"),
     File.join(local_path, "#{pod_name}.podspec.json")].each do |c|
      return c if File.exist?(c)
    end
  end

  # 回退:找目录下任意 .podspec(Ruby 格式优先于 JSON)
  (Dir.glob(File.join(local_path, '*.podspec')) +
   Dir.glob(File.join(local_path, '*.podspec.json'))).first
end