From 6b68f46ec87553777b39249e10624e451f25bce7 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 28 Dec 2025 17:25:28 +0800 Subject: [PATCH 1/8] cleanup --- .github/workflows/publish.yml | 4 ++-- src/package.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 94aef2c..daf419b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,10 +14,10 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@v2 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: '24.x' registry-url: 'https://registry.npmjs.org' diff --git a/src/package.ts b/src/package.ts index 02001f8..11fa864 100644 --- a/src/package.ts +++ b/src/package.ts @@ -1,4 +1,4 @@ -import { get, getAllPackages, post, uploadFile, doDelete } from './api'; +import { getAllPackages, post, uploadFile, doDelete } from './api'; import { question, saveToLocal } from './utils'; import { t } from './utils/i18n'; @@ -11,7 +11,7 @@ import { depVersions } from './utils/dep-versions'; import { getCommitInfo } from './utils/git'; export async function listPackage(appId: string) { - const allPkgs = await getAllPackages(appId); + const allPkgs = await getAllPackages(appId) || []; const header = [ { value: t('nativePackageId') }, @@ -49,7 +49,7 @@ export async function choosePackage(appId: string) { while (true) { const id = await question(t('enterNativePackageId')); - const app = list.find((v) => v.id.toString() === id); + const app = list?.find((v) => v.id.toString() === id); if (app) { return app; } From 8e4b9c439ab9e9a0fb81c294b025a38331597755 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 8 Dec 2025 20:28:20 +0800 Subject: [PATCH 2/8] extractapk # Conflicts: # src/package.ts --- README.md | 1 + README.zh-CN.md | 1 + cli.json | 13 ++ src/api.ts | 8 +- src/locales/en.ts | 3 + src/locales/zh.ts | 3 + src/modules/version-module.ts | 2 +- src/package.ts | 43 +++- src/utils/app-info-parser/aab.js | 326 ---------------------------- src/utils/app-info-parser/aab.ts | 341 ++++++++++++++++++++++++++++++ src/utils/app-info-parser/apk.js | 152 ++++++++++++- src/utils/app-info-parser/zip.js | 4 +- src/utils/dep-versions.ts | 11 +- src/utils/http-helper.ts | 2 +- src/utils/latest-version/index.ts | 3 +- src/versions.ts | 2 +- 16 files changed, 568 insertions(+), 347 deletions(-) delete mode 100644 src/utils/app-info-parser/aab.js create mode 100644 src/utils/app-info-parser/aab.ts diff --git a/README.md b/README.md index 26473d3..de80506 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ Each workflow step contains: - `parseApp`: Parse APP file information - `parseIpa`: Parse IPA file information - `parseApk`: Parse APK file information +- `extractApk`: Extract a universal APK from an AAB (supports `--output`, `--includeAllSplits`, `--splits`) - `packages`: List packages ### User Module (`user`) diff --git a/README.zh-CN.md b/README.zh-CN.md index a004a47..7007208 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -225,6 +225,7 @@ const workflowResult = await moduleManager.executeWorkflow('my-workflow', { - `parseApp`: 解析 APP 文件信息 - `parseIpa`: 解析 IPA 文件信息 - `parseApk`: 解析 APK 文件信息 +- `extractApk`: 从 AAB 提取通用 APK(支持 `--output`、`--includeAllSplits`、`--splits`) - `packages`: 列出包 ### User 模块 (`user`) diff --git a/cli.json b/cli.json index 8283589..8907648 100644 --- a/cli.json +++ b/cli.json @@ -56,6 +56,19 @@ "parseIpa": {}, "parseApk": {}, "parseAab": {}, + "extractApk": { + "options": { + "output": { + "hasValue": true + }, + "includeAllSplits": { + "default": false + }, + "splits": { + "hasValue": true + } + } + }, "packages": { "options": { "platform": { diff --git a/src/api.ts b/src/api.ts index 5dd5540..197907e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -6,21 +6,17 @@ import FormData from 'form-data'; import fetch from 'node-fetch'; import ProgressBar from 'progress'; import tcpp from 'tcp-ping'; +import { getBaseUrl } from 'utils/http-helper'; import packageJson from '../package.json'; import type { Package, Session } from './types'; -import { - credentialFile, - pricingPageUrl, -} from './utils/constants'; +import { credentialFile, pricingPageUrl } from './utils/constants'; import { t } from './utils/i18n'; -import { getBaseUrl } from 'utils/http-helper'; const tcpPing = util.promisify(tcpp.ping); let session: Session | undefined; let savedSession: Session | undefined; - const userAgent = `react-native-update-cli/${packageJson.version}`; export const getSession = () => session; diff --git a/src/locales/en.ts b/src/locales/en.ts index 98ed2fd..955b9d7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -116,6 +116,8 @@ This can reduce the risk of inconsistent dependencies and supply chain attacks. usageDiff: 'Usage: cresc {{command}} ', usageParseApk: 'Usage: cresc parseApk ', usageParseAab: 'Usage: cresc parseAab ', + usageExtractApk: + 'Usage: cresc extractApk [--output ] [--includeAllSplits] [--splits ]', usageParseApp: 'Usage: cresc parseApp ', usageParseIpa: 'Usage: cresc parseIpa ', usageUnderDevelopment: 'Usage is under development now.', @@ -147,4 +149,5 @@ This can reduce the risk of inconsistent dependencies and supply chain attacks. 'This function needs "node-bsdiff". Please run "{{scriptName}} install node-bsdiff" to install', nodeHdiffpatchRequired: 'This function needs "node-hdiffpatch". Please run "{{scriptName}} install node-hdiffpatch" to install', + apkExtracted: 'APK extracted to {{output}}', }; diff --git a/src/locales/zh.ts b/src/locales/zh.ts index e7c2457..12124a5 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -110,6 +110,8 @@ export default { usageDiff: '用法:pushy {{command}} ', usageParseApk: '使用方法: pushy parseApk apk后缀文件', usageParseAab: '使用方法: pushy parseAab aab后缀文件', + usageExtractApk: + '使用方法: pushy extractApk aab后缀文件 [--output apk文件] [--includeAllSplits] [--splits 分包名列表]', usageParseApp: '使用方法: pushy parseApp app后缀文件', usageParseIpa: '使用方法: pushy parseIpa ipa后缀文件', usageUploadApk: '使用方法: pushy uploadApk apk后缀文件', @@ -138,4 +140,5 @@ export default { '此功能需要 "node-bsdiff"。请运行 "{{scriptName}} install node-bsdiff" 来安装', nodeHdiffpatchRequired: '此功能需要 "node-hdiffpatch"。请运行 "{{scriptName}} install node-hdiffpatch" 来安装', + apkExtracted: 'APK 已提取到 {{output}}', }; diff --git a/src/modules/version-module.ts b/src/modules/version-module.ts index 1352ba2..b423c98 100644 --- a/src/modules/version-module.ts +++ b/src/modules/version-module.ts @@ -1,4 +1,4 @@ -import type { CLIModule} from '../types'; +import type { CLIModule } from '../types'; export const versionModule: CLIModule = { name: 'version', diff --git a/src/package.ts b/src/package.ts index 11fa864..46b6619 100644 --- a/src/package.ts +++ b/src/package.ts @@ -1,14 +1,15 @@ import { getAllPackages, post, uploadFile, doDelete } from './api'; import { question, saveToLocal } from './utils'; -import { t } from './utils/i18n'; import { getPlatform, getSelectedApp } from './app'; - import Table from 'tty-table'; import type { Platform } from './types'; -import { getApkInfo, getAppInfo, getIpaInfo, getAabInfo } from './utils'; +import { getAabInfo, getApkInfo, getAppInfo, getIpaInfo } from './utils'; import { depVersions } from './utils/dep-versions'; import { getCommitInfo } from './utils/git'; +import AabParser from './utils/app-info-parser/aab'; +import { t } from './utils/i18n'; +import path from 'path'; export async function listPackage(appId: string) { const allPkgs = await getAllPackages(appId) || []; @@ -214,6 +215,42 @@ export const packageCommands = { } console.log(await getAabInfo(fn)); }, + extractApk: async ({ + args, + options, + }: { + args: string[]; + options: Record; + }) => { + const source = args[0]; + if (!source || !source.endsWith('.aab')) { + throw new Error(t('usageExtractApk')); + } + + const output = + options.output || + path.join( + path.dirname(source), + `${path.basename(source, path.extname(source))}.apk`, + ); + + const includeAllSplits = + options.includeAllSplits === true || options.includeAllSplits === 'true'; + const splits = options.splits + ? String(options.splits) + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + : null; + + const parser = new AabParser(source); + await parser.extractApk(output, { + includeAllSplits, + splits, + }); + + console.log(t('apkExtracted', { output })); + }, packages: async ({ options }: { options: { platform: Platform } }) => { const platform = await getPlatform(options.platform); const { appId } = await getSelectedApp(platform); diff --git a/src/utils/app-info-parser/aab.js b/src/utils/app-info-parser/aab.js deleted file mode 100644 index 29a47bc..0000000 --- a/src/utils/app-info-parser/aab.js +++ /dev/null @@ -1,326 +0,0 @@ -const Zip = require('./zip'); -const yazl = require('yazl'); -const fs = require('fs-extra'); -const path = require('path'); -const { open: openZipFile } = require('yauzl'); -const os = require('os'); - -class AabParser extends Zip { - /** - * parser for parsing .aab file - * @param {String | File | Blob} file // file's path in Node, instance of File or Blob in Browser - */ - constructor(file) { - super(file); - if (!(this instanceof AabParser)) { - return new AabParser(file); - } - } - - /** - * 从 AAB 提取通用 APK - * 这个方法会合并 base/ 和所有 split/ 目录的内容 - * - * @param {String} outputPath - 输出 APK 文件路径 - * @param {Object} options - 选项 - * @param {Boolean} options.includeAllSplits - 是否包含所有 split APK(默认 false,只提取 base) - * @param {Array} options.splits - 指定要包含的 split APK 名称(如果指定,则只包含这些) - * @returns {Promise} 返回输出文件路径 - */ - async extractApk(outputPath, options = {}) { - const { includeAllSplits = false, splits = null } = options; - - return new Promise((resolve, reject) => { - if (typeof this.file !== 'string') { - return reject( - new Error('AAB file path must be a string in Node.js environment'), - ); - } - - openZipFile(this.file, { lazyEntries: true }, async (err, zipfile) => { - if (err) { - return reject(err); - } - - try { - // 1. 收集所有条目及其数据 - const baseEntries = []; - const splitEntries = []; - const metaInfEntries = []; - let pendingReads = 0; - let hasError = false; - - const processEntry = (entry, fileName) => { - return new Promise((resolve, reject) => { - zipfile.openReadStream(entry, (err, readStream) => { - if (err) { - return reject(err); - } - - const chunks = []; - readStream.on('data', (chunk) => chunks.push(chunk)); - readStream.on('end', () => { - const buffer = Buffer.concat(chunks); - resolve(buffer); - }); - readStream.on('error', reject); - }); - }); - }; - - zipfile.on('entry', async (entry) => { - const fileName = entry.fileName; - - // 跳过目录 - if (fileName.endsWith('/')) { - zipfile.readEntry(); - return; - } - - pendingReads++; - try { - const buffer = await processEntry(entry, fileName); - - if (fileName.startsWith('base/')) { - // 将 base/manifest/AndroidManifest.xml 转换为 androidmanifest.xml(APK 中通常是小写) - // 将 base/resources.arsc 转换为 resources.arsc - let apkPath = fileName.replace(/^base\//, ''); - if (apkPath === 'manifest/AndroidManifest.xml') { - apkPath = 'androidmanifest.xml'; - } - - baseEntries.push({ - buffer, - zipPath: fileName, - apkPath, - }); - } else if (fileName.startsWith('split/')) { - splitEntries.push({ - buffer, - zipPath: fileName, - }); - } else if (fileName.startsWith('META-INF/')) { - metaInfEntries.push({ - buffer, - zipPath: fileName, - apkPath: fileName, - }); - } - // BundleConfig.pb 和其他文件不需要包含在 APK 中 - - pendingReads--; - zipfile.readEntry(); - } catch (error) { - pendingReads--; - if (!hasError) { - hasError = true; - reject(error); - } - zipfile.readEntry(); - } - }); - - zipfile.on('end', async () => { - // 等待所有读取完成 - while (pendingReads > 0) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - if (hasError) { - return; - } - - try { - // 2. 创建新的 APK 文件 - const zipFile = new yazl.ZipFile(); - - // 3. 添加 base 目录的所有文件 - for (const { buffer, apkPath } of baseEntries) { - zipFile.addBuffer(buffer, apkPath); - } - - // 4. 添加 split APK 的内容(如果需要) - if (includeAllSplits || splits) { - const splitsToInclude = splits - ? splitEntries.filter((se) => - splits.some((s) => se.zipPath.includes(s)), - ) - : splitEntries; - - await this.mergeSplitApksFromBuffers(zipFile, splitsToInclude); - } - - // 5. 添加 META-INF(签名信息,虽然可能无效,但保留结构) - for (const { buffer, apkPath } of metaInfEntries) { - zipFile.addBuffer(buffer, apkPath); - } - - // 6. 写入文件 - zipFile.outputStream - .pipe(fs.createWriteStream(outputPath)) - .on('close', () => { - resolve(outputPath); - }) - .on('error', (err) => { - reject(err); - }); - - zipFile.end(); - } catch (error) { - reject(error); - } - }); - - zipfile.on('error', reject); - zipfile.readEntry(); - } catch (error) { - reject(error); - } - }); - }); - } - - /** - * 合并 split APK 的内容(从已读取的 buffer) - */ - async mergeSplitApksFromBuffers(zipFile, splitEntries) { - for (const { buffer: splitBuffer } of splitEntries) { - if (splitBuffer) { - // 创建一个临时的 ZIP 文件来读取 split APK - const tempSplitPath = path.join( - os.tmpdir(), - `split_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.apk`, - ); - - try { - await fs.writeFile(tempSplitPath, splitBuffer); - - await new Promise((resolve, reject) => { - openZipFile( - tempSplitPath, - { lazyEntries: true }, - async (err, splitZipfile) => { - if (err) { - return reject(err); - } - - splitZipfile.on('entry', (splitEntry) => { - // 跳过 META-INF,因为签名信息不需要合并 - if (splitEntry.fileName.startsWith('META-INF/')) { - splitZipfile.readEntry(); - return; - } - - splitZipfile.openReadStream(splitEntry, (err, readStream) => { - if (err) { - splitZipfile.readEntry(); - return; - } - - const chunks = []; - readStream.on('data', (chunk) => chunks.push(chunk)); - readStream.on('end', () => { - const buffer = Buffer.concat(chunks); - // 注意:如果文件已存在(在 base 中),split 中的会覆盖 base 中的 - zipFile.addBuffer(buffer, splitEntry.fileName); - splitZipfile.readEntry(); - }); - readStream.on('error', () => { - splitZipfile.readEntry(); - }); - }); - }); - - splitZipfile.on('end', resolve); - splitZipfile.on('error', reject); - splitZipfile.readEntry(); - }, - ); - }); - } finally { - // 清理临时文件 - await fs.remove(tempSplitPath).catch(() => {}); - } - } - } - } - - /** - * 解析 AAB 文件信息(类似 APK parser 的 parse 方法) - * 注意:AAB 中的 AndroidManifest.xml 在 base/manifest/AndroidManifest.xml - */ - async parse() { - // 尝试从 base/manifest/AndroidManifest.xml 读取 manifest - // 但 AAB 中的 manifest 可能是二进制格式,需要特殊处理 - const manifestPath = 'base/manifest/AndroidManifest.xml'; - const ResourceName = /^base\/resources\.arsc$/; - - try { - const manifestBuffer = await this.getEntry( - new RegExp(`^${manifestPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), - ); - - if (!manifestBuffer) { - throw new Error( - "AndroidManifest.xml can't be found in AAB base/manifest/", - ); - } - - let apkInfo = this._parseManifest(manifestBuffer); - - // 尝试解析 resources.arsc - try { - const resourceBuffer = await this.getEntry(ResourceName); - if (resourceBuffer) { - const resourceMap = this._parseResourceMap(resourceBuffer); - const { mapInfoResource } = require('./utils'); - apkInfo = mapInfoResource(apkInfo, resourceMap); - } - } catch (e) { - // resources.arsc 解析失败不影响基本信息 - console.warn('[Warning] Failed to parse resources.arsc:', e.message); - } - - return apkInfo; - } catch (error) { - throw new Error(`Failed to parse AAB: ${error.message}`); - } - } - - /** - * Parse manifest - * @param {Buffer} buffer // manifest file's buffer - */ - _parseManifest(buffer) { - try { - const ManifestXmlParser = require('./xml-parser/manifest'); - const parser = new ManifestXmlParser(buffer, { - ignore: [ - 'application.activity', - 'application.service', - 'application.receiver', - 'application.provider', - 'permission-group', - ], - }); - return parser.parse(); - } catch (e) { - throw new Error('Parse AndroidManifest.xml error: ' + e.message); - } - } - - /** - * Parse resourceMap - * @param {Buffer} buffer // resourceMap file's buffer - */ - _parseResourceMap(buffer) { - try { - const ResourceFinder = require('./resource-finder'); - return new ResourceFinder().processResourceTable(buffer); - } catch (e) { - throw new Error('Parser resources.arsc error: ' + e.message); - } - } -} - -module.exports = AabParser; diff --git a/src/utils/app-info-parser/aab.ts b/src/utils/app-info-parser/aab.ts new file mode 100644 index 0000000..e958e19 --- /dev/null +++ b/src/utils/app-info-parser/aab.ts @@ -0,0 +1,341 @@ +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import { + type Entry, + fromBuffer as openZipFromBuffer, + open as openZipFile, +} from 'yauzl'; +import { ZipFile as YazlZipFile } from 'yazl'; +import Zip from './zip'; + +interface ExtractApkOptions { + includeAllSplits?: boolean; + splits?: string[] | null; +} + +type BufferedEntry = { + apkPath: string; + buffer: Buffer; + compress?: boolean; +}; + +type SplitEntry = { + name: string; + buffer: Buffer; +}; + +/** + * 纯 JS 的 AAB 解析器,参考 https://github.com/accrescent/android-bundle + * 将 base/ 内容重打包为一个通用 APK,并在需要时合并 split APK。 + * 生成的 APK 使用 AAB 中的 proto manifest/resources(不可用于安装,但可被本工具解析)。 + */ +class AabParser extends Zip { + file: string | File; + + constructor(file: string | File) { + super(file); + this.file = file; + } + + /** + * 从 AAB 提取 APK(不依赖 bundletool) + */ + async extractApk( + outputPath: string, + options: ExtractApkOptions = {}, + ): Promise { + if (typeof this.file !== 'string') { + throw new Error('AAB file path must be a string in Node.js environment'); + } + + const { includeAllSplits = false, splits = null } = options; + const { baseEntries, splitEntries, metaInfEntries } = + await this.collectBundleEntries(); + + const entryMap = new Map(); + for (const entry of baseEntries) { + entryMap.set(entry.apkPath, entry); + } + for (const entry of metaInfEntries) { + entryMap.set(entry.apkPath, entry); + } + + const selectedSplits = this.pickSplits(splitEntries, includeAllSplits, splits); + for (const split of selectedSplits) { + await this.mergeSplitApk(entryMap, split.buffer); + } + + await this.writeApk(entryMap, outputPath); + return outputPath; + } + + /** + * 解析 AAB 文件信息(类似 APK parser 的 parse 方法) + * 注意:AAB 中的 AndroidManifest.xml 在 base/manifest/AndroidManifest.xml + */ + async parse() { + const manifestPath = 'base/manifest/AndroidManifest.xml'; + const ResourceName = /^base\/resources\.arsc$/; + + try { + const manifestBuffer = await this.getEntry( + new RegExp(`^${escapeRegExp(manifestPath)}$`), + ); + + if (!manifestBuffer) { + throw new Error( + "AndroidManifest.xml can't be found in AAB base/manifest/", + ); + } + + let apkInfo = this._parseManifest(manifestBuffer as Buffer); + + try { + const resourceBuffer = await this.getEntry(ResourceName); + if (resourceBuffer) { + const resourceMap = this._parseResourceMap(resourceBuffer as Buffer); + const { mapInfoResource } = require('./utils'); + apkInfo = mapInfoResource(apkInfo, resourceMap); + } + } catch (e: any) { + console.warn( + '[Warning] Failed to parse resources.arsc:', + e?.message ?? e, + ); + } + + return apkInfo; + } catch (error: any) { + throw new Error(`Failed to parse AAB: ${error.message ?? error}`); + } + } + + private pickSplits( + splitEntries: SplitEntry[], + includeAllSplits: boolean, + splits: string[] | null, + ) { + if (splits && splits.length > 0) { + return splitEntries.filter(({ name }) => + splits.some((s) => name.includes(s)), + ); + } + return includeAllSplits ? splitEntries : []; + } + + private async writeApk( + entries: Map, + outputPath: string, + ) { + await fs.ensureDir(path.dirname(outputPath)); + + const zipFile = new YazlZipFile(); + for (const { apkPath, buffer, compress } of entries.values()) { + zipFile.addBuffer(buffer, apkPath, { + compress, + }); + } + + await new Promise((resolve, reject) => { + zipFile.outputStream + .pipe(fs.createWriteStream(outputPath)) + .on('close', resolve) + .on('error', reject); + zipFile.end(); + }); + } + + private async collectBundleEntries() { + return new Promise<{ + baseEntries: BufferedEntry[]; + splitEntries: SplitEntry[]; + metaInfEntries: BufferedEntry[]; + }>((resolve, reject) => { + openZipFile(this.file as string, { lazyEntries: true }, (err, zipfile) => { + if (err || !zipfile) { + reject(err ?? new Error('Failed to open AAB file')); + return; + } + + const baseEntries: BufferedEntry[] = []; + const splitEntries: SplitEntry[] = []; + const metaInfEntries: BufferedEntry[] = []; + const promises: Promise[] = []; + + const readNext = () => zipfile.readEntry(); + + zipfile.on('entry', (entry: Entry) => { + if (entry.fileName.endsWith('/')) { + readNext(); + return; + } + + const promise = this.readEntryBuffer(zipfile, entry) + .then((buffer) => { + if (entry.fileName.startsWith('base/')) { + const apkPath = this.mapBasePath(entry.fileName); + if (apkPath) { + baseEntries.push({ + apkPath, + buffer, + compress: entry.compressionMethod !== 0, + }); + } + } else if ( + (entry.fileName.startsWith('splits/') || + entry.fileName.startsWith('split/')) && + entry.fileName.endsWith('.apk') + ) { + splitEntries.push({ name: entry.fileName, buffer }); + } else if (entry.fileName.startsWith('META-INF/')) { + metaInfEntries.push({ + apkPath: entry.fileName, + buffer, + compress: entry.compressionMethod !== 0, + }); + } + }) + .catch((error) => { + zipfile.close(); + reject(error); + }) + .finally(readNext); + + promises.push(promise); + }); + + zipfile.once('error', reject); + zipfile.once('end', () => { + Promise.all(promises) + .then(() => resolve({ baseEntries, splitEntries, metaInfEntries })) + .catch(reject); + }); + + readNext(); + }); + }); + } + + private mapBasePath(fileName: string) { + const relative = fileName.replace(/^base\//, ''); + if (!relative) return null; + + if (relative === 'manifest/AndroidManifest.xml') { + return 'androidmanifest.xml'; + } + + if (relative.startsWith('root/')) { + return relative.replace(/^root\//, ''); + } + + if (relative === 'resources.pb') { + return 'resources.pb'; + } + + if (relative === 'resources.arsc') { + return 'resources.arsc'; + } + + return relative; + } + + private async mergeSplitApk( + entryMap: Map, + splitBuffer: Buffer, + ) { + await new Promise((resolve, reject) => { + openZipFromBuffer(splitBuffer, { lazyEntries: true }, (err, zipfile) => { + if (err || !zipfile) { + reject(err ?? new Error('Failed to open split APK')); + return; + } + + const readNext = () => zipfile.readEntry(); + zipfile.on('entry', (entry: Entry) => { + if (entry.fileName.endsWith('/')) { + readNext(); + return; + } + if (entry.fileName.startsWith('META-INF/')) { + readNext(); + return; + } + + this.readEntryBuffer(zipfile, entry) + .then((buffer) => { + entryMap.set(entry.fileName, { + apkPath: entry.fileName, + buffer, + compress: entry.compressionMethod !== 0, + }); + }) + .catch((error) => { + zipfile.close(); + reject(error); + }) + .finally(readNext); + }); + + zipfile.once('error', reject); + zipfile.once('end', resolve); + readNext(); + }); + }); + } + + private async readEntryBuffer(zipfile: any, entry: Entry): Promise { + return new Promise((resolve, reject) => { + zipfile.openReadStream(entry, (err: any, readStream: any) => { + if (err || !readStream) { + reject(err ?? new Error('Failed to open entry stream')); + return; + } + const chunks: Buffer[] = []; + readStream.on('data', (chunk: Buffer) => chunks.push(chunk)); + readStream.on('end', () => resolve(Buffer.concat(chunks))); + readStream.on('error', reject); + }); + }); + } + + /** + * Parse manifest + * @param {Buffer} buffer // manifest file's buffer + */ + private _parseManifest(buffer: Buffer) { + try { + const ManifestXmlParser = require('./xml-parser/manifest'); + const parser = new ManifestXmlParser(buffer, { + ignore: [ + 'application.activity', + 'application.service', + 'application.receiver', + 'application.provider', + 'permission-group', + ], + }); + return parser.parse(); + } catch (e: any) { + throw new Error('Parse AndroidManifest.xml error: ' + e.message); + } + } + + /** + * Parse resourceMap + * @param {Buffer} buffer // resourceMap file's buffer + */ + private _parseResourceMap(buffer: Buffer) { + try { + const ResourceFinder = require('./resource-finder'); + return new ResourceFinder().processResourceTable(buffer); + } catch (e: any) { + throw new Error('Parser resources.arsc error: ' + e.message); + } + } +} + +const escapeRegExp = (value: string) => + value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + +export = AabParser; diff --git a/src/utils/app-info-parser/apk.js b/src/utils/app-info-parser/apk.js index c1d3b14..60745de 100644 --- a/src/utils/app-info-parser/apk.js +++ b/src/utils/app-info-parser/apk.js @@ -1,4 +1,6 @@ const Zip = require('./zip'); +const path = require('path'); +const protobuf = require('protobufjs'); const { mapInfoResource, findApkIconPath, @@ -6,6 +8,7 @@ const { } = require('./utils'); const ManifestName = /^androidmanifest\.xml$/; const ResourceName = /^resources\.arsc$/; +const ResourceProtoName = /^resources\.pb$/; const ManifestXmlParser = require('./xml-parser/manifest'); const ResourceFinder = require('./resource-finder'); @@ -23,13 +26,24 @@ class ApkParser extends Zip { } parse() { return new Promise((resolve, reject) => { - this.getEntries([ManifestName, ResourceName]) + this.getEntries([ManifestName, ResourceName, ResourceProtoName]) .then((buffers) => { - if (!buffers[ManifestName]) { + const manifestBuffer = buffers[ManifestName]; + if (!manifestBuffer) { throw new Error("AndroidManifest.xml can't be found."); } - let apkInfo = this._parseManifest(buffers[ManifestName]); + let apkInfo; let resourceMap; + + try { + apkInfo = this._parseManifest(manifestBuffer); + } catch (e) { + // 尝试解析 proto manifest(来自 AAB) + apkInfo = this._parseProtoManifest( + manifestBuffer, + buffers[ResourceProtoName], + ); + } if (!buffers[ResourceName]) { resolve(apkInfo); } else { @@ -95,6 +109,138 @@ class ApkParser extends Zip { throw new Error('Parser resources.arsc error: ' + e); } } + + _parseProtoManifest(buffer, resourceProtoBuffer) { + const rootPath = path.resolve(__dirname, '../../../proto/Resources.proto'); + const root = protobuf.loadSync(rootPath); + const XmlNode = root.lookupType('aapt.pb.XmlNode'); + const manifest = XmlNode.toObject(XmlNode.decode(buffer), { + enums: String, + longs: Number, + bytes: Buffer, + defaults: true, + arrays: true, + }).element; + + if (!manifest || manifest.name !== 'manifest') { + throw new Error('Invalid proto manifest'); + } + + const apkInfo = Object.create(null); + apkInfo.application = { metaData: [] }; + + for (const attr of manifest.attribute || []) { + if (attr.name === 'versionName') { + apkInfo.versionName = this._resolveProtoValue( + attr, + resourceProtoBuffer, + root, + ); + } else if (attr.name === 'versionCode') { + apkInfo.versionCode = this._resolveProtoValue( + attr, + resourceProtoBuffer, + root, + ); + } else if (attr.name === 'package') { + apkInfo.package = attr.value; + } + } + + const applicationNode = (manifest.child || []).find( + (c) => c.element && c.element.name === 'application', + ); + if (applicationNode?.element?.child) { + const metaDataNodes = applicationNode.element.child.filter( + (c) => c.element && c.element.name === 'meta-data', + ); + for (const meta of metaDataNodes) { + let name = ''; + let value; + for (const attr of meta.element.attribute || []) { + if (attr.name === 'name') { + name = attr.value; + } else if (attr.name === 'value') { + value = this._resolveProtoValue( + attr, + resourceProtoBuffer, + root, + ); + } + } + if (name) { + apkInfo.application.metaData.push({ + name, + value: Array.isArray(value) ? value : [value], + }); + } + } + } + return apkInfo; + } + + _resolveProtoValue(attr, resourceProtoBuffer, root) { + if (!attr) return null; + const refId = attr.compiledItem?.ref?.id; + if (refId && resourceProtoBuffer) { + const resolved = this._resolveResourceFromProto( + resourceProtoBuffer, + refId, + root, + ); + if (resolved !== null) { + return resolved; + } + } + const prim = attr.compiledItem?.prim; + if (prim?.intDecimalValue !== undefined) { + return prim.intDecimalValue.toString(); + } + if (prim?.stringValue) { + return prim.stringValue; + } + if (attr.value !== undefined && attr.value !== null) { + return attr.value; + } + return null; + } + + _resolveResourceFromProto(resourceBuffer, resourceId, root) { + try { + const ResourceTable = root.lookupType('aapt.pb.ResourceTable'); + const table = ResourceTable.toObject(ResourceTable.decode(resourceBuffer), { + enums: String, + longs: Number, + bytes: Buffer, + defaults: true, + arrays: true, + }); + + const pkgId = (resourceId >> 24) & 0xff; + const typeId = (resourceId >> 16) & 0xff; + const entryId = resourceId & 0xffff; + + const pkg = (table.package || []).find((p) => p.packageId === pkgId); + if (!pkg) return null; + + const type = (pkg.type || []).find((t) => t.typeId === typeId); + if (!type) return null; + + const entry = (type.entry || []).find((e) => e.entryId === entryId); + if (!entry || !entry.configValue?.length) return null; + + const val = entry.configValue[0].value; + if (val.item?.str) { + return val.item.str.value; + } + if (val.item?.prim?.intDecimalValue !== undefined) { + return val.item.prim.intDecimalValue.toString(); + } + return null; + } catch (e) { + return null; + } + } } module.exports = ApkParser; diff --git a/src/utils/app-info-parser/zip.js b/src/utils/app-info-parser/zip.js index d356b28..e18c28c 100644 --- a/src/utils/app-info-parser/zip.js +++ b/src/utils/app-info-parser/zip.js @@ -1,6 +1,6 @@ const Unzip = require('isomorphic-unzip'); const { isBrowser, decodeNullUnicode } = require('./utils'); -const { enumZipEntries, readEntry } = require('../../bundle'); +let bundleZipUtils; class Zip { constructor(file) { @@ -50,6 +50,8 @@ class Zip { async getEntryFromHarmonyApp(regex) { try { + const { enumZipEntries, readEntry } = + bundleZipUtils ?? (bundleZipUtils = require('../../bundle')); let originSource; await enumZipEntries(this.file, (entry, zipFile) => { if (regex.test(entry.fileName)) { diff --git a/src/utils/dep-versions.ts b/src/utils/dep-versions.ts index e518918..f9eb13a 100644 --- a/src/utils/dep-versions.ts +++ b/src/utils/dep-versions.ts @@ -29,9 +29,12 @@ if (currentPackage) { export const depVersions = Object.keys(_depVersions) .sort() // Sort the keys alphabetically - .reduce((obj, key) => { - obj[key] = _depVersions[key]; // Rebuild the object with sorted keys - return obj; - }, {} as Record); + .reduce( + (obj, key) => { + obj[key] = _depVersions[key]; // Rebuild the object with sorted keys + return obj; + }, + {} as Record, + ); // console.log({ depVersions }); diff --git a/src/utils/http-helper.ts b/src/utils/http-helper.ts index fd32348..0e6ef5f 100644 --- a/src/utils/http-helper.ts +++ b/src/utils/http-helper.ts @@ -1,5 +1,5 @@ -import { defaultEndpoints } from './constants'; import fetch from 'node-fetch'; +import { defaultEndpoints } from './constants'; // const baseUrl = `http://localhost:9000`; // let baseUrl = SERVER.main[0]; diff --git a/src/utils/latest-version/index.ts b/src/utils/latest-version/index.ts index d8adb44..d59b708 100644 --- a/src/utils/latest-version/index.ts +++ b/src/utils/latest-version/index.ts @@ -227,7 +227,8 @@ const downloadMetadata = ( }; const authInfo = registryAuthToken(pkgUrl.toString(), { recursive: true }); if (authInfo && requestOptions.headers) { - (requestOptions.headers as any).authorization = `${authInfo.type} ${authInfo.token}`; + (requestOptions.headers as any).authorization = + `${authInfo.type} ${authInfo.token}`; } if (options?.requestOptions) { requestOptions = { ...requestOptions, ...options.requestOptions }; diff --git a/src/versions.ts b/src/versions.ts index 139ed7c..b227c5f 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -4,9 +4,9 @@ import { t } from './utils/i18n'; import chalk from 'chalk'; import { satisfies } from 'compare-versions'; -import type { Package, Platform, Version } from './types'; import { getPlatform, getSelectedApp } from './app'; import { choosePackage } from './package'; +import type { Package, Platform, Version } from './types'; import { depVersions } from './utils/dep-versions'; import { getCommitInfo } from './utils/git'; From 22ccd731edabdb3ed0c1d9b47a33810c12af1d6a Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 2 Jan 2026 10:59:52 +0800 Subject: [PATCH 3/8] use node bundletool --- src/utils/app-info-parser/aab.ts | 305 +++++++------------------------ 1 file changed, 67 insertions(+), 238 deletions(-) diff --git a/src/utils/app-info-parser/aab.ts b/src/utils/app-info-parser/aab.ts index e958e19..30c3b73 100644 --- a/src/utils/app-info-parser/aab.ts +++ b/src/utils/app-info-parser/aab.ts @@ -2,34 +2,10 @@ import fs from 'fs-extra'; import path from 'path'; import os from 'os'; import { - type Entry, - fromBuffer as openZipFromBuffer, open as openZipFile, } from 'yauzl'; -import { ZipFile as YazlZipFile } from 'yazl'; import Zip from './zip'; -interface ExtractApkOptions { - includeAllSplits?: boolean; - splits?: string[] | null; -} - -type BufferedEntry = { - apkPath: string; - buffer: Buffer; - compress?: boolean; -}; - -type SplitEntry = { - name: string; - buffer: Buffer; -}; - -/** - * 纯 JS 的 AAB 解析器,参考 https://github.com/accrescent/android-bundle - * 将 base/ 内容重打包为一个通用 APK,并在需要时合并 split APK。 - * 生成的 APK 使用 AAB 中的 proto manifest/resources(不可用于安装,但可被本工具解析)。 - */ class AabParser extends Zip { file: string | File; @@ -41,33 +17,75 @@ class AabParser extends Zip { /** * 从 AAB 提取 APK(不依赖 bundletool) */ - async extractApk( - outputPath: string, - options: ExtractApkOptions = {}, - ): Promise { - if (typeof this.file !== 'string') { - throw new Error('AAB file path must be a string in Node.js environment'); - } + async extractApk(outputPath: string) { + const { exec } = require('child_process'); + const util = require('util'); + const execAsync = util.promisify(exec); - const { includeAllSplits = false, splits = null } = options; - const { baseEntries, splitEntries, metaInfEntries } = - await this.collectBundleEntries(); + // Create a temp file for the .apks output + const tempDir = os.tmpdir(); + const tempApksPath = path.join(tempDir, `temp-${Date.now()}.apks`); - const entryMap = new Map(); - for (const entry of baseEntries) { - entryMap.set(entry.apkPath, entry); - } - for (const entry of metaInfEntries) { - entryMap.set(entry.apkPath, entry); - } + try { + // 1. Build APKS (universal mode) + // We assume bundletool is in the path. + // User might need keystore to sign it properly but for simple extraction we stick to default debug key if possible or unsigned? + // actually bundletool build-apks signs with debug key by default if no keystore provided. - const selectedSplits = this.pickSplits(splitEntries, includeAllSplits, splits); - for (const split of selectedSplits) { - await this.mergeSplitApk(entryMap, split.buffer); - } + let cmd = `bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite`; + try { + await execAsync(cmd); + } catch (e) { + // Fallback to npx node-bundletool if bundletool is not in PATH + // We use -y to avoid interactive prompt for installation + cmd = `npx -y node-bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite`; + await execAsync(cmd); + } - await this.writeApk(entryMap, outputPath); - return outputPath; + // 2. Extract universal.apk from the .apks (zip) file + await new Promise((resolve, reject) => { + openZipFile(tempApksPath, { lazyEntries: true }, (err, zipfile) => { + if (err || !zipfile) { + reject(err || new Error('Failed to open generated .apks file')); + return; + } + + let found = false; + zipfile.readEntry(); + zipfile.on('entry', (entry) => { + if (entry.fileName === 'universal.apk') { + found = true; + zipfile.openReadStream(entry, (err, readStream) => { + if (err || !readStream) { + reject(err || new Error('Failed to read universal.apk')); + return; + } + const writeStream = fs.createWriteStream(outputPath); + readStream.pipe(writeStream); + writeStream.on('close', () => { + zipfile.close(); + resolve(); + }); + writeStream.on('error', reject); + }); + } else { + zipfile.readEntry(); + } + }); + + zipfile.on('end', () => { + if (!found) + reject(new Error('universal.apk not found in generated .apks')); + }); + zipfile.on('error', reject); + }); + }); + } finally { + // Cleanup + if (await fs.pathExists(tempApksPath)) { + await fs.remove(tempApksPath); + } + } } /** @@ -110,195 +128,6 @@ class AabParser extends Zip { throw new Error(`Failed to parse AAB: ${error.message ?? error}`); } } - - private pickSplits( - splitEntries: SplitEntry[], - includeAllSplits: boolean, - splits: string[] | null, - ) { - if (splits && splits.length > 0) { - return splitEntries.filter(({ name }) => - splits.some((s) => name.includes(s)), - ); - } - return includeAllSplits ? splitEntries : []; - } - - private async writeApk( - entries: Map, - outputPath: string, - ) { - await fs.ensureDir(path.dirname(outputPath)); - - const zipFile = new YazlZipFile(); - for (const { apkPath, buffer, compress } of entries.values()) { - zipFile.addBuffer(buffer, apkPath, { - compress, - }); - } - - await new Promise((resolve, reject) => { - zipFile.outputStream - .pipe(fs.createWriteStream(outputPath)) - .on('close', resolve) - .on('error', reject); - zipFile.end(); - }); - } - - private async collectBundleEntries() { - return new Promise<{ - baseEntries: BufferedEntry[]; - splitEntries: SplitEntry[]; - metaInfEntries: BufferedEntry[]; - }>((resolve, reject) => { - openZipFile(this.file as string, { lazyEntries: true }, (err, zipfile) => { - if (err || !zipfile) { - reject(err ?? new Error('Failed to open AAB file')); - return; - } - - const baseEntries: BufferedEntry[] = []; - const splitEntries: SplitEntry[] = []; - const metaInfEntries: BufferedEntry[] = []; - const promises: Promise[] = []; - - const readNext = () => zipfile.readEntry(); - - zipfile.on('entry', (entry: Entry) => { - if (entry.fileName.endsWith('/')) { - readNext(); - return; - } - - const promise = this.readEntryBuffer(zipfile, entry) - .then((buffer) => { - if (entry.fileName.startsWith('base/')) { - const apkPath = this.mapBasePath(entry.fileName); - if (apkPath) { - baseEntries.push({ - apkPath, - buffer, - compress: entry.compressionMethod !== 0, - }); - } - } else if ( - (entry.fileName.startsWith('splits/') || - entry.fileName.startsWith('split/')) && - entry.fileName.endsWith('.apk') - ) { - splitEntries.push({ name: entry.fileName, buffer }); - } else if (entry.fileName.startsWith('META-INF/')) { - metaInfEntries.push({ - apkPath: entry.fileName, - buffer, - compress: entry.compressionMethod !== 0, - }); - } - }) - .catch((error) => { - zipfile.close(); - reject(error); - }) - .finally(readNext); - - promises.push(promise); - }); - - zipfile.once('error', reject); - zipfile.once('end', () => { - Promise.all(promises) - .then(() => resolve({ baseEntries, splitEntries, metaInfEntries })) - .catch(reject); - }); - - readNext(); - }); - }); - } - - private mapBasePath(fileName: string) { - const relative = fileName.replace(/^base\//, ''); - if (!relative) return null; - - if (relative === 'manifest/AndroidManifest.xml') { - return 'androidmanifest.xml'; - } - - if (relative.startsWith('root/')) { - return relative.replace(/^root\//, ''); - } - - if (relative === 'resources.pb') { - return 'resources.pb'; - } - - if (relative === 'resources.arsc') { - return 'resources.arsc'; - } - - return relative; - } - - private async mergeSplitApk( - entryMap: Map, - splitBuffer: Buffer, - ) { - await new Promise((resolve, reject) => { - openZipFromBuffer(splitBuffer, { lazyEntries: true }, (err, zipfile) => { - if (err || !zipfile) { - reject(err ?? new Error('Failed to open split APK')); - return; - } - - const readNext = () => zipfile.readEntry(); - zipfile.on('entry', (entry: Entry) => { - if (entry.fileName.endsWith('/')) { - readNext(); - return; - } - if (entry.fileName.startsWith('META-INF/')) { - readNext(); - return; - } - - this.readEntryBuffer(zipfile, entry) - .then((buffer) => { - entryMap.set(entry.fileName, { - apkPath: entry.fileName, - buffer, - compress: entry.compressionMethod !== 0, - }); - }) - .catch((error) => { - zipfile.close(); - reject(error); - }) - .finally(readNext); - }); - - zipfile.once('error', reject); - zipfile.once('end', resolve); - readNext(); - }); - }); - } - - private async readEntryBuffer(zipfile: any, entry: Entry): Promise { - return new Promise((resolve, reject) => { - zipfile.openReadStream(entry, (err: any, readStream: any) => { - if (err || !readStream) { - reject(err ?? new Error('Failed to open entry stream')); - return; - } - const chunks: Buffer[] = []; - readStream.on('data', (chunk: Buffer) => chunks.push(chunk)); - readStream.on('end', () => resolve(Buffer.concat(chunks))); - readStream.on('error', reject); - }); - }); - } - /** * Parse manifest * @param {Buffer} buffer // manifest file's buffer @@ -317,7 +146,7 @@ class AabParser extends Zip { }); return parser.parse(); } catch (e: any) { - throw new Error('Parse AndroidManifest.xml error: ' + e.message); + throw new Error(`Parse AndroidManifest.xml error: ${e.message}`); } } @@ -330,7 +159,7 @@ class AabParser extends Zip { const ResourceFinder = require('./resource-finder'); return new ResourceFinder().processResourceTable(buffer); } catch (e: any) { - throw new Error('Parser resources.arsc error: ' + e.message); + throw new Error(`Parser resources.arsc error: ${e.message}`); } } } From 00e54461f5f4cffcefbeaf46f65018302a5dade8 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 2 Jan 2026 11:42:49 +0800 Subject: [PATCH 4/8] Update version to 2.6.0 in package.json and enhance AAB parsing error messages with localized strings for better user feedback. --- package.json | 2 +- src/locales/en.ts | 9 ++++++ src/locales/zh.ts | 8 ++++++ src/utils/app-info-parser/aab.ts | 49 +++++++++++++++++--------------- 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 64f3866..f6e27d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-update-cli", - "version": "2.5.0", + "version": "2.6.0", "description": "command line tool for react-native-update (remote updates for react native)", "main": "index.js", "bin": { diff --git a/src/locales/en.ts b/src/locales/en.ts index 955b9d7..2d300af 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -2,6 +2,15 @@ export default { addedToGitignore: 'Added {{line}} to .gitignore', androidCrunchPngsWarning: 'The crunchPngs option of android seems not disabled (Please ignore this warning if already disabled), which may cause abnormal consumption of mobile network traffic. Please refer to https://cresc.dev/docs/getting-started#disable-crunchpngs-on-android \n', + aabOpenApksFailed: 'Failed to open generated .apks file', + aabReadUniversalApkFailed: 'Failed to read universal.apk', + aabUniversalApkNotFound: 'universal.apk not found in generated .apks', + aabManifestNotFound: + "AndroidManifest.xml can't be found in AAB base/manifest/", + aabParseResourcesWarning: '[Warning] Failed to parse resources.arsc: {{error}}', + aabParseFailed: 'Failed to parse AAB: {{error}}', + aabParseManifestError: 'Parse AndroidManifest.xml error: {{error}}', + aabParseResourcesError: 'Parser resources.arsc error: {{error}}', appId: 'App ID', appIdMismatchApk: 'App ID mismatch! Current APK: {{appIdInPkg}}, current update.json: {{appId}}', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 12124a5..ceadd46 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -2,6 +2,14 @@ export default { addedToGitignore: '已将 {{line}} 添加到 .gitignore', androidCrunchPngsWarning: 'android 的 crunchPngs 选项似乎尚未禁用(如已禁用则请忽略此提示),这可能导致热更包体积异常增大,具体请参考 https://pushy.reactnative.cn/docs/getting-started.html#%E7%A6%81%E7%94%A8-android-%E7%9A%84-crunch-%E4%BC%98%E5%8C%96 \n', + aabOpenApksFailed: '无法打开生成的 .apks 文件', + aabReadUniversalApkFailed: '无法读取 universal.apk', + aabUniversalApkNotFound: '在生成的 .apks 中未找到 universal.apk', + aabManifestNotFound: '在 AAB 的 base/manifest/ 中找不到 AndroidManifest.xml', + aabParseResourcesWarning: '[警告] 解析 resources.arsc 失败:{{error}}', + aabParseFailed: '解析 AAB 失败:{{error}}', + aabParseManifestError: '解析 AndroidManifest.xml 出错:{{error}}', + aabParseResourcesError: '解析 resources.arsc 出错:{{error}}', appId: '应用 id', appIdMismatchApk: 'appId不匹配!当前apk: {{appIdInPkg}}, 当前update.json: {{appId}}', diff --git a/src/utils/app-info-parser/aab.ts b/src/utils/app-info-parser/aab.ts index 30c3b73..6929a98 100644 --- a/src/utils/app-info-parser/aab.ts +++ b/src/utils/app-info-parser/aab.ts @@ -1,10 +1,9 @@ import fs from 'fs-extra'; import path from 'path'; import os from 'os'; -import { - open as openZipFile, -} from 'yauzl'; +import { open as openZipFile } from 'yauzl'; import Zip from './zip'; +import { t } from '../i18n'; class AabParser extends Zip { file: string | File; @@ -14,13 +13,23 @@ class AabParser extends Zip { this.file = file; } - /** - * 从 AAB 提取 APK(不依赖 bundletool) - */ - async extractApk(outputPath: string) { + async extractApk( + outputPath: string, + { + includeAllSplits = true, + splits, + }: { includeAllSplits?: boolean; splits?: string[] | null }, + ) { const { exec } = require('child_process'); const util = require('util'); const execAsync = util.promisify(exec); + const normalizedSplits = Array.isArray(splits) + ? splits.map((item) => item.trim()).filter(Boolean) + : []; + const modules = includeAllSplits + ? null + : Array.from(new Set(['base', ...normalizedSplits])); + const modulesArg = modules ? ` --modules="${modules.join(',')}"` : ''; // Create a temp file for the .apks output const tempDir = os.tmpdir(); @@ -32,13 +41,13 @@ class AabParser extends Zip { // User might need keystore to sign it properly but for simple extraction we stick to default debug key if possible or unsigned? // actually bundletool build-apks signs with debug key by default if no keystore provided. - let cmd = `bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite`; + let cmd = `bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite${modulesArg}`; try { await execAsync(cmd); } catch (e) { // Fallback to npx node-bundletool if bundletool is not in PATH // We use -y to avoid interactive prompt for installation - cmd = `npx -y node-bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite`; + cmd = `npx -y node-bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite${modulesArg}`; await execAsync(cmd); } @@ -46,7 +55,7 @@ class AabParser extends Zip { await new Promise((resolve, reject) => { openZipFile(tempApksPath, { lazyEntries: true }, (err, zipfile) => { if (err || !zipfile) { - reject(err || new Error('Failed to open generated .apks file')); + reject(err || new Error(t('aabOpenApksFailed'))); return; } @@ -57,7 +66,7 @@ class AabParser extends Zip { found = true; zipfile.openReadStream(entry, (err, readStream) => { if (err || !readStream) { - reject(err || new Error('Failed to read universal.apk')); + reject(err || new Error(t('aabReadUniversalApkFailed'))); return; } const writeStream = fs.createWriteStream(outputPath); @@ -74,8 +83,7 @@ class AabParser extends Zip { }); zipfile.on('end', () => { - if (!found) - reject(new Error('universal.apk not found in generated .apks')); + if (!found) reject(new Error(t('aabUniversalApkNotFound'))); }); zipfile.on('error', reject); }); @@ -102,9 +110,7 @@ class AabParser extends Zip { ); if (!manifestBuffer) { - throw new Error( - "AndroidManifest.xml can't be found in AAB base/manifest/", - ); + throw new Error(t('aabManifestNotFound')); } let apkInfo = this._parseManifest(manifestBuffer as Buffer); @@ -117,15 +123,12 @@ class AabParser extends Zip { apkInfo = mapInfoResource(apkInfo, resourceMap); } } catch (e: any) { - console.warn( - '[Warning] Failed to parse resources.arsc:', - e?.message ?? e, - ); + console.warn(t('aabParseResourcesWarning', { error: e?.message ?? e })); } return apkInfo; } catch (error: any) { - throw new Error(`Failed to parse AAB: ${error.message ?? error}`); + throw new Error(t('aabParseFailed', { error: error.message ?? error })); } } /** @@ -146,7 +149,7 @@ class AabParser extends Zip { }); return parser.parse(); } catch (e: any) { - throw new Error(`Parse AndroidManifest.xml error: ${e.message}`); + throw new Error(t('aabParseManifestError', { error: e?.message ?? e })); } } @@ -159,7 +162,7 @@ class AabParser extends Zip { const ResourceFinder = require('./resource-finder'); return new ResourceFinder().processResourceTable(buffer); } catch (e: any) { - throw new Error(`Parser resources.arsc error: ${e.message}`); + throw new Error(t('aabParseResourcesError', { error: e?.message ?? e })); } } } From e492c5e84180afc233ab127ec098c1d4c8230800 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 2 Jan 2026 12:49:09 +0800 Subject: [PATCH 5/8] Refactor app-info-parser: migrate ApkParser and AppParser to TypeScript, remove deprecated apk.js and app.js files, and update import statements for consistency. --- src/package.ts | 11 +- src/utils/app-info-parser/aab.ts | 6 +- src/utils/app-info-parser/apk.js | 246 ----------------------------- src/utils/app-info-parser/apk.ts | 90 +++++++++++ src/utils/app-info-parser/app.js | 16 -- src/utils/app-info-parser/app.ts | 3 + src/utils/app-info-parser/index.ts | 6 +- src/utils/app-info-parser/ipa.js | 12 +- src/utils/app-info-parser/zip.js | 4 +- 9 files changed, 108 insertions(+), 286 deletions(-) delete mode 100644 src/utils/app-info-parser/apk.js create mode 100644 src/utils/app-info-parser/apk.ts delete mode 100644 src/utils/app-info-parser/app.js create mode 100644 src/utils/app-info-parser/app.ts diff --git a/src/package.ts b/src/package.ts index 46b6619..0ae5570 100644 --- a/src/package.ts +++ b/src/package.ts @@ -7,12 +7,12 @@ import type { Platform } from './types'; import { getAabInfo, getApkInfo, getAppInfo, getIpaInfo } from './utils'; import { depVersions } from './utils/dep-versions'; import { getCommitInfo } from './utils/git'; -import AabParser from './utils/app-info-parser/aab'; +import { AabParser } from './utils/app-info-parser/aab'; import { t } from './utils/i18n'; import path from 'path'; export async function listPackage(appId: string) { - const allPkgs = await getAllPackages(appId) || []; + const allPkgs = (await getAllPackages(appId)) || []; const header = [ { value: t('nativePackageId') }, @@ -261,7 +261,12 @@ export const packageCommands = { options, }: { args: string[]; - options: { appId?: string; packageId?: string; packageVersion?: string; platform?: Platform }; + options: { + appId?: string; + packageId?: string; + packageVersion?: string; + platform?: Platform; + }; }) => { let { appId, packageId, packageVersion } = options; diff --git a/src/utils/app-info-parser/aab.ts b/src/utils/app-info-parser/aab.ts index 6929a98..86005dd 100644 --- a/src/utils/app-info-parser/aab.ts +++ b/src/utils/app-info-parser/aab.ts @@ -2,10 +2,10 @@ import fs from 'fs-extra'; import path from 'path'; import os from 'os'; import { open as openZipFile } from 'yauzl'; -import Zip from './zip'; +import { Zip } from './zip'; import { t } from '../i18n'; -class AabParser extends Zip { +export class AabParser extends Zip { file: string | File; constructor(file: string | File) { @@ -169,5 +169,3 @@ class AabParser extends Zip { const escapeRegExp = (value: string) => value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); - -export = AabParser; diff --git a/src/utils/app-info-parser/apk.js b/src/utils/app-info-parser/apk.js deleted file mode 100644 index 60745de..0000000 --- a/src/utils/app-info-parser/apk.js +++ /dev/null @@ -1,246 +0,0 @@ -const Zip = require('./zip'); -const path = require('path'); -const protobuf = require('protobufjs'); -const { - mapInfoResource, - findApkIconPath, - getBase64FromBuffer, -} = require('./utils'); -const ManifestName = /^androidmanifest\.xml$/; -const ResourceName = /^resources\.arsc$/; -const ResourceProtoName = /^resources\.pb$/; - -const ManifestXmlParser = require('./xml-parser/manifest'); -const ResourceFinder = require('./resource-finder'); - -class ApkParser extends Zip { - /** - * parser for parsing .apk file - * @param {String | File | Blob} file // file's path in Node, instance of File or Blob in Browser - */ - constructor(file) { - super(file); - if (!(this instanceof ApkParser)) { - return new ApkParser(file); - } - } - parse() { - return new Promise((resolve, reject) => { - this.getEntries([ManifestName, ResourceName, ResourceProtoName]) - .then((buffers) => { - const manifestBuffer = buffers[ManifestName]; - if (!manifestBuffer) { - throw new Error("AndroidManifest.xml can't be found."); - } - let apkInfo; - let resourceMap; - - try { - apkInfo = this._parseManifest(manifestBuffer); - } catch (e) { - // 尝试解析 proto manifest(来自 AAB) - apkInfo = this._parseProtoManifest( - manifestBuffer, - buffers[ResourceProtoName], - ); - } - if (!buffers[ResourceName]) { - resolve(apkInfo); - } else { - // parse resourceMap - resourceMap = this._parseResourceMap(buffers[ResourceName]); - // update apkInfo with resourceMap - apkInfo = mapInfoResource(apkInfo, resourceMap); - - // find icon path and parse icon - const iconPath = findApkIconPath(apkInfo); - if (iconPath) { - this.getEntry(iconPath) - .then((iconBuffer) => { - apkInfo.icon = iconBuffer - ? getBase64FromBuffer(iconBuffer) - : null; - resolve(apkInfo); - }) - .catch((e) => { - apkInfo.icon = null; - resolve(apkInfo); - console.warn('[Warning] failed to parse icon: ', e); - }); - } else { - apkInfo.icon = null; - resolve(apkInfo); - } - } - }) - .catch((e) => { - reject(e); - }); - }); - } - /** - * Parse manifest - * @param {Buffer} buffer // manifest file's buffer - */ - _parseManifest(buffer) { - try { - const parser = new ManifestXmlParser(buffer, { - ignore: [ - 'application.activity', - 'application.service', - 'application.receiver', - 'application.provider', - 'permission-group', - ], - }); - return parser.parse(); - } catch (e) { - throw new Error('Parse AndroidManifest.xml error: ' + (e.message || e)); - } - } - /** - * Parse resourceMap - * @param {Buffer} buffer // resourceMap file's buffer - */ - _parseResourceMap(buffer) { - try { - return new ResourceFinder().processResourceTable(buffer); - } catch (e) { - throw new Error('Parser resources.arsc error: ' + e); - } - } - - _parseProtoManifest(buffer, resourceProtoBuffer) { - const rootPath = path.resolve(__dirname, '../../../proto/Resources.proto'); - const root = protobuf.loadSync(rootPath); - const XmlNode = root.lookupType('aapt.pb.XmlNode'); - const manifest = XmlNode.toObject(XmlNode.decode(buffer), { - enums: String, - longs: Number, - bytes: Buffer, - defaults: true, - arrays: true, - }).element; - - if (!manifest || manifest.name !== 'manifest') { - throw new Error('Invalid proto manifest'); - } - - const apkInfo = Object.create(null); - apkInfo.application = { metaData: [] }; - - for (const attr of manifest.attribute || []) { - if (attr.name === 'versionName') { - apkInfo.versionName = this._resolveProtoValue( - attr, - resourceProtoBuffer, - root, - ); - } else if (attr.name === 'versionCode') { - apkInfo.versionCode = this._resolveProtoValue( - attr, - resourceProtoBuffer, - root, - ); - } else if (attr.name === 'package') { - apkInfo.package = attr.value; - } - } - - const applicationNode = (manifest.child || []).find( - (c) => c.element && c.element.name === 'application', - ); - if (applicationNode?.element?.child) { - const metaDataNodes = applicationNode.element.child.filter( - (c) => c.element && c.element.name === 'meta-data', - ); - for (const meta of metaDataNodes) { - let name = ''; - let value; - for (const attr of meta.element.attribute || []) { - if (attr.name === 'name') { - name = attr.value; - } else if (attr.name === 'value') { - value = this._resolveProtoValue( - attr, - resourceProtoBuffer, - root, - ); - } - } - if (name) { - apkInfo.application.metaData.push({ - name, - value: Array.isArray(value) ? value : [value], - }); - } - } - } - return apkInfo; - } - - _resolveProtoValue(attr, resourceProtoBuffer, root) { - if (!attr) return null; - const refId = attr.compiledItem?.ref?.id; - if (refId && resourceProtoBuffer) { - const resolved = this._resolveResourceFromProto( - resourceProtoBuffer, - refId, - root, - ); - if (resolved !== null) { - return resolved; - } - } - const prim = attr.compiledItem?.prim; - if (prim?.intDecimalValue !== undefined) { - return prim.intDecimalValue.toString(); - } - if (prim?.stringValue) { - return prim.stringValue; - } - if (attr.value !== undefined && attr.value !== null) { - return attr.value; - } - return null; - } - - _resolveResourceFromProto(resourceBuffer, resourceId, root) { - try { - const ResourceTable = root.lookupType('aapt.pb.ResourceTable'); - const table = ResourceTable.toObject(ResourceTable.decode(resourceBuffer), { - enums: String, - longs: Number, - bytes: Buffer, - defaults: true, - arrays: true, - }); - - const pkgId = (resourceId >> 24) & 0xff; - const typeId = (resourceId >> 16) & 0xff; - const entryId = resourceId & 0xffff; - - const pkg = (table.package || []).find((p) => p.packageId === pkgId); - if (!pkg) return null; - - const type = (pkg.type || []).find((t) => t.typeId === typeId); - if (!type) return null; - - const entry = (type.entry || []).find((e) => e.entryId === entryId); - if (!entry || !entry.configValue?.length) return null; - - const val = entry.configValue[0].value; - if (val.item?.str) { - return val.item.str.value; - } - if (val.item?.prim?.intDecimalValue !== undefined) { - return val.item.prim.intDecimalValue.toString(); - } - return null; - } catch (e) { - return null; - } - } -} - -module.exports = ApkParser; diff --git a/src/utils/app-info-parser/apk.ts b/src/utils/app-info-parser/apk.ts new file mode 100644 index 0000000..7f09322 --- /dev/null +++ b/src/utils/app-info-parser/apk.ts @@ -0,0 +1,90 @@ +import { findApkIconPath, getBase64FromBuffer, mapInfoResource } from './utils'; +import { Zip } from './zip'; + +const ManifestName = /^androidmanifest\.xml$/; +const ResourceName = /^resources\.arsc$/; + +const ManifestXmlParser = require('./xml-parser/manifest'); +const ResourceFinder = require('./resource-finder'); + +export class ApkParser extends Zip { + parse(): Promise { + return new Promise((resolve, reject) => { + this.getEntries([ManifestName, ResourceName]) + .then((buffers: any) => { + const manifestBuffer = buffers[ManifestName]; + if (!manifestBuffer) { + throw new Error("AndroidManifest.xml can't be found."); + } + let apkInfo: any; + let resourceMap: any; + + apkInfo = this._parseManifest(manifestBuffer as Buffer); + + if (!buffers[ResourceName]) { + resolve(apkInfo); + } else { + resourceMap = this._parseResourceMap( + buffers[ResourceName] as Buffer, + ); + apkInfo = mapInfoResource(apkInfo, resourceMap); + + const iconPath = findApkIconPath(apkInfo); + if (iconPath) { + this.getEntry(iconPath) + .then((iconBuffer: Buffer | null) => { + apkInfo.icon = iconBuffer + ? getBase64FromBuffer(iconBuffer) + : null; + resolve(apkInfo); + }) + .catch((e: any) => { + apkInfo.icon = null; + resolve(apkInfo); + console.warn('[Warning] failed to parse icon: ', e); + }); + } else { + apkInfo.icon = null; + resolve(apkInfo); + } + } + }) + .catch((e: any) => { + reject(e); + }); + }); + } + + /** + * Parse manifest + * @param {Buffer} buffer // manifest file's buffer + */ + private _parseManifest(buffer: Buffer) { + try { + const parser = new ManifestXmlParser(buffer, { + ignore: [ + 'application.activity', + 'application.service', + 'application.receiver', + 'application.provider', + 'permission-group', + ], + }); + return parser.parse(); + } catch (e: any) { + throw new Error(`Parse AndroidManifest.xml error: ${e.message || e}`); + } + } + + /** + * Parse resourceMap + * @param {Buffer} buffer // resourceMap file's buffer + */ + private _parseResourceMap(buffer: Buffer) { + try { + return new ResourceFinder().processResourceTable(buffer); + } catch (e: any) { + throw new Error(`Parser resources.arsc error: ${e}`); + } + } +} diff --git a/src/utils/app-info-parser/app.js b/src/utils/app-info-parser/app.js deleted file mode 100644 index 6740bdf..0000000 --- a/src/utils/app-info-parser/app.js +++ /dev/null @@ -1,16 +0,0 @@ -const Zip = require('./zip'); - -class AppParser extends Zip { - /** - * parser for parsing .apk file - * @param {String | File | Blob} file // file's path in Node, instance of File or Blob in Browser - */ - constructor(file) { - super(file); - if (!(this instanceof AppParser)) { - return new AppParser(file); - } - } -} - -module.exports = AppParser; diff --git a/src/utils/app-info-parser/app.ts b/src/utils/app-info-parser/app.ts new file mode 100644 index 0000000..26f994d --- /dev/null +++ b/src/utils/app-info-parser/app.ts @@ -0,0 +1,3 @@ +import { Zip } from './zip'; + +export class AppParser extends Zip {} diff --git a/src/utils/app-info-parser/index.ts b/src/utils/app-info-parser/index.ts index fdbf8d2..f4652d4 100644 --- a/src/utils/app-info-parser/index.ts +++ b/src/utils/app-info-parser/index.ts @@ -1,7 +1,7 @@ -const ApkParser = require('./apk'); +import { ApkParser } from './apk'; const IpaParser = require('./ipa'); -const AppParser = require('./app'); -const AabParser = require('./aab'); +import { AppParser } from './app'; +import { AabParser } from './aab'; const supportFileTypes = ['ipa', 'apk', 'app', 'aab']; class AppInfoParser { diff --git a/src/utils/app-info-parser/ipa.js b/src/utils/app-info-parser/ipa.js index 3c2563e..8bb5a8d 100644 --- a/src/utils/app-info-parser/ipa.js +++ b/src/utils/app-info-parser/ipa.js @@ -2,23 +2,13 @@ const parsePlist = require('plist').parse; const parseBplist = require('bplist-parser').parseBuffer; const cgbiToPng = require('cgbi-to-png'); -const Zip = require('./zip'); +import { Zip } from './zip'; const { findIpaIconPath, getBase64FromBuffer, isBrowser } = require('./utils'); const PlistName = /payload\/[^\/]+?.app\/info.plist$/i; const ProvisionName = /payload\/.+?\.app\/embedded.mobileprovision/; class IpaParser extends Zip { - /** - * parser for parsing .ipa file - * @param {String | File | Blob} file // file's path in Node, instance of File or Blob in Browser - */ - constructor(file) { - super(file); - if (!(this instanceof IpaParser)) { - return new IpaParser(file); - } - } parse() { return new Promise((resolve, reject) => { this.getEntries([PlistName, ProvisionName]) diff --git a/src/utils/app-info-parser/zip.js b/src/utils/app-info-parser/zip.js index e18c28c..f0ec53f 100644 --- a/src/utils/app-info-parser/zip.js +++ b/src/utils/app-info-parser/zip.js @@ -2,7 +2,7 @@ const Unzip = require('isomorphic-unzip'); const { isBrowser, decodeNullUnicode } = require('./utils'); let bundleZipUtils; -class Zip { +export class Zip { constructor(file) { if (isBrowser()) { if (!(file instanceof window.Blob || typeof file.size !== 'undefined')) { @@ -64,5 +64,3 @@ class Zip { } } } - -module.exports = Zip; From 6e0f50effcd8d3b24f603c790733c1738a5ae0f1 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 2 Jan 2026 14:53:47 +0800 Subject: [PATCH 6/8] Refactor app-info-parser: migrate utility functions to TypeScript, remove deprecated utils.js, and enhance AAB and APK parsing with improved error handling and resource mapping. --- src/utils/app-info-parser/aab.ts | 67 ++- src/utils/app-info-parser/apk.ts | 7 +- src/utils/app-info-parser/index.ts | 4 +- src/utils/app-info-parser/{ipa.js => ipa.ts} | 36 +- src/utils/app-info-parser/resource-finder.js | 495 ----------------- src/utils/app-info-parser/resource-finder.ts | 508 ++++++++++++++++++ src/utils/app-info-parser/utils.js | 172 ------ src/utils/app-info-parser/utils.ts | 162 ++++++ .../xml-parser/{binary.js => binary.ts} | 130 ++--- .../xml-parser/{manifest.js => manifest.ts} | 101 ++-- src/utils/app-info-parser/zip.js | 66 --- src/utils/app-info-parser/zip.ts | 86 +++ 12 files changed, 947 insertions(+), 887 deletions(-) rename src/utils/app-info-parser/{ipa.js => ipa.ts} (74%) delete mode 100644 src/utils/app-info-parser/resource-finder.js create mode 100644 src/utils/app-info-parser/resource-finder.ts delete mode 100644 src/utils/app-info-parser/utils.js create mode 100644 src/utils/app-info-parser/utils.ts rename src/utils/app-info-parser/xml-parser/{binary.js => binary.ts} (88%) rename src/utils/app-info-parser/xml-parser/{manifest.js => manifest.ts} (69%) delete mode 100644 src/utils/app-info-parser/zip.js create mode 100644 src/utils/app-info-parser/zip.ts diff --git a/src/utils/app-info-parser/aab.ts b/src/utils/app-info-parser/aab.ts index 86005dd..dc59b72 100644 --- a/src/utils/app-info-parser/aab.ts +++ b/src/utils/app-info-parser/aab.ts @@ -1,9 +1,13 @@ -import fs from 'fs-extra'; -import path from 'path'; +import { spawn } from 'child_process'; import os from 'os'; +import path from 'path'; +import fs from 'fs-extra'; import { open as openZipFile } from 'yauzl'; -import { Zip } from './zip'; import { t } from '../i18n'; +import { ResourceFinder } from './resource-finder'; +import { mapInfoResource } from './utils'; +import { ManifestParser } from './xml-parser/manifest'; +import { Zip } from './zip'; export class AabParser extends Zip { file: string | File; @@ -16,20 +20,40 @@ export class AabParser extends Zip { async extractApk( outputPath: string, { - includeAllSplits = true, + includeAllSplits, splits, }: { includeAllSplits?: boolean; splits?: string[] | null }, ) { - const { exec } = require('child_process'); - const util = require('util'); - const execAsync = util.promisify(exec); const normalizedSplits = Array.isArray(splits) ? splits.map((item) => item.trim()).filter(Boolean) : []; const modules = includeAllSplits ? null : Array.from(new Set(['base', ...normalizedSplits])); - const modulesArg = modules ? ` --modules="${modules.join(',')}"` : ''; + const modulesArgs = modules ? [`--modules=${modules.join(',')}`] : []; + + const runCommand = (command: string, args: string[]) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + stderr.trim() || `Command failed: ${command} (code ${code})`, + ), + ); + }); + }); // Create a temp file for the .apks output const tempDir = os.tmpdir(); @@ -41,14 +65,28 @@ export class AabParser extends Zip { // User might need keystore to sign it properly but for simple extraction we stick to default debug key if possible or unsigned? // actually bundletool build-apks signs with debug key by default if no keystore provided. - let cmd = `bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite${modulesArg}`; try { - await execAsync(cmd); + await runCommand('bundletool', [ + 'build-apks', + '--mode=universal', + `--bundle=${this.file}`, + `--output=${tempApksPath}`, + '--overwrite', + ...modulesArgs, + ]); } catch (e) { // Fallback to npx node-bundletool if bundletool is not in PATH // We use -y to avoid interactive prompt for installation - cmd = `npx -y node-bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite${modulesArg}`; - await execAsync(cmd); + await runCommand('npx', [ + '-y', + 'node-bundletool', + 'build-apks', + '--mode=universal', + `--bundle=${this.file}`, + `--output=${tempApksPath}`, + '--overwrite', + ...modulesArgs, + ]); } // 2. Extract universal.apk from the .apks (zip) file @@ -119,7 +157,6 @@ export class AabParser extends Zip { const resourceBuffer = await this.getEntry(ResourceName); if (resourceBuffer) { const resourceMap = this._parseResourceMap(resourceBuffer as Buffer); - const { mapInfoResource } = require('./utils'); apkInfo = mapInfoResource(apkInfo, resourceMap); } } catch (e: any) { @@ -137,8 +174,7 @@ export class AabParser extends Zip { */ private _parseManifest(buffer: Buffer) { try { - const ManifestXmlParser = require('./xml-parser/manifest'); - const parser = new ManifestXmlParser(buffer, { + const parser = new ManifestParser(buffer, { ignore: [ 'application.activity', 'application.service', @@ -159,7 +195,6 @@ export class AabParser extends Zip { */ private _parseResourceMap(buffer: Buffer) { try { - const ResourceFinder = require('./resource-finder'); return new ResourceFinder().processResourceTable(buffer); } catch (e: any) { throw new Error(t('aabParseResourcesError', { error: e?.message ?? e })); diff --git a/src/utils/app-info-parser/apk.ts b/src/utils/app-info-parser/apk.ts index 7f09322..ec351a0 100644 --- a/src/utils/app-info-parser/apk.ts +++ b/src/utils/app-info-parser/apk.ts @@ -1,12 +1,11 @@ +import { ResourceFinder } from './resource-finder'; import { findApkIconPath, getBase64FromBuffer, mapInfoResource } from './utils'; +import { ManifestParser } from './xml-parser/manifest'; import { Zip } from './zip'; const ManifestName = /^androidmanifest\.xml$/; const ResourceName = /^resources\.arsc$/; -const ManifestXmlParser = require('./xml-parser/manifest'); -const ResourceFinder = require('./resource-finder'); - export class ApkParser extends Zip { parse(): Promise { return new Promise((resolve, reject) => { @@ -61,7 +60,7 @@ export class ApkParser extends Zip { */ private _parseManifest(buffer: Buffer) { try { - const parser = new ManifestXmlParser(buffer, { + const parser = new ManifestParser(buffer, { ignore: [ 'application.activity', 'application.service', diff --git a/src/utils/app-info-parser/index.ts b/src/utils/app-info-parser/index.ts index f4652d4..ae923a9 100644 --- a/src/utils/app-info-parser/index.ts +++ b/src/utils/app-info-parser/index.ts @@ -1,7 +1,7 @@ +import { AabParser } from './aab'; import { ApkParser } from './apk'; -const IpaParser = require('./ipa'); import { AppParser } from './app'; -import { AabParser } from './aab'; +import { IpaParser } from './ipa'; const supportFileTypes = ['ipa', 'apk', 'app', 'aab']; class AppInfoParser { diff --git a/src/utils/app-info-parser/ipa.js b/src/utils/app-info-parser/ipa.ts similarity index 74% rename from src/utils/app-info-parser/ipa.js rename to src/utils/app-info-parser/ipa.ts index 8bb5a8d..973ccde 100644 --- a/src/utils/app-info-parser/ipa.js +++ b/src/utils/app-info-parser/ipa.ts @@ -2,39 +2,37 @@ const parsePlist = require('plist').parse; const parseBplist = require('bplist-parser').parseBuffer; const cgbiToPng = require('cgbi-to-png'); +import { findIpaIconPath, getBase64FromBuffer, isBrowser } from './utils'; import { Zip } from './zip'; -const { findIpaIconPath, getBase64FromBuffer, isBrowser } = require('./utils'); const PlistName = /payload\/[^\/]+?.app\/info.plist$/i; const ProvisionName = /payload\/.+?\.app\/embedded.mobileprovision/; -class IpaParser extends Zip { - parse() { +export class IpaParser extends Zip { + parse(): Promise { return new Promise((resolve, reject) => { this.getEntries([PlistName, ProvisionName]) - .then((buffers) => { - if (!buffers[PlistName]) { + .then((buffers: any) => { + if (!buffers[PlistName as any]) { throw new Error("Info.plist can't be found."); } - const plistInfo = this._parsePlist(buffers[PlistName]); - // parse mobile provision - const provisionInfo = this._parseProvision(buffers[ProvisionName]); + const plistInfo = this._parsePlist(buffers[PlistName as any]); + const provisionInfo = this._parseProvision( + buffers[ProvisionName as any], + ); plistInfo.mobileProvision = provisionInfo; - // find icon path and parse icon const iconRegex = new RegExp( findIpaIconPath(plistInfo).toLowerCase(), ); this.getEntry(iconRegex) - .then((iconBuffer) => { + .then((iconBuffer: any) => { try { - // In general, the ipa file's icon has been specially processed, should be converted plistInfo.icon = iconBuffer ? getBase64FromBuffer(cgbiToPng.revert(iconBuffer)) : null; } catch (err) { if (isBrowser()) { - // Normal conversion in other cases plistInfo.icon = iconBuffer ? getBase64FromBuffer( window.btoa(String.fromCharCode(...iconBuffer)), @@ -47,11 +45,11 @@ class IpaParser extends Zip { } resolve(plistInfo); }) - .catch((e) => { + .catch((e: any) => { reject(e); }); }) - .catch((e) => { + .catch((e: any) => { reject(e); }); }); @@ -60,8 +58,8 @@ class IpaParser extends Zip { * Parse plist * @param {Buffer} buffer // plist file's buffer */ - _parsePlist(buffer) { - let result; + private _parsePlist(buffer: Buffer) { + let result: any; const bufferType = buffer[0]; if (bufferType === 60 || bufferType === '<' || bufferType === 239) { result = parsePlist(buffer.toString()); @@ -76,8 +74,8 @@ class IpaParser extends Zip { * parse provision * @param {Buffer} buffer // provision file's buffer */ - _parseProvision(buffer) { - let info = {}; + private _parseProvision(buffer: Buffer | undefined) { + let info: Record = {}; if (buffer) { let content = buffer.toString('utf-8'); const firstIndex = content.indexOf(' { - var uint8Array = new Uint8Array(len); - for (var i = 0; i < len; i++) { - uint8Array[i] = bb.readUint8(); - } - - return ByteBuffer.wrap(uint8Array, 'binary', true); -}; - -// -/** - * - * @param {ByteBuffer} bb - * @return {Map>} - */ -ResourceFinder.prototype.processResourceTable = function (resourceBuffer) { - const bb = ByteBuffer.wrap(resourceBuffer, 'binary', true); - - // Resource table structure - var type = bb.readShort(), - headerSize = bb.readShort(), - size = bb.readInt(), - packageCount = bb.readInt(), - buffer, - bb2; - if (type != RES_TABLE_TYPE) { - throw new Error('No RES_TABLE_TYPE found!'); - } - if (size != bb.limit) { - throw new Error('The buffer size not matches to the resource table size.'); - } - bb.offset = headerSize; - - var realStringPoolCount = 0, - realPackageCount = 0; - - while (true) { - var pos, t, hs, s; - try { - pos = bb.offset; - t = bb.readShort(); - hs = bb.readShort(); - s = bb.readInt(); - } catch (e) { - break; - } - if (t == RES_STRING_POOL_TYPE) { - // Process the string pool - if (realStringPoolCount == 0) { - // Only the first string pool is processed. - if (DEBUG) { - console.log('Processing the string pool ...'); - } - - buffer = new ByteBuffer(s); - bb.offset = pos; - bb.prependTo(buffer); - - bb2 = ByteBuffer.wrap(buffer, 'binary', true); - - bb2.LE(); - this.valueStringPool = this.processStringPool(bb2); - } - realStringPoolCount++; - } else if (t == RES_TABLE_PACKAGE_TYPE) { - // Process the package - if (DEBUG) { - console.log('Processing the package ' + realPackageCount + ' ...'); - } - - buffer = new ByteBuffer(s); - bb.offset = pos; - bb.prependTo(buffer); - - bb2 = ByteBuffer.wrap(buffer, 'binary', true); - bb2.LE(); - this.processPackage(bb2); - - realPackageCount++; - } else { - throw new Error('Unsupported type'); - } - bb.offset = pos + s; - if (!bb.remaining()) break; - } - - if (realStringPoolCount != 1) { - throw new Error('More than 1 string pool found!'); - } - if (realPackageCount != packageCount) { - throw new Error('Real package count not equals the declared count.'); - } - - return this.responseMap; -}; - -/** - * - * @param {ByteBuffer} bb - */ -ResourceFinder.prototype.processPackage = function (bb) { - // Package structure - var type = bb.readShort(), - headerSize = bb.readShort(), - size = bb.readInt(), - id = bb.readInt(); - - this.package_id = id; - - for (var i = 0; i < 256; ++i) { - bb.readUint8(); - } - - var typeStrings = bb.readInt(), - lastPublicType = bb.readInt(), - keyStrings = bb.readInt(), - lastPublicKey = bb.readInt(); - - if (typeStrings != headerSize) { - throw new Error( - 'TypeStrings must immediately following the package structure header.', - ); - } - - if (DEBUG) { - console.log('Type strings:'); - } - - var lastPosition = bb.offset; - bb.offset = typeStrings; - var bbTypeStrings = ResourceFinder.readBytes(bb, bb.limit - bb.offset); - bb.offset = lastPosition; - this.typeStringPool = this.processStringPool(bbTypeStrings); - - // Key strings - if (DEBUG) { - console.log('Key strings:'); - } - - bb.offset = keyStrings; - var key_type = bb.readShort(), - key_headerSize = bb.readShort(), - key_size = bb.readInt(); - - lastPosition = bb.offset; - bb.offset = keyStrings; - var bbKeyStrings = ResourceFinder.readBytes(bb, bb.limit - bb.offset); - bb.offset = lastPosition; - this.keyStringPool = this.processStringPool(bbKeyStrings); - - // Iterate through all chunks - var typeSpecCount = 0; - var typeCount = 0; - - bb.offset = keyStrings + key_size; - - var bb2; - - while (true) { - var pos = bb.offset; - try { - var t = bb.readShort(); - var hs = bb.readShort(); - var s = bb.readInt(); - } catch (e) { - break; - } - - if (t == RES_TABLE_TYPE_SPEC_TYPE) { - bb.offset = pos; - bb2 = ResourceFinder.readBytes(bb, s); - this.processTypeSpec(bb2); - - typeSpecCount++; - } else if (t == RES_TABLE_TYPE_TYPE) { - bb.offset = pos; - bb2 = ResourceFinder.readBytes(bb, s); - this.processType(bb2); - - typeCount++; - } - - if (s == 0) { - break; - } - - bb.offset = pos + s; - - if (!bb.remaining()) { - break; - } - } -}; - -/** - * - * @param {ByteBuffer} bb - */ -ResourceFinder.prototype.processType = function (bb) { - var type = bb.readShort(), - headerSize = bb.readShort(), - size = bb.readInt(), - id = bb.readByte(), - res0 = bb.readByte(), - res1 = bb.readShort(), - entryCount = bb.readInt(), - entriesStart = bb.readInt(); - - var refKeys = {}; - - var config_size = bb.readInt(); - - // Skip the config data - bb.offset = headerSize; - - if (headerSize + entryCount * 4 != entriesStart) { - throw new Error('HeaderSize, entryCount and entriesStart are not valid.'); - } - - // Start to get entry indices - var entryIndices = new Array(entryCount); - for (var i = 0; i < entryCount; ++i) { - entryIndices[i] = bb.readInt(); - } - - // Get entries - for (var i = 0; i < entryCount; ++i) { - if (entryIndices[i] == -1) continue; - - var resource_id = (this.package_id << 24) | (id << 16) | i; - - var pos = bb.offset, - entry_size, - entry_flag, - entry_key, - value_size, - value_res0, - value_dataType, - value_data; - try { - entry_size = bb.readShort(); - entry_flag = bb.readShort(); - entry_key = bb.readInt(); - } catch (e) { - break; - } - - // Get the value (simple) or map (complex) - - var FLAG_COMPLEX = 0x0001; - if ((entry_flag & FLAG_COMPLEX) == 0) { - // Simple case - value_size = bb.readShort(); - value_res0 = bb.readByte(); - value_dataType = bb.readByte(); - value_data = bb.readInt(); - - var idStr = Number(resource_id).toString(16); - var keyStr = this.keyStringPool[entry_key]; - - var data = null; - - if (DEBUG) { - console.log( - 'Entry 0x' + idStr + ', key: ' + keyStr + ', simple value type: ', - ); - } - - var key = Number.parseInt(idStr, 16); - - var entryArr = this.entryMap[key]; - if (entryArr == null) { - entryArr = []; - } - entryArr.push(keyStr); - - this.entryMap[key] = entryArr; - - if (value_dataType == TYPE_STRING) { - data = this.valueStringPool[value_data]; - - if (DEBUG) { - console.log(', data: ' + this.valueStringPool[value_data] + ''); - } - } else if (value_dataType == TYPE_REFERENCE) { - var hexIndex = Number(value_data).toString(16); - - refKeys[idStr] = value_data; - } else { - data = '' + value_data; - if (DEBUG) { - console.log(', data: ' + value_data + ''); - } - } - - this.putIntoMap('@' + idStr, data); - } else { - // Complex case - var entry_parent = bb.readInt(); - var entry_count = bb.readInt(); - - for (var j = 0; j < entry_count; ++j) { - var ref_name = bb.readInt(); - value_size = bb.readShort(); - value_res0 = bb.readByte(); - value_dataType = bb.readByte(); - value_data = bb.readInt(); - } - - if (DEBUG) { - console.log( - 'Entry 0x' + - Number(resource_id).toString(16) + - ', key: ' + - this.keyStringPool[entry_key] + - ', complex value, not printed.', - ); - } - } - } - - for (var refK in refKeys) { - var values = - this.responseMap['@' + Number(refKeys[refK]).toString(16).toUpperCase()]; - if (values != null && Object.keys(values).length < 1000) { - for (var value in values) { - this.putIntoMap('@' + refK, values[value]); - } - } - } -}; - -/** - * - * @param {ByteBuffer} bb - * @return {Array} - */ -ResourceFinder.prototype.processStringPool = (bb) => { - // String pool structure - // - var type = bb.readShort(), - headerSize = bb.readShort(), - size = bb.readInt(), - stringCount = bb.readInt(), - styleCount = bb.readInt(), - flags = bb.readInt(), - stringsStart = bb.readInt(), - stylesStart = bb.readInt(), - u16len, - buffer; - - var isUTF_8 = (flags & 256) != 0; - - var offsets = new Array(stringCount); - for (var i = 0; i < stringCount; ++i) { - offsets[i] = bb.readInt(); - } - - var strings = new Array(stringCount); - - for (var i = 0; i < stringCount; ++i) { - var pos = stringsStart + offsets[i]; - bb.offset = pos; - - strings[i] = ''; - - if (isUTF_8) { - u16len = bb.readUint8(); - - if ((u16len & 0x80) != 0) { - u16len = ((u16len & 0x7f) << 8) + bb.readUint8(); - } - - var u8len = bb.readUint8(); - if ((u8len & 0x80) != 0) { - u8len = ((u8len & 0x7f) << 8) + bb.readUint8(); - } - - if (u8len > 0) { - buffer = ResourceFinder.readBytes(bb, u8len); - try { - strings[i] = ByteBuffer.wrap(buffer, 'utf8', true).toString('utf8'); - } catch (e) { - if (DEBUG) { - console.error(e); - console.log('Error when turning buffer to utf-8 string.'); - } - } - } else { - strings[i] = ''; - } - } else { - u16len = bb.readUint16(); - if ((u16len & 0x8000) != 0) { - // larger than 32768 - u16len = ((u16len & 0x7fff) << 16) + bb.readUint16(); - } - - if (u16len > 0) { - var len = u16len * 2; - buffer = ResourceFinder.readBytes(bb, len); - try { - strings[i] = ByteBuffer.wrap(buffer, 'utf8', true).toString('utf8'); - } catch (e) { - if (DEBUG) { - console.error(e); - console.log('Error when turning buffer to utf-8 string.'); - } - } - } - } - - if (DEBUG) { - console.log('Parsed value: {0}', strings[i]); - } - } - - return strings; -}; - -/** - * - * @param {ByteBuffer} bb - */ -ResourceFinder.prototype.processTypeSpec = function (bb) { - var type = bb.readShort(), - headerSize = bb.readShort(), - size = bb.readInt(), - id = bb.readByte(), - res0 = bb.readByte(), - res1 = bb.readShort(), - entryCount = bb.readInt(); - - if (DEBUG) { - console.log('Processing type spec ' + this.typeStringPool[id - 1] + '...'); - } - - var flags = new Array(entryCount); - - for (var i = 0; i < entryCount; ++i) { - flags[i] = bb.readInt(); - } -}; - -ResourceFinder.prototype.putIntoMap = function (resId, value) { - if (this.responseMap[resId.toUpperCase()] == null) { - this.responseMap[resId.toUpperCase()] = []; - } - if (value) { - this.responseMap[resId.toUpperCase()].push(value); - } -}; - -module.exports = ResourceFinder; diff --git a/src/utils/app-info-parser/resource-finder.ts b/src/utils/app-info-parser/resource-finder.ts new file mode 100644 index 0000000..fcfc465 --- /dev/null +++ b/src/utils/app-info-parser/resource-finder.ts @@ -0,0 +1,508 @@ +/** + * Code translated from a C# project https://github.com/hylander0/Iteedee.ApkReader/blob/master/Iteedee.ApkReader/ApkResourceFinder.cs + * + * Decode binary file `resources.arsc` from a .apk file to a JavaScript Object. + */ + +const ByteBuffer = require('bytebuffer'); + +const DEBUG = false; + +const RES_STRING_POOL_TYPE = 0x0001; +const RES_TABLE_TYPE = 0x0002; +const RES_TABLE_PACKAGE_TYPE = 0x0200; +const RES_TABLE_TYPE_TYPE = 0x0201; +const RES_TABLE_TYPE_SPEC_TYPE = 0x0202; + +const TYPE_REFERENCE = 0x01; +const TYPE_STRING = 0x03; + +export class ResourceFinder { + private valueStringPool: string[] | null = null; + private typeStringPool: string[] | null = null; + private keyStringPool: string[] | null = null; + + private packageId = 0; + + private responseMap: Record = {}; + private entryMap: Record = {}; + + /** + * Same to C# BinaryReader.readBytes + * + * @param bb ByteBuffer + * @param len length + * @returns {Buffer} + */ + static readBytes(bb: any, len: number) { + const uint8Array = new Uint8Array(len); + for (let i = 0; i < len; i++) { + uint8Array[i] = bb.readUint8(); + } + + return ByteBuffer.wrap(uint8Array, 'binary', true); + } + + /** + * + * @param {ByteBuffer} bb + * @return {Map>} + */ + processResourceTable(resourceBuffer: Buffer) { + const bb = ByteBuffer.wrap(resourceBuffer, 'binary', true); + + const type = bb.readShort(); + const headerSize = bb.readShort(); + const size = bb.readInt(); + const packageCount = bb.readInt(); + let buffer: any; + let bb2: any; + + if (type !== RES_TABLE_TYPE) { + throw new Error('No RES_TABLE_TYPE found!'); + } + if (size !== bb.limit) { + throw new Error( + 'The buffer size not matches to the resource table size.', + ); + } + bb.offset = headerSize; + + let realStringPoolCount = 0; + let realPackageCount = 0; + + while (true) { + let pos = 0; + let t = 0; + let hs = 0; + let s = 0; + try { + pos = bb.offset; + t = bb.readShort(); + hs = bb.readShort(); + s = bb.readInt(); + } catch (e) { + break; + } + if (t === RES_STRING_POOL_TYPE) { + if (realStringPoolCount === 0) { + if (DEBUG) { + console.log('Processing the string pool ...'); + } + + buffer = new ByteBuffer(s); + bb.offset = pos; + bb.prependTo(buffer); + + bb2 = ByteBuffer.wrap(buffer, 'binary', true); + + bb2.LE(); + this.valueStringPool = this.processStringPool(bb2); + } + realStringPoolCount++; + } else if (t === RES_TABLE_PACKAGE_TYPE) { + if (DEBUG) { + console.log(`Processing the package ${realPackageCount} ...`); + } + + buffer = new ByteBuffer(s); + bb.offset = pos; + bb.prependTo(buffer); + + bb2 = ByteBuffer.wrap(buffer, 'binary', true); + bb2.LE(); + this.processPackage(bb2); + + realPackageCount++; + } else { + throw new Error('Unsupported type'); + } + bb.offset = pos + s; + if (!bb.remaining()) break; + } + + if (realStringPoolCount !== 1) { + throw new Error('More than 1 string pool found!'); + } + if (realPackageCount !== packageCount) { + throw new Error('Real package count not equals the declared count.'); + } + + return this.responseMap; + } + + /** + * + * @param {ByteBuffer} bb + */ + private processPackage(bb: any) { + const type = bb.readShort(); + const headerSize = bb.readShort(); + const size = bb.readInt(); + const id = bb.readInt(); + + void type; + void size; + + this.packageId = id; + + for (let i = 0; i < 256; ++i) { + bb.readUint8(); + } + + const typeStrings = bb.readInt(); + const lastPublicType = bb.readInt(); + const keyStrings = bb.readInt(); + const lastPublicKey = bb.readInt(); + + void lastPublicType; + void lastPublicKey; + + if (typeStrings !== headerSize) { + throw new Error( + 'TypeStrings must immediately following the package structure header.', + ); + } + + if (DEBUG) { + console.log('Type strings:'); + } + + let lastPosition = bb.offset; + bb.offset = typeStrings; + const bbTypeStrings = ResourceFinder.readBytes(bb, bb.limit - bb.offset); + bb.offset = lastPosition; + this.typeStringPool = this.processStringPool(bbTypeStrings); + + if (DEBUG) { + console.log('Key strings:'); + } + + bb.offset = keyStrings; + const keyType = bb.readShort(); + const keyHeaderSize = bb.readShort(); + const keySize = bb.readInt(); + + void keyType; + void keyHeaderSize; + + lastPosition = bb.offset; + bb.offset = keyStrings; + const bbKeyStrings = ResourceFinder.readBytes(bb, bb.limit - bb.offset); + bb.offset = lastPosition; + this.keyStringPool = this.processStringPool(bbKeyStrings); + + let typeSpecCount = 0; + let typeCount = 0; + + bb.offset = keyStrings + keySize; + + let bb2: any; + + while (true) { + const pos = bb.offset; + try { + const t = bb.readShort(); + const hs = bb.readShort(); + const s = bb.readInt(); + + void hs; + + if (t === RES_TABLE_TYPE_SPEC_TYPE) { + bb.offset = pos; + bb2 = ResourceFinder.readBytes(bb, s); + this.processTypeSpec(bb2); + + typeSpecCount++; + } else if (t === RES_TABLE_TYPE_TYPE) { + bb.offset = pos; + bb2 = ResourceFinder.readBytes(bb, s); + this.processType(bb2); + + typeCount++; + } + + if (s === 0) { + break; + } + + bb.offset = pos + s; + + if (!bb.remaining()) { + break; + } + } catch (e) { + break; + } + } + + void typeSpecCount; + void typeCount; + } + + /** + * + * @param {ByteBuffer} bb + */ + private processType(bb: any) { + const type = bb.readShort(); + const headerSize = bb.readShort(); + const size = bb.readInt(); + const id = bb.readByte(); + const res0 = bb.readByte(); + const res1 = bb.readShort(); + const entryCount = bb.readInt(); + const entriesStart = bb.readInt(); + + void type; + void size; + void res0; + void res1; + + const refKeys: Record = {}; + + const configSize = bb.readInt(); + + void configSize; + + bb.offset = headerSize; + + if (headerSize + entryCount * 4 !== entriesStart) { + throw new Error('HeaderSize, entryCount and entriesStart are not valid.'); + } + + const entryIndices = new Array(entryCount); + for (let i = 0; i < entryCount; ++i) { + entryIndices[i] = bb.readInt(); + } + + for (let i = 0; i < entryCount; ++i) { + if (entryIndices[i] === -1) continue; + + const resourceId = (this.packageId << 24) | (id << 16) | i; + + let entrySize = 0; + let entryFlag = 0; + let entryKey = 0; + try { + entrySize = bb.readShort(); + entryFlag = bb.readShort(); + entryKey = bb.readInt(); + } catch (e) { + break; + } + + void entrySize; + + const FLAG_COMPLEX = 0x0001; + if ((entryFlag & FLAG_COMPLEX) === 0) { + const valueSize = bb.readShort(); + const valueRes0 = bb.readByte(); + const valueDataType = bb.readByte(); + const valueData = bb.readInt(); + + void valueSize; + void valueRes0; + + const idStr = Number(resourceId).toString(16); + const keyStr = this.keyStringPool ? this.keyStringPool[entryKey] : ''; + + let data: string | null = null; + + if (DEBUG) { + console.log(`Entry 0x${idStr}, key: ${keyStr}, simple value type: `); + } + + const key = Number.parseInt(idStr, 16); + + const entryArr = this.entryMap[key] ?? []; + entryArr.push(keyStr); + this.entryMap[key] = entryArr; + + if (valueDataType === TYPE_STRING) { + data = this.valueStringPool ? this.valueStringPool[valueData] : null; + + if (DEBUG && this.valueStringPool) { + console.log(`, data: ${this.valueStringPool[valueData]}`); + } + } else if (valueDataType === TYPE_REFERENCE) { + refKeys[idStr] = valueData; + } else { + data = `${valueData}`; + if (DEBUG) { + console.log(`, data: ${valueData}`); + } + } + + this.putIntoMap(`@${idStr}`, data); + } else { + const entryParent = bb.readInt(); + const entryCountValue = bb.readInt(); + + void entryParent; + + for (let j = 0; j < entryCountValue; ++j) { + const refName = bb.readInt(); + const valueSize = bb.readShort(); + const valueRes0 = bb.readByte(); + const valueDataType = bb.readByte(); + const valueData = bb.readInt(); + + void refName; + void valueSize; + void valueRes0; + void valueDataType; + void valueData; + } + + if (DEBUG) { + const keyStr = this.keyStringPool ? this.keyStringPool[entryKey] : ''; + console.log( + `Entry 0x${Number(resourceId).toString(16)}, key: ${keyStr}, complex value, not printed.`, + ); + } + } + } + + for (const refKey in refKeys) { + const values = + this.responseMap[ + `@${Number(refKeys[refKey]).toString(16).toUpperCase()}` + ]; + if (values != null && Object.keys(values).length < 1000) { + for (const value of values) { + this.putIntoMap(`@${refKey}`, value); + } + } + } + } + + /** + * + * @param {ByteBuffer} bb + * @return {Array} + */ + private processStringPool(bb: any) { + const type = bb.readShort(); + const headerSize = bb.readShort(); + const size = bb.readInt(); + const stringCount = bb.readInt(); + const styleCount = bb.readInt(); + const flags = bb.readInt(); + const stringsStart = bb.readInt(); + const stylesStart = bb.readInt(); + + void type; + void headerSize; + void size; + void styleCount; + void stylesStart; + + const isUtf8 = (flags & 256) !== 0; + + const offsets = new Array(stringCount); + for (let i = 0; i < stringCount; ++i) { + offsets[i] = bb.readInt(); + } + + const strings = new Array(stringCount); + + for (let i = 0; i < stringCount; ++i) { + const pos = stringsStart + offsets[i]; + bb.offset = pos; + + strings[i] = ''; + + if (isUtf8) { + let u16len = bb.readUint8(); + + if ((u16len & 0x80) !== 0) { + u16len = ((u16len & 0x7f) << 8) + bb.readUint8(); + } + + let u8len = bb.readUint8(); + if ((u8len & 0x80) !== 0) { + u8len = ((u8len & 0x7f) << 8) + bb.readUint8(); + } + + if (u8len > 0) { + const buffer = ResourceFinder.readBytes(bb, u8len); + try { + strings[i] = ByteBuffer.wrap(buffer, 'utf8', true).toString('utf8'); + } catch (e) { + if (DEBUG) { + console.error(e); + console.log('Error when turning buffer to utf-8 string.'); + } + } + } else { + strings[i] = ''; + } + } else { + let u16len = bb.readUint16(); + if ((u16len & 0x8000) !== 0) { + u16len = ((u16len & 0x7fff) << 16) + bb.readUint16(); + } + + if (u16len > 0) { + const len = u16len * 2; + const buffer = ResourceFinder.readBytes(bb, len); + try { + strings[i] = ByteBuffer.wrap(buffer, 'utf8', true).toString('utf8'); + } catch (e) { + if (DEBUG) { + console.error(e); + console.log('Error when turning buffer to utf-8 string.'); + } + } + } + } + + if (DEBUG) { + console.log('Parsed value: {0}', strings[i]); + } + } + + return strings; + } + + /** + * + * @param {ByteBuffer} bb + */ + private processTypeSpec(bb: any) { + const type = bb.readShort(); + const headerSize = bb.readShort(); + const size = bb.readInt(); + const id = bb.readByte(); + const res0 = bb.readByte(); + const res1 = bb.readShort(); + const entryCount = bb.readInt(); + + void type; + void headerSize; + void size; + void res0; + void res1; + + if (DEBUG && this.typeStringPool) { + console.log(`Processing type spec ${this.typeStringPool[id - 1]}...`); + } + + const flags = new Array(entryCount); + + for (let i = 0; i < entryCount; ++i) { + flags[i] = bb.readInt(); + } + } + + private putIntoMap(resId: string, value: any) { + const key = resId.toUpperCase(); + if (this.responseMap[key] == null) { + this.responseMap[key] = []; + } + if (value) { + this.responseMap[key].push(value); + } + } +} diff --git a/src/utils/app-info-parser/utils.js b/src/utils/app-info-parser/utils.js deleted file mode 100644 index d85bfc5..0000000 --- a/src/utils/app-info-parser/utils.js +++ /dev/null @@ -1,172 +0,0 @@ -function objectType(o) { - return Object.prototype.toString.call(o).slice(8, -1).toLowerCase(); -} - -function isArray(o) { - return objectType(o) === 'array'; -} - -function isObject(o) { - return objectType(o) === 'object'; -} - -function isPrimitive(o) { - return ( - o === null || - ['boolean', 'number', 'string', 'undefined'].includes(objectType(o)) - ); -} - -function isBrowser() { - return ( - typeof process === 'undefined' || - Object.prototype.toString.call(process) !== '[object process]' - ); -} - -/** - * map file place with resourceMap - * @param {Object} apkInfo // json info parsed from .apk file - * @param {Object} resourceMap // resourceMap - */ -function mapInfoResource(apkInfo, resourceMap) { - iteratorObj(apkInfo); - return apkInfo; - function iteratorObj(obj) { - for (const i in obj) { - if (isArray(obj[i])) { - iteratorArray(obj[i]); - } else if (isObject(obj[i])) { - iteratorObj(obj[i]); - } else if (isPrimitive(obj[i])) { - if (isResources(obj[i])) { - obj[i] = resourceMap[transKeyToMatchResourceMap(obj[i])]; - } - } - } - } - - function iteratorArray(array) { - const l = array.length; - for (let i = 0; i < l; i++) { - if (isArray(array[i])) { - iteratorArray(array[i]); - } else if (isObject(array[i])) { - iteratorObj(array[i]); - } else if (isPrimitive(array[i])) { - if (isResources(array[i])) { - array[i] = resourceMap[transKeyToMatchResourceMap(array[i])]; - } - } - } - } - - function isResources(attrValue) { - if (!attrValue) return false; - if (typeof attrValue !== 'string') { - attrValue = attrValue.toString(); - } - return attrValue.indexOf('resourceId:') === 0; - } - - function transKeyToMatchResourceMap(resourceId) { - return '@' + resourceId.replace('resourceId:0x', '').toUpperCase(); - } -} - -/** - * find .apk file's icon path from json info - * @param info // json info parsed from .apk file - */ -function findApkIconPath(info) { - if (!info.application.icon || !info.application.icon.splice) { - return ''; - } - const rulesMap = { - mdpi: 48, - hdpi: 72, - xhdpi: 96, - xxdpi: 144, - xxxhdpi: 192, - }; - const resultMap = {}; - const maxDpiIcon = { dpi: 120, icon: '' }; - - for (const i in rulesMap) { - info.application.icon.some((icon) => { - if (icon && icon.indexOf(i) !== -1) { - resultMap['application-icon-' + rulesMap[i]] = icon; - return true; - } - }); - - // get the maximal size icon - if ( - resultMap['application-icon-' + rulesMap[i]] && - rulesMap[i] >= maxDpiIcon.dpi - ) { - maxDpiIcon.dpi = rulesMap[i]; - maxDpiIcon.icon = resultMap['application-icon-' + rulesMap[i]]; - } - } - - if (Object.keys(resultMap).length === 0 || !maxDpiIcon.icon) { - maxDpiIcon.dpi = 120; - maxDpiIcon.icon = info.application.icon[0] || ''; - resultMap['applicataion-icon-120'] = maxDpiIcon.icon; - } - return maxDpiIcon.icon; -} - -/** - * find .ipa file's icon path from json info - * @param info // json info parsed from .ipa file - */ -function findIpaIconPath(info) { - if ( - info.CFBundleIcons && - info.CFBundleIcons.CFBundlePrimaryIcon && - info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles && - info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles.length - ) { - return info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles[ - info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles.length - 1 - ]; - } else if (info.CFBundleIconFiles && info.CFBundleIconFiles.length) { - return info.CFBundleIconFiles[info.CFBundleIconFiles.length - 1]; - } else { - return '.app/Icon.png'; - } -} - -/** - * transform buffer to base64 - * @param {Buffer} buffer - */ -function getBase64FromBuffer(buffer) { - return 'data:image/png;base64,' + buffer.toString('base64'); -} - -/** - * 去除unicode空字符 - * @param {String} str - */ -function decodeNullUnicode(str) { - if (typeof str === 'string') { - // eslint-disable-next-line - str = str.replace(/\u0000/g, ''); - } - return str; -} - -module.exports = { - isArray, - isObject, - isPrimitive, - isBrowser, - mapInfoResource, - findApkIconPath, - findIpaIconPath, - getBase64FromBuffer, - decodeNullUnicode, -}; diff --git a/src/utils/app-info-parser/utils.ts b/src/utils/app-info-parser/utils.ts new file mode 100644 index 0000000..73ef96f --- /dev/null +++ b/src/utils/app-info-parser/utils.ts @@ -0,0 +1,162 @@ +const objectType = (value: unknown): string => + Object.prototype.toString.call(value).slice(8, -1).toLowerCase(); + +const isArray = (value: unknown): value is unknown[] => + objectType(value) === 'array'; + +const isObject = (value: unknown): value is Record => + objectType(value) === 'object'; + +const isPrimitive = (value: unknown): boolean => + value === null || + ['boolean', 'number', 'string', 'undefined'].includes(objectType(value)); + +const isBrowser = (): boolean => + typeof process === 'undefined' || + Object.prototype.toString.call(process) !== '[object process]'; + +/** + * map file place with resourceMap + * @param {Object} apkInfo // json info parsed from .apk file + * @param {Object} resourceMap // resourceMap + */ +const mapInfoResource = ( + apkInfo: Record, + resourceMap: Record, +) => { + const iteratorObj = (obj: Record) => { + for (const i in obj) { + if (isArray(obj[i])) { + iteratorArray(obj[i] as any[]); + } else if (isObject(obj[i])) { + iteratorObj(obj[i] as Record); + } else if (isPrimitive(obj[i])) { + if (isResources(obj[i])) { + obj[i] = resourceMap[transKeyToMatchResourceMap(obj[i])]; + } + } + } + }; + + const iteratorArray = (array: any[]) => { + const l = array.length; + for (let i = 0; i < l; i++) { + if (isArray(array[i])) { + iteratorArray(array[i] as any[]); + } else if (isObject(array[i])) { + iteratorObj(array[i] as Record); + } else if (isPrimitive(array[i])) { + if (isResources(array[i])) { + array[i] = resourceMap[transKeyToMatchResourceMap(array[i])]; + } + } + } + }; + + const isResources = (attrValue: unknown) => { + if (!attrValue) return false; + const value = + typeof attrValue === 'string' ? attrValue : attrValue.toString(); + return value.indexOf('resourceId:') === 0; + }; + + const transKeyToMatchResourceMap = (resourceId: string) => + `@${resourceId.replace('resourceId:0x', '').toUpperCase()}`; + + iteratorObj(apkInfo); + return apkInfo; +}; + +/** + * find .apk file's icon path from json info + * @param info // json info parsed from .apk file + */ +const findApkIconPath = (info: any) => { + if (!info.application.icon || !info.application.icon.splice) { + return ''; + } + const rulesMap: Record = { + mdpi: 48, + hdpi: 72, + xhdpi: 96, + xxdpi: 144, + xxxhdpi: 192, + }; + const resultMap: Record = {}; + const maxDpiIcon = { dpi: 120, icon: '' }; + + for (const i in rulesMap) { + info.application.icon.some((icon: string) => { + if (icon && icon.indexOf(i) !== -1) { + resultMap[`application-icon-${rulesMap[i]}`] = icon; + return true; + } + return false; + }); + + if ( + resultMap[`application-icon-${rulesMap[i]}`] && + rulesMap[i] >= maxDpiIcon.dpi + ) { + maxDpiIcon.dpi = rulesMap[i]; + maxDpiIcon.icon = resultMap[`application-icon-${rulesMap[i]}`]; + } + } + + if (Object.keys(resultMap).length === 0 || !maxDpiIcon.icon) { + maxDpiIcon.dpi = 120; + maxDpiIcon.icon = info.application.icon[0] || ''; + resultMap['applicataion-icon-120'] = maxDpiIcon.icon; + } + return maxDpiIcon.icon; +}; + +/** + * find .ipa file's icon path from json info + * @param info // json info parsed from .ipa file + */ +const findIpaIconPath = (info: any) => { + if (info.CFBundleIcons?.CFBundlePrimaryIcon?.CFBundleIconFiles?.length) { + return info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles[ + info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles.length - 1 + ]; + } + if (info.CFBundleIconFiles?.length) { + return info.CFBundleIconFiles[info.CFBundleIconFiles.length - 1]; + } + return '.app/Icon.png'; +}; + +/** + * transform buffer to base64 + * @param {Buffer} buffer + */ +const getBase64FromBuffer = (buffer: Buffer | string) => { + const base64 = + typeof buffer === 'string' ? buffer : buffer.toString('base64'); + return `data:image/png;base64,${base64}`; +}; + +/** + * 去除unicode空字符 + * @param {String} str + */ +const decodeNullUnicode = (value: string | RegExp) => { + if (typeof value === 'string') { + // biome-ignore lint/suspicious/noControlCharactersInRegex: + return value.replace(/\u0000/g, ''); + } + return value; +}; + +export { + isArray, + isObject, + isPrimitive, + isBrowser, + mapInfoResource, + findApkIconPath, + findIpaIconPath, + getBase64FromBuffer, + decodeNullUnicode, +}; diff --git a/src/utils/app-info-parser/xml-parser/binary.js b/src/utils/app-info-parser/xml-parser/binary.ts similarity index 88% rename from src/utils/app-info-parser/xml-parser/binary.js rename to src/utils/app-info-parser/xml-parser/binary.ts index d8f2b86..086b418 100644 --- a/src/utils/app-info-parser/xml-parser/binary.js +++ b/src/utils/app-info-parser/xml-parser/binary.ts @@ -70,8 +70,21 @@ const TypedValue = { TYPE_STRING: 0x00000003, }; -class BinaryXmlParser { - constructor(buffer, options = {}) { +type BinaryXmlParserOptions = { + debug?: boolean; +}; + +export class BinaryXmlParser { + buffer: Buffer; + cursor = 0; + strings: string[] = []; + resources: number[] = []; + document: any = null; + parent: any = null; + stack: any[] = []; + debug: boolean; + + constructor(buffer: Buffer, options: BinaryXmlParserOptions = {}) { this.buffer = buffer; this.cursor = 0; this.strings = []; @@ -149,14 +162,18 @@ class BinaryXmlParser { readDimension() { this.debug && console.group('readDimension'); - const dimension = { + const dimension: { + value: number | null; + unit: string | null; + rawUnit: number | null; + } = { value: null, unit: null, rawUnit: null, }; const value = this.readU32(); - const unit = dimension.value & 0xff; + const unit = (dimension.value ?? 0) & 0xff; dimension.value = value >> 8; dimension.rawUnit = unit; @@ -190,7 +207,11 @@ class BinaryXmlParser { readFraction() { this.debug && console.group('readFraction'); - const fraction = { + const fraction: { + value: number | null; + type: string | null; + rawType: number | null; + } = { value: null, type: null, rawType: null, @@ -218,14 +239,14 @@ class BinaryXmlParser { readHex24() { this.debug && console.group('readHex24'); - var val = (this.readU32() & 0xffffff).toString(16); + const val = (this.readU32() & 0xffffff).toString(16); this.debug && console.groupEnd(); return val; } readHex32() { this.debug && console.group('readHex32'); - var val = this.readU32().toString(16); + const val = this.readU32().toString(16); this.debug && console.groupEnd(); return val; } @@ -233,7 +254,11 @@ class BinaryXmlParser { readTypedValue() { this.debug && console.group('readTypedValue'); - const typedValue = { + const typedValue: { + value: any; + type: string | null; + rawType: number | null; + } = { value: null, type: null, rawType: null, @@ -245,7 +270,6 @@ class BinaryXmlParser { /* const zero = */ this.readU8(); const dataType = this.readU8(); - // Yes, there has been a real world APK where the size is malformed. if (size === 0) { size = 8; } @@ -261,16 +285,18 @@ class BinaryXmlParser { typedValue.value = this.readS32(); typedValue.type = 'int_hex'; break; - case TypedValue.TYPE_STRING: - var ref = this.readS32(); + case TypedValue.TYPE_STRING: { + const ref = this.readS32(); typedValue.value = ref > 0 ? this.strings[ref] : ''; typedValue.type = 'string'; break; - case TypedValue.TYPE_REFERENCE: - var id = this.readU32(); + } + case TypedValue.TYPE_REFERENCE: { + const id = this.readU32(); typedValue.value = `resourceId:0x${id.toString(16)}`; typedValue.type = 'reference'; break; + } case TypedValue.TYPE_INT_BOOLEAN: typedValue.value = this.readS32() !== 0; typedValue.type = 'boolean'; @@ -314,7 +340,6 @@ class BinaryXmlParser { } } - // Ensure we consume the whole value const end = start + size; if (this.cursor !== end) { const type = dataType.toString(16); @@ -331,21 +356,21 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); } // https://twitter.com/kawasima/status/427730289201139712 - convertIntToFloat(int) { + convertIntToFloat(int: number) { const buf = new ArrayBuffer(4); new Int32Array(buf)[0] = int; return new Float32Array(buf)[0]; } - readString(encoding) { + readString(encoding: string) { this.debug && console.group('readString', encoding); switch (encoding) { - case 'utf-8': - var stringLength = this.readLength8(encoding); + case 'utf-8': { + const stringLength = this.readLength8(); this.debug && console.debug('stringLength:', stringLength); - var byteLength = this.readLength8(encoding); + const byteLength = this.readLength8(); this.debug && console.debug('byteLength:', byteLength); - var value = this.buffer.toString( + const value = this.buffer.toString( encoding, this.cursor, (this.cursor += byteLength), @@ -353,12 +378,13 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); this.debug && console.debug('value:', value); this.debug && console.groupEnd(); return value; - case 'ucs2': - stringLength = this.readLength16(encoding); + } + case 'ucs2': { + const stringLength = this.readLength16(); this.debug && console.debug('stringLength:', stringLength); - byteLength = stringLength * 2; + const byteLength = stringLength * 2; this.debug && console.debug('byteLength:', byteLength); - value = this.buffer.toString( + const value = this.buffer.toString( encoding, this.cursor, (this.cursor += byteLength), @@ -366,6 +392,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); this.debug && console.debug('value:', value); this.debug && console.groupEnd(); return value; + } default: throw new Error(`Unsupported encoding '${encoding}'`); } @@ -373,7 +400,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); readChunkHeader() { this.debug && console.group('readChunkHeader'); - var header = { + const header = { startOffset: this.cursor, chunkType: this.readU16(), headerSize: this.readU16(), @@ -387,7 +414,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); return header; } - readStringPool(header) { + readStringPool(header: any) { this.debug && console.group('readStringPool'); header.stringCount = this.readU32(); @@ -405,7 +432,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); throw new Error('Invalid string pool header'); } - const offsets = []; + const offsets: number[] = []; for (let i = 0, l = header.stringCount; i < l; ++i) { this.debug && console.debug('offset:', i); offsets.push(this.readU32()); @@ -426,7 +453,6 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); this.strings.push(this.readString(encoding)); } - // Skip styles this.cursor = header.startOffset + header.chunkSize; this.debug && console.groupEnd(); @@ -434,7 +460,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); return null; } - readResourceMap(header) { + readResourceMap(header: any) { this.debug && console.group('readResourceMap'); const count = Math.floor((header.chunkSize - header.headerSize) / 4); for (let i = 0; i < count; ++i) { @@ -444,7 +470,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); return null; } - readXmlNamespaceStart(/* header */) { + readXmlNamespaceStart() { this.debug && console.group('readXmlNamespaceStart'); /* const line = */ this.readU32(); @@ -452,18 +478,12 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); /* const prefixRef = */ this.readS32(); /* const uriRef = */ this.readS32(); - // We don't currently care about the values, but they could - // be accessed like so: - // - // namespaceURI.prefix = this.strings[prefixRef] // if prefixRef > 0 - // namespaceURI.uri = this.strings[uriRef] // if uriRef > 0 - this.debug && console.groupEnd(); return null; } - readXmlNamespaceEnd(/* header */) { + readXmlNamespaceEnd() { this.debug && console.group('readXmlNamespaceEnd'); /* const line = */ this.readU32(); @@ -471,21 +491,15 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); /* const prefixRef = */ this.readS32(); /* const uriRef = */ this.readS32(); - // We don't currently care about the values, but they could - // be accessed like so: - // - // namespaceURI.prefix = this.strings[prefixRef] // if prefixRef > 0 - // namespaceURI.uri = this.strings[uriRef] // if uriRef > 0 - this.debug && console.groupEnd(); return null; } - readXmlElementStart(/* header */) { + readXmlElementStart() { this.debug && console.group('readXmlElementStart'); - const node = { + const node: any = { namespaceURI: null, nodeType: NodeType.ELEMENT_NODE, nodeName: null, @@ -532,7 +546,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); readXmlAttribute() { this.debug && console.group('readXmlAttribute'); - const attr = { + const attr: any = { namespaceURI: null, nodeType: NodeType.ATTRIBUTE_NODE, nodeName: null, @@ -552,10 +566,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); attr.nodeName = attr.name = this.strings[nameRef]; if (valueRef > 0) { - // some apk have versionName with special characters if (attr.name === 'versionName') { - // only keep printable characters - // https://www.ascii-code.com/characters/printable-characters this.strings[valueRef] = this.strings[valueRef].replace( /[^\x21-\x7E]/g, '', @@ -571,7 +582,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); return attr; } - readXmlElementEnd(/* header */) { + readXmlElementEnd() { this.debug && console.group('readXmlCData'); /* const line = */ this.readU32(); @@ -587,10 +598,10 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); return null; } - readXmlCData(/* header */) { + readXmlCData() { this.debug && console.group('readXmlCData'); - const cdata = { + const cdata: any = { namespaceURI: null, nodeType: NodeType.CDATA_SECTION_NODE, nodeName: '#cdata', @@ -615,7 +626,7 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); return cdata; } - readNull(header) { + readNull(header: any) { this.debug && console.group('readNull'); this.cursor += header.chunkSize - header.headerSize; this.debug && console.groupEnd(); @@ -642,19 +653,19 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); this.readResourceMap(header); break; case ChunkType.XML_START_NAMESPACE: - this.readXmlNamespaceStart(header); + this.readXmlNamespaceStart(); break; case ChunkType.XML_END_NAMESPACE: - this.readXmlNamespaceEnd(header); + this.readXmlNamespaceEnd(); break; case ChunkType.XML_START_ELEMENT: - this.readXmlElementStart(header); + this.readXmlElementStart(); break; case ChunkType.XML_END_ELEMENT: - this.readXmlElementEnd(header); + this.readXmlElementEnd(); break; case ChunkType.XML_CDATA: - this.readXmlCData(header); + this.readXmlCData(); break; case ChunkType.NULL: this.readNull(header); @@ -663,7 +674,6 @@ and is supposed to end at offset ${end}. Ignoring the rest of the value.`); throw new Error(`Unsupported chunk type '${header.chunkType}'`); } - // Ensure we consume the whole chunk const end = start + header.chunkSize; if (this.cursor !== end) { const diff = end - this.cursor; @@ -682,5 +692,3 @@ supposed to end at offset ${end}. Ignoring the rest of the chunk.`); return this.document; } } - -module.exports = BinaryXmlParser; diff --git a/src/utils/app-info-parser/xml-parser/manifest.js b/src/utils/app-info-parser/xml-parser/manifest.ts similarity index 69% rename from src/utils/app-info-parser/xml-parser/manifest.js rename to src/utils/app-info-parser/xml-parser/manifest.ts index 1a74c8e..5914ccf 100644 --- a/src/utils/app-info-parser/xml-parser/manifest.js +++ b/src/utils/app-info-parser/xml-parser/manifest.ts @@ -1,61 +1,64 @@ // From https://github.com/openstf/adbkit-apkreader -const BinaryXmlParser = require('./binary'); +import { BinaryXmlParser } from './binary'; const INTENT_MAIN = 'android.intent.action.MAIN'; const CATEGORY_LAUNCHER = 'android.intent.category.LAUNCHER'; -class ManifestParser { - constructor(buffer, options = {}) { +export class ManifestParser { + private buffer: Buffer; + private xmlParser: BinaryXmlParser; + + constructor(buffer: Buffer, options: Record = {}) { this.buffer = buffer; this.xmlParser = new BinaryXmlParser(this.buffer, options); } - collapseAttributes(element) { - const collapsed = Object.create(null); + private collapseAttributes(element: any) { + const collapsed: Record = Object.create(null); for (const attr of Array.from(element.attributes)) { collapsed[attr.name] = attr.typedValue.value; } return collapsed; } - parseIntents(element, target) { + private parseIntents(element: any, target: any) { target.intentFilters = []; target.metaData = []; - return element.childNodes.forEach((element) => { - switch (element.nodeName) { + for (const child of element.childNodes) { + switch (child.nodeName) { case 'intent-filter': { - const intentFilter = this.collapseAttributes(element); + const intentFilter = this.collapseAttributes(child); intentFilter.actions = []; intentFilter.categories = []; intentFilter.data = []; - element.childNodes.forEach((element) => { - switch (element.nodeName) { + for (const item of child.childNodes) { + switch (item.nodeName) { case 'action': - intentFilter.actions.push(this.collapseAttributes(element)); + intentFilter.actions.push(this.collapseAttributes(item)); break; case 'category': - intentFilter.categories.push(this.collapseAttributes(element)); + intentFilter.categories.push(this.collapseAttributes(item)); break; case 'data': - intentFilter.data.push(this.collapseAttributes(element)); + intentFilter.data.push(this.collapseAttributes(item)); break; } - }); + } target.intentFilters.push(intentFilter); break; } case 'meta-data': - target.metaData.push(this.collapseAttributes(element)); + target.metaData.push(this.collapseAttributes(child)); break; } - }); + } } - parseApplication(element) { + private parseApplication(element: any) { const app = this.collapseAttributes(element); app.activities = []; @@ -67,11 +70,11 @@ class ManifestParser { app.usesLibraries = []; app.metaData = []; - element.childNodes.forEach((element) => { - switch (element.nodeName) { + for (const child of element.childNodes) { + switch (child.nodeName) { case 'activity': { - const activity = this.collapseAttributes(element); - this.parseIntents(element, activity); + const activity = this.collapseAttributes(child); + this.parseIntents(child, activity); app.activities.push(activity); if (this.isLauncherActivity(activity)) { app.launcherActivities.push(activity); @@ -79,8 +82,8 @@ class ManifestParser { break; } case 'activity-alias': { - const activityAlias = this.collapseAttributes(element); - this.parseIntents(element, activityAlias); + const activityAlias = this.collapseAttributes(child); + this.parseIntents(child, activityAlias); app.activityAliases.push(activityAlias); if (this.isLauncherActivity(activityAlias)) { app.launcherActivities.push(activityAlias); @@ -88,65 +91,65 @@ class ManifestParser { break; } case 'service': { - const service = this.collapseAttributes(element); - this.parseIntents(element, service); + const service = this.collapseAttributes(child); + this.parseIntents(child, service); app.services.push(service); break; } case 'receiver': { - const receiver = this.collapseAttributes(element); - this.parseIntents(element, receiver); + const receiver = this.collapseAttributes(child); + this.parseIntents(child, receiver); app.receivers.push(receiver); break; } case 'provider': { - const provider = this.collapseAttributes(element); + const provider = this.collapseAttributes(child); provider.grantUriPermissions = []; provider.metaData = []; provider.pathPermissions = []; - element.childNodes.forEach((element) => { - switch (element.nodeName) { + for (const item of child.childNodes) { + switch (item.nodeName) { case 'grant-uri-permission': provider.grantUriPermissions.push( - this.collapseAttributes(element), + this.collapseAttributes(item), ); break; case 'meta-data': - provider.metaData.push(this.collapseAttributes(element)); + provider.metaData.push(this.collapseAttributes(item)); break; case 'path-permission': - provider.pathPermissions.push(this.collapseAttributes(element)); + provider.pathPermissions.push(this.collapseAttributes(item)); break; } - }); + } app.providers.push(provider); break; } case 'uses-library': - app.usesLibraries.push(this.collapseAttributes(element)); + app.usesLibraries.push(this.collapseAttributes(child)); break; case 'meta-data': - app.metaData.push(this.collapseAttributes(element)); + app.metaData.push(this.collapseAttributes(child)); break; } - }); + } return app; } - isLauncherActivity(activity) { - return activity.intentFilters.some((filter) => { + private isLauncherActivity(activity: any) { + return activity.intentFilters.some((filter: any) => { const hasMain = filter.actions.some( - (action) => action.name === INTENT_MAIN, + (action: any) => action.name === INTENT_MAIN, ); if (!hasMain) { return false; } return filter.categories.some( - (category) => category.name === CATEGORY_LAUNCHER, + (category: any) => category.name === CATEGORY_LAUNCHER, ); }); } @@ -169,7 +172,7 @@ class ManifestParser { manifest.supportsGlTextures = []; manifest.application = Object.create(null); - document.childNodes.forEach((element) => { + for (const element of document.childNodes) { switch (element.nodeName) { case 'uses-permission': manifest.usesPermissions.push(this.collapseAttributes(element)); @@ -202,11 +205,9 @@ class ManifestParser { manifest.supportsScreens = this.collapseAttributes(element); break; case 'compatible-screens': - element.childNodes.forEach((screen) => { - return manifest.compatibleScreens.push( - this.collapseAttributes(screen), - ); - }); + for (const screen of element.childNodes) { + manifest.compatibleScreens.push(this.collapseAttributes(screen)); + } break; case 'supports-gl-texture': manifest.supportsGlTextures.push(this.collapseAttributes(element)); @@ -215,10 +216,8 @@ class ManifestParser { manifest.application = this.parseApplication(element); break; } - }); + } return manifest; } } - -module.exports = ManifestParser; diff --git a/src/utils/app-info-parser/zip.js b/src/utils/app-info-parser/zip.js deleted file mode 100644 index f0ec53f..0000000 --- a/src/utils/app-info-parser/zip.js +++ /dev/null @@ -1,66 +0,0 @@ -const Unzip = require('isomorphic-unzip'); -const { isBrowser, decodeNullUnicode } = require('./utils'); -let bundleZipUtils; - -export class Zip { - constructor(file) { - if (isBrowser()) { - if (!(file instanceof window.Blob || typeof file.size !== 'undefined')) { - throw new Error( - 'Param error: [file] must be an instance of Blob or File in browser.', - ); - } - this.file = file; - } else { - if (typeof file !== 'string') { - throw new Error('Param error: [file] must be file path in Node.'); - } - this.file = require('path').resolve(file); - } - this.unzip = new Unzip(this.file); - } - - /** - * get entries by regexps, the return format is: { : } - * @param {Array} regexps // regexps for matching files - * @param {String} type // return type, can be buffer or blob, default buffer - */ - getEntries(regexps, type = 'buffer') { - regexps = regexps.map((regex) => decodeNullUnicode(regex)); - return new Promise((resolve, reject) => { - this.unzip.getBuffer(regexps, { type }, (err, buffers) => { - err ? reject(err) : resolve(buffers); - }); - }); - } - /** - * get entry by regex, return an instance of Buffer or Blob - * @param {Regex} regex // regex for matching file - * @param {String} type // return type, can be buffer or blob, default buffer - */ - getEntry(regex, type = 'buffer') { - regex = decodeNullUnicode(regex); - return new Promise((resolve, reject) => { - this.unzip.getBuffer([regex], { type }, (err, buffers) => { - // console.log(buffers); - err ? reject(err) : resolve(buffers[regex]); - }); - }); - } - - async getEntryFromHarmonyApp(regex) { - try { - const { enumZipEntries, readEntry } = - bundleZipUtils ?? (bundleZipUtils = require('../../bundle')); - let originSource; - await enumZipEntries(this.file, (entry, zipFile) => { - if (regex.test(entry.fileName)) { - return readEntry(entry, zipFile).then((v) => (originSource = v)); - } - }); - return originSource; - } catch (error) { - console.error('Error in getEntryFromHarmonyApp:', error); - } - } -} diff --git a/src/utils/app-info-parser/zip.ts b/src/utils/app-info-parser/zip.ts new file mode 100644 index 0000000..cb1f8e3 --- /dev/null +++ b/src/utils/app-info-parser/zip.ts @@ -0,0 +1,86 @@ +const Unzip = require('isomorphic-unzip'); + +import { decodeNullUnicode, isBrowser } from './utils'; + +let bundleZipUtils: any; + +export class Zip { + file: string | File | Blob; + unzip: any; + + constructor(file: string | File | Blob) { + if (isBrowser()) { + if (!(file instanceof window.Blob || typeof file.size !== 'undefined')) { + throw new Error( + 'Param error: [file] must be an instance of Blob or File in browser.', + ); + } + this.file = file; + } else { + if (typeof file !== 'string') { + throw new Error('Param error: [file] must be file path in Node.'); + } + this.file = require('path').resolve(file); + } + this.unzip = new Unzip(this.file); + } + + /** + * get entries by regexps, the return format is: { : } + * @param {Array} regexps // regexps for matching files + * @param {String} type // return type, can be buffer or blob, default buffer + */ + getEntries( + regexps: Array, + type: 'buffer' | 'blob' = 'buffer', + ) { + const decoded = regexps.map((regex) => decodeNullUnicode(regex)); + return new Promise>((resolve, reject) => { + this.unzip.getBuffer( + decoded, + { type }, + (err: Error | null, buffers: Record) => { + err ? reject(err) : resolve(buffers); + }, + ); + }); + } + + /** + * get entry by regex, return an instance of Buffer or Blob + * @param {Regex} regex // regex for matching file + * @param {String} type // return type, can be buffer or blob, default buffer + */ + getEntry(regex: RegExp | string, type: 'buffer' | 'blob' = 'buffer') { + const decoded = decodeNullUnicode(regex); + return new Promise((resolve, reject) => { + this.unzip.getBuffer( + [decoded], + { type }, + (err: Error | null, buffers: Record) => { + err ? reject(err) : resolve(buffers[decoded as any]); + }, + ); + }); + } + + async getEntryFromHarmonyApp(regex: RegExp) { + try { + const { enumZipEntries, readEntry } = + bundleZipUtils ?? (bundleZipUtils = require('../../bundle')); + let originSource: Buffer | Blob | undefined; + await enumZipEntries(this.file, (entry: any, zipFile: any) => { + if (regex.test(entry.fileName)) { + return readEntry(entry, zipFile).then( + (value: Buffer | Blob | undefined) => { + originSource = value; + }, + ); + } + }); + return originSource; + } catch (error) { + console.error('Error in getEntryFromHarmonyApp:', error); + } + } +} From 1503b5a5fcb4d70c3167cf4d5bae6994e1382057 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 2 Jan 2026 15:07:21 +0800 Subject: [PATCH 7/8] Implement AAB upload functionality: add uploadAab command to handle AAB file uploads, including options for split management and update documentation in README files. Remove deprecated .babelrc configuration. --- .babelrc | 8 ------ README.md | 1 + README.zh-CN.md | 1 + cli.json | 13 ++++++++++ src/locales/en.ts | 2 ++ src/locales/zh.ts | 2 ++ src/package.ts | 63 +++++++++++++++++++++++++++++++++++++++++------ src/provider.ts | 3 +++ 8 files changed, 78 insertions(+), 15 deletions(-) delete mode 100644 .babelrc diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 8f93aac..0000000 --- a/.babelrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "plugins": [ - "syntax-object-rest-spread", - "transform-es2015-modules-commonjs", - "transform-es2015-spread", - "transform-object-rest-spread" - ] -} diff --git a/README.md b/README.md index de80506..648fa41 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,7 @@ Each workflow step contains: - `uploadIpa`: Upload IPA files (supports `--version` to override extracted version) - `uploadApk`: Upload APK files (supports `--version` to override extracted version) +- `uploadAab`: Upload AAB files (converted to APK, supports `--version`, `--includeAllSplits`, `--splits`) - `uploadApp`: Upload APP files (supports `--version` to override extracted version) - `parseApp`: Parse APP file information - `parseIpa`: Parse IPA file information diff --git a/README.zh-CN.md b/README.zh-CN.md index 7007208..f46a730 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -221,6 +221,7 @@ const workflowResult = await moduleManager.executeWorkflow('my-workflow', { - `uploadIpa`: 上传 IPA 文件(支持 `--version` 参数覆盖提取的版本) - `uploadApk`: 上传 APK 文件(支持 `--version` 参数覆盖提取的版本) +- `uploadAab`: 上传 AAB 文件(转换为 APK,支持 `--version`、`--includeAllSplits`、`--splits`) - `uploadApp`: 上传 APP 文件(支持 `--version` 参数覆盖提取的版本) - `parseApp`: 解析 APP 文件信息 - `parseIpa`: 解析 IPA 文件信息 diff --git a/cli.json b/cli.json index 8907648..ec27e1e 100644 --- a/cli.json +++ b/cli.json @@ -45,6 +45,19 @@ } } }, + "uploadAab": { + "options": { + "version": { + "hasValue": true + }, + "includeAllSplits": { + "default": false + }, + "splits": { + "hasValue": true + } + } + }, "uploadApp": { "options": { "version": { diff --git a/src/locales/en.ts b/src/locales/en.ts index 2d300af..5d020ca 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -131,6 +131,8 @@ This can reduce the risk of inconsistent dependencies and supply chain attacks. usageParseIpa: 'Usage: cresc parseIpa ', usageUnderDevelopment: 'Usage is under development now.', usageUploadApk: 'Usage: cresc uploadApk ', + usageUploadAab: + 'Usage: cresc uploadAab [--includeAllSplits] [--splits ]', usageUploadApp: 'Usage: cresc uploadApp ', usageUploadIpa: 'Usage: cresc uploadIpa ', versionBind: diff --git a/src/locales/zh.ts b/src/locales/zh.ts index ceadd46..1b0bcfb 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -123,6 +123,8 @@ export default { usageParseApp: '使用方法: pushy parseApp app后缀文件', usageParseIpa: '使用方法: pushy parseIpa ipa后缀文件', usageUploadApk: '使用方法: pushy uploadApk apk后缀文件', + usageUploadAab: + '使用方法: pushy uploadAab aab后缀文件 [--includeAllSplits] [--splits 分包名列表]', usageUploadApp: '使用方法: pushy uploadApp app后缀文件', usageUploadIpa: '使用方法: pushy uploadIpa ipa后缀文件', versionBind: diff --git a/src/package.ts b/src/package.ts index 0ae5570..6a7de82 100644 --- a/src/package.ts +++ b/src/package.ts @@ -1,15 +1,22 @@ -import { getAllPackages, post, uploadFile, doDelete } from './api'; -import { question, saveToLocal } from './utils'; - -import { getPlatform, getSelectedApp } from './app'; +import os from 'os'; +import path from 'path'; +import fs from 'fs-extra'; import Table from 'tty-table'; +import { doDelete, getAllPackages, post, uploadFile } from './api'; +import { getPlatform, getSelectedApp } from './app'; import type { Platform } from './types'; -import { getAabInfo, getApkInfo, getAppInfo, getIpaInfo } from './utils'; +import { + getAabInfo, + getApkInfo, + getAppInfo, + getIpaInfo, + question, + saveToLocal, +} from './utils'; +import { AabParser } from './utils/app-info-parser/aab'; import { depVersions } from './utils/dep-versions'; import { getCommitInfo } from './utils/git'; -import { AabParser } from './utils/app-info-parser/aab'; import { t } from './utils/i18n'; -import path from 'path'; export async function listPackage(appId: string) { const allPkgs = (await getAllPackages(appId)) || []; @@ -144,6 +151,48 @@ export const packageCommands = { saveToLocal(fn, `${appId}/package/${id}.apk`); console.log(t('apkUploadSuccess', { id, version: versionName, buildTime })); }, + uploadAab: async ({ + args, + options, + }: { + args: string[]; + options: Record; + }) => { + const source = args[0]; + if (!source || !source.endsWith('.aab')) { + throw new Error(t('usageUploadAab')); + } + + const output = path.join( + os.tmpdir(), + `${path.basename(source, path.extname(source))}-${Date.now()}.apk`, + ); + + const includeAllSplits = + options.includeAllSplits === true || options.includeAllSplits === 'true'; + const splits = options.splits + ? String(options.splits) + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + : null; + + const parser = new AabParser(source); + try { + await parser.extractApk(output, { + includeAllSplits, + splits, + }); + await packageCommands.uploadApk({ + args: [output], + options, + }); + } finally { + if (await fs.pathExists(output)) { + await fs.remove(output); + } + } + }, uploadApp: async ({ args, options, diff --git a/src/provider.ts b/src/provider.ts index 8f735cf..f18ddae 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -122,6 +122,9 @@ export class CLIProviderImpl implements CLIProvider { case 'apk': await packageCommands.uploadApk(context); break; + case 'aab': + await packageCommands.uploadAab(context); + break; case 'app': await packageCommands.uploadApp(context); break; From 9e584fe929619343e57506351c0ee9631d2ab7a6 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 2 Jan 2026 15:14:31 +0800 Subject: [PATCH 8/8] Enhance AAB parsing: add localized download hint for bundletool and improve error handling in runCommand function. Refactor command execution to support stdio options. --- src/locales/en.ts | 5 ++- src/locales/zh.ts | 2 + src/utils/app-info-parser/aab.ts | 64 ++++++++++++++++++++++++-------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 5d020ca..97d7fa9 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -5,9 +5,12 @@ export default { aabOpenApksFailed: 'Failed to open generated .apks file', aabReadUniversalApkFailed: 'Failed to read universal.apk', aabUniversalApkNotFound: 'universal.apk not found in generated .apks', + aabBundletoolDownloadHint: + 'bundletool not found. Downloading node-bundletool via npx (first run may take a while).', aabManifestNotFound: "AndroidManifest.xml can't be found in AAB base/manifest/", - aabParseResourcesWarning: '[Warning] Failed to parse resources.arsc: {{error}}', + aabParseResourcesWarning: + '[Warning] Failed to parse resources.arsc: {{error}}', aabParseFailed: 'Failed to parse AAB: {{error}}', aabParseManifestError: 'Parse AndroidManifest.xml error: {{error}}', aabParseResourcesError: 'Parser resources.arsc error: {{error}}', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 1b0bcfb..f8a84f9 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -5,6 +5,8 @@ export default { aabOpenApksFailed: '无法打开生成的 .apks 文件', aabReadUniversalApkFailed: '无法读取 universal.apk', aabUniversalApkNotFound: '在生成的 .apks 中未找到 universal.apk', + aabBundletoolDownloadHint: + '未找到 bundletool,正在通过 npx 下载 node-bundletool(首次下载可能需要一些时间)。', aabManifestNotFound: '在 AAB 的 base/manifest/ 中找不到 AndroidManifest.xml', aabParseResourcesWarning: '[警告] 解析 resources.arsc 失败:{{error}}', aabParseFailed: '解析 AAB 失败:{{error}}', diff --git a/src/utils/app-info-parser/aab.ts b/src/utils/app-info-parser/aab.ts index dc59b72..dd7c4c1 100644 --- a/src/utils/app-info-parser/aab.ts +++ b/src/utils/app-info-parser/aab.ts @@ -32,15 +32,23 @@ export class AabParser extends Zip { : Array.from(new Set(['base', ...normalizedSplits])); const modulesArgs = modules ? [`--modules=${modules.join(',')}`] : []; - const runCommand = (command: string, args: string[]) => + const runCommand = ( + command: string, + args: string[], + options: { stdio?: 'inherit'; env?: NodeJS.ProcessEnv } = {}, + ) => new Promise((resolve, reject) => { + const inheritStdio = options.stdio === 'inherit'; const child = spawn(command, args, { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: inheritStdio ? 'inherit' : ['ignore', 'pipe', 'pipe'], + env: options.env, }); let stderr = ''; - child.stderr?.on('data', (chunk) => { - stderr += chunk.toString(); - }); + if (!inheritStdio) { + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + } child.on('error', reject); child.on('close', (code) => { if (code === 0) { @@ -59,6 +67,19 @@ export class AabParser extends Zip { const tempDir = os.tmpdir(); const tempApksPath = path.join(tempDir, `temp-${Date.now()}.apks`); + const needsNpxDownload = async () => { + try { + await runCommand('npx', [ + '--no-install', + 'node-bundletool', + '--version', + ]); + return false; + } catch { + return true; + } + }; + try { // 1. Build APKS (universal mode) // We assume bundletool is in the path. @@ -77,16 +98,29 @@ export class AabParser extends Zip { } catch (e) { // Fallback to npx node-bundletool if bundletool is not in PATH // We use -y to avoid interactive prompt for installation - await runCommand('npx', [ - '-y', - 'node-bundletool', - 'build-apks', - '--mode=universal', - `--bundle=${this.file}`, - `--output=${tempApksPath}`, - '--overwrite', - ...modulesArgs, - ]); + if (await needsNpxDownload()) { + console.log(t('aabBundletoolDownloadHint')); + } + await runCommand( + 'npx', + [ + '-y', + 'node-bundletool', + 'build-apks', + '--mode=universal', + `--bundle=${this.file}`, + `--output=${tempApksPath}`, + '--overwrite', + ...modulesArgs, + ], + { + stdio: 'inherit', + env: { + ...process.env, + npm_config_progress: 'true', + }, + }, + ); } // 2. Extract universal.apk from the .apks (zip) file