pindo 5.17.4 → 5.18.3

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/pindo/base/git_handler.rb +120 -38
  3. data/lib/pindo/command/android/autobuild.rb +92 -31
  4. data/lib/pindo/command/appstore/adhocbuild.rb +1 -1
  5. data/lib/pindo/command/appstore/autobuild.rb +1 -1
  6. data/lib/pindo/command/appstore/autoresign.rb +1 -1
  7. data/lib/pindo/command/appstore/updateid.rb +229 -0
  8. data/lib/pindo/command/appstore.rb +1 -0
  9. data/lib/pindo/command/ios/autobuild.rb +70 -33
  10. data/lib/pindo/command/ios/podpush.rb +1 -1
  11. data/lib/pindo/command/unity/autobuild.rb +38 -18
  12. data/lib/pindo/command/utils/allcopyconfig.rb +144 -0
  13. data/lib/pindo/command/utils/copyconfig.rb +207 -0
  14. data/lib/pindo/command/utils/icon.rb +2 -2
  15. data/lib/pindo/command/utils/renewbundleid.rb +199 -0
  16. data/lib/pindo/command/utils/renewcert.rb +56 -54
  17. data/lib/pindo/command/utils.rb +3 -0
  18. data/lib/pindo/command/web/autobuild.rb +10 -8
  19. data/lib/pindo/config/build_info_manager.rb +1 -3
  20. data/lib/pindo/module/android/android_build_helper.rb +198 -33
  21. data/lib/pindo/module/android/android_config_helper.rb +305 -88
  22. data/lib/pindo/module/android/android_project_helper.rb +124 -14
  23. data/lib/pindo/module/android/android_res_helper.rb +349 -51
  24. data/lib/pindo/module/android/keystore_helper.rb +611 -295
  25. data/lib/pindo/module/android/workflow_gradle_injector.rb +702 -0
  26. data/lib/pindo/module/appselect.rb +4 -4
  27. data/lib/pindo/module/appstore/bundleid_helper.rb +204 -14
  28. data/lib/pindo/module/build/build_helper.rb +76 -10
  29. data/lib/pindo/module/build/git_repo_helper.rb +4 -4
  30. data/lib/pindo/module/cert/mode/base_cert_operator.rb +12 -6
  31. data/lib/pindo/module/pgyer/pgyerhelper.rb +124 -39
  32. data/lib/pindo/module/task/model/build/android_build_dev_task.rb +64 -6
  33. data/lib/pindo/module/task/model/git/git_commit_task.rb +70 -54
  34. data/lib/pindo/module/task/model/git/git_tag_task.rb +13 -9
  35. data/lib/pindo/module/task/model/jps/jps_upload_task.rb +110 -3
  36. data/lib/pindo/module/task/model/unity/unity_export_task.rb +2 -1
  37. data/lib/pindo/module/task/model/unity/unity_update_task.rb +2 -1
  38. data/lib/pindo/module/task/model/unity/unity_yoo_asset_task.rb +2 -1
  39. data/lib/pindo/module/task/model/unity_task.rb +2 -1
  40. data/lib/pindo/module/unity/unity_helper.rb +13 -10
  41. data/lib/pindo/module/unity/unity_proc_helper.rb +27 -2
  42. data/lib/pindo/module/xcode/applovin_xcode_helper.rb +6 -2
  43. data/lib/pindo/module/xcode/res/xcode_res_constant.rb +72 -0
  44. data/lib/pindo/module/xcode/res/xcode_res_handler.rb +3 -3
  45. data/lib/pindo/module/xcode/xcode_build_config.rb +46 -17
  46. data/lib/pindo/module/xcode/xcode_build_helper.rb +186 -25
  47. data/lib/pindo/module/xcode/xcode_project_helper.rb +1 -1
  48. data/lib/pindo/module/xcode/xcode_res_helper.rb +32 -16
  49. data/lib/pindo/options/groups/build_options.rb +5 -5
  50. data/lib/pindo/options/groups/git_options.rb +7 -5
  51. data/lib/pindo/options/groups/unity_options.rb +11 -0
  52. data/lib/pindo/options/helpers/bundleid_selector.rb +25 -0
  53. data/lib/pindo/options/helpers/git_constants.rb +7 -6
  54. data/lib/pindo/version.rb +3 -3
  55. metadata +12 -7
