pindo 5.21.0 → 5.21.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 071f26ed15db7f7eb690949907738546aad119b040538a1d33fc5e20ddf78eb1
4
- data.tar.gz: 40a2fa27141ba019576ffacb6418ad534184efc6edc7e17d773a19e910f89a15
3
+ metadata.gz: 9b5f3575cc74dd8a938b4d669903f9a80bdd3a097919b0e28b22cb62eb90a804
4
+ data.tar.gz: d7317665a2b0e1175236e1818f033220827ba5debab25e05d2a0dc809780a2e7
5
5
  SHA512:
6
- metadata.gz: bcb1ea3adbf32a4b12d5b8f227d6d6d355938953ccc550b995ef621fb65f9e304ae2df1900e929bd1d6c8bdb09d61525293d31ac038beeb8c690237fe6080fdf
7
- data.tar.gz: b2ddccffee40abb74e7460759dd21addc8ce3a465454bd90eb188bfcf2f5720731cbc9a83241bd75b0440d128667d82f4db76b10f4bcc7eda06a53b896c3e645
6
+ metadata.gz: cbe2ea9dba2f316e0c9bffe62821f4d96beceb57de318d22e3a16c12944361c83ab49f1c1ae0db6e987c7433ce535af5523d0b30e95ac92a01646ff14825a205
7
+ data.tar.gz: 54d5ed72289954705f10f91bbb8281c7b9bdf543fbdc16f63d7bf863f84f4c0a606c0d5d2386aa6620d51e1349ce91de05a0fbe88aa9b8a68a45bce7a9d3d9f6
@@ -2,6 +2,7 @@
2
2
  require 'openssl'
3
3
  require 'match'
4
4
  require 'pindo/base/informative'
5
+ require 'pindo/base/funlog'
5
6
 
6
7
  module Pindo
7
8
 
@@ -62,7 +63,10 @@ module Pindo
62
63
 
63
64
  unless password
64
65
  puts "\e[33m[DEBUG] Keychain中未找到密码,需要用户输入: #{server_name}\e[0m" if ENV['PINDO_DEBUG']
65
- password = FastlaneCore::Helper.ask_password(message: "请输入证书仓库的加密密码: ", confirm: true)
66
+ # 收起 spinner,否则密码提示会被动画行覆盖,终端看起来像卡死
67
+ password = Funlog.with_paused_spinner do
68
+ FastlaneCore::Helper.ask_password(message: "请输入证书仓库的加密密码: ", confirm: true)
69
+ end
66
70
  puts "\e[33m[DEBUG] 用户输入密码成功,等待验证后保存到Keychain\e[0m" if ENV['PINDO_DEBUG']
67
71
  else
68
72
  puts "\e[32m[DEBUG] 从Keychain获取密码成功: #{server_name}\e[0m" if ENV['PINDO_DEBUG']
@@ -36,32 +36,59 @@ module Pindo
36
36
  @spinner_log
37
37
  end
38
38
 
39
+ # 临时让出终端,执行需要用户交互输入的代码块
40
+ #
41
+ # spinner 在后台线程中持续重绘同一行,会覆盖掉 fastlane、HighLine 等
42
+ # 交互提示(如 match passphrase、Apple ID 2FA、证书仓库密码),
43
+ # 导致终端看起来像卡死。执行交互代码前必须先收起 spinner。
44
+ #
45
+ # @yield 需要交互输入的代码块
46
+ # @return [Object] 代码块的返回值
47
+ def with_paused_spinner
48
+ return yield if @spinner_log.nil?
49
+
50
+ title = @spinner_title
51
+ @spinner_log.stop
52
+ @spinner_log = nil
39
53
 
54
+ begin
55
+ yield
56
+ ensure
57
+ fancyinfo_start(title) unless title.nil? || title.empty?
58
+ end
59
+ end
40
60
 
41
61
  def fancyinfo_start(*args)
42
62
  message = Funlog.ensure_utf8(args.join(" "))
43
-
63
+ @spinner_title = message
64
+
44
65
  # 检查是否在任务上下文中
45
66
  if Funlog.in_task_context?
46
67
  # 并发模式:路由到输出管理器(只写日志文件)
47
68
  Funlog.current_output_sink.log_info(Funlog.current_task_id, message)
69
+ elsif Funlog.spinner_disabled?
70
+ # 禁用动画:只输出一次静态行,不占用终端
71
+ puts " #{message}"
48
72
  else
49
73
  # 串行模式:使用 spinner
50
74
  spinner_log_handle.update(title:message)
51
75
  spinner_log_handle.auto_spin
52
76
  end
53
-
77
+
54
78
  @spinner_log
55
79
  end
56
80
 
57
81
 
58
82
  def fancyinfo_update(*args)
59
83
  message = Funlog.ensure_utf8(args.join(" "))
84
+ @spinner_title = message
60
85
 
61
86
  # 检查是否在任务上下文中
62
87
  if Funlog.in_task_context?
63
88
  # 并发模式:不更新终端,只写日志
64
89
  Funlog.current_output_sink.log_info(Funlog.current_task_id, message)
