pindo 5.15.3 → 5.15.9
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/funlog.rb +37 -10
- data/lib/pindo/module/android/gp_compliance_helper.rb +531 -159
- data/lib/pindo/module/task/output/multi_line_output_manager.rb +30 -3
- data/lib/pindo/module/task/output/stdout_redirector.rb +22 -4
- data/lib/pindo/module/task/task_manager.rb +12 -0
- data/lib/pindo/version.rb +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef2974c733368b841567503a425a32f359fe03154f73809574b2c1b0a1d2201b
|
|
4
|
+
data.tar.gz: ac37d8ed02529bc1c487696ef1e450965bc42e8914104bc381ca1486123d35da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 23730681434d87847ef4c5f43aee490d74cfcea7dd754fa9f1f59f6018b59df99296ba67ae808a5a7520865ffebac30b952d304495ce1a2222fc3b73b1828e41
|
|
7
|
+
data.tar.gz: feef72ac4cfcc0671458083fb6de016e1213ca37ef28be5ff820107bca499962e06ac3e26aaef10d470a52f0df52410085b74f4cc74a92aefcd6b1914dc6d5f4
|
data/lib/pindo/base/funlog.rb
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
1
4
|
require "tty-spinner"
|
|
2
5
|
|
|
3
6
|
module Pindo
|
|
4
7
|
|
|
5
8
|
class Funlog
|
|
6
9
|
|
|
10
|
+
# 确保字符串使用 UTF-8 编码,防止编码错误导致任务中断
|
|
11
|
+
# @param str [String] 输入字符串
|
|
12
|
+
# @return [String] UTF-8 编码的字符串
|
|
13
|
+
def self.ensure_utf8(str)
|
|
14
|
+
return str if str.nil?
|
|
15
|
+
return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
|
|
16
|
+
|
|
17
|
+
# 尝试转换为 UTF-8
|
|
18
|
+
begin
|
|
19
|
+
str.encode(Encoding::UTF_8)
|
|
20
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
21
|
+
# 如果转换失败,尝试使用替换字符
|
|
22
|
+
str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
7
25
|
|
|
8
26
|
def create_spinner(info_key:nil)
|
|
9
27
|
# spinner = TTY::Spinner.new("[:spinner] :#{info_key}", format: :dots_2, error_mark: "❌", success_mark: "✅")
|
|
@@ -21,15 +39,24 @@ module Pindo
|
|
|
21
39
|
|
|
22
40
|
|
|
23
41
|
def fancyinfo_start(*args)
|
|
24
|
-
message = args.join(" ")
|
|
25
|
-
|
|
26
|
-
|
|
42
|
+
message = Funlog.ensure_utf8(args.join(" "))
|
|
43
|
+
|
|
44
|
+
# 检查是否在任务上下文中
|
|
45
|
+
if Funlog.in_task_context?
|
|
46
|
+
# 并发模式:路由到输出管理器(只写日志文件)
|
|
47
|
+
Funlog.current_output_sink.log_info(Funlog.current_task_id, message)
|
|
48
|
+
else
|
|
49
|
+
# 串行模式:使用 spinner
|
|
50
|
+
spinner_log_handle.update(title:message)
|
|
51
|
+
spinner_log_handle.auto_spin
|
|
52
|
+
end
|
|
53
|
+
|
|
27
54
|
@spinner_log
|
|
28
55
|
end
|
|
29
56
|
|
|
30
57
|
|
|
31
58
|
def fancyinfo_update(*args)
|
|
32
|
-
message = args.join(" ")
|
|
59
|
+
message = Funlog.ensure_utf8(args.join(" "))
|
|
33
60
|
|
|
34
61
|
# 检查是否在任务上下文中
|
|
35
62
|
if Funlog.in_task_context?
|
|
@@ -45,14 +72,14 @@ module Pindo
|
|
|
45
72
|
end
|
|
46
73
|
|
|
47
74
|
def fancyinfo_success(*args)
|
|
48
|
-
message = args.join(" ")
|
|
75
|
+
message = Funlog.ensure_utf8(args.join(" "))
|
|
49
76
|
spinner_log_handle.update(title:message)
|
|
50
77
|
spinner_log_handle.success
|
|
51
78
|
@spinner_log =nil
|
|
52
79
|
end
|
|
53
80
|
|
|
54
81
|
def fancyinfo_error(*args)
|
|
55
|
-
message = args.join(" ")
|
|
82
|
+
message = Funlog.ensure_utf8(args.join(" "))
|
|
56
83
|
spinner_log_handle.update(title:message)
|
|
57
84
|
spinner_log_handle.error
|
|
58
85
|
@spinner_log =nil
|
|
@@ -61,7 +88,7 @@ module Pindo
|
|
|
61
88
|
# 输出警告信息(使用spinner,黄色警告标记)
|
|
62
89
|
# @param args [Array] 要输出的消息
|
|
63
90
|
def fancyinfo_warning(*args)
|
|
64
|
-
message = args.join(" ")
|
|
91
|
+
message = Funlog.ensure_utf8(args.join(" "))
|
|
65
92
|
spinner_log_handle.update(title:message)
|
|
66
93
|
spinner_log_handle.stop("\e[33m⚠\e[0m")
|
|
67
94
|
@spinner_log = nil
|
|
@@ -70,7 +97,7 @@ module Pindo
|
|
|
70
97
|
# 输出静态成功信息(不使用spinner,对应 fancyinfo_success)
|
|
71
98
|
# @param args [Array] 要输出的消息
|
|
72
99
|
def info(*args)
|
|
73
|
-
message = args.join(" ")
|
|
100
|
+
message = Funlog.ensure_utf8(args.join(" "))
|
|
74
101
|
|
|
75
102
|
# 检查是否在任务上下文中
|
|
76
103
|
if Funlog.in_task_context?
|
|
@@ -85,7 +112,7 @@ module Pindo
|
|
|
85
112
|
# 输出静态警告信息(不使用spinner)
|
|
86
113
|
# @param args [Array] 要输出的消息
|
|
87
114
|
def warning(*args)
|
|
88
|
-
message = args.join(" ")
|
|
115
|
+
message = Funlog.ensure_utf8(args.join(" "))
|
|
89
116
|
|
|
90
117
|
# 检查是否在任务上下文中
|
|
91
118
|
if Funlog.in_task_context?
|
|
@@ -100,7 +127,7 @@ module Pindo
|
|
|
100
127
|
# 输出静态错误信息(不使用spinner,对应 fancyinfo_error)
|
|
101
128
|
# @param args [Array] 要输出的消息
|
|
102
129
|
def error(*args)
|
|
103
|
-
message = args.join(" ")
|
|
130
|
+
message = Funlog.ensure_utf8(args.join(" "))
|
|
104
131
|
|
|
105
132
|
# 检查是否在任务上下文中
|
|
106
133
|
if Funlog.in_task_context?
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
require 'fileutils'
|
|
2
2
|
require 'tempfile'
|
|
3
3
|
require 'nokogiri'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
require 'open3'
|
|
4
6
|
require_relative 'android_project_helper'
|
|
5
7
|
require_relative 'java_env_helper'
|
|
6
8
|
require_relative '../../base/funlog'
|
|
@@ -49,12 +51,16 @@ module Pindo
|
|
|
49
51
|
def self.check_aab_compliance(aab_path)
|
|
50
52
|
result = ComplianceResult.new
|
|
51
53
|
|
|
54
|
+
# 先输出标题,使用 puts 确保始终在终端可见
|
|
55
|
+
puts "\n\e[1m-- 检测 AAB 文件的 Google Play 合规性 --\e[0m"
|
|
56
|
+
|
|
52
57
|
unless File.exist?(aab_path)
|
|
58
|
+
puts "\e[31m✗ AAB 文件不存在: #{aab_path}\e[0m"
|
|
53
59
|
result.add_issue("AAB 文件不存在: #{aab_path}")
|
|
54
60
|
return result
|
|
55
61
|
end
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
puts "\e[1m检测的 AAB 文件:#{File.basename(aab_path)}\e[0m"
|
|
58
64
|
|
|
59
65
|
# 创建临时目录用于解压 AAB
|
|
60
66
|
temp_dir = nil
|
|
@@ -68,9 +74,11 @@ module Pindo
|
|
|
68
74
|
return result
|
|
69
75
|
end
|
|
70
76
|
|
|
71
|
-
# 解压 AAB
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
# 解压 AAB 文件中的必要文件
|
|
78
|
+
extract_result = extract_required_files_from_aab(aab_path, temp_dir)
|
|
79
|
+
unless extract_result[:success]
|
|
80
|
+
puts "\e[31m✗ #{extract_result[:error_message]}\e[0m"
|
|
81
|
+
result.add_issue(extract_result[:error_message])
|
|
74
82
|
return result
|
|
75
83
|
end
|
|
76
84
|
|
|
@@ -99,6 +107,331 @@ module Pindo
|
|
|
99
107
|
|
|
100
108
|
private
|
|
101
109
|
|
|
110
|
+
# 获取 UTF-8 环境变量(用于执行命令)
|
|
111
|
+
# @return [Hash] 环境变量哈希
|
|
112
|
+
def self.get_utf8_env
|
|
113
|
+
{
|
|
114
|
+
'LANG' => 'en_US.UTF-8',
|
|
115
|
+
'LC_ALL' => 'en_US.UTF-8'
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# 判断错误信息是否表示文件不存在(可以安全忽略)
|
|
120
|
+
# @param error_text [String] 错误信息
|
|
121
|
+
# @return [Boolean] 如果错误表示文件不存在,返回 true
|
|
122
|
+
def self.is_file_not_found_error?(error_text)
|
|
123
|
+
error_text.include?('filename not matched') ||
|
|
124
|
+
error_text.include?('cannot find') ||
|
|
125
|
+
error_text.include?('not found') ||
|
|
126
|
+
error_text.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# 解压压缩文件(AAB/APK)内部的单个文件
|
|
130
|
+
# 注意:内部文件路径不会被 File.expand_path 处理,保持原样
|
|
131
|
+
# @param archive_path [String] 压缩文件路径(AAB 或 APK)
|
|
132
|
+
# @param internal_file [String] 压缩文件内部的相对路径(如 base/lib/arm64-v8a/libunity.so)
|
|
133
|
+
# @param dest_dir [String] 解压目标目录
|
|
134
|
+
# @return [Hash] {success: Boolean, error: String}
|
|
135
|
+
def self.extract_internal_file(archive_path, internal_file, dest_dir)
|
|
136
|
+
begin
|
|
137
|
+
abs_archive_path = File.expand_path(archive_path)
|
|
138
|
+
abs_dest_dir = File.expand_path(dest_dir)
|
|
139
|
+
stdout, stderr, status = Open3.capture3(get_utf8_env, 'unzip', '-q', abs_archive_path, internal_file, '-d', abs_dest_dir)
|
|
140
|
+
|
|
141
|
+
if status.success?
|
|
142
|
+
return { success: true, error: nil }
|
|
143
|
+
else
|
|
144
|
+
error_text = [stderr, stdout].compact.join(' ')
|
|
145
|
+
if is_file_not_found_error?(error_text)
|
|
146
|
+
# 文件不存在,可以忽略
|
|
147
|
+
return { success: true, error: nil }
|
|
148
|
+
else
|
|
149
|
+
return { success: false, error: error_text }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
rescue => e
|
|
153
|
+
return { success: false, error: e.message }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# 获取可能的 lib 目录列表(用于查找 .so 文件)
|
|
158
|
+
# @param temp_dir [String] 临时目录路径
|
|
159
|
+
# @return [Array<String>] lib 目录路径数组
|
|
160
|
+
def self.get_lib_dirs(temp_dir)
|
|
161
|
+
[
|
|
162
|
+
"#{temp_dir}/lib",
|
|
163
|
+
"#{temp_dir}/libs",
|
|
164
|
+
"#{temp_dir}/base/lib", # AAB文件结构
|
|
165
|
+
"#{temp_dir}/base/libs" # AAB文件结构
|
|
166
|
+
]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# 在临时目录中查找指定的 .so 文件
|
|
170
|
+
# @param temp_dir [String] 临时目录路径
|
|
171
|
+
# @param so_filename [String] .so 文件名(如 libunity.so, libil2cpp.so)
|
|
172
|
+
# @return [Array<String>] 找到的文件路径数组
|
|
173
|
+
def self.find_so_files(temp_dir, so_filename)
|
|
174
|
+
so_files = []
|
|
175
|
+
get_lib_dirs(temp_dir).each do |lib_dir|
|
|
176
|
+
if Dir.exist?(lib_dir)
|
|
177
|
+
found_files = Dir.glob("#{lib_dir}/**/#{so_filename}")
|
|
178
|
+
so_files += found_files
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
so_files
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# 判断文件是否是合规检测需要的文件
|
|
185
|
+
# @param filename [String] AAB 文件内部的相对路径
|
|
186
|
+
# @return [Boolean] 如果是需要的文件,返回 true
|
|
187
|
+
def self.is_required_file?(filename)
|
|
188
|
+
filename.start_with?('base/manifest/') ||
|
|
189
|
+
(filename.start_with?('base/lib/') && filename.end_with?('.so')) ||
|
|
190
|
+
filename == 'base.apk' ||
|
|
191
|
+
filename == 'base/assets/bin/Data/boot.config'
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# 从 unzip -Z1 输出中提取文件列表
|
|
195
|
+
# @param output [String] unzip -Z1 的输出
|
|
196
|
+
# @return [Array<String>] 文件路径数组
|
|
197
|
+
def self.parse_unzip_z1_output(output)
|
|
198
|
+
files = []
|
|
199
|
+
output.lines.each do |line|
|
|
200
|
+
filename = line.strip
|
|
201
|
+
next if filename.empty?
|
|
202
|
+
files << filename if is_required_file?(filename)
|
|
203
|
+
end
|
|
204
|
+
files
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# 从 unzip -l 输出中提取文件列表
|
|
208
|
+
# @param output [String] unzip -l 的输出
|
|
209
|
+
# @return [Array<String>] 文件路径数组
|
|
210
|
+
def self.parse_unzip_l_output(output)
|
|
211
|
+
files = []
|
|
212
|
+
output.lines.each do |line|
|
|
213
|
+
# unzip -l 输出格式:长度 日期 时间 文件名
|
|
214
|
+
# 跳过表头和空行
|
|
215
|
+
next if line.strip.empty? || line.start_with?('Archive:') ||
|
|
216
|
+
line.start_with?('Length') || line.start_with?('------') ||
|
|
217
|
+
line.match(/^\s*$/) || line.match(/^\s*\d+\s+files?\s*$/)
|
|
218
|
+
|
|
219
|
+
# 提取文件名:从行尾开始,跳过前面的数字和日期时间字段
|
|
220
|
+
filename_match = line.match(/^\s*\d+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.+)$/)
|
|
221
|
+
next unless filename_match
|
|
222
|
+
|
|
223
|
+
filename = filename_match[1].strip
|
|
224
|
+
files << filename if is_required_file?(filename)
|
|
225
|
+
end
|
|
226
|
+
files
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# 从压缩文件中列出并提取需要的文件
|
|
230
|
+
# @param archive_path [String] 压缩文件路径
|
|
231
|
+
# @param dest_dir [String] 解压目标目录
|
|
232
|
+
# @return [Hash] {success: Boolean, error_messages: Array<String>}
|
|
233
|
+
def self.extract_required_files(archive_path, dest_dir)
|
|
234
|
+
error_messages = []
|
|
235
|
+
failed = false
|
|
236
|
+
|
|
237
|
+
# 优先使用 unzip -Z1
|
|
238
|
+
unzip_list_output, _, unzip_list_status = safe_execute_command('unzip', '-Z1', archive_path)
|
|
239
|
+
if unzip_list_status.success? && unzip_list_output && !unzip_list_output.strip.empty?
|
|
240
|
+
required_files = parse_unzip_z1_output(unzip_list_output)
|
|
241
|
+
else
|
|
242
|
+
# 回退到 unzip -l
|
|
243
|
+
unzip_list_output, _, unzip_list_status = safe_execute_command('unzip', '-l', archive_path)
|
|
244
|
+
if unzip_list_status.success? && unzip_list_output
|
|
245
|
+
required_files = parse_unzip_l_output(unzip_list_output)
|
|
246
|
+
else
|
|
247
|
+
return { success: false, error_messages: ["无法列出 #{archive_path} 中的文件"] }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# 解压文件
|
|
252
|
+
required_files.each do |file|
|
|
253
|
+
extract_result = extract_internal_file(archive_path, file, dest_dir)
|
|
254
|
+
unless extract_result[:success]
|
|
255
|
+
failed = true
|
|
256
|
+
error_messages << "解压 #{file} 失败: #{extract_result[:error]}"
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
{ success: !failed, error_messages: error_messages }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# 解压 AAB 文件中的必要文件
|
|
264
|
+
# 只解压合规检测需要的文件,避免解压大型 assets 文件导致错误
|
|
265
|
+
# 合规检测只需要:
|
|
266
|
+
# 1. base/manifest/ - Target SDK 检测(如果 bundletool 失败)
|
|
267
|
+
# 2. base/lib/ - ELF 对齐检测(递归解压所有 .so 文件)
|
|
268
|
+
# 3. base.apk - 需要进一步解压其中的 lib/
|
|
269
|
+
# 4. base/assets/bin/Data/boot.config - Unity 检测(如果存在)
|
|
270
|
+
# 注意:不解压 assets 目录下的其他文件(如 bundle 文件),避免解压错误
|
|
271
|
+
# @param aab_path [String] AAB 文件路径
|
|
272
|
+
# @param temp_dir [String] 临时目录路径
|
|
273
|
+
# @return [Hash] {success: Boolean, error_message: String}
|
|
274
|
+
def self.extract_required_files_from_aab(aab_path, temp_dir)
|
|
275
|
+
# 解压 AAB 文件中的必要文件
|
|
276
|
+
extract_result = extract_required_files(aab_path, temp_dir)
|
|
277
|
+
unzip_error_messages = extract_result[:error_messages]
|
|
278
|
+
unzip_failed = !extract_result[:success]
|
|
279
|
+
|
|
280
|
+
# 如果 base.apk 存在,需要进一步解压其中的 lib 目录
|
|
281
|
+
base_apk_path = File.join(temp_dir, "base.apk")
|
|
282
|
+
if File.exist?(base_apk_path)
|
|
283
|
+
# 尝试解压整个 lib/* 目录(包括所有子目录和文件)
|
|
284
|
+
# 这样可以避免逐个文件解压时遇到文件名编码问题
|
|
285
|
+
base_apk_lib_output, base_apk_lib_error, base_apk_lib_status = safe_execute_command('unzip', '-q', base_apk_path, 'lib/*', '-d', temp_dir)
|
|
286
|
+
unless base_apk_lib_status.success?
|
|
287
|
+
# 如果解压 lib/* 失败,尝试只解压 .so 文件
|
|
288
|
+
# 优先使用 unzip -Z1 列出文件
|
|
289
|
+
base_apk_list_output, _, base_apk_list_status = safe_execute_command('unzip', '-Z1', base_apk_path)
|
|
290
|
+
if base_apk_list_status.success? && base_apk_list_output && !base_apk_list_output.strip.empty?
|
|
291
|
+
lib_files = base_apk_list_output.lines.map(&:strip).select { |f| f.start_with?('lib/') && f.end_with?('.so') }
|
|
292
|
+
else
|
|
293
|
+
# 回退到 unzip -l
|
|
294
|
+
base_apk_list_output, _, base_apk_list_status = safe_execute_command('unzip', '-l', base_apk_path)
|
|
295
|
+
if base_apk_list_status.success? && base_apk_list_output
|
|
296
|
+
lib_files = parse_unzip_l_output(base_apk_list_output).select { |f| f.start_with?('lib/') && f.end_with?('.so') }
|
|
297
|
+
else
|
|
298
|
+
lib_files = []
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# 尝试逐个解压 .so 文件
|
|
303
|
+
lib_files.each do |lib_file|
|
|
304
|
+
extract_result = extract_internal_file(base_apk_path, lib_file, temp_dir)
|
|
305
|
+
unless extract_result[:success]
|
|
306
|
+
# 单个文件解压失败,记录警告但不标记为整体失败
|
|
307
|
+
# 因为 find_shared_libraries 会再次尝试解压
|
|
308
|
+
unzip_error_messages << "警告: 解压 base.apk 中的 #{lib_file} 失败: #{extract_result[:error]}"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# 返回结果
|
|
315
|
+
# 只在实际失败时返回 false,警告信息不影响成功状态
|
|
316
|
+
# 因为 find_shared_libraries 会再次尝试解压 base.apk 中的文件
|
|
317
|
+
critical_errors = unzip_error_messages.reject { |msg| msg.include?('警告:') }
|
|
318
|
+
if unzip_failed && critical_errors.any?
|
|
319
|
+
error_text = critical_errors.join('; ')
|
|
320
|
+
error_msg = "无法解压 AAB 文件中的必要文件: #{error_text}"
|
|
321
|
+
return { success: false, error_message: error_msg }
|
|
322
|
+
else
|
|
323
|
+
return { success: true, error_message: nil }
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# 安全执行命令并处理路径编码问题
|
|
328
|
+
# 确保包含非ASCII字符(如中文、韩语)的路径能够正确处理
|
|
329
|
+
def self.safe_execute_command(*args)
|
|
330
|
+
begin
|
|
331
|
+
# 确保所有参数都是字符串,并正确处理编码
|
|
332
|
+
safe_args = args.map.with_index do |arg, index|
|
|
333
|
+
if arg.is_a?(String)
|
|
334
|
+
# 第一个参数通常是命令名(如 'unzip' 或 '/usr/bin/java')
|
|
335
|
+
# 绝对路径(以 '/' 开头)不需要展开,已经是完整路径
|
|
336
|
+
# 相对路径(包含 '/' 或 '.')需要展开
|
|
337
|
+
if index == 0
|
|
338
|
+
# 第一个参数(命令名):绝对路径保持原样,相对路径展开
|
|
339
|
+
if arg.start_with?('/')
|
|
340
|
+
# 绝对路径:只处理编码,不展开
|
|
341
|
+
arg.dup.force_encoding('UTF-8').scrub('')
|
|
342
|
+
else
|
|
343
|
+
# 相对路径或命令名:不展开(让系统在 PATH 中查找)
|
|
344
|
+
arg.dup.force_encoding('UTF-8').scrub('')
|
|
345
|
+
end
|
|
346
|
+
elsif arg.start_with?('/')
|
|
347
|
+
# 绝对路径参数:只处理编码,不展开
|
|
348
|
+
arg.dup.force_encoding('UTF-8').scrub('')
|
|
349
|
+
elsif arg.include?('/') || arg.start_with?('.')
|
|
350
|
+
# 相对路径参数:使用 File.expand_path 规范化路径
|
|
351
|
+
normalized_path = File.expand_path(arg)
|
|
352
|
+
normalized_path.dup.force_encoding('UTF-8').scrub('')
|
|
353
|
+
else
|
|
354
|
+
# 其他参数(如选项 '-q', '-d'):只处理编码
|
|
355
|
+
arg.dup.force_encoding('UTF-8').scrub('')
|
|
356
|
+
end
|
|
357
|
+
else
|
|
358
|
+
arg.to_s
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# 使用 Open3 执行命令,避免shell解析问题
|
|
363
|
+
# 设置环境变量确保输出编码正确
|
|
364
|
+
stdout, stderr, status = Open3.capture3(get_utf8_env, *safe_args)
|
|
365
|
+
|
|
366
|
+
# 确保输出使用UTF-8编码,并清理可能的路径拼接错误
|
|
367
|
+
if stdout
|
|
368
|
+
stdout = stdout.force_encoding('UTF-8')
|
|
369
|
+
stdout = stdout.scrub('?') unless stdout.valid_encoding?
|
|
370
|
+
|
|
371
|
+
# 清理可能的路径拼接错误:移除临时目录路径和AAB文件名的错误拼接
|
|
372
|
+
# 例如:launcher-debug.aab/private/var/folders/... 应该被清理
|
|
373
|
+
stdout = stdout.lines.map do |line|
|
|
374
|
+
# 如果行中包含临时目录路径模式(/private/var/folders 或 /tmp/),
|
|
375
|
+
# 并且前面有AAB文件名,则移除临时目录部分
|
|
376
|
+
if line.match(/\.aab\/[\/\w\-]+(?:private\/var\/folders|tmp\/|aab_compliance_check_)/)
|
|
377
|
+
# 提取AAB文件名后的相对路径部分
|
|
378
|
+
# 例如:launcher-debug.aab/private/var/.../yoo_pack/...
|
|
379
|
+
# 应该只保留 yoo_pack/... 部分
|
|
380
|
+
if match = line.match(/\.aab\/[\/\w\-]+(?:private\/var\/folders|tmp\/|aab_compliance_check_)[\/\w\-]+(.+)$/)
|
|
381
|
+
# 保留压缩包内的相对路径
|
|
382
|
+
line.sub(/^[^:]*\.aab\/[\/\w\-]+(?:private\/var\/folders|tmp\/|aab_compliance_check_)[\/\w\-]+/, '')
|
|
383
|
+
else
|
|
384
|
+
line
|
|
385
|
+
end
|
|
386
|
+
else
|
|
387
|
+
line
|
|
388
|
+
end
|
|
389
|
+
end.join
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
[stdout, stderr, status]
|
|
393
|
+
rescue => e
|
|
394
|
+
# 如果出错,返回空结果
|
|
395
|
+
# 创建一个简单的状态对象,模拟 Process::Status
|
|
396
|
+
failed_status = Object.new
|
|
397
|
+
def failed_status.success?; false; end
|
|
398
|
+
['', e.message, failed_status]
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# 安全执行shell命令(用于需要管道等shell特性的情况)
|
|
403
|
+
# 使用 Shellwords.escape 转义路径参数
|
|
404
|
+
def self.safe_execute_shell_command(command_template, **kwargs)
|
|
405
|
+
begin
|
|
406
|
+
# 转义所有路径参数
|
|
407
|
+
escaped_kwargs = kwargs.transform_values do |value|
|
|
408
|
+
if value.is_a?(String)
|
|
409
|
+
# 确保字符串使用UTF-8编码
|
|
410
|
+
safe_value = value.dup.force_encoding('UTF-8').scrub('')
|
|
411
|
+
Shellwords.escape(safe_value)
|
|
412
|
+
else
|
|
413
|
+
Shellwords.escape(value.to_s)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# 替换模板中的占位符
|
|
418
|
+
command = command_template % escaped_kwargs
|
|
419
|
+
|
|
420
|
+
# 执行命令
|
|
421
|
+
output = `#{command} 2>/dev/null`
|
|
422
|
+
|
|
423
|
+
# 确保输出使用UTF-8编码
|
|
424
|
+
if output
|
|
425
|
+
output = output.force_encoding('UTF-8')
|
|
426
|
+
output = output.scrub('?') unless output.valid_encoding?
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
output || ''
|
|
430
|
+
rescue => e
|
|
431
|
+
''
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
102
435
|
# 检查工具是否可用
|
|
103
436
|
def self.tool_available?(tool_name)
|
|
104
437
|
case tool_name
|
|
@@ -129,8 +462,6 @@ module Pindo
|
|
|
129
462
|
|
|
130
463
|
# 检测 AAB 包体积合规性
|
|
131
464
|
def self.check_aab_size_compliance(aab_path, result)
|
|
132
|
-
Funlog.fancyinfo_update("检测 AAB 包体积...")
|
|
133
|
-
|
|
134
465
|
begin
|
|
135
466
|
# 获取 AAB 文件大小
|
|
136
467
|
aab_size = File.size(aab_path)
|
|
@@ -141,36 +472,69 @@ module Pindo
|
|
|
141
472
|
base_limit_bytes = 194615705 # 185MB
|
|
142
473
|
base_limit_mb = 185
|
|
143
474
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
475
|
+
# 使用安全的方式执行 unzip 命令,处理路径中的非ASCII字符
|
|
476
|
+
# 注意:unzip -v 的输出格式为:
|
|
477
|
+
# Archive: filename.aab
|
|
478
|
+
# Length Method Size Cmpr Date Time CRC-32 Name
|
|
479
|
+
# --------- ------ ------- ---- ---------- ----- -------- ----
|
|
480
|
+
# 1012 Defl:N 403 60% 01-01-1970 08:00 70391f2c base/manifest/AndroidManifest.xml
|
|
481
|
+
# 我们需要提取的是压缩包内的压缩大小(Size列,第3列,索引为2),而不是解压后的大小(Length列)
|
|
482
|
+
unzip_out, _, status = safe_execute_command('unzip', '-v', aab_path)
|
|
483
|
+
if status.success? && unzip_out && !unzip_out.empty?
|
|
484
|
+
# 过滤掉头部信息,只处理文件列表行
|
|
485
|
+
# 跳过 "Archive:" 行和表头行,只处理实际的文件条目
|
|
486
|
+
file_lines = unzip_out.lines.reject do |line|
|
|
487
|
+
line.strip.empty? ||
|
|
488
|
+
line.start_with?('Archive:') ||
|
|
489
|
+
line.start_with?('Length') ||
|
|
490
|
+
line.start_with?('------') ||
|
|
491
|
+
line.match(/^\s*$/) # 空行
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# 只处理包含 " base/" 的行,并提取压缩后文件大小(Size列,第3列,索引为2)
|
|
495
|
+
# unzip -v 输出格式:Length Method Size Cmpr Date Time CRC-32 Name
|
|
496
|
+
base_lines = file_lines.select { |l| l.include?(" base/") }
|
|
497
|
+
base_size = base_lines.map do |line|
|
|
498
|
+
parts = line.split
|
|
499
|
+
# 确保有足够的列,并且第3列(Size)是数字(压缩后大小)
|
|
500
|
+
# 至少需要8列:Length Method Size Cmpr Date Time CRC-32 Name
|
|
501
|
+
if parts.length >= 8 && parts[2].match(/^\d+$/)
|
|
502
|
+
parts[2].to_i
|
|
503
|
+
else
|
|
504
|
+
0
|
|
505
|
+
end
|
|
506
|
+
end.sum
|
|
507
|
+
|
|
148
508
|
result.base_size_mb = (base_size.to_f / 1024 / 1024).round(2)
|
|
149
509
|
result.base_percent = aab_size > 0 ? ((base_size.to_f * 100) / aab_size).round(2) : 0
|
|
150
510
|
end
|
|
151
|
-
|
|
511
|
+
|
|
152
512
|
if base_size > base_limit_bytes
|
|
153
513
|
result.size_compliant = false
|
|
154
514
|
result.add_issue("base 文件夹已超出 Google Play 限制(#{base_limit_mb}MB),请优化资源或分包")
|
|
155
|
-
|
|
515
|
+
# 只在不合规时输出日志
|
|
516
|
+
puts "\n\e[1m--- AAB 包体积检测 ---\e[0m"
|
|
517
|
+
puts "\e[31m✗ base 文件夹超出限制: #{result.base_size_mb}MB (限制 #{base_limit_mb}MB)\e[0m"
|
|
156
518
|
else
|
|
157
519
|
result.size_compliant = true
|
|
520
|
+
# 合规时不输出日志
|
|
158
521
|
end
|
|
159
522
|
|
|
160
523
|
rescue => e
|
|
524
|
+
result.size_compliant = false
|
|
161
525
|
result.add_issue("AAB 包体积检测失败: #{e.message}")
|
|
526
|
+
# 检测失败时输出日志
|
|
527
|
+
puts "\n\e[1m--- AAB 包体积检测 ---\e[0m"
|
|
528
|
+
puts "\e[31m✗ AAB 包体积检测失败: #{e.message}\e[0m"
|
|
162
529
|
end
|
|
163
530
|
end
|
|
164
531
|
|
|
165
532
|
# 检测 Target SDK 版本合规性
|
|
166
533
|
def self.check_target_sdk_compliance(temp_dir, result, aab_path = nil)
|
|
167
|
-
Funlog.fancyinfo_update("检测 Target SDK 版本...")
|
|
168
|
-
|
|
169
534
|
target_sdk = 0
|
|
170
535
|
|
|
171
536
|
# 方法1: 使用 bundletool dump manifest(首要方法)
|
|
172
537
|
if aab_path && File.exist?(aab_path)
|
|
173
|
-
# puts "使用 bundletool 解析 AAB 文件: #{File.basename(aab_path)}"
|
|
174
538
|
target_sdk = extract_target_sdk_with_bundletool(aab_path)
|
|
175
539
|
end
|
|
176
540
|
|
|
@@ -186,14 +550,17 @@ module Pindo
|
|
|
186
550
|
|
|
187
551
|
if target_sdk >= 35
|
|
188
552
|
result.target_sdk_compliant = true
|
|
553
|
+
# 合规时不输出日志
|
|
189
554
|
else
|
|
190
555
|
result.target_sdk_compliant = false
|
|
556
|
+
# 只在不合规时输出日志
|
|
557
|
+
puts "\n\e[1m--- Target SDK 版本检测 ---\e[0m"
|
|
191
558
|
if target_sdk == 0
|
|
192
559
|
result.add_issue("无法检测到 Target SDK 版本,请检查 AAB 文件结构")
|
|
193
|
-
|
|
560
|
+
puts "\e[31m✗ 无法检测到 Target SDK 版本\e[0m"
|
|
194
561
|
else
|
|
195
562
|
result.add_issue("Target SDK #{target_sdk} 不符合要求,需要至少 Target SDK 35 (Android 15)")
|
|
196
|
-
|
|
563
|
+
puts "\e[33m✗ Target SDK: #{target_sdk} (需要至少 35)\e[0m"
|
|
197
564
|
end
|
|
198
565
|
end
|
|
199
566
|
end
|
|
@@ -211,21 +578,26 @@ module Pindo
|
|
|
211
578
|
# 使用 Pindo 自带的 bundletool.jar
|
|
212
579
|
# 确保使用正确的 Java 版本 (Java 11+)
|
|
213
580
|
java_cmd = get_java_command_for_bundletool
|
|
214
|
-
|
|
215
|
-
|
|
581
|
+
|
|
582
|
+
# 使用安全的方式执行 bundletool 命令,处理路径中的非ASCII字符
|
|
583
|
+
# 先执行 bundletool 命令,然后通过管道传递给 grep
|
|
584
|
+
bundletool_args = [java_cmd, '-jar', bundletool_jar, 'dump', 'manifest', '--bundle', aab_path]
|
|
585
|
+
bundletool_output, _, bundletool_status = safe_execute_command(*bundletool_args)
|
|
586
|
+
|
|
587
|
+
# 如果命令成功,在输出中查找 targetSdkVersion
|
|
588
|
+
output = ''
|
|
589
|
+
if bundletool_status.success? && bundletool_output
|
|
590
|
+
output = bundletool_output.lines.grep(/targetSdkVersion/).join
|
|
591
|
+
end
|
|
216
592
|
|
|
217
593
|
if output && !output.empty?
|
|
218
|
-
# puts "bundletool 输出: #{output.strip}"
|
|
219
|
-
|
|
220
594
|
# 解析输出格式: <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="34"/>
|
|
221
595
|
if output =~ /android:targetSdkVersion="(\d+)"/
|
|
222
596
|
target_sdk = $1.to_i
|
|
223
|
-
# puts "从 bundletool 中找到 targetSdkVersion: #{target_sdk}"
|
|
224
597
|
return target_sdk
|
|
225
598
|
end
|
|
226
599
|
end
|
|
227
600
|
|
|
228
|
-
# puts "bundletool 未找到 targetSdkVersion"
|
|
229
601
|
return 0
|
|
230
602
|
rescue => e
|
|
231
603
|
return 0
|
|
@@ -241,21 +613,23 @@ module Pindo
|
|
|
241
613
|
end
|
|
242
614
|
|
|
243
615
|
# 使用系统 bundletool 命令
|
|
244
|
-
|
|
245
|
-
|
|
616
|
+
# 使用安全的方式执行 bundletool 命令,处理路径中的非ASCII字符
|
|
617
|
+
bundletool_output, _, bundletool_status = safe_execute_command('bundletool', 'dump', 'manifest', '--bundle', aab_path)
|
|
618
|
+
|
|
619
|
+
# 如果命令成功,在输出中查找 targetSdkVersion
|
|
620
|
+
output = ''
|
|
621
|
+
if bundletool_status.success? && bundletool_output
|
|
622
|
+
output = bundletool_output.lines.grep(/targetSdkVersion/).join
|
|
623
|
+
end
|
|
246
624
|
|
|
247
625
|
if output && !output.empty?
|
|
248
|
-
# puts "bundletool 输出: #{output.strip}"
|
|
249
|
-
|
|
250
626
|
# 解析输出格式: <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="34"/>
|
|
251
627
|
if output =~ /android:targetSdkVersion="(\d+)"/
|
|
252
628
|
target_sdk = $1.to_i
|
|
253
|
-
# puts "从系统 bundletool 中找到 targetSdkVersion: #{target_sdk}"
|
|
254
629
|
return target_sdk
|
|
255
630
|
end
|
|
256
631
|
end
|
|
257
632
|
|
|
258
|
-
# puts "系统 bundletool 未找到 targetSdkVersion"
|
|
259
633
|
return 0
|
|
260
634
|
rescue => e
|
|
261
635
|
return 0
|
|
@@ -314,26 +688,22 @@ module Pindo
|
|
|
314
688
|
# 在二进制 XML 中搜索 targetSdkVersion 后的数字
|
|
315
689
|
# 模式: targetSdkVersion + 一些二进制数据 + 数字
|
|
316
690
|
if content =~ /targetSdkVersion[^\d]*(\d{1,2})/
|
|
317
|
-
# puts "从二进制 XML 中找到 targetSdkVersion: #{$1}"
|
|
318
691
|
return $1.to_i
|
|
319
692
|
end
|
|
320
693
|
|
|
321
694
|
# 尝试更宽泛的搜索模式
|
|
322
695
|
if content =~ /targetSdkVersion.*?(\d{1,2})/
|
|
323
|
-
# puts "从二进制 XML 中找到 targetSdkVersion (宽泛模式): #{$1}"
|
|
324
696
|
return $1.to_i
|
|
325
697
|
end
|
|
326
698
|
|
|
327
|
-
# puts "二进制 XML 中未找到 targetSdkVersion"
|
|
328
699
|
return 0
|
|
329
700
|
end
|
|
330
701
|
|
|
331
702
|
# 尝试使用 aapt 工具解析(适用于文本格式)
|
|
332
703
|
if tool_available?('aapt')
|
|
333
|
-
aapt_output =
|
|
334
|
-
if aapt_output && !aapt_output.empty?
|
|
704
|
+
aapt_output, _, aapt_status = safe_execute_command('aapt', 'dump', 'badging', manifest_path)
|
|
705
|
+
if aapt_status.success? && aapt_output && !aapt_output.empty?
|
|
335
706
|
if aapt_output =~ /targetSdkVersion:'(\d+)'/
|
|
336
|
-
# puts "从 aapt 中找到 targetSdkVersion: #{$1}"
|
|
337
707
|
return $1.to_i
|
|
338
708
|
end
|
|
339
709
|
end
|
|
@@ -341,10 +711,9 @@ module Pindo
|
|
|
341
711
|
|
|
342
712
|
# 尝试使用 aapt2 工具
|
|
343
713
|
if tool_available?('aapt2')
|
|
344
|
-
aapt2_output =
|
|
345
|
-
if aapt2_output && !aapt2_output.empty?
|
|
714
|
+
aapt2_output, _, aapt2_status = safe_execute_command('aapt2', 'dump', 'badging', manifest_path)
|
|
715
|
+
if aapt2_status.success? && aapt2_output && !aapt2_output.empty?
|
|
346
716
|
if aapt2_output =~ /targetSdkVersion:'(\d+)'/
|
|
347
|
-
# puts "从 aapt2 中找到 targetSdkVersion: #{$1}"
|
|
348
717
|
return $1.to_i
|
|
349
718
|
end
|
|
350
719
|
end
|
|
@@ -352,10 +721,9 @@ module Pindo
|
|
|
352
721
|
|
|
353
722
|
# 尝试使用 aapt dump xmltree
|
|
354
723
|
if tool_available?('aapt')
|
|
355
|
-
aapt_xml_output =
|
|
356
|
-
if aapt_xml_output && !aapt_xml_output.empty?
|
|
724
|
+
aapt_xml_output, _, aapt_xml_status = safe_execute_command('aapt', 'dump', 'xmltree', manifest_path)
|
|
725
|
+
if aapt_xml_status.success? && aapt_xml_output && !aapt_xml_output.empty?
|
|
357
726
|
if aapt_xml_output =~ /targetSdkVersion.*?(\d+)/
|
|
358
|
-
# puts "从 aapt xmltree 中找到 targetSdkVersion: #{$1}"
|
|
359
727
|
return $1.to_i
|
|
360
728
|
end
|
|
361
729
|
end
|
|
@@ -372,7 +740,6 @@ module Pindo
|
|
|
372
740
|
if uses_sdk
|
|
373
741
|
target_sdk = uses_sdk['targetSdkVersion']
|
|
374
742
|
if target_sdk
|
|
375
|
-
# puts "从 XML 中找到 targetSdkVersion: #{target_sdk}"
|
|
376
743
|
return target_sdk.to_i
|
|
377
744
|
end
|
|
378
745
|
end
|
|
@@ -381,7 +748,6 @@ module Pindo
|
|
|
381
748
|
target_sdk_attrs = doc.xpath('//@targetSdkVersion')
|
|
382
749
|
if !target_sdk_attrs.empty?
|
|
383
750
|
target_sdk = target_sdk_attrs.first.value
|
|
384
|
-
# puts "从 XML 属性中找到 targetSdkVersion: #{target_sdk}"
|
|
385
751
|
return target_sdk.to_i
|
|
386
752
|
end
|
|
387
753
|
|
|
@@ -389,11 +755,9 @@ module Pindo
|
|
|
389
755
|
all_attrs = doc.xpath('//@*[contains(name(), "targetSdkVersion")]')
|
|
390
756
|
if !all_attrs.empty?
|
|
391
757
|
target_sdk = all_attrs.first.value
|
|
392
|
-
# puts "从 XML 中找到 targetSdkVersion 属性: #{target_sdk}"
|
|
393
758
|
return target_sdk.to_i
|
|
394
759
|
end
|
|
395
760
|
|
|
396
|
-
# puts "未找到 targetSdkVersion,返回默认值 0"
|
|
397
761
|
return 0
|
|
398
762
|
|
|
399
763
|
rescue => e
|
|
@@ -403,14 +767,13 @@ module Pindo
|
|
|
403
767
|
|
|
404
768
|
# 检测 ELF 对齐合规性
|
|
405
769
|
def self.check_elf_alignment_compliance(temp_dir, result)
|
|
406
|
-
Funlog.fancyinfo_update("检测 ELF 对齐 (16KB 页面大小)...")
|
|
407
|
-
|
|
408
770
|
# 查找所有 .so 文件
|
|
409
771
|
so_files = find_shared_libraries(temp_dir)
|
|
410
772
|
result.total_libs = so_files.length
|
|
411
773
|
|
|
412
774
|
if so_files.empty?
|
|
413
775
|
result.elf_alignment_compliant = true
|
|
776
|
+
# 合规时不输出日志
|
|
414
777
|
return
|
|
415
778
|
end
|
|
416
779
|
|
|
@@ -438,12 +801,16 @@ module Pindo
|
|
|
438
801
|
|
|
439
802
|
if critical_unaligned.empty?
|
|
440
803
|
result.elf_alignment_compliant = true
|
|
804
|
+
# 合规时不输出日志
|
|
441
805
|
else
|
|
442
806
|
result.elf_alignment_compliant = false
|
|
807
|
+
# 只在不合规时输出日志
|
|
808
|
+
puts "\n\e[1m--- ELF 对齐检测 ---\e[0m"
|
|
443
809
|
critical_unaligned.each do |lib|
|
|
444
810
|
result.add_issue("#{lib[:architecture]} 架构的共享库 #{File.basename(lib[:file])} 未对齐 (16KB 页面大小要求)")
|
|
811
|
+
puts "\e[31m✗ #{lib[:architecture]} 架构的共享库 #{File.basename(lib[:file])} 未对齐 (16KB 页面大小要求)\e[0m"
|
|
445
812
|
end
|
|
446
|
-
|
|
813
|
+
puts "\e[31m✗ 发现 #{critical_unaligned.length} 个关键架构的未对齐共享库\e[0m"
|
|
447
814
|
end
|
|
448
815
|
end
|
|
449
816
|
|
|
@@ -464,7 +831,9 @@ module Pindo
|
|
|
464
831
|
base_temp_dir = Dir.mktmpdir("base_apk_libs_")
|
|
465
832
|
|
|
466
833
|
# 解压 base.apk 中的 lib 目录
|
|
467
|
-
|
|
834
|
+
# 使用 safe_execute_command 解压 lib/*,因为这是通配符模式
|
|
835
|
+
lib_extract_output, _, lib_extract_status = safe_execute_command('unzip', '-q', base_apk_path, 'lib/*', '-d', base_temp_dir)
|
|
836
|
+
if lib_extract_status.success?
|
|
468
837
|
Dir.glob(File.join(base_temp_dir, "lib", "**", "*.so")).each do |so_file|
|
|
469
838
|
so_files << so_file
|
|
470
839
|
end
|
|
@@ -481,8 +850,9 @@ module Pindo
|
|
|
481
850
|
def self.check_elf_alignment(so_file)
|
|
482
851
|
begin
|
|
483
852
|
# 首先检查文件是否为有效的 ELF 文件
|
|
484
|
-
|
|
485
|
-
|
|
853
|
+
# 使用安全的方式执行 file 命令,处理路径中的非ASCII字符
|
|
854
|
+
file_output, _, file_status = safe_execute_command('file', so_file)
|
|
855
|
+
unless file_status.success? && file_output && file_output.include?('ELF')
|
|
486
856
|
return {
|
|
487
857
|
aligned: false,
|
|
488
858
|
alignment: "not_elf",
|
|
@@ -499,9 +869,10 @@ module Pindo
|
|
|
499
869
|
}
|
|
500
870
|
end
|
|
501
871
|
|
|
502
|
-
|
|
872
|
+
# 使用安全的方式执行 objdump 命令,处理路径中的非ASCII字符
|
|
873
|
+
objdump_output, _, objdump_status = safe_execute_command('objdump', '-p', so_file)
|
|
503
874
|
|
|
504
|
-
if objdump_output && !objdump_output.empty?
|
|
875
|
+
if objdump_status.success? && objdump_output && !objdump_output.empty?
|
|
505
876
|
# 查找 LOAD 段的对齐信息
|
|
506
877
|
load_sections = objdump_output.lines.select { |line| line.include?('LOAD') }
|
|
507
878
|
|
|
@@ -539,8 +910,9 @@ module Pindo
|
|
|
539
910
|
}
|
|
540
911
|
end
|
|
541
912
|
|
|
542
|
-
|
|
543
|
-
|
|
913
|
+
# 使用安全的方式执行 readelf 命令,处理路径中的非ASCII字符
|
|
914
|
+
readelf_output, _, readelf_status = safe_execute_command('readelf', '-l', so_file)
|
|
915
|
+
if readelf_status.success? && readelf_output && !readelf_output.empty?
|
|
544
916
|
# 查找 LOAD 段的对齐信息
|
|
545
917
|
load_sections = readelf_output.lines.select { |line| line.include?('LOAD') }
|
|
546
918
|
|
|
@@ -600,7 +972,7 @@ module Pindo
|
|
|
600
972
|
|
|
601
973
|
# 打印合规检测摘要
|
|
602
974
|
def self.print_compliance_summary(result)
|
|
603
|
-
|
|
975
|
+
puts "\n\e[1m--- 生成合规检测摘要 ---\e[0m"
|
|
604
976
|
|
|
605
977
|
# 总体合规状态
|
|
606
978
|
if result.compliant?
|
|
@@ -629,39 +1001,47 @@ module Pindo
|
|
|
629
1001
|
|
|
630
1002
|
# 检测 Unity 漏洞合规性
|
|
631
1003
|
def self.check_unity_patch_compliance(temp_dir, result)
|
|
632
|
-
puts "\n\e[1m--- Unity 漏洞检测 ---\e[0m"
|
|
633
|
-
|
|
634
1004
|
# 检查是否存在 Unity 相关文件
|
|
635
1005
|
unity_files = find_unity_files(temp_dir)
|
|
636
1006
|
|
|
637
1007
|
if unity_files.empty?
|
|
638
|
-
|
|
1008
|
+
# 没有 Unity 文件,可能是非 Unity 项目,合规
|
|
639
1009
|
result.unity_patch_compliant = true
|
|
1010
|
+
# 不输出日志
|
|
640
1011
|
return
|
|
641
1012
|
end
|
|
642
1013
|
|
|
643
|
-
|
|
1014
|
+
# 找到了 Unity 文件,需要检查漏洞修复
|
|
1015
|
+
# 检查 libunity.so 文件(不输出日志)
|
|
1016
|
+
libunity_result = check_libunity_patch(temp_dir, verbose: false)
|
|
644
1017
|
|
|
645
|
-
# 检查
|
|
646
|
-
|
|
1018
|
+
# 检查 boot.config 文件(不输出日志)
|
|
1019
|
+
boot_config_result = check_boot_config_patch(temp_dir, verbose: false)
|
|
647
1020
|
|
|
648
|
-
#
|
|
649
|
-
boot_config_result = check_boot_config_patch(temp_dir)
|
|
650
|
-
|
|
651
|
-
# 综合判断
|
|
1021
|
+
# 综合判断:libunity.so 和 boot.config 都必须通过检查
|
|
652
1022
|
if libunity_result && boot_config_result
|
|
653
1023
|
result.unity_patch_compliant = true
|
|
654
1024
|
result.unity_xrsdk_patched = true
|
|
655
1025
|
result.unity_override_patched = true
|
|
656
|
-
|
|
1026
|
+
# 合规时不输出日志
|
|
657
1027
|
else
|
|
658
1028
|
result.unity_patch_compliant = false
|
|
1029
|
+
# 只在不合规时输出详细日志
|
|
1030
|
+
puts "\n\e[1m--- Unity 漏洞检测 ---\e[0m"
|
|
1031
|
+
puts "检测到 Unity 项目,开始检查漏洞修复..."
|
|
1032
|
+
|
|
659
1033
|
if !libunity_result
|
|
660
1034
|
result.add_issue("Unity libunity.so 漏洞未修复")
|
|
1035
|
+
# 重新检查并输出详细日志
|
|
1036
|
+
check_libunity_patch(temp_dir, verbose: true)
|
|
661
1037
|
end
|
|
1038
|
+
|
|
662
1039
|
if !boot_config_result
|
|
663
1040
|
result.add_issue("Unity boot.config 漏洞未修复")
|
|
1041
|
+
# 重新检查并输出详细日志
|
|
1042
|
+
check_boot_config_patch(temp_dir, verbose: true)
|
|
664
1043
|
end
|
|
1044
|
+
|
|
665
1045
|
puts "\e[31m✗ Unity 漏洞检测: 未通过\e[0m"
|
|
666
1046
|
end
|
|
667
1047
|
end
|
|
@@ -671,18 +1051,7 @@ module Pindo
|
|
|
671
1051
|
unity_files = []
|
|
672
1052
|
|
|
673
1053
|
# 查找 libunity.so 文件
|
|
674
|
-
|
|
675
|
-
"#{temp_dir}/lib",
|
|
676
|
-
"#{temp_dir}/libs",
|
|
677
|
-
"#{temp_dir}/base/lib", # AAB文件结构
|
|
678
|
-
"#{temp_dir}/base/libs" # AAB文件结构
|
|
679
|
-
]
|
|
680
|
-
lib_dirs.each do |lib_dir|
|
|
681
|
-
if Dir.exist?(lib_dir)
|
|
682
|
-
found_so_files = Dir.glob("#{lib_dir}/**/libunity.so")
|
|
683
|
-
unity_files += found_so_files
|
|
684
|
-
end
|
|
685
|
-
end
|
|
1054
|
+
unity_files += find_so_files(temp_dir, 'libunity.so')
|
|
686
1055
|
|
|
687
1056
|
# 查找 boot.config 文件
|
|
688
1057
|
boot_config_paths = [
|
|
@@ -704,110 +1073,107 @@ module Pindo
|
|
|
704
1073
|
# 检查是否使用 il2cpp
|
|
705
1074
|
def self.uses_il2cpp?(temp_dir)
|
|
706
1075
|
# 检查是否存在 libil2cpp.so 文件
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
"#{temp_dir}/libs",
|
|
710
|
-
"#{temp_dir}/base/lib", # AAB文件结构
|
|
711
|
-
"#{temp_dir}/base/libs" # AAB文件结构
|
|
712
|
-
]
|
|
713
|
-
|
|
714
|
-
lib_dirs.each do |lib_dir|
|
|
715
|
-
if Dir.exist?(lib_dir)
|
|
716
|
-
il2cpp_files = Dir.glob("#{lib_dir}/**/libil2cpp.so")
|
|
717
|
-
return true if il2cpp_files.any?
|
|
718
|
-
end
|
|
719
|
-
end
|
|
720
|
-
|
|
721
|
-
false
|
|
1076
|
+
il2cpp_files = find_so_files(temp_dir, 'libil2cpp.so')
|
|
1077
|
+
il2cpp_files.any?
|
|
722
1078
|
end
|
|
723
1079
|
|
|
724
1080
|
# 检查 libunity.so 漏洞修复
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1081
|
+
# @param temp_dir [String] 临时目录路径
|
|
1082
|
+
# @param verbose [Boolean] 是否输出详细日志,默认为 true
|
|
1083
|
+
# @return [Boolean] 是否通过检测
|
|
1084
|
+
def self.check_libunity_patch(temp_dir, verbose: true)
|
|
1085
|
+
# 收集所有 libunity.so 文件
|
|
1086
|
+
libunity_files = find_so_files(temp_dir, 'libunity.so')
|
|
1087
|
+
|
|
1088
|
+
# 如果没有找到 libunity.so 文件,返回 true(不是 Unity 项目或不需要检查)
|
|
1089
|
+
if libunity_files.empty?
|
|
1090
|
+
return true
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
# 必须所有 libunity.so 文件都通过检查
|
|
1094
|
+
xrsdk_all_passed = true
|
|
1095
|
+
override_all_disabled = true
|
|
734
1096
|
override_found = false
|
|
735
1097
|
uses_il2cpp = uses_il2cpp?(temp_dir)
|
|
736
1098
|
|
|
737
|
-
|
|
738
|
-
|
|
1099
|
+
libunity_files.each do |so_file|
|
|
1100
|
+
puts "检查文件: #{File.basename(so_file)}" if verbose
|
|
739
1101
|
|
|
740
|
-
|
|
1102
|
+
# 检查 xrsdk 字符串修改
|
|
1103
|
+
xrsdk_passed = check_xrsdk_patch(so_file, verbose: verbose)
|
|
1104
|
+
if xrsdk_passed
|
|
1105
|
+
puts " \e[32m✓ xrsdk-pre-init-library 字符串已正确修改\e[0m" if verbose
|
|
1106
|
+
else
|
|
1107
|
+
puts " \e[31m✗ xrsdk-pre-init-library 字符串未正确修改\e[0m" if verbose
|
|
1108
|
+
xrsdk_all_passed = false
|
|
1109
|
+
end
|
|
741
1110
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
#
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
# 检查 overrideMonoSearchPath 禁用(仅在使用 Mono 时检查)
|
|
754
|
-
if uses_il2cpp
|
|
755
|
-
puts " \e[36m信息: 使用 IL2CPP,跳过 overrideMonoSearchPath 检测\e[0m"
|
|
756
|
-
override_disabled = true # IL2CPP 不需要检查 overrideMonoSearchPath
|
|
757
|
-
else
|
|
758
|
-
override_result = check_override_patch(so_file)
|
|
759
|
-
if override_result[:found]
|
|
760
|
-
override_found = true
|
|
761
|
-
if override_result[:disabled]
|
|
762
|
-
override_disabled = true
|
|
763
|
-
puts " \e[32m✓ overrideMonoSearchPath 已正确禁用\e[0m"
|
|
764
|
-
else
|
|
765
|
-
puts " \e[31m✗ overrideMonoSearchPath 未正确禁用\e[0m"
|
|
766
|
-
end
|
|
1111
|
+
# 检查 overrideMonoSearchPath 禁用(仅在使用 Mono 时检查)
|
|
1112
|
+
if uses_il2cpp
|
|
1113
|
+
puts " \e[36m信息: 使用 IL2CPP,跳过 overrideMonoSearchPath 检测\e[0m" if verbose
|
|
1114
|
+
# IL2CPP 不需要检查 overrideMonoSearchPath
|
|
1115
|
+
else
|
|
1116
|
+
override_result = check_override_patch(so_file, verbose: verbose)
|
|
1117
|
+
if override_result[:found]
|
|
1118
|
+
override_found = true
|
|
1119
|
+
if override_result[:disabled]
|
|
1120
|
+
puts " \e[32m✓ overrideMonoSearchPath 已正确禁用\e[0m" if verbose
|
|
767
1121
|
else
|
|
768
|
-
puts " \e[
|
|
1122
|
+
puts " \e[31m✗ overrideMonoSearchPath 未正确禁用\e[0m" if verbose
|
|
1123
|
+
override_all_disabled = false
|
|
769
1124
|
end
|
|
1125
|
+
else
|
|
1126
|
+
puts " \e[36m信息: 未找到 overrideMonoSearchPath 字符串\e[0m" if verbose
|
|
770
1127
|
end
|
|
771
1128
|
end
|
|
772
1129
|
end
|
|
773
1130
|
|
|
774
|
-
# 如果没有找到 overrideMonoSearchPath 或使用 IL2CPP
|
|
1131
|
+
# 如果没有找到 overrideMonoSearchPath 或使用 IL2CPP,认为 override 检查通过
|
|
775
1132
|
if !override_found || uses_il2cpp
|
|
776
|
-
|
|
1133
|
+
override_all_disabled = true
|
|
777
1134
|
end
|
|
778
1135
|
|
|
779
|
-
|
|
1136
|
+
# 所有 libunity.so 文件都必须通过 xrsdk 检查,并且 override 检查也必须通过
|
|
1137
|
+
xrsdk_all_passed && override_all_disabled
|
|
780
1138
|
end
|
|
781
1139
|
|
|
782
1140
|
# 检查 xrsdk 字符串修补
|
|
783
|
-
|
|
1141
|
+
# @param so_file [String] .so 文件路径
|
|
1142
|
+
# @param verbose [Boolean] 是否输出详细日志,默认为 true
|
|
1143
|
+
# @return [Boolean] 是否通过检测
|
|
1144
|
+
def self.check_xrsdk_patch(so_file, verbose: true)
|
|
784
1145
|
# 检查是否还存在原始的 xrsdk 字符串
|
|
785
1146
|
has_original = false
|
|
786
1147
|
has_modified = false
|
|
787
1148
|
|
|
788
1149
|
begin
|
|
789
|
-
#
|
|
790
|
-
strings_output =
|
|
1150
|
+
# 使用安全的方式执行 strings 命令,处理路径中的非ASCII字符
|
|
1151
|
+
strings_output, _, strings_status = safe_execute_command('strings', so_file)
|
|
791
1152
|
|
|
792
|
-
if
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1153
|
+
if strings_status.success? && strings_output
|
|
1154
|
+
if strings_output.include?("xrsdk-pre-init-library")
|
|
1155
|
+
has_original = true
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
if strings_output.include?("8rsdk-pre-init-library")
|
|
1159
|
+
has_modified = true
|
|
1160
|
+
end
|
|
798
1161
|
end
|
|
799
1162
|
|
|
800
1163
|
# 如果存在修改后的字符串且不存在原始字符串,说明修补成功
|
|
801
1164
|
has_modified && !has_original
|
|
802
1165
|
|
|
803
1166
|
rescue => e
|
|
804
|
-
puts " \e[33m警告: 检查 #{so_file} 时出错: #{e.message}\e[0m"
|
|
1167
|
+
puts " \e[33m警告: 检查 #{so_file} 时出错: #{e.message}\e[0m" if verbose
|
|
805
1168
|
false
|
|
806
1169
|
end
|
|
807
1170
|
end
|
|
808
1171
|
|
|
809
1172
|
# 检查 overrideMonoSearchPath 禁用
|
|
810
|
-
|
|
1173
|
+
# @param so_file [String] .so 文件路径
|
|
1174
|
+
# @param verbose [Boolean] 是否输出详细日志,默认为 true
|
|
1175
|
+
# @return [Hash] 检测结果 {found: Boolean, disabled: Boolean}
|
|
1176
|
+
def self.check_override_patch(so_file, verbose: true)
|
|
811
1177
|
result = { found: false, disabled: false }
|
|
812
1178
|
|
|
813
1179
|
begin
|
|
@@ -831,14 +1197,17 @@ module Pindo
|
|
|
831
1197
|
end
|
|
832
1198
|
|
|
833
1199
|
rescue => e
|
|
834
|
-
puts " \e[33m警告: 检查 #{so_file} 时出错: #{e.message}\e[0m"
|
|
1200
|
+
puts " \e[33m警告: 检查 #{so_file} 时出错: #{e.message}\e[0m" if verbose
|
|
835
1201
|
end
|
|
836
1202
|
|
|
837
1203
|
result
|
|
838
1204
|
end
|
|
839
1205
|
|
|
840
1206
|
# 检查 boot.config 漏洞修复
|
|
841
|
-
|
|
1207
|
+
# @param temp_dir [String] 临时目录路径
|
|
1208
|
+
# @param verbose [Boolean] 是否输出详细日志,默认为 true
|
|
1209
|
+
# @return [Boolean] 是否通过检测
|
|
1210
|
+
def self.check_boot_config_patch(temp_dir, verbose: true)
|
|
842
1211
|
# 尝试多个可能的boot.config路径
|
|
843
1212
|
boot_config_paths = [
|
|
844
1213
|
"#{temp_dir}/assets/bin/Data/boot.config", # 标准APK路径
|
|
@@ -855,11 +1224,14 @@ module Pindo
|
|
|
855
1224
|
end
|
|
856
1225
|
|
|
857
1226
|
unless boot_config_path
|
|
858
|
-
|
|
859
|
-
|
|
1227
|
+
# 如果没有找到 boot.config 文件,检查是否是 Unity 项目
|
|
1228
|
+
# 如果找到了 libunity.so,说明是 Unity 项目,但 boot.config 不存在,这可能是正常的(某些 Unity 版本可能没有)
|
|
1229
|
+
# 如果没找到 libunity.so,说明不是 Unity 项目,返回 true 是合理的
|
|
1230
|
+
puts " \e[33m信息: 未找到 boot.config 文件\e[0m" if verbose
|
|
1231
|
+
return true # 没有 boot.config 文件,认为是正常的(可能不是 Unity 项目或不需要修补)
|
|
860
1232
|
end
|
|
861
1233
|
|
|
862
|
-
puts "检查文件: #{File.basename(boot_config_path)}"
|
|
1234
|
+
puts "检查文件: #{File.basename(boot_config_path)}" if verbose
|
|
863
1235
|
|
|
864
1236
|
begin
|
|
865
1237
|
content = File.read(boot_config_path)
|
|
@@ -869,39 +1241,39 @@ module Pindo
|
|
|
869
1241
|
modified_count = content.scan(/8rsdk-pre-init-library/).length
|
|
870
1242
|
|
|
871
1243
|
if original_count > 0
|
|
872
|
-
puts " \e[31m✗ boot.config 中仍存在 #{original_count} 个未修改的 xrsdk-pre-init-library\e[0m"
|
|
1244
|
+
puts " \e[31m✗ boot.config 中仍存在 #{original_count} 个未修改的 xrsdk-pre-init-library\e[0m" if verbose
|
|
873
1245
|
end
|
|
874
1246
|
|
|
875
1247
|
if modified_count > 0
|
|
876
|
-
puts " \e[32m✓ boot.config 中发现 #{modified_count} 个修改后的 8rsdk-pre-init-library\e[0m"
|
|
1248
|
+
puts " \e[32m✓ boot.config 中发现 #{modified_count} 个修改后的 8rsdk-pre-init-library\e[0m" if verbose
|
|
877
1249
|
end
|
|
878
1250
|
|
|
879
1251
|
# 检查是否还有其他 xrsdk 相关字符串
|
|
880
1252
|
other_xrsdk_count = content.scan(/xrsdk/).length
|
|
881
1253
|
if other_xrsdk_count > 0
|
|
882
|
-
puts " \e[36m信息: boot.config 中发现 #{other_xrsdk_count} 个其他 xrsdk 相关字符串\e[0m"
|
|
1254
|
+
puts " \e[36m信息: boot.config 中发现 #{other_xrsdk_count} 个其他 xrsdk 相关字符串\e[0m" if verbose
|
|
883
1255
|
end
|
|
884
1256
|
|
|
885
1257
|
# 如果没有 xrsdk 相关字符串,认为是正常的
|
|
886
1258
|
if other_xrsdk_count == 0
|
|
887
|
-
puts " \e[32m✓ boot.config 检查结果: 通过(未使用 XR SDK,无需修补)\e[0m"
|
|
1259
|
+
puts " \e[32m✓ boot.config 检查结果: 通过(未使用 XR SDK,无需修补)\e[0m" if verbose
|
|
888
1260
|
return true
|
|
889
1261
|
end
|
|
890
1262
|
|
|
891
1263
|
# 如果存在修改后的字符串且不存在未修改的字符串,说明修补成功
|
|
892
1264
|
if modified_count > 0 && original_count == 0
|
|
893
|
-
puts " \e[32m✓ boot.config 检查结果: 通过(已正确替换 #{modified_count} 个字符串)\e[0m"
|
|
1265
|
+
puts " \e[32m✓ boot.config 检查结果: 通过(已正确替换 #{modified_count} 个字符串)\e[0m" if verbose
|
|
894
1266
|
return true
|
|
895
1267
|
elsif original_count > 0
|
|
896
|
-
puts " \e[31m✗ boot.config 检查结果: 未通过(存在未修改的字符串)\e[0m"
|
|
1268
|
+
puts " \e[31m✗ boot.config 检查结果: 未通过(存在未修改的字符串)\e[0m" if verbose
|
|
897
1269
|
return false
|
|
898
1270
|
else
|
|
899
|
-
puts " \e[31m✗ boot.config 检查结果: 未通过(未找到修改后的字符串)\e[0m"
|
|
1271
|
+
puts " \e[31m✗ boot.config 检查结果: 未通过(未找到修改后的字符串)\e[0m" if verbose
|
|
900
1272
|
return false
|
|
901
1273
|
end
|
|
902
1274
|
|
|
903
1275
|
rescue => e
|
|
904
|
-
puts " \e[33m警告: 检查 boot.config 时出错: #{e.message}\e[0m"
|
|
1276
|
+
puts " \e[33m警告: 检查 boot.config 时出错: #{e.message}\e[0m" if verbose
|
|
905
1277
|
false
|
|
906
1278
|
end
|
|
907
1279
|
end
|
|
@@ -65,10 +65,10 @@ module Pindo
|
|
|
65
65
|
)
|
|
66
66
|
@task_displays[task_id] = display
|
|
67
67
|
|
|
68
|
-
#
|
|
68
|
+
# 创建日志文件(明确指定 UTF-8 编码,防止中文写入错误)
|
|
69
69
|
log_file_name = "#{task.task_key}_#{task_id}.log"
|
|
70
70
|
log_file_path = File.join(@log_dir, log_file_name)
|
|
71
|
-
@task_loggers[task_id] = File.open(log_file_path, 'w')
|
|
71
|
+
@task_loggers[task_id] = File.open(log_file_path, 'w:utf-8')
|
|
72
72
|
|
|
73
73
|
# 渲染终端
|
|
74
74
|
render_terminal
|
|
@@ -356,11 +356,29 @@ module Pindo
|
|
|
356
356
|
logger = @task_loggers[task_id]
|
|
357
357
|
return unless logger
|
|
358
358
|
|
|
359
|
+
# 确保消息使用 UTF-8 编码,防止编码错误导致写入失败
|
|
360
|
+
safe_message = ensure_utf8(message)
|
|
359
361
|
timestamp = Time.now.strftime('%H:%M:%S')
|
|
360
|
-
logger.puts("[#{timestamp}] #{
|
|
362
|
+
logger.puts("[#{timestamp}] #{safe_message}")
|
|
361
363
|
logger.flush
|
|
362
364
|
end
|
|
363
365
|
|
|
366
|
+
# 确保字符串使用 UTF-8 编码
|
|
367
|
+
# @param str [String] 输入字符串
|
|
368
|
+
# @return [String] UTF-8 编码的字符串
|
|
369
|
+
def ensure_utf8(str)
|
|
370
|
+
return str if str.nil?
|
|
371
|
+
return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
|
|
372
|
+
|
|
373
|
+
# 尝试转换为 UTF-8
|
|
374
|
+
begin
|
|
375
|
+
str.encode(Encoding::UTF_8)
|
|
376
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
377
|
+
# 如果转换失败,尝试使用替换字符
|
|
378
|
+
str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
364
382
|
# 关闭日志文件
|
|
365
383
|
# @param task_id [String] 任务 ID
|
|
366
384
|
def close_log(task_id)
|
|
@@ -370,6 +388,15 @@ module Pindo
|
|
|
370
388
|
logger.close
|
|
371
389
|
@task_loggers.delete(task_id)
|
|
372
390
|
end
|
|
391
|
+
|
|
392
|
+
# 关闭所有日志文件(用于清理资源)
|
|
393
|
+
def close_all_logs
|
|
394
|
+
@mutex.synchronize do
|
|
395
|
+
@task_loggers.each_key do |task_id|
|
|
396
|
+
close_log(task_id)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
373
400
|
end
|
|
374
401
|
end
|
|
375
402
|
end
|
|
@@ -45,23 +45,41 @@ module Pindo
|
|
|
45
45
|
def write(message)
|
|
46
46
|
# 捕获输出,写入日志文件(跳过空行)
|
|
47
47
|
unless message.strip.empty?
|
|
48
|
+
# 确保消息使用 UTF-8 编码,防止编码错误导致任务中断
|
|
49
|
+
safe_message = ensure_utf8(message.chomp)
|
|
48
50
|
# 根据流类型选择日志级别
|
|
49
51
|
if @stream_type == :stderr
|
|
50
|
-
@output_manager.log_error(@task_id,
|
|
52
|
+
@output_manager.log_error(@task_id, safe_message)
|
|
51
53
|
else
|
|
52
|
-
@output_manager.log_detail(@task_id,
|
|
54
|
+
@output_manager.log_detail(@task_id, safe_message)
|
|
53
55
|
end
|
|
54
56
|
end
|
|
55
57
|
message.length
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
# 确保字符串使用 UTF-8 编码
|
|
61
|
+
# @param str [String] 输入字符串
|
|
62
|
+
# @return [String] UTF-8 编码的字符串
|
|
63
|
+
def ensure_utf8(str)
|
|
64
|
+
return str if str.nil?
|
|
65
|
+
return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
|
|
66
|
+
|
|
67
|
+
# 尝试转换为 UTF-8
|
|
68
|
+
begin
|
|
69
|
+
str.encode(Encoding::UTF_8)
|
|
70
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
71
|
+
# 如果转换失败,尝试使用替换字符
|
|
72
|
+
str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
58
76
|
# puts 方法
|
|
59
77
|
# @param args [Array] 参数列表
|
|
60
78
|
def puts(*args)
|
|
61
79
|
if args.empty?
|
|
62
80
|
write("\n")
|
|
63
81
|
else
|
|
64
|
-
args.each { |arg| write("#{arg}\n") }
|
|
82
|
+
args.each { |arg| write("#{ensure_utf8(arg.to_s)}\n") }
|
|
65
83
|
end
|
|
66
84
|
nil
|
|
67
85
|
end
|
|
@@ -69,7 +87,7 @@ module Pindo
|
|
|
69
87
|
# print 方法
|
|
70
88
|
# @param args [Array] 参数列表
|
|
71
89
|
def print(*args)
|
|
72
|
-
write(args.join)
|
|
90
|
+
write(ensure_utf8(args.join))
|
|
73
91
|
nil
|
|
74
92
|
end
|
|
75
93
|
|
|
@@ -40,6 +40,9 @@ module Pindo
|
|
|
40
40
|
# 启用输出管理系统
|
|
41
41
|
# @param options [Hash] 配置选项
|
|
42
42
|
def enable_output_management(options = {})
|
|
43
|
+
# 如果已经有一个输出管理器,先关闭所有日志文件(避免文件句柄泄漏)
|
|
44
|
+
@output_manager.close_all_logs if @output_manager
|
|
45
|
+
|
|
43
46
|
@output_manager = MultiLineOutputManager.new(
|
|
44
47
|
log_dir: options[:log_dir] || './pindo_logs',
|
|
45
48
|
max_lines_per_task: options[:max_lines_per_task] || 5,
|
|
@@ -90,6 +93,15 @@ module Pindo
|
|
|
90
93
|
mode = parse_execution_mode(options)
|
|
91
94
|
strategy = ExecutionStrategy.create(mode, options)
|
|
92
95
|
|
|
96
|
+
# 并发模式必须启用输出管理器,否则多线程输出会混乱
|
|
97
|
+
if mode == :concurrent && @output_manager.nil?
|
|
98
|
+
enable_output_management(
|
|
99
|
+
log_dir: options[:log_dir] || './pindo_logs',
|
|
100
|
+
max_lines_per_task: options[:max_lines_per_task] || 5,
|
|
101
|
+
max_recent_completed: options[:max_recent_completed] || 3
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
93
105
|
# 如果配置了输出管理器,注册所有任务
|
|
94
106
|
if @output_manager
|
|
95
107
|
@queue.pending_snapshot.each do |task|
|
data/lib/pindo/version.rb
CHANGED
|
@@ -6,13 +6,13 @@ require 'time'
|
|
|
6
6
|
|
|
7
7
|
module Pindo
|
|
8
8
|
|
|
9
|
-
VERSION = "5.15.
|
|
9
|
+
VERSION = "5.15.9"
|
|
10
10
|
|
|
11
11
|
class VersionCheck
|
|
12
12
|
RUBYGEMS_API = 'https://rubygems.org/api/v1/gems/pindo.json'
|
|
13
13
|
VERSION_INFO_FILE = File.expand_path('~/.pindo/version_info.yml')
|
|
14
14
|
CHECK_INTERVAL = 5 * 60 * 60 # 5小时检查一次
|
|
15
|
-
CONFIG_MIN_VERSION = '1.
|
|
15
|
+
CONFIG_MIN_VERSION = '1.4.0' # 硬编码的配置版本要求
|
|
16
16
|
|
|
17
17
|
class << self
|
|
18
18
|
# 主版本检查方法(保持向后兼容)
|