pindo 5.18.13 → 5.18.20

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.
@@ -51,93 +51,108 @@ module Pindo
51
51
 
52
52
  root_dir = @git_root_path
53
53
  git_repo_helper = Pindo::GitRepoHelper.share_instance
54
- stash_saved = false
55
54
  stash_name = "pindo_stash_#{Time.now.strftime('%Y%m%d%H%M%S')}_#{rand(1000)}"
56
55
  coding_branch = Pindo::GitHandler.git!(%W(-C #{root_dir} rev-parse --abbrev-ref HEAD)).strip
56
+ git_commit_data = get_data_param_by_key(:git_commit)
57
+ stash_saved = false
57
58
 
58
59
  begin
59
- # 1. 检查工作目录,不干净则 stash
60
- status = Pindo::GitHandler.git!(%W(-C #{root_dir} status --porcelain)).strip
61
- unless status.empty?
62
- Funlog.instance.fancyinfo_update("工作目录有未提交更改,暂存到 stash: #{stash_name}")
63
- Pindo::GitHandler.git!(%W(-C #{root_dir} stash push -u -m #{stash_name}))
64
- stash_saved = true
65
- end
66
-
67
- # 2. 从 GitCommitTask 获取参数(必须在合并之前,确保 release_branch 等参数正确)
68
- git_commit_data = get_data_param_by_key(:git_commit)
69
- if git_commit_data && git_commit_data[:task_param]
70
- param = git_commit_data[:task_param]
71
- @release_branch = param[:release_branch] if param[:release_branch]
72
- @tag_pre = param[:tag_pre] if param[:tag_pre]
73
- @tag_type = param[:tag_type] if param[:tag_type]
74
- @ver_inc = param[:ver_inc] if param[:ver_inc]
75
- end
76
-
77
- # 3. 合并到发布分支(使用已回填的 @release_branch)
78
- if coding_branch != @release_branch
79
- Pindo::GitHandler.merge_branches(
80
- project_dir: root_dir,
81
- source_branch: coding_branch,
82
- target_branch: @release_branch,
83
- sync_branches: true # 双向合并,确保两分支完全同步
84
- )
85
- end
86
-
87
- # 4. 获取版本信息
88
- if git_commit_data && git_commit_data[:task_param] &&
89
- git_commit_data[:task_param][:build_version] && git_commit_data[:task_param][:build_number]
90
- @build_version = git_commit_data[:task_param][:build_version]
91
- @build_number = git_commit_data[:task_param][:build_number]
92
- Funlog.instance.fancyinfo_success("使用 GitCommitTask 的版本号: #{@build_version}, Build: #{@build_number}, Tag前缀: #{@tag_pre}, Tag类型: #{@tag_type}")
93
-
94
- # 降级:自己计算(向后兼容或 GitCommitTask 无有效数据)
95
- else
96
- @build_number, @commit_hash = git_repo_helper.get_build_number_from_commit(project_dir: root_dir)
97
- @build_version = git_repo_helper.calculate_build_version(
98
- project_dir: root_dir,
99
- tag_prefix: @tag_pre,
100
- create_tag_type: @tag_type,
101
- version_increase_type: @ver_inc
102
- )
103
- commit_short = @commit_hash ? @commit_hash[0..7] : "unknown"
104
- Funlog.instance.fancyinfo_warning("未找到 GitCommitTask 依赖,降级为自己计算版本号: #{@build_version}, Build: #{@build_number}, Commit: #{commit_short}")
105
- end
106
-
107
- # 4. 创建并推送 Tag
60
+ # 1. 工作目录不干净则暂存
61
+ stash_saved = stash_if_dirty(root_dir, stash_name)
62
+
63
+ # 2. GitCommitTask 回填参数(须在合并之前,确保 release_branch 等正确)
64
+ backfill_params_from_commit_task(git_commit_data)
65
+
66
+ # 3. 合并到发布分支
67
+ merge_to_release_branch(root_dir, coding_branch)
68
+
69
+ # 4. 解析版本号
70
+ resolve_build_version(root_dir, git_commit_data, git_repo_helper)
71
+
72
+ # 5. 创建并推送 Tag
108
73
  tag_name = "#{@tag_pre}#{@build_version}"
109
74
  git_repo_helper.create_and_push_tag(
110
75
  project_dir: root_dir,
111
76
  tag_name: tag_name,
112
77
  tag_type: @tag_type
113
78
  )
114
-
115
79
  ensure
116
- # 5. 还原 stash
117
-
118
- if stash_saved
119
- begin
120
- # 查找指定名字的 stash
121
- stash_list = Pindo::GitHandler.git!(%W(-C #{root_dir} stash list)).strip
122
- matched_line = stash_list.lines.find { |l| l.include?(stash_name) }
123
-
124
- if matched_line && (stash_id = matched_line.match(/(stash@\{\d+\})/)&.[](1))
125
- # 先应用 stash(不删除)
126
- Pindo::GitHandler.git!(%W(-C #{root_dir} stash apply #{stash_id}))
127
- # 应用成功后再删除 stash
128
- Pindo::GitHandler.git!(%W(-C #{root_dir} stash drop #{stash_id}))
129
- Funlog.instance.fancyinfo_success("已还原 stash: #{stash_name} (#{stash_id})")
130
- else
131
- Funlog.instance.fancyinfo_warning("未找到名为 #{stash_name} 的 stash,请手动检查")
132
- end
133
- rescue => e
134
- # 如果应用失败(通常是有冲突),stash 仍然保留,用户可以手动处理
135
- Funlog.instance.fancyinfo_warning("stash 还原时发生冲突: #{e.message}")
136
- Funlog.instance.fancyinfo_warning("stash 已保留,请手动执行 'git stash pop' 处理冲突")
137
- # 不抛出异常,任务仍然标记为成功(因为 tag 已经创建成功)
138
- end
139
- end
80
+ # 6. 还原 stash
81
+ restore_stash(root_dir, stash_name) if stash_saved
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # 工作目录有未提交更改则暂存,返回是否已暂存
88
+ def stash_if_dirty(root_dir, stash_name)
89
+ status = Pindo::GitHandler.git!(%W(-C #{root_dir} status --porcelain)).strip
90
+ return false if status.empty?
91
+
92
+ Funlog.instance.fancyinfo_update("工作目录有未提交更改,暂存到 stash: #{stash_name}")
93
+ Pindo::GitHandler.git!(%W(-C #{root_dir} stash push -u -m #{stash_name}))
94
+ true
95
+ end
96
+
97
+ # GitCommitTask 回填 release_branch / tag_pre / tag_type / ver_inc
98
+ def backfill_params_from_commit_task(git_commit_data)
99
+ return unless git_commit_data && git_commit_data[:task_param]
100
+
101
+ param = git_commit_data[:task_param]
102
+ @release_branch = param[:release_branch] if param[:release_branch]
103
+ @tag_pre = param[:tag_pre] if param[:tag_pre]
104
+ @tag_type = param[:tag_type] if param[:tag_type]
105
+ @ver_inc = param[:ver_inc] if param[:ver_inc]
106
+ end
107
+
108
+ # 将编码分支双向合并到发布分支,确保两分支同步到同一节点
109
+ def merge_to_release_branch(root_dir, coding_branch)
110
+ return if coding_branch == @release_branch
111
+
112
+ Pindo::GitHandler.merge_branches(
113
+ project_dir: root_dir,
114
+ source_branch: coding_branch,
115
+ target_branch: @release_branch,
116
+ sync_branches: true # 双向合并,确保两分支完全同步
117
+ )
118
+ end
119
+
120
+ # 解析构建版本号:优先用 GitCommitTask 的,否则自己计算(向后兼容)
121
+ def resolve_build_version(root_dir, git_commit_data, git_repo_helper)
122
+ if git_commit_data && git_commit_data[:task_param] &&
123
+ git_commit_data[:task_param][:build_version] && git_commit_data[:task_param][:build_number]
124
+ @build_version = git_commit_data[:task_param][:build_version]
125
+ @build_number = git_commit_data[:task_param][:build_number]
126
+ Funlog.instance.fancyinfo_success("使用 GitCommitTask 的版本号: #{@build_version}, Build: #{@build_number}, Tag前缀: #{@tag_pre}, Tag类型: #{@tag_type}")
127
+ else
128
+ @build_number, @commit_hash = git_repo_helper.get_build_number_from_commit(project_dir: root_dir)
129
+ @build_version = git_repo_helper.calculate_build_version(
130
+ project_dir: root_dir,
131
+ tag_prefix: @tag_pre,
132
+ create_tag_type: @tag_type,
133
+ version_increase_type: @ver_inc
134
+ )
135
+ commit_short = @commit_hash ? @commit_hash[0..7] : "unknown"
136
+ Funlog.instance.fancyinfo_warning("未找到 GitCommitTask 依赖,降级为自己计算版本号: #{@build_version}, Build: #{@build_number}, Commit: #{commit_short}")
137
+ end
138
+ end
139
+
140
+ # 还原之前暂存的 stash;冲突时保留 stash 并告警,不抛异常(tag 已创建成功)
141
+ def restore_stash(root_dir, stash_name)
142
+ stash_list = Pindo::GitHandler.git!(%W(-C #{root_dir} stash list)).strip
143
+ matched_line = stash_list.lines.find { |l| l.include?(stash_name) }
144
+
145
+ if matched_line && (stash_id = matched_line.match(/(stash@\{\d+\})/)&.[](1))
146
+ # 先应用 stash(不删除),成功后再删除
147
+ Pindo::GitHandler.git!(%W(-C #{root_dir} stash apply #{stash_id}))
148
+ Pindo::GitHandler.git!(%W(-C #{root_dir} stash drop #{stash_id}))
149
+ Funlog.instance.fancyinfo_success("已还原 stash: #{stash_name} (#{stash_id})")
150
+ else
151
+ Funlog.instance.fancyinfo_warning("未找到名为 #{stash_name} 的 stash,请手动检查")
140
152
  end
153
+ rescue => e
154
+ Funlog.instance.fancyinfo_warning("stash 还原时发生冲突: #{e.message}")
155
+ Funlog.instance.fancyinfo_warning("stash 已保留,请手动执行 'git stash pop' 处理冲突")
141
156
  end
142
157
 
143
158
  end
@@ -1,5 +1,6 @@
1
1
  require 'pindo/module/task/model/unity_task'
2
2
  require 'pindo/base/funlog'
3
+ require 'json'
3
4
 
4
5
  module Pindo
5
6
  module TaskSystem
@@ -18,6 +19,7 @@ module Pindo
18
19
  # - :project_path [String] Unity 项目路径(默认当前目录)
19
20
  # - :export_path [String] 导出路径(nil 表示自动检测)
20
21
  # - :deploy_mode [String] 部署模式 ('dev', 'adhoc', 'release'),默认 'dev'
22
+ # - :app_config [Hash] 需写入工程 AppConfig 的键值(传入则在导出前先改写 AppConfig)
21
23
  # - :context [Hash] 上下文信息(如 index_count)
22
24
  def initialize(platform, options = {})
23
25
  @platform = platform
@@ -26,6 +28,7 @@ module Pindo
26
28
  @index_count = options[:context] ? options[:context][:index_count] : nil
27
29
  @export_path = options[:export_path] # 可由参数传入,nil 表示自动检测
28
30
  @deploy_mode = options[:deploy_mode] || 'dev' # 部署模式:dev/adhoc/release,默认 dev
31
+ @app_config = options[:app_config] # 需写入 AppConfig 的键值,nil/空表示跳过
29
32
 
30
33
  name = case platform
31
34
  when 'ios', 'ipa'
@@ -57,6 +60,9 @@ module Pindo
57
60
  protected
58
61
 
59
62
  def do_work
63
+ # 若传入了配置参数,先改写工程内的 AppConfig
64
+ update_app_config_file if @app_config && !@app_config.empty?
65
+
60
66
  # 获取 Unity 版本
61
67
  project_unity_version = get_unity_version
62
68
 
@@ -106,6 +112,44 @@ module Pindo
106
112
 
107
113
  private
108
114
 
115
+ # 使用传入的配置改写 Unity 工程内的 AppConfig(TextAsset JSON)
116
+ # 仅覆盖 @app_config 中提供的键,其余键保持原值
117
+ def update_app_config_file
118
+ app_config_file = File.join(@unity_root_path, "Assets", "Resources", "AppConfig", "AppConfig.json")
119
+ unless File.exist?(app_config_file)
120
+ Funlog.warning("未找到 AppConfig 文件,跳过 AppConfig 更新:#{app_config_file}")
121
+ return
122
+ end
123
+
124
+ puts "更新 AppConfig: #{app_config_file}"
125
+
126
+ begin
127
+ app_config_json = JSON.parse(File.read(app_config_file))
128
+ rescue JSON::ParserError => e
129
+ Funlog.warning("AppConfig 不是合法 JSON,跳过更新:#{e.message}")
130
+ return
131
+ end
132
+
133
+ changed = false
134
+ @app_config.each do |key, value|
135
+ next if value.nil? || value.to_s.strip.empty?
136
+ new_value = value.to_s.strip
137
+ old_value = app_config_json[key]
138
+ next if old_value.to_s == new_value
139
+
140
+ app_config_json[key] = new_value
141
+ puts " ✓ #{key}: #{old_value} → #{new_value}"
142
+ changed = true
143
+ end
144
+
145
+ if changed
146
+ File.open(app_config_file, "w") { |f| f.write(JSON.pretty_generate(app_config_json)) }
147
+ puts "✅ AppConfig 更新完成\n"
148
+ else
149
+ puts "AppConfig 无需更新(值一致)\n"
150
+ end
151
+ end
152
+
109
153
  # 导出 iOS
110
154
  def export_ios
111
155
  platform = 'iOS'
@@ -41,12 +41,57 @@ module Pindo
41
41
  end
42
42
  end
43
43
 
44
+ # 按依赖拓扑排序展示真实执行顺序
45
+ ordered = topological_execution_order(@queue.pending_snapshot)
46
+ is_serial = strategy.respond_to?(:name) && strategy.name.include?("串行")
47
+ puts "\e[34m"
48
+ if is_serial
49
+ puts "\e[34m" + " 实际执行顺序:"
50
+ else
51
+ puts "\e[34m" + " 执行顺序预览(并发模式下实际顺序受运行时调度影响):"
52
+ end
53
+ ordered.each_with_index do |task, index|
54
+ puts "\e[34m" + " #{index + 1}. #{task.name}"
55
+ end
56
+
44
57
  puts "\e[34m"
45
58
  puts "\e[34m" + " 总计: #{@queue.pending_count} 个任务"
46
59
  puts "\e[34m" + "=" * 60 + "\e[0m"
47
60
  puts "\n"
48
61
  end
49
62
 
63
+ # 按依赖关系生成执行顺序(贪心拓扑排序,模拟串行调度:
64
+ # 每轮取「入队顺序中第一个依赖已满足」的任务),与串行实际执行顺序一致
65
+ # @param tasks [Array<PindoTask>] 待排序任务(入队顺序)
66
+ # @return [Array<PindoTask>] 按执行顺序排列的任务
67
+ def topological_execution_order(tasks)
68
+ all_ids = tasks.map(&:id)
69
+ remaining = tasks.dup
70
+ completed_ids = []
71
+ ordered = []
72
+
73
+ until remaining.empty?
74
+ index = remaining.index do |task|
75
+ task.dependencies.all? do |dep_id|
76
+ # 依赖已完成,或依赖不在本批任务中(视为已提前完成)
77
+ completed_ids.include?(dep_id) || !all_ids.include?(dep_id)
78
+ end
79
+ end
80
+
81
+ # 存在循环依赖或依赖缺失,剩余任务按原顺序追加并结束
82
+ if index.nil?
83
+ ordered.concat(remaining)
84
+ break
85
+ end
86
+
87
+ task = remaining.delete_at(index)
88
+ completed_ids << task.id
89
+ ordered << task
90
+ end
91
+
92
+ ordered
93
+ end
94
+
50
95
  # 输出任务执行标题
51
96
  # @param task [PindoTask] 任务对象
52
97
  def print_task_title(task)
@@ -224,41 +224,29 @@ module Pindo
224
224
  end
225
225
  end
226
226
 
227
- # 获取Build号(从commit hash转换而来)
227
+ # 获取Build号(基于 git 提交总数,单调递增)
228
+ # 对标 fastlane number_of_commits:versionCode = git rev-list --count HEAD
228
229
  # @param project_dir [String] 项目目录路径
229
- # @return [Integer, String] 返回 build_number commit_hash
230
- # - build_number: Build号(commit hash前6位16进制转10进制)
231
- # - commit_hash: 完整的commit hash(40位)
230
+ # @return [Array(Integer, String)] build_number 和完整 commit_hash
231
+ # - build_number: 当前 HEAD 的 commit 总数(最小为 1)
232
+ # - commit_hash: 完整的 commit hash(40位)
232
233
  def get_build_number_from_commit(project_dir: nil)
233
234
  raise ArgumentError, "项目目录不能为空" if project_dir.nil?
234
235
 
235
- # 获取git根目录
236
236
  git_root = Pindo::GitHandler.git_root_directory(local_repo_dir: project_dir)
237
- return 1, nil unless git_root
238
-
239
- begin
240
- # 获取当前HEAD的完整commit hash
241
- full_commit_hash = Pindo::GitHandler.git!(%W(-C #{git_root} rev-parse HEAD)).strip
242
-
243
- # 取前6位计算build_number
244
- short_hash = full_commit_hash[0..5]
237
+ raise Pindo::Informative, "非 Git 仓库,无法计算 versionCode: #{project_dir}" unless git_root
245
238
 
246
- # 将16进制转换为10进制
247
- build_number = short_hash.to_i(16)
248
-
249
- # 确保在Android versionCode安全范围内(最大值为2^31-1)
250
- max_value = 2**31 - 1
251
- build_number = build_number % max_value if build_number > max_value
239
+ # shallow clone 下 rev-list --count 结果不完整,必须先 unshallow
240
+ shallow = Pindo::GitHandler.git!(%W(-C #{git_root} rev-parse --is-shallow-repository)).strip
241
+ if shallow == "true"
242
+ raise Pindo::Informative, "shallow clone 无法计算 versionCode,请执行 git fetch --unshallow 后重试"
243
+ end
252
244
 
253
- # 确保build_number不为0
254
- build_number = 1 if build_number == 0
245
+ count = Pindo::GitHandler.git!(%W(-C #{git_root} rev-list --count HEAD)).strip.to_i
246
+ full_commit_hash = Pindo::GitHandler.git!(%W(-C #{git_root} rev-parse HEAD)).strip
255
247
 
256
- return build_number, full_commit_hash
257
- rescue StandardError => e
258
- Funlog.instance.fancyinfo_error("获取commit hash失败: #{e.message}")
259
- # 如果获取失败,返回基于时间戳的默认值
260
- return Time.now.to_i % 1000000 + 1, nil
261
- end
248
+ build_number = count < 1 ? 1 : count
249
+ [build_number, full_commit_hash]
262
250
  end
263
251
 
264
252
 
@@ -364,7 +352,7 @@ module Pindo
364
352
  if local_exists && Pindo::GitHandler.is_tag_at_head?(git_root_dir: project_dir, tag_name: tag_name)
365
353
  if !remote_exists
366
354
  # 本地存在且在 HEAD,但远程不存在,推送到远程
367
- Pindo::GitHandler.git_remote!(project_dir, %W(-C #{project_dir} push origin #{tag_name}))
355
+ push_tag_with_verify(project_dir: project_dir, tag_name: tag_name)
368
356
  Funlog.instance.fancyinfo_success("推送 Tag 到远程: #{tag_name}")
369
357
  else
370
358
  Funlog.instance.fancyinfo_success("Tag 已存在且在 HEAD: #{tag_name}")
@@ -386,10 +374,48 @@ module Pindo
386
374
 
387
375
  # 创建并推送 tag
388
376
  Pindo::GitHandler.git!(%W(-C #{project_dir} tag #{tag_name}))
389
- Pindo::GitHandler.git_remote!(project_dir, %W(-C #{project_dir} push origin #{tag_name}))
377
+ push_tag_with_verify(project_dir: project_dir, tag_name: tag_name)
390
378
  Funlog.instance.fancyinfo_success("创建并推送 Tag: #{tag_name}")
391
379
  tag_name
392
380
  end
393
381
 
382
+ # 推送 tag 到远程并校验是否真正推送成功,失败则重试
383
+ # 校验方式为「远程 tag 指向的 commit == 本地 tag commit」,
384
+ # 一次 ls-remote 同时覆盖「存在性」与「指向正确性」
385
+ # @param project_dir [String] 仓库目录
386
+ # @param tag_name [String] tag 名称
387
+ # @param max_retries [Integer] 最大重试次数(默认 3)
388
+ # @return [Boolean] 是否推送成功(失败到达上限会 raise)
389
+ def push_tag_with_verify(project_dir:, tag_name:, max_retries: 3)
390
+ local_commit = Pindo::GitHandler.git!(%W(-C #{project_dir} rev-parse #{tag_name}^{commit})).strip
391
+
392
+ attempt = 0
393
+ loop do
394
+ attempt += 1
395
+ begin
396
+ Pindo::GitHandler.git_remote!(project_dir, %W(-C #{project_dir} push origin #{tag_name}))
397
+ rescue => e
398
+ first_line = e.message.lines.first&.strip
399
+ Funlog.instance.fancyinfo_warning("推送 Tag 失败(第 #{attempt}/#{max_retries} 次): #{first_line}")
400
+ end
401
+
402
+ # 校验远程 tag 指向的 commit 是否与本地一致,确认推送成功
403
+ remote_commit = Pindo::GitHandler.remote_tag_commit(local_repo_dir: project_dir, tag_name: tag_name)
404
+ if remote_commit && remote_commit == local_commit
405
+ Funlog.instance.fancyinfo_success("已确认 Tag 推送到远程: #{tag_name}")
406
+ return true
407
+ elsif remote_commit
408
+ Funlog.instance.fancyinfo_warning("远程 Tag 指向不同 commit(远程 #{remote_commit[0..7]} ≠ 本地 #{local_commit[0..7]}),疑似残留旧 Tag: #{tag_name}")
409
+ end
410
+
411
+ if attempt >= max_retries
412
+ raise Informative, "Tag 推送失败,已重试 #{max_retries} 次仍未在远程确认: #{tag_name}"
413
+ end
414
+
415
+ Funlog.instance.fancyinfo_update("远程未确认 Tag,#{attempt}s 后重试推送: #{tag_name}")
416
+ sleep(attempt)
417
+ end
418
+ end
419
+
394
420
  end
395
421
  end
@@ -12,13 +12,13 @@ module Pindo
12
12
  @project_obj = Xcodeproj::Project.open(proj_fullname)
13
13
  end
14
14
 
15
- def get_xcodeproj_icon_path()
16
-
15
+ # 查找工程的 AppIcon.appiconset 目录,找不到返回 nil(不抛异常)
16
+ def find_xcodeproj_icon_path()
17
17
  icon_path = nil
18
18
  select_target = @project_obj.targets.select { |target| target.respond_to?(:product_type) && target.product_type.include?(Xcodeproj::Constants::PRODUCT_TYPE_UTI[:application]) }.first
19
19
 
20
20
  project_dir = @project_obj.project_dir
21
-
21
+
22
22
  if !select_target.nil?
23
23
  file_refs = select_target.resources_build_phase.files_references.select { |file| file.display_name.include?("Assets.xcassets") } || []
24
24
  file_refs.each do |file_ref|
@@ -40,12 +40,33 @@ module Pindo
40
40
  end
41
41
 
42
42
  if icon_path.nil? || icon_path.empty? || !File.exist?(icon_path)
43
- raise Informative, "没有找到Xcode icon 目录"
43
+ return nil
44
44
  end
45
45
 
46
46
  return icon_path
47
47
  end
48
48
 
49
+ # 工程是否包含 AppIcon 目录(macOS 命令行工具等无 icon 工程返回 false)
50
+ def has_app_icon_dir?()
51
+ !find_xcodeproj_icon_path().nil?
52
+ end
53
+
54
+ # 工程是否为命令行工具(com.apple.product-type.tool)
55
+ def command_line_tool?()
56
+ tool_uti = Xcodeproj::Constants::PRODUCT_TYPE_UTI[:command_line_tool]
57
+ @project_obj.targets.any? do |target|
58
+ target.respond_to?(:product_type) && !target.product_type.nil? && target.product_type == tool_uti
59
+ end
60
+ end
61
+
62
+ def get_xcodeproj_icon_path()
63
+ icon_path = find_xcodeproj_icon_path()
64
+ if icon_path.nil?
65
+ raise Informative, "没有找到Xcode icon 目录"
66
+ end
67
+ return icon_path
68
+ end
69
+
49
70
  def install_icon_res(new_icon_dir:nil)
50
71
  icon_path = get_xcodeproj_icon_path()
51
72
  begin
@@ -356,7 +356,7 @@ module Pindo
356
356
  project.save
357
357
 
358
358
  if updated_targets.empty?
359
- Funlog.instance.fancyinfo_error("未找到需要更新的application target")
359
+ Funlog.instance.fancyinfo_warning("未找到需要更新的application target(如命令行工具工程),跳过版本更新")
360
360
  return false
361
361
  else
362
362
  Funlog.instance.fancyinfo_success("iOS版本更新完成!")
@@ -480,6 +480,12 @@ module Pindo
480
480
 
481
481
  puts "\n检测到项目 Icon URL: #{icon_url}"
482
482
 
483
+ # 命令行工具且无 AppIcon 目录时跳过 Icon 替换,视为成功
484
+ if XcodeResHelper.skip_icon_replacement?(proj_dir: project_dir)
485
+ Funlog.instance.fancyinfo_success("命令行工具工程无 AppIcon 目录,跳过 Icon 替换")
486
+ return true
487
+ end
488
+
483
489
  # 创建临时目录(所有 icon 处理都在此目录内完成)
484
490
  temp_icon_dir = File.join(project_dir, ".pindo_temp_ios_icon")
485
491
  icon_download_path = nil
@@ -699,8 +699,15 @@ module Pindo
699
699
  project_build_platform = project_obj.root_object.build_configuration_list.get_setting("SDKROOT")["Release"]
700
700
  is_macos = !project_build_platform.nil? && project_build_platform.eql?("macosx")
701
701
 
702
- # 根据平台查找输出文件
703
- if is_macos
702
+ # 命令行工具(com.apple.product-type.tool)产物为裸可执行文件,而非 .app/.ipa
703
+ tool_uti = Xcodeproj::Constants::PRODUCT_TYPE_UTI[:command_line_tool]
704
+ tool_target = project_obj.targets.find { |t| t.respond_to?(:product_type) && t.product_type == tool_uti }
705
+
706
+ # 根据工程类型查找输出文件
707
+ if tool_target
708
+ output_file = find_command_line_tool_output(build_dir: build_dir, target: tool_target)
709
+ puts "命令行工具: 查找可执行文件" if output_file
710
+ elsif is_macos
704
711
  # macOS 平台查找 .app 文件
705
712
  build_path = File.join(build_dir, "*.app")
706
713
  output_file = Dir.glob(build_path).select { |f| File.directory?(f) }.max_by { |f| File.mtime(f) }
@@ -715,6 +722,24 @@ module Pindo
715
722
  output_file
716
723
  end
717
724
 
725
+ # 查找命令行工具的输出可执行文件
726
+ # @param build_dir [String] 构建输出目录
727
+ # @param target [Xcodeproj::Project::Object::PBXNativeTarget] 命令行工具 target
728
+ # @return [String, nil] 可执行文件路径
729
+ def find_command_line_tool_output(build_dir:, target:)
730
+ product_name = target.build_configurations.first.build_settings['PRODUCT_NAME']
731
+ product_name = target.name if product_name.nil? || product_name.to_s.include?('$(')
732
+
733
+ # 优先取 gym 导出到 build 根目录的同名可执行文件
734
+ candidate = File.join(build_dir, product_name)
735
+ return candidate if File.file?(candidate)
736
+
737
+ # 回退:build 根目录下任意无扩展名的普通文件(排除 .dSYM 等目录)
738
+ Dir.glob(File.join(build_dir, "*")).find do |f|
739
+ File.file?(f) && File.extname(f).empty?
740
+ end
741
+ end
742
+
718
743
  # 获取 Gym 构建参数
719
744
  # @param project_fullname [String] 工程文件完整路径
720
745
  # @param icloud_id [String, nil] iCloud ID(可选)
@@ -735,8 +760,15 @@ module Pindo
735
760
  project_obj = Xcodeproj::Project.open(project_fullname)
736
761
 
737
762
  project_build_platform = project_obj.root_object.build_configuration_list.get_setting("SDKROOT")["Release"]
738
- main_target = project_obj.targets.select { |target| target.respond_to?(:product_type) && target.product_type.include?(Xcodeproj::Constants::PRODUCT_TYPE_UTI[:application]) }.first
739
- provisioning_profile_name = main_target.build_configurations.first.build_settings['PROVISIONING_PROFILE_SPECIFIER'].downcase
763
+ main_target = project_obj.targets.select { |target| target.respond_to?(:product_type) && !target.product_type.nil? && target.product_type.include?(Xcodeproj::Constants::PRODUCT_TYPE_UTI[:application]) }.first
764
+ # 命令行工具等无 application target 的工程,回退到第一个可用 native target
765
+ main_target ||= project_obj.targets.find { |target| target.respond_to?(:product_type) && !target.product_type.nil? }
766
+ if main_target.nil?
767
+ raise Informative, "未找到可编译的 target"
768
+ end
769
+ # 命令行工具通常无 provisioning profile,做 nil 保护
770
+ profile_specifier = main_target.build_configurations.first.build_settings['PROVISIONING_PROFILE_SPECIFIER']
771
+ provisioning_profile_name = profile_specifier.nil? ? "" : profile_specifier.downcase
740
772
 
741
773
  # 确定构建类型和 iCloud 环境
742
774
  build_type = "app-store"
@@ -114,6 +114,14 @@ module Pindo
114
114
  end
115
115
  end
116
116
 
117
+ # 是否应跳过 Icon 替换:工程既无 AppIcon 目录、又是命令行工具时才跳过
118
+ def skip_icon_replacement?(proj_dir:nil)
119
+ xcodeproj_file_name = Dir.glob(File.join(proj_dir, "/*.xcodeproj")).max_by {|f| File.mtime(f)}
120
+ return false if xcodeproj_file_name.nil?
121
+ xcodereshandler = XcodeResHandler.new(proj_fullname: xcodeproj_file_name)
122
+ !xcodereshandler.has_app_icon_dir? && xcodereshandler.command_line_tool?
123
+ end
124
+
117
125
  def install_icon(proj_dir:nil, new_icon_dir:nil)
118
126
  xcodeproj_file_name = Dir.glob(File.join(proj_dir, "/*.xcodeproj")).max_by {|f| File.mtime(f)}
119
127
  xcodereshandler = XcodeResHandler.new(proj_fullname: xcodeproj_file_name)
@@ -95,17 +95,26 @@ module Pindo
95
95
  end
96
96
 
97
97
  # 加载 all_bundleid_group 映射表
98
- # @return [Hash] group_name => bundle_id 的映射,如 {"fancyapptest" => "com.heroneverdie101.fancyapptest"}
98
+ # @return [Hash, nil] group_name => bundle_id 的映射,如 {"fancyapptest" => "com.heroneverdie101.fancyapptest"};加载失败返回 nil
99
99
  def self.load_bundleid_group
100
100
  config_file = File.join(pindo_env_configdir, 'bundleid_config.json')
101
- return {} unless File.exist?(config_file)
101
+ unless File.exist?(config_file)
102
+ puts "加载 bundleid_config.json 失败: 文件不存在 #{config_file},请运行 pindo setup 更新配置"
103
+ return nil
104
+ end
102
105
 
103
106
  begin
104
107
  config = JSON.parse(File.read(config_file))
105
- config['all_bundleid_group'] || {}
108
+ group = config['all_bundleid_group']
109
+ unless group.is_a?(Hash) && !group.empty?
110
+ puts "加载 bundleid_config.json 失败: all_bundleid_group 缺失或为空 #{config_file},请运行 pindo setup 更新配置"
111
+ return nil
112
+ end
113
+
114
+ group
106
115
  rescue => e
107
- puts "加载 bundleid_config.json 失败: #{e.message}"
108
- {}
116
+ puts "加载 bundleid_config.json 失败: #{e.message},请运行 pindo setup 更新配置"
117
+ nil
109
118
  end
110
119
  end
111
120
 
@@ -116,7 +125,15 @@ module Pindo
116
125
  return nil if group_name.nil? || group_name.empty?
117
126
 
118
127
  group = load_bundleid_group
119
- group[group_name]
128
+ return nil unless group
129
+
130
+ bundle_id = group[group_name]
131
+ if bundle_id.nil? || bundle_id.empty?
132
+ puts "解析 Bundle ID 失败: all_bundleid_group 中未找到 #{group_name},请运行 pindo setup 更新配置"
133
+ return nil
134
+ end
135
+
136
+ bundle_id
120
137
  end
121
138
 
122
139
  # 获取 pindo 环境配置目录
data/lib/pindo/version.rb CHANGED
@@ -6,7 +6,7 @@ require 'time'
6
6
 
7
7
  module Pindo
8
8
 
9
- VERSION = "5.18.13"
9
+ VERSION = "5.18.20"
10
10
 
11
11
  class VersionCheck
12
12
  RUBYGEMS_API = 'https://rubygems.org/api/v1/gems/pindo.json'