90
+ elsif Funlog.spinner_disabled?
91
+ puts " #{message}"
65
92
  else
66
93
  # 串行模式:使用 spinner
67
94
  spinner_log_handle.update(title:message)
@@ -73,25 +100,31 @@ module Pindo
73
100
 
74
101
  def fancyinfo_success(*args)
75
102
  message = Funlog.ensure_utf8(args.join(" "))
103
+ return finish_without_spinner("\e[32m ✓ #{message}\e[0m") if Funlog.spinner_disabled?
104
+
76
105
  spinner_log_handle.update(title:message)
77
106
  spinner_log_handle.success
78
- @spinner_log =nil
107
+ reset_spinner_state
79
108
  end
80
109
 
81
110
  def fancyinfo_error(*args)
82
111
  message = Funlog.ensure_utf8(args.join(" "))
112
+ return finish_without_spinner("\e[31m ✗ #{message}\e[0m") if Funlog.spinner_disabled?
113
+
83
114
  spinner_log_handle.update(title:message)
84
115
  spinner_log_handle.error
85
- @spinner_log =nil
116
+ reset_spinner_state
86
117
  end
87
118
 
88
119
  # 输出警告信息(使用spinner,黄色警告标记)
89
120
  # @param args [Array] 要输出的消息
90
121
  def fancyinfo_warning(*args)
91
122
  message = Funlog.ensure_utf8(args.join(" "))
123
+ return finish_without_spinner("\e[33m ⚠ #{message}\e[0m") if Funlog.spinner_disabled?
124
+
92
125
  spinner_log_handle.update(title:message)
93
126
  spinner_log_handle.stop("\e[33m⚠\e[0m")
94
- @spinner_log = nil
127
+ reset_spinner_state
95
128
  end
96
129
 
97
130
  # 输出静态成功信息(不使用spinner,对应 fancyinfo_success)
@@ -139,6 +172,21 @@ module Pindo
139
172
  end
140
173
  end
141
174
 
175
+ private
176
+
177
+ # 禁用动画时输出静态收尾行
178
+ # @param line [String] 已经带好颜色和标记的整行文本
179
+ def finish_without_spinner(line)
180
+ puts line
181
+ reset_spinner_state
182
+ end
183
+
184
+ # 清空 spinner 状态,等待下一次 fancyinfo_start 重新创建
185
+ def reset_spinner_state
186
+ @spinner_log = nil
187
+ @spinner_title = nil
188
+ end
189
+
142
190
  #-------------------------------------------------------------------------#
143
191
  #-------------------------------------------------------------------------#
144
192
  # @!group Singleton
@@ -185,6 +233,19 @@ module Pindo
185
233
  !current_output_sink.nil? && !current_task_id.nil?
186
234
  end
187
235
 
236
+ # 检查是否禁用了 spinner 动画
237
+ # @return [Boolean] PINDO_NO_SPINNER=1 时禁用
238
+ def spinner_disabled?
239
+ ENV['PINDO_NO_SPINNER'] == '1'
240
+ end
241
+
242
+ # 临时让出终端执行交互输入,详见实例方法说明
243
+ # @yield 需要交互输入的代码块
244
+ # @return [Object] 代码块的返回值
245
+ def with_paused_spinner(&block)
246
+ instance.with_paused_spinner(&block)
247
+ end
248
+
188
249
  # ============================================
189
250
  # 类方法委托,简化调用方式
190
251
  # 使用 Funlog.info("消息") 代替 Funlog.instance.info("消息")
@@ -1,20 +1,21 @@
1
1
 
2
2
  require 'open3'
3
3
  require 'security'
4
+ require 'pindo/base/funlog'
4
5
 
5
6
  module Pindo
6
7
 
7
8
  class KeychainHelper
8
9
  class << self
9
10
 
10
- def import_file(path, keychain_path, keychain_password: nil, certificate_password: "", skip_set_partition_list: false, output: false)
11
+ def import_file(path, keychain_path, keychain_password: nil, certificate_password: "", skip_set_partition_list: false, output: false, strict: false)
11
12
  # puts path
12
13
 
13
14
  # puts "Could not find file '#{path}'" unless File.exist?(path)
14
15
 
15
16
  password_part = " -P #{certificate_password.shellescape}"
16
17
 
17
- command = "security import #{path.shellescape} -k '#{keychain_path.shellescape}'"
18
+ command = "security import #{path.shellescape} -k #{keychain_path.shellescape}"
18
19
  command << password_part
19
20
  command << " -T /usr/bin/codesign" # to not be asked for permission when running a tool like `gym` (before Sierra)
20
21
  command << " -T /usr/bin/security"
@@ -30,20 +31,22 @@ module Pindo
30
31
  # UI.command_output(stdout.read.to_s) if output
31
32
  # puts "123"
32
33
  # Set partition list only if success since it can be a time consuming process if a lot of keys are installed
33
- if thrd.value.success? && !skip_set_partition_list
34
+ if thrd.value.success?
34
35
  # puts "安装成功!"
