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.
- checksums.yaml +4 -4
- data/lib/pindo/base/git_handler.rb +120 -38
- data/lib/pindo/command/android/autobuild.rb +92 -31
- data/lib/pindo/command/appstore/adhocbuild.rb +1 -1
- data/lib/pindo/command/appstore/autobuild.rb +1 -1
- data/lib/pindo/command/appstore/autoresign.rb +1 -1
- data/lib/pindo/command/appstore/updateid.rb +229 -0
- data/lib/pindo/command/appstore.rb +1 -0
- data/lib/pindo/command/ios/autobuild.rb +70 -33
- data/lib/pindo/command/ios/podpush.rb +1 -1
- data/lib/pindo/command/unity/autobuild.rb +38 -18
- data/lib/pindo/command/utils/allcopyconfig.rb +144 -0
- data/lib/pindo/command/utils/copyconfig.rb +207 -0
- data/lib/pindo/command/utils/icon.rb +2 -2
- data/lib/pindo/command/utils/renewbundleid.rb +199 -0
- data/lib/pindo/command/utils/renewcert.rb +56 -54
- data/lib/pindo/command/utils.rb +3 -0
- data/lib/pindo/command/web/autobuild.rb +10 -8
- data/lib/pindo/config/build_info_manager.rb +1 -3
- data/lib/pindo/module/android/android_build_helper.rb +198 -33
- data/lib/pindo/module/android/android_config_helper.rb +305 -88
- data/lib/pindo/module/android/android_project_helper.rb +124 -14
- data/lib/pindo/module/android/android_res_helper.rb +349 -51
- data/lib/pindo/module/android/keystore_helper.rb +611 -295
- data/lib/pindo/module/android/workflow_gradle_injector.rb +702 -0
- data/lib/pindo/module/appselect.rb +4 -4
- data/lib/pindo/module/appstore/bundleid_helper.rb +204 -14
- data/lib/pindo/module/build/build_helper.rb +76 -10
- data/lib/pindo/module/build/git_repo_helper.rb +4 -4
- data/lib/pindo/module/cert/mode/base_cert_operator.rb +12 -6
- data/lib/pindo/module/pgyer/pgyerhelper.rb +124 -39
- data/lib/pindo/module/task/model/build/android_build_dev_task.rb +64 -6
- data/lib/pindo/module/task/model/git/git_commit_task.rb +70 -54
- data/lib/pindo/module/task/model/git/git_tag_task.rb +13 -9
- data/lib/pindo/module/task/model/jps/jps_upload_task.rb +110 -3
- data/lib/pindo/module/task/model/unity/unity_export_task.rb +2 -1
- data/lib/pindo/module/task/model/unity/unity_update_task.rb +2 -1
- data/lib/pindo/module/task/model/unity/unity_yoo_asset_task.rb +2 -1
- data/lib/pindo/module/task/model/unity_task.rb +2 -1
- data/lib/pindo/module/unity/unity_helper.rb +13 -10
- data/lib/pindo/module/unity/unity_proc_helper.rb +27 -2
- data/lib/pindo/module/xcode/applovin_xcode_helper.rb +6 -2
- data/lib/pindo/module/xcode/res/xcode_res_constant.rb +72 -0
- data/lib/pindo/module/xcode/res/xcode_res_handler.rb +3 -3
- data/lib/pindo/module/xcode/xcode_build_config.rb +46 -17
- data/lib/pindo/module/xcode/xcode_build_helper.rb +186 -25
- data/lib/pindo/module/xcode/xcode_project_helper.rb +1 -1
- data/lib/pindo/module/xcode/xcode_res_helper.rb +32 -16
- data/lib/pindo/options/groups/build_options.rb +5 -5
- data/lib/pindo/options/groups/git_options.rb +7 -5
- data/lib/pindo/options/groups/unity_options.rb +11 -0
- data/lib/pindo/options/helpers/bundleid_selector.rb +25 -0
- data/lib/pindo/options/helpers/git_constants.rb +7 -6
- data/lib/pindo/version.rb +3 -3
- 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
|
+
|