@@ -0,0 +1,702 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "digest"
4
+ require "securerandom"
5
+ require "set"
6
+
7
+ require_relative "android_build_helper"
8
+
9
+ module Pindo
10
+ module Android
11
+ class WorkflowGradleInjector
12
+ TMP_ROOT_RELATIVE = ".pindo_tmp".freeze
13
+ LOCK_RELATIVE_PATH = File.join(TMP_ROOT_RELATIVE, "locks", "gradle_inject.lock").freeze
14
+ RUNS_RELATIVE_DIR = File.join(TMP_ROOT_RELATIVE, "gradle_inject").freeze
15
+
16
+ MARK_BEGIN = "PINDO_WORKFLOW_GRADLE_INJECTOR_BEGIN".freeze
17
+ MARK_END = "PINDO_WORKFLOW_GRADLE_INJECTOR_END".freeze
18
+
19
+ RELEASE_SIGNING_ENV_VARS = %w[
20
+ RELEASE_KEYSTORE_PATH
21
+ RELEASE_KEYSTORE_PASSWORD
22
+ RELEASE_KEY_ALIAS
23
+ RELEASE_KEY_PASSWORD
24
+ ].freeze
25
+
26
+ class << self
27
+ # 仅执行 prepare! 并 yield(context)。不在此自动 cleanup!:
28
+ # Gradle 注入与 JKS 的清理时机由调用方在「AAB→APK(universal.apk)成功产出后」统一处理;
29
+ # 构建失败时调用方应在 ensure 中执行 cleanup! 以恢复 gradle,但不应删除尚未完成转换流程所需的 JKS。
30
+ def with_injection(project_dir:, workflow_name:, workflow_build_type: nil, enable_pad: true, main_module: nil)
31
+ injector = new
32
+ injector.prepare!(
33
+ project_dir: project_dir,
34
+ workflow_name: workflow_name,
35
+ workflow_build_type: workflow_build_type,
36
+ enable_pad: enable_pad,
37
+ main_module: main_module
38
+ ).tap { |context| yield(context) }
39
+ end
40
+
41
+ # 删除工程根下打包过程生成的 `.pindo_tmp`(锁文件、gradle_inject 等)。
42
+ # 须在 `cleanup!` 释放文件锁之后调用。
43
+ def remove_project_pindo_tmp_dir!(project_dir)
44
+ return if project_dir.to_s.empty?
45
+
46
+ base = File.expand_path(project_dir)
47
+ return unless File.directory?(base)
48
+
49
+ target = File.expand_path(File.join(base, TMP_ROOT_RELATIVE))
50
+ return unless File.dirname(target) == base
51
+
52
+ FileUtils.rm_rf(target) if File.exist?(target)
53
+ end
54
+ end
55
+
56
+ def prepare!(project_dir:, workflow_name:, workflow_build_type: nil, enable_pad: true, main_module: nil)
57
+ raise ArgumentError, "project_dir 不能为空" if project_dir.to_s.empty?
58
+ raise ArgumentError, "项目目录不存在: #{project_dir}" unless File.directory?(project_dir)
59
+ raise ArgumentError, "workflow_name 不能为空" if workflow_name.to_s.empty?
60
+
61
+ run_id = SecureRandom.uuid
62
+ workflow_build_type ||= sanitize_workflow_build_type(workflow_name)
63
+ signing_config_name = workflow_build_type
64
+
65
+ tmp_run_dir = File.join(project_dir, RUNS_RELATIVE_DIR, run_id)
66
+ backup_dir = File.join(tmp_run_dir, "backup")
67
+ marker_path = File.join(tmp_run_dir, "marker")
68
+ manifest_path = File.join(tmp_run_dir, "manifest.json")
69
+
70
+ FileUtils.mkdir_p(backup_dir)
71
+
72
+ lock_file = File.join(project_dir, LOCK_RELATIVE_PATH)
73
+ FileUtils.mkdir_p(File.dirname(lock_file))
74
+
75
+ lock_io = File.open(lock_file, File::RDWR | File::CREAT, 0o644)
76
+ lock_io.flock(File::LOCK_EX)
77
+
78
+ begin
79
+ self_check_restore!(project_dir)
80
+
81
+ context = {
82
+ project_dir: project_dir,
83
+ run_id: run_id,
84
+ workflow_name: workflow_name,
85
+ workflow_build_type: workflow_build_type,
86
+ signing_config_name: signing_config_name,
87
+ tmp_run_dir: tmp_run_dir,
88
+ backup_dir: backup_dir,
89
+ marker_path: marker_path,
90
+ manifest_path: manifest_path,
91
+ lock_file: lock_file,
92
+ lock_io: lock_io,
93
+ backups: [],
94
+ created_dirs: [],
95
+ }
96
+
97
+ # 1) 先备份/再 PAD(PAD 会改 settings.gradle/launcher build.gradle/新增 pack 目录/移动 assets)
98
+ if enable_pad
99
+ pad_backup!(context)
100
+ run_pad!(context)
101
+ end
102
+
103
+ # 2) 注入 buildType/signingConfig(对所有 Android modules)
104
+ gradle_files = discover_gradle_module_files(project_dir)
105
+ main_module_gradle = resolve_main_module_gradle_file(project_dir, gradle_files, main_module: main_module)
106
+
107
+ # 需要改写的文件清单(幂等:已包含标记则不会重复注入)
108
+ to_inject = gradle_files.dup
109
+ to_inject << main_module_gradle if main_module_gradle && !to_inject.include?(main_module_gradle)
110
+ to_inject.compact!
111
+ to_inject.uniq!
112
+
113
+ to_inject.each { |path| backup_file!(context, path) }
114
+
115
+ File.write(marker_path, JSON.pretty_generate({
116
+ run_id: run_id,
117
+ workflow_name: workflow_name,
118
+ workflow_build_type: workflow_build_type,
119
+ signing_config_name: signing_config_name,
120
+ started_at: Time.now.to_i,
121
+ }))
122
+
123
+ to_inject.each do |gradle_path|
124
+ inject_into_gradle_file!(
125
+ gradle_path: gradle_path,
126
+ workflow_name: workflow_name,
127
+ workflow_build_type: workflow_build_type,
128
+ signing_config_name: signing_config_name,
129
+ is_main_module: (gradle_path == main_module_gradle)
130
+ )
131
+ end
132
+
133
+ File.write(manifest_path, JSON.pretty_generate({
134
+ run_id: run_id,
135
+ marker_path: marker_path,
136
+ backups: context[:backups],
137
+ created_dirs: context[:created_dirs],
138
+ }))
139
+
140
+ context
141
+ rescue
142
+ # 如果 prepare! 中途失败,也尽可能清理掉 marker/恢复备份,避免污染后续运行
143
+ begin
144
+ cleanup!({
145
+ project_dir: project_dir,
146
+ tmp_run_dir: tmp_run_dir,
147
+ marker_path: marker_path,
148
+ manifest_path: manifest_path,
149
+ lock_io: lock_io,
150
+ backups: [],
151
+ created_dirs: [],
152
+ })
153
+ rescue
154
+ # ignore
155
+ end
156
+ raise
157
+ end
158
+ end
159
+
160
+ def cleanup!(context)
161
+ project_dir = context[:project_dir]
162
+ tmp_run_dir = context[:tmp_run_dir]
163
+
164
+ restore_from_manifest!(context)
165
+
166
+ FileUtils.rm_f(context[:marker_path]) if context[:marker_path]
167
+ FileUtils.rm_rf(tmp_run_dir) if tmp_run_dir && tmp_run_dir.start_with?(project_dir.to_s)
168
+ ensure
169
+ begin
170
+ if (lock_io = context[:lock_io])
171
+ lock_io.flock(File::LOCK_UN)
172
+ lock_io.close
173
+ end
174
+ rescue
175
+ # ignore
176
+ end
177
+ end
178
+
179
+ def sanitize_workflow_build_type(workflow_name)
180
+ raw = workflow_name.to_s
181
+
182
+ parts = raw.gsub(/[^A-Za-z0-9_]+/, " ").strip.split(/\s+/)
183
+ # 去掉开头的 "test" 词:AGP 禁止 BuildType 名以 test 开头;如 "test demo" 应生成 demo 而非 testDemo
184
+ while parts.first && parts.first.casecmp("test").zero?
185
+ parts.shift
186
+ end
187
+
188
+ base = if parts.empty?
189
+ "workflow"
190
+ else
191
+ first = parts.first.downcase
192
+ rest = parts.drop(1).map { |p| p[0].to_s.upcase + p[1..].to_s.downcase }
193
+ ([first] + rest).join
194
+ end
195
+
196
+ base = base.gsub(/[^A-Za-z0-9_]/, "")
197
+ base = "w_#{base}" unless base.match?(/\A[A-Za-z]/)
198
+ # AGP:BuildType 名称不能以 test 开头(保留字),否则报 BuildType names cannot start with 'test'
199
+ base = "wf_#{base}" if base.match?(/\Atest/i)
200
+
201
+ max_len = 40
202
+ if base.length > max_len
203
+ digest = Digest::SHA256.hexdigest(raw)[0, 8]
204
+ base = "#{base[0, max_len - 9]}_#{digest}"
205
+ end
206
+
207
+ base
208
+ end
209
+
210
+ private
211
+
212
+ def self_check_restore!(project_dir)
213
+ runs_dir = File.join(project_dir, RUNS_RELATIVE_DIR)
214
+ return unless File.directory?(runs_dir)
215
+
216
+ Dir.each_child(runs_dir) do |run_id|
217
+ run_dir = File.join(runs_dir, run_id)
218
+ marker = File.join(run_dir, "marker")
219
+ manifest = File.join(run_dir, "manifest.json")
220
+ next unless File.file?(marker) && File.file?(manifest)
221
+
222
+ begin
223
+ data = JSON.parse(File.read(manifest))
224
+ self.class.restore_from_manifest_data!(project_dir, data)
225
+ FileUtils.rm_rf(run_dir)
226
+ rescue => e
227
+ raise RuntimeError, "检测到未恢复的 Gradle 注入残留,但自动恢复失败: #{e.message}\n残留目录: #{run_dir}"
228
+ end
229
+ end
230
+ end
231
+
232
+ def restore_from_manifest!(context)
233
+ manifest_path = context[:manifest_path]
234
+ return unless manifest_path && File.file?(manifest_path)
235
+
236
+ data = JSON.parse(File.read(manifest_path))
237
+ self.class.restore_from_manifest_data!(context[:project_dir], data)
238
+ end
239
+
240
+ def self.restore_from_manifest_data!(project_dir, data)
241
+ backups = data["backups"] || []
242
+ backups.each do |item|
243
+ path = File.join(project_dir, item.fetch("path"))
244
+ backup_path = File.join(project_dir, item.fetch("backup_path"))
245
+ next unless File.file?(backup_path)
246
+
247
+ FileUtils.mkdir_p(File.dirname(path))
248
+ bin_copy(backup_path, path)
249
+ end
250
+
251
+ (data["created_dirs"] || []).each do |rel|
252
+ abs = File.join(project_dir, rel)
253
+ FileUtils.rm_rf(abs) if abs.start_with?(project_dir.to_s)
254
+ end
255
+ end
256
+
257
+ def pad_backup!(context)
258
+ project_dir = context[:project_dir]
259
+
260
+ settings = find_settings_gradle(project_dir)
261
+ backup_file!(context, settings) if settings
262
+
263
+ # 备份 launcher/app build.gradle(PAD 会写 assetPacks;我们先按既有规则猜主模块)
264
+ gradle_files = discover_gradle_module_files(project_dir)
265
+ main_gradle = resolve_main_module_gradle_file(project_dir, gradle_files, main_module: nil)
266
+ backup_file!(context, main_gradle) if main_gradle
267
+
268
+ assets_root = File.join(project_dir, "unityLibrary", "src", "main", "assets")
269
+ return unless File.directory?(assets_root)
270
+
271
+ subdirs = Dir.glob(File.join(assets_root, "*")).select { |p| File.directory?(p) }
272
+ subdirs.reject! { |p| File.basename(p).downcase == "bin" }
273
+ return if subdirs.empty?
274
+
275
+ subdirs.each do |abs_dir|
276
+ rel = abs_dir.delete_prefix("#{project_dir}/")
277
+ backup_dir = File.join(project_dir, context[:backup_dir].delete_prefix("#{project_dir}/"), rel)
278
+ FileUtils.mkdir_p(File.dirname(backup_dir))
279
+ FileUtils.rm_rf(backup_dir)
280
+ FileUtils.cp_r(abs_dir, backup_dir)
281
+ context[:backups] << {
282
+ "type" => "dir",
283
+ "path" => rel,
284
+ "backup_path" => backup_dir.delete_prefix("#{project_dir}/"),
285
+ }
286
+ end
287
+ end
288
+
289
+ def run_pad!(context)
290
+ project_dir = context[:project_dir]
291
+
292
+ before_children = Dir.children(project_dir).to_set
293
+ Pindo::AndroidBuildHelper.share_instance.setup_play_asset_delivery(project_dir)
294
+ after_children = Dir.children(project_dir).to_set
295
+
296
+ created = (after_children - before_children).select do |name|
297
+ # PAD 产物一般是 *_pack 目录
298
+ name.end_with?("_pack") && File.directory?(File.join(project_dir, name))
299
+ end
300
+
301
+ created.each do |name|
302
+ context[:created_dirs] << name
303
+ end
304
+ end
305
+
306
+ def discover_gradle_module_files(project_dir)
307
+ settings = find_settings_gradle(project_dir)
308
+ module_names = settings ? parse_settings_includes(settings) : []
309
+
310
+ paths = module_names.filter_map do |m|
311
+ rel = m.tr(":", "/").sub(%r{\A/+}, "")
312
+ next if rel.empty?
313
+
314
+ groovy = File.join(project_dir, rel, "build.gradle")
315
+ kts = File.join(project_dir, rel, "build.gradle.kts")
316
+ if File.file?(kts)
317
+ kts
318
+ elsif File.file?(groovy)
319
+ groovy
320
+ end
321
+ end
322
+
323
+ # settings 解析失败时,兜底扫描常见主模块
324
+ if paths.empty?
325
+ %w[launcher app unityLibrary].each do |name|
326
+ %w[build.gradle build.gradle.kts].each do |f|
327
+ p = File.join(project_dir, name, f)
328
+ paths << p if File.file?(p)
329
+ end
330
+ end
331
+ end
332
+
333
+ paths.uniq
334
+ end
335
+
336
+ def find_settings_gradle(project_dir)
337
+ groovy = File.join(project_dir, "settings.gradle")
338
+ kts = File.join(project_dir, "settings.gradle.kts")
339
+ return kts if File.file?(kts)
340
+ return groovy if File.file?(groovy)
341
+
342
+ nil
343
+ end
344
+
345
+ def parse_settings_includes(settings_path)
346
+ content = File.read(settings_path)
347
+ includes = []
348
+
349
+ content.scan(/include\s+(.*)$/).each do |m|
350
+ line = m.first.to_s
351
+ line.scan(/["'](:[^"']+)["']/).each do |mm|
352
+ includes << mm.first
353
+ end
354
+ end
355
+
356
+ includes.uniq
357
+ end
358
+
359
+ def resolve_main_module_gradle_file(project_dir, gradle_files, main_module:)
360
+ if main_module && !main_module.to_s.empty?
361
+ %w[build.gradle.kts build.gradle].each do |f|
362
+ p = File.join(project_dir, main_module.to_s.delete_prefix(":"), f)
363
+ return p if File.file?(p)
364
+ end
365
+ end
366
+
367
+ # 优先 launcher,其次 app,其次第一个 application module
368
+ candidate = gradle_files.find { |p| p.include?("/launcher/") } ||
369
+ gradle_files.find { |p| p.include?("/app/") }
370
+ return candidate if candidate && android_application_module?(candidate)
371
+
372
+ gradle_files.find { |p| android_application_module?(p) }
373
+ end
374
+
375
+ def android_application_module?(gradle_path)
376
+ content = File.read(gradle_path)
377
+ return true if content.include?("com.android.application")
378
+ return true if content.match?(/id\s*\(?\s*["']com\.android\.application["']\s*\)?/)
379
+ return true if content.match?(/alias\(\s*libs\.plugins\.android\.application\s*\)/)
380
+
381
+ false
382
+ end
383
+
384
+ def android_asset_pack_module?(gradle_path)
385
+ content = File.read(gradle_path)
386
+ content.include?("com.android.asset-pack") ||
387
+ content.match?(/id\s*\(?\s*["']com\.android\.asset-pack["']\s*\)?/) ||
388
+ content.match?(/alias\(\s*libs\.plugins\.android\.asset\.pack\s*\)/)
389
+ end
390
+
391
+ def android_module?(gradle_path)
392
+ content = File.read(gradle_path)
393
+ return false if android_asset_pack_module?(gradle_path)
394
+
395
+ content.include?("com.android.application") ||
396
+ content.include?("com.android.library") ||
397
+ content.include?("com.android.dynamic-feature") ||
398
+ content.match?(/com\.android\.(application|library|dynamic-feature)/) ||
399
+ content.match?(/alias\(\s*libs\.plugins\.android\.(application|library|dynamic\.feature)\s*\)/)
400
+ end
401
+
402
+ def gradle_dsl(gradle_path)
403
+ gradle_path.end_with?(".kts") ? :kts : :groovy
404
+ end
405
+
406
+ def inject_into_gradle_file!(gradle_path:, workflow_name:, workflow_build_type:, signing_config_name:, is_main_module:)
407
+ return unless File.file?(gradle_path)
408
+ return unless android_module?(gradle_path)
409
+ return if android_asset_pack_module?(gradle_path)
410
+
411
+ original = File.read(gradle_path)
412
+ # 模板可能迭代升级:若存在旧注入块,先移除再按新模板注入
413
+ if original.include?(MARK_BEGIN) && original.include?(MARK_END)
414
+ original = remove_marked_blocks(original)
415
+ end
416
+
417
+ dsl = gradle_dsl(gradle_path)
418
+
419
+ signing_entry = if is_main_module
420
+ dsl == :kts ? kts_signing_config_entry(workflow_name:, workflow_build_type:, signing_config_name:) :
421
+ groovy_signing_config_entry(workflow_name:, workflow_build_type:, signing_config_name:)
422
+ end
423
+
424
+ build_type_entry = dsl == :kts ? kts_build_type_entry(workflow_name:, workflow_build_type:, signing_config_name:, is_main_module:) :
425
+ groovy_build_type_entry(workflow_name:, workflow_build_type:, signing_config_name:, is_main_module:)
426
+
427
+ updated = inject_into_existing_android_block(
428
+ original,
429
+ signing_entry: signing_entry,
430
+ build_type_entry: build_type_entry,
431
+ workflow_build_type: workflow_build_type,
432
+ signing_config_name: signing_config_name,
433
+ dsl: dsl
434
+ )
435
+ File.write(gradle_path, updated)
436
+ end
437
+
438
+ def remove_marked_blocks(content)
439
+ out = content.dup
440
+ loop do
441
+ b = out.index(MARK_BEGIN)
442
+ e = out.index(MARK_END)
443
+ break unless b && e && e > b
444
+
445
+ line_start = out.rindex("\n", b) || 0
446
+ line_start = 0 if line_start == 0
447
+ line_end = out.index("\n", e) || (out.length - 1)
448
+ out = out[0...line_start] + out[(line_end + 1)..]
449
+ end
450
+ out
451
+ end
452
+
453
+ def groovy_signing_config_entry(workflow_name:, workflow_build_type:, signing_config_name:)
454
+ lines = []
455
+ lines << "// #{MARK_BEGIN}"
456
+ lines << "// workflowName=#{workflow_name.inspect}"
457
+ lines << "#{signing_config_name} {"
458
+ lines << " def signingEnvVars = #{RELEASE_SIGNING_ENV_VARS.inspect}"
459
+ lines << " def hasCompleteReleaseSigningEnv = signingEnvVars.every { System.getenv(it) }"
460
+ lines << " if (!hasCompleteReleaseSigningEnv) {"
461
+ lines << " throw new GradleException(\"Missing required environment variables for release signing: ${signingEnvVars.join(', ')}\")"
462
+ lines << " }"
463
+ lines << " def releaseKeystorePath = System.getenv(\"RELEASE_KEYSTORE_PATH\")"
464
+ lines << " def keystoreFile = file(releaseKeystorePath)"
465
+ lines << " if (!keystoreFile.exists()) {"
466
+ lines << " throw new GradleException(\"Keystore file not found: ${keystoreFile.absolutePath}\")"
467
+ lines << " }"
468
+ lines << " storeFile keystoreFile"
469
+ lines << " storePassword System.getenv(\"RELEASE_KEYSTORE_PASSWORD\")"
470
+ lines << " keyAlias System.getenv(\"RELEASE_KEY_ALIAS\")"
471
+ lines << " keyPassword System.getenv(\"RELEASE_KEY_PASSWORD\")"
472
+ lines << "}"
473
+ lines << "// #{MARK_END}"
474
+ lines.join("\n")
475
+ end
476
+
477
+ def kts_signing_config_entry(workflow_name:, workflow_build_type:, signing_config_name:)
478
+ lines = []
479
+ lines << "// #{MARK_BEGIN}"
480
+ lines << "// workflowName=#{workflow_name.inspect}"
481
+ lines << "create(\"#{signing_config_name}\") {"
482
+ lines << " val signingEnvVars = listOf("
483
+ RELEASE_SIGNING_ENV_VARS.each_with_index do |v, idx|
484
+ comma = idx == RELEASE_SIGNING_ENV_VARS.length - 1 ? "" : ","
485
+ lines << " \"#{v}\"#{comma}"
486
+ end
487
+ lines << " )"
488
+ lines << " val hasCompleteReleaseSigningEnv = signingEnvVars.all { System.getenv(it)?.isNotBlank() == true }"
489
+ lines << " if (!hasCompleteReleaseSigningEnv) {"
490
+ lines << " throw GradleException(\"Missing required environment variables for release signing: ${signingEnvVars.joinToString(\", \")}\")"
491
+ lines << " }"
492
+ lines << " val releaseKeystorePath = System.getenv(\"RELEASE_KEYSTORE_PATH\")"
493
+ lines << " val keystoreFile = file(releaseKeystorePath)"
494
+ lines << " if (!keystoreFile.exists()) {"
495
+ lines << " throw GradleException(\"Keystore file not found: ${keystoreFile.absolutePath}\")"
496
+ lines << " }"
497
+ lines << " storeFile = keystoreFile"
498
+ lines << " storePassword = System.getenv(\"RELEASE_KEYSTORE_PASSWORD\")"
499
+ lines << " keyAlias = System.getenv(\"RELEASE_KEY_ALIAS\")"
500
+ lines << " keyPassword = System.getenv(\"RELEASE_KEY_PASSWORD\")"
501
+ lines << "}"
502
+ lines << "// #{MARK_END}"
503
+ lines.join("\n")
504
+ end
505
+
506
+ def groovy_build_type_entry(workflow_name:, workflow_build_type:, signing_config_name:, is_main_module:)
507
+ lines = []
508
+ lines << "// #{MARK_BEGIN}"
509
+ lines << "// workflowName=#{workflow_name.inspect}"
510
+ lines << "#{workflow_build_type} {"
511
+ lines << " minifyEnabled false"
512
+ if is_main_module
513
+ lines << " signingConfig signingConfigs.#{signing_config_name}"
514
+ end
515
+ lines << " proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'"
516
+ lines << "}"
517
+ lines << "// #{MARK_END}"
518
+ lines.join("\n")
519
+ end
520
+
521
+ def kts_build_type_entry(workflow_name:, workflow_build_type:, signing_config_name:, is_main_module:)
522
+ lines = []
523
+ lines << "// #{MARK_BEGIN}"
524
+ lines << "// workflowName=#{workflow_name.inspect}"
525
+ lines << "create(\"#{workflow_build_type}\") {"
526
+ lines << " isMinifyEnabled = false"
527
+ if is_main_module
528
+ lines << " signingConfig = signingConfigs.getByName(\"#{signing_config_name}\")"
529
+ end
530
+ lines << " proguardFiles("
531
+ lines << " getDefaultProguardFile(\"proguard-android-optimize.txt\"),"
532
+ lines << " \"proguard-rules.pro\""
533
+ lines << " )"
534
+ lines << "}"
535
+ lines << "// #{MARK_END}"
536
+ lines.join("\n")
537
+ end
538
+
539
+ def inject_into_existing_android_block(content, signing_entry:, build_type_entry:, workflow_build_type:, signing_config_name:, dsl:)
540
+ android_open = content.index(/\bandroid\s*\{/)
541
+ unless android_open
542
+ android_body = []
543
+ if signing_entry
544
+ android_body << "signingConfigs {\n#{indent_lines(signing_entry, ' ')}\n}"
545
+ end
546
+ android_body << "buildTypes {\n#{indent_lines(build_type_entry, ' ')}\n}"
547
+ return content.rstrip + "\n\nandroid {\n" + android_body.join("\n") + "\n}\n"
548
+ end
549
+
550
+ android_line_start = content.rindex("\n", android_open) || 0
551
+ android_line_start = 0 if android_line_start == 0
552
+ android_indent = content[android_line_start..android_open].match(/^\s*/).to_s
553
+
554
+ block_start = content.index("{", android_open)
555
+ block_end = find_matching_brace(content, block_start)
556
+ raise "无法定位 android { } 结束位置" unless block_end
557
+
558
+ android_block = content[block_start..block_end]
559
+ android_inner = content[(block_start + 1)...block_end]
560
+
561
+ updated_inner = android_inner.dup
562
+
563
+ # 仅在 android 块前半段查找,避免误匹配 dependencies/注释等处的 "buildTypes"/"signingConfigs" 子串
564
+ build_types_anchor_before = updated_inner.index(/\n\s*buildTypes\s*\{/)
565
+ signing_search_space = build_types_anchor_before ? updated_inner[0...build_types_anchor_before] : updated_inner
566
+
567
+ if signing_entry
568
+ updated_inner = inject_into_child_block(
569
+ updated_inner,
570
+ block_name: "signingConfigs",
571
+ entry: signing_entry,
572
+ entry_present: false,
573
+ base_indent: android_indent,
574
+ search_space: signing_search_space,
575
+ brace_end_exclusive: build_types_anchor_before
576
+ )
577
+ end
578
+
579
+ # 签名块注入会改变 updated_inner 长度,必须重新定位 buildTypes / compileOptions
580
+ build_types_anchor = updated_inner.index(/\n\s*buildTypes\s*\{/)
581
+ compile_options_anchor = if build_types_anchor
582
+ updated_inner.index(/\n\s*compileOptions\s*\{/, build_types_anchor)
583
+ else
584
+ updated_inner.index(/\n\s*compileOptions\s*\{/)
585
+ end
586
+ build_types_search_space = compile_options_anchor ? updated_inner[0...compile_options_anchor] : updated_inner
587
+
588
+ updated_inner = inject_into_child_block(
589
+ updated_inner,
590
+ block_name: "buildTypes",
591
+ entry: build_type_entry,
592
+ entry_present: false,
593
+ base_indent: android_indent,
594
+ search_space: build_types_search_space,
595
+ brace_end_exclusive: compile_options_anchor
596
+ )
597
+
598
+ before_android = content[0..block_start]
599
+ after_android = content[block_end..]
600
+ before_android + updated_inner + after_android
601
+ end
602
+
603
+ def inject_into_child_block(android_inner, block_name:, entry:, entry_present:, base_indent:, search_space: nil, brace_end_exclusive: nil)
604
+ return android_inner if entry_present
605
+
606
+ space = search_space || android_inner
607
+ m = space.match(/\b#{Regexp.escape(block_name)}\s*\{/)
608
+ if m
609
+ open_idx = m.begin(0)
610
+ brace_idx = android_inner.index("{", open_idx)
611
+ close_idx = if brace_end_exclusive && brace_end_exclusive > brace_idx
612
+ fragment = android_inner[brace_idx...brace_end_exclusive]
613
+ rel = find_matching_brace(fragment, 0)
614
+ rel ? (brace_idx + rel) : nil
615
+ else
616
+ scan = search_space || android_inner
617
+ find_matching_brace(scan, brace_idx)
618
+ end
619
+ return android_inner unless close_idx
620
+
621
+ line_start = android_inner.rindex("\n", open_idx) || 0
622
+ line_start = 0 if line_start == 0
623
+ block_indent = android_inner[line_start..open_idx].match(/^\s*/).to_s
624
+ entry_text = indent_lines(entry, block_indent + " ")
625
+
626
+ before = android_inner[0...close_idx].rstrip
627
+ after = android_inner[close_idx..]
628
+ return before + "\n" + entry_text + "\n" + after
629
+ end
630
+
631
+ # child block 不存在:在 android 末尾创建一个
632
+ entry_text = indent_lines(entry, base_indent + " ")
633
+ block = []
634
+ block << "\n" + (base_indent + " ") + "#{block_name} {"
635
+ block << "\n" + entry_text
636
+ block << "\n" + (base_indent + " ") + "}"
637
+ android_inner.rstrip + block.join + "\n"
638
+ end
639
+
640
+ # 注意:不要依赖“是否已存在同名条目”来跳过注入。
641
+ # 很多项目可能已经有同名 buildType/signingConfig(但签名读取方式不符合 workflow 要求),
642
+ # 这里通过再次配置同名条目来覆盖关键字段(Gradle DSL 会合并同名对象),并以文件级 marker 保证幂等。
643
+
644
+ def find_matching_brace(str, open_brace_index)
645
+ depth = 0
646
+ i = open_brace_index
647
+ while i < str.length
648
+ ch = str.getbyte(i)
649
+ if ch == 123 # {
650
+ depth += 1
651
+ elsif ch == 125 # }
652
+ depth -= 1
653
+ return i if depth == 0
654
+ end
655
+ i += 1
656
+ end
657
+ nil
658
+ end
659
+
660
+ def indent_lines(text, base_indent)
661
+ text.lines.map do |line|
662
+ if line.strip.empty?
663
+ line
664
+ else
665
+ base_indent + line
666
+ end
667
+ end.join
668
+ end
669
+
670
+ def backup_file!(context, abs_path)
671
+ project_dir = context[:project_dir]
672
+ return unless abs_path && File.file?(abs_path)
673
+ return unless abs_path.start_with?(project_dir.to_s)
674
+
675
+ rel = abs_path.delete_prefix("#{project_dir}/")
676
+
677
+ # 已备份过就跳过
678
+ return if context[:backups].any? { |b| b["path"] == rel && b["type"] == "file" }
679
+
680
+ backup_path = File.join(context[:backup_dir], rel)
681
+ FileUtils.mkdir_p(File.dirname(backup_path))
682
+ self.class.bin_copy(abs_path, backup_path)
683
+
684
+ context[:backups] << {
685
+ "type" => "file",
686
+ "path" => rel,
687
+ "backup_path" => backup_path.delete_prefix("#{project_dir}/"),
688
+ "sha256_before" => Digest::SHA256.file(abs_path).hexdigest,
689
+ }
690
+ end
691
+
692
+ def self.bin_copy(src, dst)
693
+ File.open(src, "rb") do |r|
694
+ File.open(dst, "wb") do |w|
695
+ IO.copy_stream(r, w)
696
+ end
697
+ end
698
+ end
699
+ end
700
+ end
701
+ end
702
+