35
- keychain_password ||= resolve_keychain_password(keychain_path)
36
- set_partition_list(path, keychain_path, keychain_password: keychain_password, output: output)
36
+ unless skip_set_partition_list
37
+ keychain_password ||= resolve_keychain_password(keychain_path)
38
+ set_partition_list(path, keychain_path, keychain_password: keychain_password, output: output, strict: strict)
39
+ end
37
40
  else
38
41
  # Output verbose if file is already installed since not an error otherwise we will show the whole error
39
42
  err = stderr.read.to_s.strip
40
43
 
41
44
  if err.include?("SecKeychainItemImport") && err.include?("The specified item already exists in the keychain")
45
+ raise Informative, "钥匙串已存在相关材料,无法确认私钥访问权限;请检查签名身份及访问配置", cause: nil if strict
42
46
  # UI.verbose("'}' is already installed on this machine")
43
47
  # puts "证书key已经存在,安装完成!"
44
48
  else
45
- # puts err
46
- # raise Informative, err
49
+ raise Informative, "钥匙串导入失败(访问权限、钥匙串状态或 P12 格式不受支持)", cause: nil if strict
47
50
  end
48
51
  end
49
52
  end
@@ -56,7 +59,7 @@ module Pindo
56
59
  return result
57
60
  end
58
61
 
59
- def set_partition_list(path, keychain_path, keychain_password: nil, output: false)
62
+ def set_partition_list(path, keychain_path, keychain_password: nil, output: false, strict: false)
60
63
  # When security supports partition lists, also add the partition IDs
61
64
  # See https://openradar.appspot.com/28524119
62
65
  if help_backticks('security -h | grep set-key-partition-list', print: false).length > 0
@@ -79,6 +82,7 @@ module Pindo
79
82
  Open3.popen3(command) do |stdin, stdout, stderr, thrd|
80
83
  unless thrd.value.success?
81
84
  err = stderr.read.to_s.strip
85
+ raise Informative, "钥匙串访问配置失败,请检查 macOS 钥匙串密码", cause: nil if strict
82
86
 
83
87
  # Inform user when no/wrong password was used as its needed to prevent UI permission popup from Xcode when signing
84
88
  if err.include?("SecKeychainItemSetAccessWithPassword")
@@ -130,7 +134,10 @@ module Pindo
130
134
  # UI.important("Enter the password for #{keychain_path}")
131
135
  # UI.important("This passphrase will be stored in your local keychain with the name #{server} and used in future runs")
132
136
  # UI.important("This prompt can be avoided by specifying the 'keychain_password' option or 'MATCH_KEYCHAIN_PASSWORD' environment variable")
133
- keychain_password = FastlaneCore::Helper.ask_password(message: "Password for #{keychain_name} keychain: ", confirm: true, confirmation_message: "Type password for #{keychain_name} keychain again: ")
137
+ # 收起 spinner,否则密码提示会被动画行覆盖,终端看起来像卡死
138
+ keychain_password = Funlog.with_paused_spinner do
139
+ FastlaneCore::Helper.ask_password(message: "Password for #{keychain_name} keychain: ", confirm: true, confirmation_message: "Type password for #{keychain_name} keychain again: ")
140
+ end
134
141
  Security::InternetPassword.add(server, "", keychain_password)
135
142
  # else
136
143
  # UI.important("Keychain password for #{keychain_path} was not specified and not found in your keychain. Specify the 'keychain_password' option to prevent the UI permission popup when code signing")
@@ -6,6 +6,9 @@ require 'pindo/module/cert/keychain_helper'
6
6
  require 'pindo/module/cert/provisioning_helper'
7
7
  require 'pindo/module/utils/file_downloader'
8
8
  require 'jpsclient'
9
+ require 'json'
10
+ require 'digest'
11
+ require 'pindo/module/cert/p12_material'
9
12
 
10
13
  module Pindo
11
14
  module CertProcess
@@ -51,7 +54,8 @@ module Pindo
51
54
  bundle_id_array: bundle_id_array,
52
55
  cert_type: cert_type,
53
56
  platform_type: platform_type,
54
- temp_dir: temp_dir
57
+ temp_dir: temp_dir,
58
+ signing_certificate: cert_info[:certificate]
55
59
  )
56
60
 
57
61
  # 6. 验证并保存证书信息,提取 Team ID
@@ -78,14 +82,7 @@ module Pindo
78
82
  provisioning_info_array
79
83
 
80
84
  ensure
81
- # 清理临时目录(调试模式下保留)
82
- if temp_dir && Dir.exist?(temp_dir)
83
- if debug_mode?
84
- Funlog.instance.info("调试模式:临时目录已保留: #{temp_dir}")
85
- else
86
- FileUtils.remove_entry_secure(temp_dir)
87
- end
88
- end
85
+ FileUtils.remove_entry_secure(temp_dir) if temp_dir && Dir.exist?(temp_dir)
89
86
  end
90
87
  end
91
88
 
@@ -118,6 +115,17 @@ module Pindo
118
115
  end
119
116
  end
120
117
 
