publish-apk 0.0.1-alpha.10

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.
package/src/apk.js ADDED
@@ -0,0 +1,694 @@
1
+ /**
2
+ * APK 安装包查看命令(`publish-apk apk`)。
3
+ *
4
+ * APK 本质是 ZIP:AndroidManifest.xml 是 Android 二进制 XML,签名可能是
5
+ * JAR v1(META-INF/*.RSA 等)或 APK Signature Scheme v2 / v3 / v3.1。
6
+ *
7
+ * 本命令只读文件,不安装、不验签:
8
+ * - 解析二进制 AndroidManifest.xml,输出包名、版本、SDK、权限、启动 Activity;
9
+ * - 解析 APK Signing Block 与 META-INF v1 签名,输出签名方案与证书;
10
+ * - --verbose 追加 ZIP 条目、签名块 pair、完整证书链与全部权限 / Activity。
11
+ *
12
+ * 不引入 npm 第三方依赖;ZIP 解析复用 src/hap.js 的 node:zlib 实现。
13
+ * 退出代码与其它命令一致:0 成功,1 参数错误或文件读取 / 解析失败。
14
+ */
15
+ import { X509Certificate } from 'node:crypto'
16
+ import { readFileSync } from 'node:fs'
17
+
18
+ import {
19
+ EXIT_OK,
20
+ PROGRAM,
21
+ UsageError,
22
+ isHelpToken,
23
+ parseOptions,
24
+ readEnv,
25
+ stdout,
26
+ } from './cli.js'
27
+ import { certificateDetailLines } from './cert.js'
28
+ import {
29
+ addSignatureCertificates,
30
+ findZipEntry,
31
+ formatBytes,
32
+ readEndOfCentralDirectory,
33
+ readZipEntries,
34
+ readZipEntryData,
35
+ } from './hap.js'
36
+ import { renderHelp } from './help.js'
37
+ import { parseP7bDer } from './profile.js'
38
+
39
+ const COMMAND_NAME = 'apk'
40
+ const OPTION_FILE = '--file'
41
+ const OPTION_VERBOSE = '--verbose'
42
+ const APK_OPTIONS = new Set([OPTION_FILE, OPTION_VERBOSE])
43
+ const APK_FLAGS = new Set([OPTION_VERBOSE])
44
+ const APK_MULTI_OPTIONS = new Set([OPTION_FILE])
45
+
46
+ const ENV_FILE = 'APK_FILE'
47
+ const ENV_VERBOSE = 'APK_VERBOSE'
48
+
49
+ const VERBOSE_ON_VALUES = new Set(['1', 'true', 'yes', 'on'])
50
+ const VERBOSE_OFF_VALUES = new Set(['0', 'false', 'no', 'off'])
51
+
52
+ /** Android 二进制 XML chunk 类型 */
53
+ const XML_TYPE = 0x0003
54
+ const STRING_POOL_TYPE = 0x0001
55
+ const START_ELEMENT_TYPE = 0x0102
56
+ const END_ELEMENT_TYPE = 0x0103
57
+
58
+ /** Android 二进制 XML typed value */
59
+ const TYPE_NULL = 0x00
60
+ const TYPE_REFERENCE = 0x01
61
+ const TYPE_STRING = 0x03
62
+ const TYPE_FLOAT = 0x04
63
+ const TYPE_INT_DEC = 0x10
64
+ const TYPE_INT_HEX = 0x11
65
+ const TYPE_INT_BOOLEAN = 0x12
66
+
67
+ /** APK Signing Block magic 与 scheme id */
68
+ const APK_SIG_BLOCK_MAGIC = Buffer.from('APK Sig Block 42')
69
+ const APK_SIG_SCHEMES = new Map([
70
+ [0x7109871a, 'APK Signature Scheme v2'],
71
+ [0xf05368c0, 'APK Signature Scheme v3'],
72
+ [0x1b93ad61, 'APK Signature Scheme v3.1'],
73
+ ])
74
+ const APK_SIG_BLOCK_NAMES = new Map([
75
+ ...APK_SIG_SCHEMES,
76
+ [0x504b4453, 'Source Stamp'],
77
+ [0x42726577, 'Verity Padding'],
78
+ ])
79
+
80
+ // -------------------------------------------------------------- 开关型参数
81
+
82
+ function resolveVerbose(options, env, help) {
83
+ if (options.get(OPTION_VERBOSE) === true) {
84
+ return true
85
+ }
86
+ const value = readEnv(env, ENV_VERBOSE)
87
+ if (value === undefined) {
88
+ return false
89
+ }
90
+ const normalized = value.trim().toLowerCase()
91
+ if (VERBOSE_ON_VALUES.has(normalized)) {
92
+ return true
93
+ }
94
+ if (VERBOSE_OFF_VALUES.has(normalized)) {
95
+ return false
96
+ }
97
+ throw new UsageError(
98
+ `环境变量 ${ENV_VERBOSE} 的值无效: "${value}"(开启用 1/true/yes/on,关闭用 0/false/no/off)`,
99
+ help(),
100
+ )
101
+ }
102
+
103
+ // ------------------------------------------------------------------ 帮助文档
104
+
105
+ function renderApkHelp() {
106
+ return renderHelp({
107
+ title: `${PROGRAM} ${COMMAND_NAME} - 查看 APK 安装包信息`,
108
+ usage: [`${PROGRAM} ${COMMAND_NAME} [选项]`],
109
+ sections: [
110
+ {
111
+ title: '选项',
112
+ entries: [
113
+ [`${OPTION_FILE} <路径...>`, `一个或多个 APK 文件路径(支持 shell glob),必填(可用 ${ENV_FILE} 补齐)`],
114
+ [
115
+ OPTION_VERBOSE,
116
+ `输出全部字段(ZIP 条目、签名块 pair、完整证书链、全部权限 / Activity);命令行未给出时读取 ${ENV_VERBOSE}`,
117
+ ],
118
+ ['-h, --help', '显示帮助信息'],
119
+ ],
120
+ },
121
+ {
122
+ title: '环境变量',
123
+ entries: [
124
+ [ENV_FILE, `APK 文件路径,等价于 ${OPTION_FILE}`],
125
+ [ENV_VERBOSE, '输出全部字段,1/true/yes/on 开启、0/false/no/off 关闭,等价于 --verbose'],
126
+ ],
127
+ },
128
+ {
129
+ title: '输出',
130
+ entries: [
131
+ ['stdout', 'APK 包信息与签名证书,每行一条「标签: 值」;--verbose 时追加 ZIP 条目、完整证书链与明细'],
132
+ ['stderr', '错误信息'],
133
+ ],
134
+ },
135
+ {
136
+ title: '退出代码',
137
+ entries: [
138
+ ['0', '成功'],
139
+ ['1', '参数缺失、参数错误或文件读取 / 解析失败'],
140
+ ],
141
+ },
142
+ ],
143
+ notes: [
144
+ '示例:',
145
+ ` ${PROGRAM} ${COMMAND_NAME} --file ./app-arm64-v8a-release.apk`,
146
+ ` ${PROGRAM} ${COMMAND_NAME} --file ./app-armeabi-v7a-release.apk --verbose`,
147
+ ` ${ENV_FILE}=./app.apk ${PROGRAM} ${COMMAND_NAME}`,
148
+ ` ${ENV_VERBOSE}=1 ${PROGRAM} ${COMMAND_NAME} --file ./app.apk`,
149
+ '',
150
+ '说明: --file 可以一次给多个文件(shell glob 也可),按顺序输出,文件之间空一行',
151
+ '说明: AndroidManifest.xml 是二进制 XML;本命令直接解析,不依赖 aapt / apkanalyzer',
152
+ '说明: 签名只检测 JAR v1 与 APK Signature Scheme v2 / v3 / v3.1,未执行证书链与摘要验签',
153
+ '说明: 默认每组证书只输出叶子证书;--verbose 输出完整证书链',
154
+ '说明: 不输出证书 PEM 原文;APK 里没有私钥',
155
+ ],
156
+ })
157
+ }
158
+
159
+ // ------------------------------------------------------------------ 二进制 XML
160
+
161
+ function readStringPool(buffer, chunkOffset, help) {
162
+ const headerSize = buffer.readUInt16LE(chunkOffset + 2)
163
+ const count = buffer.readUInt32LE(chunkOffset + 8)
164
+ const flags = buffer.readUInt32LE(chunkOffset + 16)
165
+ const stringsStart = buffer.readUInt32LE(chunkOffset + 20)
166
+ const utf8 = (flags & 0x100) !== 0
167
+ const offsetsStart = chunkOffset + headerSize
168
+ const dataStart = chunkOffset + stringsStart
169
+ const strings = []
170
+ for (let index = 0; index < count; index += 1) {
171
+ const relative = buffer.readUInt32LE(offsetsStart + index * 4)
172
+ let offset = dataStart + relative
173
+ let length
174
+ if (utf8) {
175
+ let first = buffer[offset]
176
+ offset += 1
177
+ if ((first & 0x80) !== 0) {
178
+ length = ((first & 0x7f) << 8) | buffer[offset]
179
+ offset += 1
180
+ } else {
181
+ length = first
182
+ }
183
+ strings.push(buffer.toString('utf8', offset, offset + length))
184
+ } else {
185
+ let first = buffer.readUInt16LE(offset)
186
+ offset += 2
187
+ if ((first & 0x8000) !== 0) {
188
+ length = ((first & 0x7fff) << 16) | buffer.readUInt16LE(offset)
189
+ offset += 2
190
+ } else {
191
+ length = first
192
+ }
193
+ strings.push(buffer.toString('utf16le', offset, offset + length * 2))
194
+ }
195
+ }
196
+ return strings
197
+ }
198
+
199
+ function attributeValue(buffer, offset, offsetEnd, strings) {
200
+ const rawValue = buffer.readUInt32LE(offset + 8)
201
+ if (rawValue !== 0xffffffff && strings[rawValue] !== undefined) {
202
+ return strings[rawValue]
203
+ }
204
+ const dataType = buffer[offset + 15]
205
+ const data = buffer.readUInt32LE(offset + 16)
206
+ if (offsetEnd > buffer.length) {
207
+ return undefined
208
+ }
209
+ switch (dataType) {
210
+ case TYPE_NULL:
211
+ return null
212
+ case TYPE_STRING:
213
+ return strings[data]
214
+ case TYPE_REFERENCE:
215
+ return `@0x${data.toString(16)}`
216
+ case TYPE_INT_DEC:
217
+ return data
218
+ case TYPE_INT_HEX:
219
+ return `0x${data.toString(16)}`
220
+ case TYPE_INT_BOOLEAN:
221
+ return data !== 0
222
+ case TYPE_FLOAT:
223
+ return buffer.readFloatLE(offset + 16)
224
+ default:
225
+ return `0x${data.toString(16)}`
226
+ }
227
+ }
228
+
229
+ /** 解析 Android 二进制 XML,返回元素树 */
230
+ function parseBinaryXml(buffer, help) {
231
+ if (buffer.length < 8 || buffer.readUInt16LE(0) !== XML_TYPE) {
232
+ throw new UsageError('AndroidManifest.xml 不是 Android 二进制 XML', help())
233
+ }
234
+ const headerSize = buffer.readUInt16LE(2)
235
+ const totalSize = buffer.readUInt32LE(4)
236
+ const end = Math.min(buffer.length, totalSize)
237
+ let offset = headerSize
238
+ let strings = []
239
+
240
+ const root = { name: '#document', attrs: {}, children: [] }
241
+ const stack = [root]
242
+ while (offset + 8 <= end) {
243
+ const type = buffer.readUInt16LE(offset)
244
+ const chunkSize = buffer.readUInt32LE(offset + 4)
245
+ if (chunkSize === 0 || offset + chunkSize > end) {
246
+ break
247
+ }
248
+ if (type === STRING_POOL_TYPE) {
249
+ strings = readStringPool(buffer, offset, help)
250
+ } else if (type === START_ELEMENT_TYPE) {
251
+ const elementHeaderSize = buffer.readUInt16LE(offset + 2)
252
+ const ext = offset + elementHeaderSize
253
+ if (ext + 20 > offset + chunkSize) {
254
+ throw new UsageError('AndroidManifest.xml 的 start element 越界', help())
255
+ }
256
+ const name = strings[buffer.readUInt32LE(ext + 4)]
257
+ const attributeStart = buffer.readUInt16LE(ext + 8)
258
+ const attributeSize = buffer.readUInt16LE(ext + 10)
259
+ const attributeCount = buffer.readUInt16LE(ext + 12)
260
+ const attrs = {}
261
+ for (let index = 0; index < attributeCount; index += 1) {
262
+ const attrOffset = ext + attributeStart + index * attributeSize
263
+ if (attrOffset + attributeSize > offset + chunkSize) {
264
+ throw new UsageError('AndroidManifest.xml 的 attribute 越界', help())
265
+ }
266
+ const attrName = strings[buffer.readUInt32LE(attrOffset + 4)]
267
+ if (attrName !== undefined) {
268
+ attrs[attrName] = attributeValue(
269
+ buffer,
270
+ attrOffset,
271
+ offset + chunkSize,
272
+ strings,
273
+ )
274
+ }
275
+ }
276
+ const element = { name, attrs, children: [] }
277
+ stack[stack.length - 1].children.push(element)
278
+ stack.push(element)
279
+ } else if (type === END_ELEMENT_TYPE) {
280
+ if (stack.length > 1) {
281
+ stack.pop()
282
+ }
283
+ }
284
+ offset += chunkSize
285
+ }
286
+ return root
287
+ }
288
+
289
+ // ------------------------------------------------------------------ Manifest
290
+
291
+ function child(element, name) {
292
+ return element?.children.find((item) => item.name === name)
293
+ }
294
+
295
+ function children(element, name) {
296
+ return element?.children.filter((item) => item.name === name) ?? []
297
+ }
298
+
299
+ function normalizeClassName(name, packageName) {
300
+ if (typeof name !== 'string' || name === '') {
301
+ return undefined
302
+ }
303
+ if (name.startsWith('.')) {
304
+ return `${packageName}${name}`
305
+ }
306
+ if (!name.includes('.')) {
307
+ return `${packageName}.${name}`
308
+ }
309
+ return name
310
+ }
311
+
312
+ function hasLauncher(intentFilter) {
313
+ const actions = children(intentFilter, 'action').map((item) => item.attrs.name)
314
+ const categories = children(intentFilter, 'category').map((item) => item.attrs.name)
315
+ return (
316
+ actions.includes('android.intent.action.MAIN') &&
317
+ categories.includes('android.intent.category.LAUNCHER')
318
+ )
319
+ }
320
+
321
+ function extractManifest(buffer, help) {
322
+ const document = parseBinaryXml(buffer, help)
323
+ const manifest = child(document, 'manifest')
324
+ if (manifest === undefined) {
325
+ throw new UsageError('AndroidManifest.xml 缺少 manifest 根元素', help())
326
+ }
327
+ const packageName = manifest.attrs.package
328
+ const usesSdk = child(manifest, 'uses-sdk')
329
+ const application = child(manifest, 'application')
330
+ const permissionElements = [
331
+ ...children(manifest, 'uses-permission'),
332
+ ...children(manifest, 'uses-permission-sdk-23'),
333
+ ]
334
+ const permissions = permissionElements
335
+ .map((item) => item.attrs.name)
336
+ .filter((name) => typeof name === 'string')
337
+ const activities = [
338
+ ...children(application, 'activity'),
339
+ ...children(application, 'activity-alias'),
340
+ ].map((item) => {
341
+ const rawName = item.attrs.name
342
+ const name = normalizeClassName(rawName, packageName)
343
+ return {
344
+ name,
345
+ rawName,
346
+ launcher: children(item, 'intent-filter').some(hasLauncher),
347
+ }
348
+ })
349
+ const launcher = activities.find((item) => item.launcher)
350
+ return {
351
+ packageName,
352
+ versionCode: manifest.attrs.versionCode,
353
+ versionName: manifest.attrs.versionName,
354
+ compileSdkVersion: manifest.attrs.compileSdkVersion,
355
+ minSdkVersion: usesSdk?.attrs.minSdkVersion,
356
+ targetSdkVersion: usesSdk?.attrs.targetSdkVersion,
357
+ label: application?.attrs.label,
358
+ debuggable: application?.attrs.debuggable,
359
+ allowBackup: application?.attrs.allowBackup,
360
+ permissions,
361
+ activities,
362
+ launcher,
363
+ usesFeatures: children(manifest, 'uses-feature')
364
+ .map((item) => item.attrs.name)
365
+ .filter((name) => typeof name === 'string'),
366
+ }
367
+ }
368
+
369
+ // ------------------------------------------------------------------ 签名
370
+
371
+ function readU32(buffer, offset, end) {
372
+ if (offset < 0 || offset + 4 > end) {
373
+ throw new Error('签名数据长度越界')
374
+ }
375
+ return buffer.readUInt32LE(offset)
376
+ }
377
+
378
+ /** 解析 v2 / v3 / v3.1 signer 里的证书;返回 DER 证书列表 */
379
+ function readSchemeCertificates(value) {
380
+ const certificates = []
381
+ const signersLength = readU32(value, 0, value.length)
382
+ let offset = 4
383
+ const signersEnd = offset + signersLength
384
+ if (signersEnd > value.length) {
385
+ throw new Error('signers 长度越界')
386
+ }
387
+ while (offset < signersEnd) {
388
+ const signerLength = readU32(value, offset, signersEnd)
389
+ offset += 4
390
+ const signerEnd = offset + signerLength
391
+ if (signerEnd > signersEnd) {
392
+ throw new Error('signer 长度越界')
393
+ }
394
+ const signedDataLength = readU32(value, offset, signerEnd)
395
+ offset += 4
396
+ const signedDataEnd = offset + signedDataLength
397
+ if (signedDataEnd > signerEnd) {
398
+ throw new Error('signed data 长度越界')
399
+ }
400
+ const digestsLength = readU32(value, offset, signedDataEnd)
401
+ offset += 4 + digestsLength
402
+ const certificatesLength = readU32(value, offset, signedDataEnd)
403
+ offset += 4
404
+ const certificatesEnd = offset + certificatesLength
405
+ if (certificatesEnd > signedDataEnd) {
406
+ throw new Error('certificates 长度越界')
407
+ }
408
+ while (offset < certificatesEnd) {
409
+ const certificateLength = readU32(value, offset, certificatesEnd)
410
+ offset += 4
411
+ if (offset + certificateLength > certificatesEnd) {
412
+ throw new Error('certificate 长度越界')
413
+ }
414
+ certificates.push(value.subarray(offset, offset + certificateLength))
415
+ offset += certificateLength
416
+ }
417
+ offset = signerEnd
418
+ }
419
+ return certificates
420
+ }
421
+
422
+ /** 读取 APK Signing Block(v2 / v3 / v3.1),返回 pair 与证书 */
423
+ function readApkSigningBlock(buffer, centralOffset, help) {
424
+ if (centralOffset < 32) {
425
+ return { pairs: [], schemes: [] }
426
+ }
427
+ const magic = buffer.subarray(centralOffset - 16, centralOffset)
428
+ if (!magic.equals(APK_SIG_BLOCK_MAGIC)) {
429
+ return { pairs: [], schemes: [] }
430
+ }
431
+ const sizeBig = buffer.readBigUInt64LE(centralOffset - 24)
432
+ if (sizeBig > BigInt(Number.MAX_SAFE_INTEGER)) {
433
+ throw new UsageError('APK Signing Block 太大', help())
434
+ }
435
+ const size = Number(sizeBig)
436
+ const start = centralOffset - size - 8
437
+ if (size < 24 || start < 0 || start + 8 > centralOffset) {
438
+ throw new UsageError('APK Signing Block 大小异常', help())
439
+ }
440
+ if (Number(buffer.readBigUInt64LE(start)) !== size) {
441
+ throw new UsageError('APK Signing Block 首尾大小不一致', help())
442
+ }
443
+ const pairs = []
444
+ const schemes = []
445
+ let offset = start + 8
446
+ const pairsEnd = centralOffset - 24
447
+ while (offset + 12 <= pairsEnd) {
448
+ const pairLength = Number(buffer.readBigUInt64LE(offset))
449
+ const id = buffer.readUInt32LE(offset + 8)
450
+ const valueStart = offset + 12
451
+ const pairEnd = offset + 8 + pairLength
452
+ if (pairLength < 4 || pairEnd > pairsEnd) {
453
+ break
454
+ }
455
+ pairs.push({ id, length: pairLength })
456
+ const scheme = APK_SIG_SCHEMES.get(id)
457
+ if (scheme !== undefined) {
458
+ try {
459
+ const derCertificates = readSchemeCertificates(
460
+ buffer.subarray(valueStart, pairEnd),
461
+ )
462
+ const certificates = derCertificates.map(
463
+ (der) => new X509Certificate(der),
464
+ )
465
+ schemes.push({ scheme, certificates })
466
+ } catch (error) {
467
+ schemes.push({ scheme, certificates: [], error: error.message })
468
+ }
469
+ }
470
+ offset = pairEnd
471
+ }
472
+ return { pairs, schemes }
473
+ }
474
+
475
+ /** 读取 META-INF v1 JAR 签名证书 */
476
+ function readV1Certificates(buffer, entries, help) {
477
+ const signatureEntries = entries.filter((entry) =>
478
+ /^META-INF\/[^/]+\.(RSA|DSA|EC)$/i.test(entry.name),
479
+ )
480
+ return signatureEntries.map((entry) => {
481
+ try {
482
+ const p7b = parseP7bDer(readZipEntryData(buffer, entry, help))
483
+ return { label: `JAR v1 签名(${entry.name})`, certificates: p7b.certificates }
484
+ } catch (error) {
485
+ return {
486
+ label: `JAR v1 签名(${entry.name})`,
487
+ certificates: [],
488
+ error: error.message,
489
+ }
490
+ }
491
+ })
492
+ }
493
+
494
+ function signatureLabel(v1Groups, schemes) {
495
+ const parts = []
496
+ if (v1Groups.length > 0) {
497
+ parts.push('JAR v1')
498
+ }
499
+ for (const item of schemes) {
500
+ parts.push(item.scheme)
501
+ }
502
+ if (parts.length === 0) {
503
+ return '未签名(未检测到 APK 签名)'
504
+ }
505
+ return `已签名(${parts.join(' + ')};未验签)`
506
+ }
507
+
508
+ // ------------------------------------------------------------------ 输出
509
+
510
+ function apkLines(buffer, path, entries, manifest, signing, verbose) {
511
+ const v1Groups = readV1Certificates(buffer, entries, renderApkHelp)
512
+ const certificateGroups = [
513
+ ...v1Groups,
514
+ ...signing.schemes.map((item) => ({
515
+ label: item.scheme,
516
+ certificates: item.certificates,
517
+ error: item.error,
518
+ })),
519
+ ]
520
+ const abis = [
521
+ ...new Set(
522
+ entries
523
+ .map((entry) => /^lib\/([^/]+)\//.exec(entry.name)?.[1])
524
+ .filter((value) => typeof value === 'string'),
525
+ ),
526
+ ]
527
+ const dexCount = entries.filter((entry) => /(^|\/)classes\d*\.dex$/i.test(entry.name)).length
528
+ const lines = [
529
+ `文件: ${path}`,
530
+ `大小: ${formatBytes(buffer.length)}(${buffer.length} 字节)`,
531
+ `格式: APK / ZIP(${entries.length} 个条目)`,
532
+ `签名: ${signatureLabel(v1Groups, signing.schemes)}`,
533
+ ]
534
+ if (manifest.packageName !== undefined) {
535
+ lines.push(`包名: ${manifest.packageName}`)
536
+ }
537
+ if (manifest.versionName !== undefined || manifest.versionCode !== undefined) {
538
+ const version = [
539
+ manifest.versionName,
540
+ manifest.versionCode === undefined ? '' : `(${manifest.versionCode})`,
541
+ ]
542
+ .filter(Boolean)
543
+ .join(' ')
544
+ lines.push(`版本: ${version}`)
545
+ }
546
+ if (manifest.minSdkVersion !== undefined) {
547
+ lines.push(`Min SDK: ${manifest.minSdkVersion}`)
548
+ }
549
+ if (manifest.targetSdkVersion !== undefined) {
550
+ lines.push(`Target SDK: ${manifest.targetSdkVersion}`)
551
+ }
552
+ if (manifest.compileSdkVersion !== undefined) {
553
+ lines.push(`编译 SDK: ${manifest.compileSdkVersion}`)
554
+ }
555
+ if (abis.length > 0) {
556
+ lines.push(`ABI: ${abis.join('、')}`)
557
+ }
558
+ lines.push(`DEX: ${dexCount} 个`)
559
+ if (manifest.permissions.length > 0) {
560
+ lines.push(`权限: ${manifest.permissions.join('、')}`)
561
+ }
562
+ if (manifest.launcher?.name !== undefined) {
563
+ lines.push(`启动 Activity: ${manifest.launcher.name}`)
564
+ }
565
+ if (manifest.debuggable !== undefined) {
566
+ lines.push(`调试: ${manifest.debuggable === true ? '是' : '否'}`)
567
+ }
568
+ addSignatureCertificates(lines, certificateGroups, verbose)
569
+ if (signing.schemes.length > 0 && certificateGroups.length === 0) {
570
+ lines.push('', '签名证书: 未解析到证书(仅检测到签名块)')
571
+ }
572
+ return { lines, v1Groups, certificateGroups }
573
+ }
574
+
575
+ function verboseLines(lines, entries, manifest, signing) {
576
+ lines.push('', 'APK 签名块:')
577
+ if (signing.pairs.length === 0) {
578
+ lines.push('无(可能是 JAR v1 或未签名)')
579
+ } else {
580
+ for (const pair of signing.pairs) {
581
+ const name = APK_SIG_BLOCK_NAMES.get(pair.id) ?? '未知类型'
582
+ lines.push(`- 0x${pair.id.toString(16)} ${name},${pair.length} 字节`)
583
+ }
584
+ }
585
+
586
+ lines.push('', 'ZIP 条目:')
587
+ for (const entry of entries) {
588
+ const method =
589
+ entry.method === 0 ? 'store' : entry.method === 8 ? 'deflate' : `method ${entry.method}`
590
+ lines.push(`- ${entry.name}(${entry.size} 字节,${method})`)
591
+ }
592
+
593
+ lines.push('', 'Manifest:')
594
+ if (manifest.label !== undefined) {
595
+ lines.push(`应用标签: ${manifest.label}`)
596
+ }
597
+ if (manifest.allowBackup !== undefined) {
598
+ lines.push(`允许备份: ${manifest.allowBackup === true ? '是' : '否'}`)
599
+ }
600
+ if (manifest.usesFeatures.length > 0) {
601
+ lines.push(`特性: ${manifest.usesFeatures.join('、')}`)
602
+ }
603
+ if (manifest.activities.length > 0) {
604
+ lines.push('Activity:')
605
+ for (const activity of manifest.activities) {
606
+ lines.push(`- ${activity.name}${activity.launcher ? '(启动)' : ''}`)
607
+ }
608
+ }
609
+
610
+ }
611
+
612
+ // ------------------------------------------------------------------ 命令入口
613
+
614
+ function readApkFile(path, help) {
615
+ try {
616
+ return readFileSync(path)
617
+ } catch (error) {
618
+ if (error.code === 'ENOENT') {
619
+ throw new UsageError(`文件不存在: ${path}`, help())
620
+ }
621
+ throw new UsageError(`读取文件失败: ${path}: ${error.message}`, help())
622
+ }
623
+ }
624
+
625
+ function parseApk(buffer, path, help) {
626
+ const eocd = readEndOfCentralDirectory(buffer, help)
627
+ const entries = readZipEntries(buffer, eocd, help)
628
+ const manifestEntry = findZipEntry(entries, 'AndroidManifest.xml')
629
+ if (manifestEntry === null) {
630
+ throw new UsageError(`不是 APK:${path} 里没有 AndroidManifest.xml`, help())
631
+ }
632
+ const manifest = extractManifest(readZipEntryData(buffer, manifestEntry, help), help)
633
+ const signing = readApkSigningBlock(buffer, eocd.centralOffset, help)
634
+ return { entries, manifest, signing }
635
+ }
636
+
637
+ function runApk(argv, env) {
638
+ if (argv.some((token) => isHelpToken(token))) {
639
+ stdout(renderApkHelp())
640
+ return EXIT_OK
641
+ }
642
+
643
+ const options = parseOptions(
644
+ argv,
645
+ APK_OPTIONS,
646
+ renderApkHelp,
647
+ APK_FLAGS,
648
+ APK_MULTI_OPTIONS,
649
+ )
650
+ const verbose = resolveVerbose(options, env, renderApkHelp)
651
+ const envPath = readEnv(env, ENV_FILE)
652
+ const optionPaths = options.get(OPTION_FILE)
653
+ const paths =
654
+ optionPaths === undefined
655
+ ? envPath === undefined
656
+ ? undefined
657
+ : [envPath]
658
+ : Array.isArray(optionPaths)
659
+ ? optionPaths
660
+ : [optionPaths]
661
+ if (paths === undefined) {
662
+ throw new UsageError(
663
+ `缺少必填参数: ${OPTION_FILE}(APK 文件路径,可多个),可用环境变量 ${ENV_FILE}`,
664
+ renderApkHelp(),
665
+ )
666
+ }
667
+
668
+ for (const [index, path] of paths.entries()) {
669
+ const buffer = readApkFile(path, renderApkHelp)
670
+ const parsed = parseApk(buffer, path, renderApkHelp)
671
+ const { lines } = apkLines(
672
+ buffer,
673
+ path,
674
+ parsed.entries,
675
+ parsed.manifest,
676
+ parsed.signing,
677
+ verbose,
678
+ )
679
+ if (verbose) {
680
+ verboseLines(lines, parsed.entries, parsed.manifest, parsed.signing)
681
+ }
682
+ stdout(lines.join('\n'))
683
+ if (index < paths.length - 1) {
684
+ stdout('')
685
+ }
686
+ }
687
+ return EXIT_OK
688
+ }
689
+
690
+ export const apkCommand = {
691
+ name: COMMAND_NAME,
692
+ summary: '查看 APK 安装包信息(包名 / SDK / ABI / 签名证书)',
693
+ run: runApk,
694
+ }