118
+ # JPS_DEBUG 会打印原始响应(含授权下载 URL);证书接口必须禁用。
119
+ def certificate_api(method, **parameters)
120
+ previous_debug = ENV['JPS_DEBUG']
121
+ ENV['JPS_DEBUG'] = '0'
122
+ @jps_client.public_send(method, **parameters)
123
+ rescue ::StandardError
124
+ raise Informative, "证书接口请求失败,请检查接口权限和网络", cause: nil
125
+ ensure
126
+ previous_debug.nil? ? ENV.delete('JPS_DEBUG') : ENV['JPS_DEBUG'] = previous_debug
127
+ end
128
+
121
129
  # 下载并安装证书
122
130
  def download_and_install_certificates(apple_id:, bundle_id_array:, cert_type:, platform_type:, temp_dir:)
123
131
  # 映射到 JPS API 参数
@@ -130,7 +138,7 @@ module Pindo
130
138
 
131
139
  Funlog.instance.info("查询证书: apple_id=#{apple_id}, platform=#{platform}, profile_type=#{profile_type}") if debug_mode?
132
140
 
133
- result = @jps_client.get_apple_cert_list(
141
+ result = certificate_api(:get_apple_cert_list,
134
142
  apple_dev_account: apple_id,
135
143
  bundle_id: main_bundle_id,
136
144
  platform: platform,
@@ -152,8 +160,7 @@ module Pindo
152
160
  cert_url = cert_data['cerKeyUrl'] # API 返回字段: cerKeyUrl
153
161
  cert_p12_url = cert_data['privateKeyUrl'] # API 返回字段: privateKeyUrl
154
162
  cert_name = cert_data['name'] # API 返回字段: name
155
- # 注意:API 没有返回 certPassword,使用空字符串(与 Git 模式一致)
156
- cert_password = ''
163
+
157
164
 
158
165
  Funlog.instance.info("找到证书: #{cert_name}") if debug_mode?
159
166
 
@@ -176,18 +183,21 @@ module Pindo
176
183
  download_file_from_url(url: cert_p12_url, dest_path: key_path)
177
184
  Funlog.instance.info("私钥文件路径: #{key_path}") if debug_mode?
178
185
 
186
+ cert_password = p12_export_password(key_path)
187
+ material = P12Material.load(cert_path: cert_path, key_path: key_path, password: cert_password)
188
+
179
189
  # 安装证书到 Keychain(明文,无需 AES 解密)
180
190
  install_certificate_to_keychain(cert_path: cert_path, key_path: key_path, cert_password: cert_password)
181
191
 
182
192
  return {
183
193
  cert_path: cert_path,
184
- cert_password: cert_password,
194
+ certificate: material.certificate,
185
195
  cert_name: cert_name
186
196
  }
187
197
  end
188
198
 
189
199
  # 下载并安装描述文件
190
- def download_and_install_profiles(apple_id:, bundle_id_array:, cert_type:, platform_type:, temp_dir:)
200
+ def download_and_install_profiles(apple_id:, bundle_id_array:, cert_type:, platform_type:, temp_dir:, signing_certificate:)
191
201
  # 映射到 JPS API 参数
192
202
  jps_params = map_to_jps_api_params(cert_type: cert_type, platform_type: platform_type)
193
203
  platform = jps_params[:platform]
@@ -205,7 +215,7 @@ module Pindo
205
215
 
206
216
  # 一次性查询所有 bundle_id 的描述文件
207
217
  Funlog.instance.info("查询描述文件: bundle_ids=#{bundle_ids.join(', ')}") if debug_mode?
208
- result = @jps_client.get_apple_profile_list(
218
+ result = certificate_api(:get_apple_profile_list,
209
219
  apple_dev_account: apple_id,
210
220
  bundle_ids: bundle_ids, # 修改为 bundle_ids,传递数组
211
221
  platform: platform,
@@ -252,16 +262,8 @@ module Pindo
252
262
  }
253
263
  end
254
264
 
255
- # 并行下载
256
- batch_result = Pindo::FileDownloader.download_batch(
257
- tasks: download_tasks,
258
- max_threads: 4,
259
- silent: !debug_mode?
260
- )
261
-
262
- # 检查下载结果
263
- if batch_result[:failed] > 0
264
- raise Informative, "部分描述文件下载失败 (#{batch_result[:failed]}/#{batch_result[:total]})"
265
+ download_tasks.each do |task|
266
+ download_file_from_url(**task)
265
267
  end
266
268
 
267
269
  Funlog.instance.fancyinfo_success("所有描述文件下载完成") unless debug_mode?
@@ -285,6 +287,8 @@ module Pindo
285
287
  # 解析描述文件元数据以获取 bundle_id
286
288
  profile_info = parse_provisioning_profile(profile_path: temp_profile_path, silent: true)
287
289
 
290
+ validate_profile_certificate!(profile_info, signing_certificate)
291
+
288
292
  # 从解析结果中获取实际的 bundle_id
289
293
  actual_bundle_id = profile_bundle_id || extract_bundle_id_from_profile(profile_info)
290
294
 
@@ -336,8 +340,8 @@ module Pindo
336
340
  }
337
341
 
338
342
  # 提取签名身份
339
- if parsed_data['DeveloperCertificates'] && parsed_data['DeveloperCertificates'].first
340
- cert_info = Pindo::CertHelper.send(:get_cert_info, parsed_data["DeveloperCertificates"].first.string).to_h
343
+ if signing_certificate
344
+ cert_info = Pindo::CertHelper.send(:get_cert_info, signing_certificate.to_der).to_h
341
345
  provisioning_info['signing_identity'] = cert_info["Common Name"]
342
346
  end
343
347
 
@@ -360,51 +364,78 @@ module Pindo
360
364
  return provisioning_info_array
361
365
  end
362
366
 
363
- # 从 URL 下载文件(带重试机制)
367
+ # 安静下载敏感材料,强制 TLS 验证和受限权限
364
368
  def download_file_from_url(url:, dest_path:, silent: false)
365
- success = Pindo::FileDownloader.download(
366
- url: url,
367
- dest_path: dest_path,
368
- silent: silent || !debug_mode?
369
- )
369
+ uri = URI.parse(url.to_s)
370
+ raise Informative, "证书下载地址必须使用 HTTPS" unless uri.is_a?(URI::HTTPS)
371
+ URI.open(uri, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER,
372
+ open_timeout: 10, read_timeout: 30) do |remote|
373
+ File.open(dest_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
374
+ IO.copy_stream(remote, file)
375
+ end
376
+ end
377
+ rescue ::StandardError
378
+ raise Informative, "证书材料下载失败,请检查下载权限与网络", cause: nil
379
+ end
370
380
 
371
- unless success
372
- raise Informative, "下载文件失败"
381
+ # 密码只按当前 P12 的内容指纹查找;不缓存、不读取 API 猜测字段。
382
+ def p12_export_password(key_path)
383
+ source = ENV['PINDO_P12_PASSWORD_FILE']
384
+ return '' if source.nil? || source.empty?
385
+
386
+ File.open(source, File::RDONLY | File::NOFOLLOW) do |file|
387
+ stat = file.stat
388
+ unless stat.file? && stat.uid == Process.uid && [0o600, 0o400].include?(stat.mode & 0o777)
389
+ raise Informative, "P12 密码文件必须属于当前用户且权限为 0600 或 0400"
390
+ end
391
+ passwords = JSON.parse(file.read)
392
+ fingerprint = Digest::SHA256.file(key_path).hexdigest
393
+ password = passwords.fetch(fingerprint, '')
394
+ raise TypeError unless password.is_a?(String)
395
+ password
373
396
  end
397
+ rescue ::StandardError
398
+ raise Informative, "无法读取 P12 密码文件;请检查权限和 SHA-256 密码映射", cause: nil
374
399
  end
375
400
 
376
- # 安装证书到 Keychain(与 Git 模式逻辑相同,跳过 AES 解密)
377
- def install_certificate_to_keychain(cert_path:, key_path:, cert_password:)
378
- # 只在调试模式下显示安装进度
379
- Funlog.instance.fancyinfo_start("正在安装证书...") if debug_mode?
401
+ def validate_profile_certificate!(profile, certificate)
402
+ certificates = profile['DeveloperCertificates'] || []
403
+ matched = certificates.any? do |entry|
404
+ der = entry.respond_to?(:string) ? entry.string : entry
405
+ OpenSSL::X509::Certificate.new(der).to_der == certificate.to_der
406
+ end
407
+ raise Informative, "描述文件不包含本次 P12 的签名证书,请更新描述文件" unless matched
408
+ rescue OpenSSL::X509::CertificateError, TypeError
409
+ raise Informative, "描述文件中的签名证书无效", cause: nil
410
+ end
411
+
412
+ def signing_identity_installed?(cert_path, keychain_path)
413
+ fingerprint = FastlaneCore::CertChecker.sha1_fingerprint(cert_path)
414
+ output, _error, status = Open3.capture3('security', 'find-identity', '-v', '-p', 'codesigning', keychain_path)
415
+ raise Informative, "无法检查钥匙串签名身份" unless status.success?
416
+ output.include?(fingerprint)
417
+ end
380
418
 
381
- # 注意:HTTPS 模式的证书是明文,直接安装,无需 AES 解密
382
- # 证书验证
419
+ def install_certificate_to_keychain(cert_path:, key_path:, cert_password:)
420
+ P12Material.load(cert_path: cert_path, key_path: key_path, password: cert_password)
383
421
  unless Pindo::CertHelper.is_cert_valid?(cert_path)
384
422
  raise Informative, "证书已经过期,请重新生成新证书!"
385
423
  end
424
+ raise Informative, "非Mac电脑不支持安装证书!" unless Pindo::CertHelper.isMac?
386
425
 
387
- if Pindo::CertHelper.isMac?
388
- keychain_name = "login.keychain"
389
-
390
- # 检查证书是否已安装
391
- if certificate_installed?(cert_path)
392
- Funlog.instance.fancyinfo_success("证书已安装") if debug_mode?
393
- else
394
- # 获取 Keychain 密码
395
- keychain_password = Pindoconfig.instance.cert_key_password
396
- keychain = 'login.keychain'
397
- keychain_path = FastlaneCore::Helper.keychain_path(keychain)
398
-
399
- # 安装证书和私钥(与 CustomGitCertOperator 一致)
400
- KeychainHelper.import_file(cert_path, keychain_path, keychain_password: keychain_password, certificate_password: '')
401
- KeychainHelper.import_file(key_path, keychain_path, keychain_password: keychain_password, certificate_password: '')
426
+ keychain_path = FastlaneCore::Helper.keychain_path('login.keychain')
427
+ return if signing_identity_installed?(cert_path, keychain_path)
402
428
 
403
- Funlog.instance.fancyinfo_success("证书安装完成") if debug_mode?
404
- end
405
- else
406
- Funlog.instance.fancyinfo_error("非Mac电脑不支持安装证书!")
429
+ keychain_password = Pindoconfig.instance.cert_key_password
430
+ KeychainHelper.import_file(key_path, keychain_path,
431
+ keychain_password: keychain_password, certificate_password: cert_password, strict: true)
432
+ unless signing_identity_installed?(cert_path, keychain_path)
433
+ raise Informative, "钥匙串未出现证书与私钥组成的签名身份,请检查钥匙串访问权限"
407
434
  end
435
+ rescue Informative
436
+ raise
437
+ rescue ::StandardError
438
+ raise Informative, "证书私钥导入失败,请检查钥匙串密码和访问权限", cause: nil
408
439
  end
409
440
 
410
441
  # 安装描述文件(与 Git 模式逻辑相同,跳过 AES 解密)
@@ -1,4 +1,5 @@
1
1
  require 'match'
2
+ require 'pindo/base/funlog'
2
3
  require 'pindo/config/pindoconfig'
3
4
  require 'pindo/config/ios_config_parser'
4
5
  require 'pindo/module/cert/mode/base_cert_operator'
@@ -144,7 +145,8 @@ module Pindo
144
145
  ENV["GIT_CONFIG_VALUE_#{index}"] = '/dev/null'
145
146
  ENV['GIT_CONFIG_COUNT'] = (index + 1).to_s
146
147
 
147
- Match::Runner.new.run(config)
148
+ # 收起 spinner:match 过程中会提示输入仓库 passphrase、Apple ID 密码和 2FA 验证码
149
+ Funlog.with_paused_spinner { Match::Runner.new.run(config) }
148
150
  ensure
149
151
  saved_env.each { |key, value| ENV[key] = value } if saved_env
150
152
  end
@@ -0,0 +1,41 @@
1
+ require 'openssl'
2
+
3
+ module Pindo
4
+ module CertProcess
5
+ # Validate the freshly downloaded material before touching a user's keychain.
6
+ class P12Material
7
+ def self.load(cert_path:, key_path:, password:)
8
+ material = parse(key_path, password)
9
+ unless material.key
10
+ raise Informative, 'P12 私钥缺失,请重新导出包含私钥的 P12', cause: nil
11
+ end
12
+ unless material.certificate
13
+ raise Informative, 'P12 证书缺失,请重新导出包含证书的 P12', cause: nil
14
+ end
15
+
16
+ certificate = OpenSSL::X509::Certificate.new(File.binread(cert_path))
17
+ unless material.certificate.to_der == certificate.to_der && certificate.check_private_key(material.key)
18
+ raise Informative, 'P12 私钥或证书与下载的 CER 证书不匹配,请更新同一套证书材料', cause: nil
19
+ end
20
+ material
21
+ rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::PKeyError
22
+ raise Informative, '证书或私钥格式无效,请重新导出证书材料', cause: nil
23
+ rescue SystemCallError, IOError
24
+ raise Informative, '无法读取证书材料,请重新下载', cause: nil
25
+ end
26
+
27
+ def self.parse(key_path, password)
28
+ OpenSSL::PKCS12.new(File.binread(key_path), password)
29
+ rescue OpenSSL::PKCS12::PKCS12Error => error
30
+ if error.message.match?(/unsupported|unknown cipher|unknown pbe|fetch failed|disabled|not supported/i)
31
+ raise Informative, 'P12 加密算法不兼容当前 OpenSSL;请使用兼容旧算法的工具读取后,以现代加密算法重新导出 P12', cause: nil
32
+ elsif error.message.match?(/mac verify|invalid password|cipherfinal|bad decrypt/i)
33
+ raise Informative, 'P12 导出密码错误或文件完整性校验失败,请核对该文件对应的导出密码', cause: nil
34
+ else
35
+ raise Informative, 'P12 文件格式无效或已损坏,请重新导出(不能据此判定为密码错误)', cause: nil
36
+ end
37
+ end
38
+ private_class_method :parse
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,129 @@
1
+ require 'find'
2
+ require 'open3'
3
+ require 'xcodeproj'
4
+ require 'pindo/base/funlog'
5
+ require 'pindo/base/informative'
6
+
7
+ module Pindo
8
+ # iOS 27 起,链接 iOS 27 SDK 却未采用 UIScene 生命周期的 App 启动即被 UIKit 终止
9
+ # (EXC_BREAKPOINT:_UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption)。
10
+ # 在 gym 之前拦截,避免导出装上就崩的 IPA。Info.plist 路径解析复用 XcodeBuildHelper,
11
+ # 由调用方 xcode_build_helper.rb 负责加载。
12
+ module SceneLifecycleGuard
13
+ REQUIRED_SDK_MAJOR = 27
14
+
15
+ SCENE_MANIFEST_GENERATION_SETTING = 'INFOPLIST_KEY_UIApplicationSceneManifest_Generation'.freeze
16
+
17
+ # Apple 把 AppDelegate 实现 application:configurationForConnectingSceneSession:options:
18
+ # (Swift 为 configurationForConnecting)也算作采用;只认方法实现,不认调用和注释
19
+ SCENE_CONFIGURATION_PATTERN = Regexp.union(
20
+ /-\s*\(\s*UISceneConfiguration\s*\*\s*\)\s*application\s*:[^;{]*configurationForConnectingSceneSession\s*:/,
21
+ /func\s+application\s*\([^)]*configurationForConnecting\b/
22
+ ).freeze
23
+
24
+ SOURCE_EXTENSIONS = %w[.m .mm .swift].freeze
25
+
26
+ EXCLUDED_DIRECTORY_NAMES = %w[.git Pods build DerivedData].freeze
27
+
28
+ class << self
29
+ # @param project_dir [String] 工程目录(绝对路径)
30
+ # @param project_fullname [String] .xcodeproj 路径
31
+ # @raise [Informative] 使用 iOS 27+ SDK 构建且主 App target 未采用 UIScene 生命周期
32
+ def check!(project_dir:, project_fullname:)
33
+ project_obj = Xcodeproj::Project.open(project_fullname)
34
+ app_target = main_app_target(project_obj)
35
+ return if app_target.nil? || macos_target?(app_target)
36
+
37
+ sdk_version = iphoneos_sdk_version
38
+ if sdk_version.nil?
39
+ Funlog.instance.fancyinfo_warning("无法获取 iOS SDK 版本,跳过 UIScene 生命周期检查")
40
+ return
41
+ end
42
+ return if sdk_version.split('.').first.to_i < REQUIRED_SDK_MAJOR
43
+
44
+ release_config = release_configuration(app_target)
45
+ info_plist_path = XcodeBuildHelper.resolve_info_plist_path(
46
+ project_dir, release_config.build_settings['INFOPLIST_FILE'], target: app_target
47
+ )
48
+ return if scene_manifest_declared?(info_plist_path)
49
+ return if scene_manifest_generated?(release_config)
50
+ return if delegate_provides_scene_configuration?(project_dir)
51
+
52
+ raise Informative, adoption_error_message(sdk_version, info_plist_path)
53
+ end
54
+
55
+ # 构建将使用的 iphoneos SDK 版本(随 xcode-select / DEVELOPER_DIR),取不到返回 nil
56
+ def iphoneos_sdk_version
57
+ stdout, _stderr, status = Open3.capture3('xcrun', '--sdk', 'iphoneos', '--show-sdk-version')
58
+ version = stdout.strip
59
+ status.success? && version.match?(/\A\d+(\.\d+)*\z/) ? version : nil
60
+ rescue SystemCallError
61
+ nil
62
+ end
63
+
64
+ private
65
+
66
+ def main_app_target(project_obj)
67
+ application_uti = Xcodeproj::Constants::PRODUCT_TYPE_UTI[:application]
68
+ project_obj.targets.find do |target|
69
+ target.respond_to?(:product_type) && target.product_type == application_uti
70
+ end
71
+ end
72
+
73
+ def release_configuration(target)
74
+ target.build_configurations.find { |config| config.name == 'Release' } || target.build_configurations.first
75
+ end
76
+
77
+ def macos_target?(target)
78
+ release_configuration(target).resolve_build_setting('SDKROOT').to_s.start_with?('macosx')
79
+ end
80
+
81
+ def scene_manifest_declared?(info_plist_path)
82
+ return false if info_plist_path.nil?
83
+
84
+ manifest = Xcodeproj::Plist.read_from_path(info_plist_path)['UIApplicationSceneManifest']
85
+ configurations = manifest.is_a?(Hash) ? manifest['UISceneConfigurations'] : nil
86
+ return false unless configurations.is_a?(Hash)
87
+
88
+ configurations.values.any? { |roles| roles.is_a?(Array) && !roles.empty? }
89
+ end
90
+
91
+ # Xcode 只在 GENERATE_INFOPLIST_FILE = YES 时采用 INFOPLIST_KEY_* 设置
92
+ def scene_manifest_generated?(release_config)
93
+ settings = release_config.build_settings
94
+ settings['GENERATE_INFOPLIST_FILE'].to_s.casecmp?('YES') &&
95
+ settings[SCENE_MANIFEST_GENERATION_SETTING].to_s.casecmp?('YES')
96
+ end
97
+
98
+ def delegate_provides_scene_configuration?(project_dir)
99
+ each_source_file(project_dir).any? do |path|
100
+ File.binread(path).match?(SCENE_CONFIGURATION_PATTERN)
101
+ end
102
+ end
103
+
104
+ def each_source_file(project_dir)
105
+ return enum_for(:each_source_file, project_dir) unless block_given?
106
+
107
+ Find.find(project_dir) do |path|
108
+ if File.directory?(path)
109
+ Find.prune if path != project_dir && EXCLUDED_DIRECTORY_NAMES.include?(File.basename(path))
110
+ elsif SOURCE_EXTENSIONS.include?(File.extname(path))
111
+ yield path
112
+ end
113
+ end
114
+ end
115
+
116
+ def adoption_error_message(sdk_version, info_plist_path)
117
+ [
118
+ "使用 iOS SDK #{sdk_version} 构建的 App 未采用 UIScene 生命周期,在 iOS 27 上启动即崩溃" \
119
+ "(EXC_BREAKPOINT:_UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption)。",
120
+ "Unity 导出工程:把 GoodUnityBuild 升级到 1.17.0 及以上后重新导出" \
121
+ "(自动加入 GoodUnitySceneDelegate 与 Info.plist UIApplicationSceneManifest)。",
122
+ "原生工程:添加 scene delegate 并在 Info.plist 声明 UIApplicationSceneManifest," \
123
+ "或由 AppDelegate 实现 application:configurationForConnectingSceneSession:options:。",
124
+ "检查的 Info.plist:#{info_plist_path || '未找到'}"
125
+ ].join("\n")
126
+ end
127
+ end
128
+ end
129
+ end
@@ -1,6 +1,7 @@
1
1
  require 'fileutils'
2
2
  require 'xcodeproj'
3
3
  require 'json'
4
+ require 'pindo/module/xcode/scene_lifecycle_guard'
4
5
 
5
6
  module Pindo
6
7
 
@@ -676,6 +677,7 @@ module Pindo
676
677
  # @param icloud_id [String, nil] iCloud ID(可选)
677
678
  # @return [String, nil] 生成的 IPA 文件路径,失败返回 nil
678
679
  def build_project(project_dir:, icloud_id: nil)
680
+ project_dir = File.expand_path(project_dir)
679
681
  project_fullname = Dir.glob(File.join(project_dir, "/*.xcodeproj")).max_by { |f| File.mtime(f) }
680
682
 
681
683
  if project_fullname.nil?
@@ -686,6 +688,9 @@ module Pindo
686
688
  # 修复 Xcode 16 链接器兼容性问题
687
689
  fix_xcode16_linker_flags(project_dir: project_dir)
688
690
 
691
+ # iOS 27+ SDK 构建的 App 未采用 UIScene 生命周期会启动即崩,gym 之前拦截
692
+ SceneLifecycleGuard.check!(project_dir: project_dir, project_fullname: project_fullname)
693
+
689
694
  # 清理旧的 build 目录 (使用绝对路径)
690
695
  build_dir = File.join(project_dir, "build")
691
696
  FileUtils.rm_rf(build_dir) if File.exist?(build_dir)
@@ -705,6 +710,15 @@ module Pindo
705
710
  config = FastlaneCore::Configuration.create(Gym::Options.available_options, gym_options)
706
711
  Gym::Manager.new.work(config)
707
712
 
713
+ # An external Unity export may replace the caller's directory while gym runs.
714
+ # Xcodeproj::Project.open reads Pathname.pwd even for absolute paths.
715
+ # Rebind only an invalid cwd; leave valid callers/concurrent tasks unchanged.
716
+ begin
717
+ Dir.pwd
718
+ rescue Errno::ENOENT
719
+ Dir.chdir(project_dir)
720
+ end
721
+
708
722
  # 检测平台类型
709
723
  require 'xcodeproj'
710
724
  project_obj = Xcodeproj::Project.open(project_fullname)
data/lib/pindo/version.rb CHANGED
@@ -6,7 +6,7 @@ require 'time'
6
6
 
7
7
  module Pindo
8
8
 
9
- VERSION = "5.21.0"
9
+ VERSION = "5.21.1"
10
10
 
11
11
  class VersionCheck
12
12
  RUBYGEMS_API = 'https://rubygems.org/api/v1/gems/pindo.json'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pindo
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.21.0
4
+ version: 5.21.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - wade
@@ -404,6 +404,7 @@ files:
404
404
  - lib/pindo/module/cert/mode/custom_git_cert_operator.rb
405
405
  - lib/pindo/module/cert/mode/custom_https_cert_operator.rb
406
406
  - lib/pindo/module/cert/mode/match_git_cert_operator.rb
407
+ - lib/pindo/module/cert/p12_material.rb
407
408
  - lib/pindo/module/cert/pem_helper.rb
408
409
  - lib/pindo/module/cert/provisioning_helper.rb
409
410
  - lib/pindo/module/pgyer/media_url_helper.rb
@@ -485,6 +486,7 @@ files:
485
486
  - lib/pindo/module/xcode/mac_app_resign_helper.rb
486
487
  - lib/pindo/module/xcode/res/xcode_res_constant.rb
487
488
  - lib/pindo/module/xcode/res/xcode_res_handler.rb
489
+ - lib/pindo/module/xcode/scene_lifecycle_guard.rb
488
490
  - lib/pindo/module/xcode/xcode_app_config.rb
489
491
  - lib/pindo/module/xcode/xcode_build_config.rb
490
492
  - lib/pindo/module/xcode/xcode_build_helper.rb