react-native-update-cli 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/cli.json +26 -0
  4. package/lib/api.js +45 -12
  5. package/lib/locales/en.js +13 -1
  6. package/lib/locales/zh.js +13 -1
  7. package/lib/package.js +53 -6
  8. package/lib/provider.js +3 -0
  9. package/lib/utils/app-info-parser/aab.js +165 -201
  10. package/lib/utils/app-info-parser/apk.js +25 -27
  11. package/lib/utils/app-info-parser/app.js +10 -11
  12. package/lib/utils/app-info-parser/index.js +8 -8
  13. package/lib/utils/app-info-parser/ipa.js +16 -21
  14. package/lib/utils/app-info-parser/resource-finder.js +365 -305
  15. package/lib/utils/app-info-parser/utils.js +78 -63
  16. package/lib/utils/app-info-parser/xml-parser/binary.js +57 -51
  17. package/lib/utils/app-info-parser/xml-parser/manifest.js +47 -39
  18. package/lib/utils/app-info-parser/zip.js +21 -11
  19. package/lib/utils/http-helper.js +1 -1
  20. package/package.json +1 -1
  21. package/src/api.ts +45 -19
  22. package/src/locales/en.ts +17 -0
  23. package/src/locales/zh.ts +15 -0
  24. package/src/modules/version-module.ts +1 -1
  25. package/src/package.ts +102 -11
  26. package/src/provider.ts +3 -0
  27. package/src/utils/app-info-parser/aab.ts +240 -0
  28. package/src/utils/app-info-parser/{apk.js → apk.ts} +30 -41
  29. package/src/utils/app-info-parser/app.ts +3 -0
  30. package/src/utils/app-info-parser/index.ts +4 -4
  31. package/src/utils/app-info-parser/{ipa.js → ipa.ts} +17 -31
  32. package/src/utils/app-info-parser/resource-finder.ts +508 -0
  33. package/src/utils/app-info-parser/utils.ts +162 -0
  34. package/src/utils/app-info-parser/xml-parser/{binary.js → binary.ts} +69 -61
  35. package/src/utils/app-info-parser/xml-parser/{manifest.js → manifest.ts} +50 -51
  36. package/src/utils/app-info-parser/zip.ts +86 -0
  37. package/src/utils/dep-versions.ts +7 -4
  38. package/src/utils/http-helper.ts +1 -1
  39. package/src/utils/latest-version/index.ts +2 -1
  40. package/src/versions.ts +1 -1
  41. package/src/utils/app-info-parser/aab.js +0 -326
  42. package/src/utils/app-info-parser/app.js +0 -16
  43. package/src/utils/app-info-parser/resource-finder.js +0 -495
  44. package/src/utils/app-info-parser/utils.js +0 -172
  45. package/src/utils/app-info-parser/zip.js +0 -66
@@ -1,16 +1,25 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ Object.defineProperty(exports, "Zip", {
6
+ enumerable: true,
7
+ get: function() {
8
+ return Zip;
9
+ }
10
+ });
11
+ const _utils = require("./utils");
2
12
  const Unzip = require('isomorphic-unzip');
3
- const { isBrowser, decodeNullUnicode } = require('./utils');
4
- const { enumZipEntries, readEntry } = require('../../bundle');
13
+ let bundleZipUtils;
5
14
  class Zip {
6
15
  /**
7
16
  * get entries by regexps, the return format is: { <filename>: <Buffer|Blob> }
8
17
  * @param {Array} regexps // regexps for matching files
9
18
  * @param {String} type // return type, can be buffer or blob, default buffer
10
19
  */ getEntries(regexps, type = 'buffer') {
11
- regexps = regexps.map((regex)=>decodeNullUnicode(regex));
20
+ const decoded = regexps.map((regex)=>(0, _utils.decodeNullUnicode)(regex));
12
21
  return new Promise((resolve, reject)=>{
13
- this.unzip.getBuffer(regexps, {
22
+ this.unzip.getBuffer(decoded, {
14
23
  type
15
24
  }, (err, buffers)=>{
16
25
  err ? reject(err) : resolve(buffers);
@@ -22,24 +31,26 @@ class Zip {
22
31
  * @param {Regex} regex // regex for matching file
23
32
  * @param {String} type // return type, can be buffer or blob, default buffer
24
33
  */ getEntry(regex, type = 'buffer') {
25
- regex = decodeNullUnicode(regex);
34
+ const decoded = (0, _utils.decodeNullUnicode)(regex);
26
35
  return new Promise((resolve, reject)=>{
27
36
  this.unzip.getBuffer([
28
- regex
37
+ decoded
29
38
  ], {
30
39
  type
31
40
  }, (err, buffers)=>{
32
- // console.log(buffers);
33
- err ? reject(err) : resolve(buffers[regex]);
41
+ err ? reject(err) : resolve(buffers[decoded]);
34
42
  });
35
43
  });
36
44
  }
37
45
  async getEntryFromHarmonyApp(regex) {
38
46
  try {
47
+ const { enumZipEntries, readEntry } = bundleZipUtils != null ? bundleZipUtils : bundleZipUtils = require('../../bundle');
39
48
  let originSource;
40
49
  await enumZipEntries(this.file, (entry, zipFile)=>{
41
50
  if (regex.test(entry.fileName)) {
42
- return readEntry(entry, zipFile).then((v)=>originSource = v);
51
+ return readEntry(entry, zipFile).then((value)=>{
52
+ originSource = value;
53
+ });
43
54
  }
44
55
  });
45
56
  return originSource;
@@ -48,7 +59,7 @@ class Zip {
48
59
  }
49
60
  }
50
61
  constructor(file){
51
- if (isBrowser()) {
62
+ if ((0, _utils.isBrowser)()) {
52
63
  if (!(file instanceof window.Blob || typeof file.size !== 'undefined')) {
53
64
  throw new Error('Param error: [file] must be an instance of Blob or File in browser.');
54
65
  }
@@ -62,4 +73,3 @@ class Zip {
62
73
  this.unzip = new Unzip(this.file);
63
74
  }
64
75
  }
65
- module.exports = Zip;
@@ -22,8 +22,8 @@ _export(exports, {
22
22
  return testUrls;
23
23
  }
24
24
  });
25
- const _constants = require("./constants");
26
25
  const _nodefetch = /*#__PURE__*/ _interop_require_default(require("node-fetch"));
26
+ const _constants = require("./constants");
27
27
  function _interop_require_default(obj) {
28
28
  return obj && obj.__esModule ? obj : {
29
29
  default: obj
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-update-cli",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "command line tool for react-native-update (remote updates for react native)",
5
5
  "main": "index.js",
6
6
  "bin": {
package/src/api.ts CHANGED
@@ -6,30 +6,44 @@ import FormData from 'form-data';
6
6
  import fetch from 'node-fetch';
7
7
  import ProgressBar from 'progress';
8
8
  import tcpp from 'tcp-ping';
9
+ import { getBaseUrl } from 'utils/http-helper';
9
10
  import packageJson from '../package.json';
10
11
  import type { Package, Session } from './types';
11
- import {
12
- credentialFile,
13
- pricingPageUrl,
14
- } from './utils/constants';
12
+ import { credentialFile, pricingPageUrl, IS_CRESC } from './utils/constants';
15
13
  import { t } from './utils/i18n';
16
- import { getBaseUrl } from 'utils/http-helper';
17
14
 
18
15
  const tcpPing = util.promisify(tcpp.ping);
19
16
 
20
17
  let session: Session | undefined;
21
18
  let savedSession: Session | undefined;
22
-
19
+ let apiToken: string | undefined;
23
20
 
24
21
  const userAgent = `react-native-update-cli/${packageJson.version}`;
25
22
 
26
23
  export const getSession = () => session;
27
24
 
25
+ export const getApiToken = () => apiToken;
26
+
27
+ export const setApiToken = (token: string) => {
28
+ apiToken = token;
29
+ };
30
+
31
+ const loadApiTokenFromEnv = () => {
32
+ // Use CRESC_API_TOKEN for cresc, PUSHY_API_TOKEN for pushy
33
+ const envToken = IS_CRESC
34
+ ? process.env.CRESC_API_TOKEN
35
+ : process.env.PUSHY_API_TOKEN;
36
+ if (envToken) {
37
+ apiToken = envToken;
38
+ }
39
+ };
40
+
28
41
  export const replaceSession = (newSession: { token: string }) => {
29
42
  session = newSession;
30
43
  };
31
44
 
32
45
  export const loadSession = async () => {
46
+ loadApiTokenFromEnv();
33
47
  if (fs.existsSync(credentialFile)) {
34
48
  try {
35
49
  replaceSession(JSON.parse(fs.readFileSync(credentialFile, 'utf8')));
@@ -82,27 +96,39 @@ async function query(url: string, options: fetch.RequestInit) {
82
96
  }
83
97
 
84
98
  function queryWithoutBody(method: string) {
85
- return (api: string) =>
86
- query(api, {
99
+ return (api: string) => {
100
+ const headers: Record<string, string> = {
101
+ 'User-Agent': userAgent,
102
+ };
103
+ if (apiToken) {
104
+ headers['x-api-token'] = apiToken;
105
+ } else if (session?.token) {
106
+ headers['X-AccessToken'] = session.token;
107
+ }
108
+ return query(api, {
87
109
  method,
88
- headers: {
89
- 'User-Agent': userAgent,
90
- 'X-AccessToken': session ? session.token : '',
91
- },
110
+ headers,
92
111
  });
112
+ };
93
113
  }
94
114
 
95
115
  function queryWithBody(method: string) {
96
- return (api: string, body?: Record<string, any>) =>
97
- query(api, {
116
+ return (api: string, body?: Record<string, any>) => {
117
+ const headers: Record<string, string> = {
118
+ 'User-Agent': userAgent,
119
+ 'Content-Type': 'application/json',
120
+ };
121
+ if (apiToken) {
122
+ headers['x-api-token'] = apiToken;
123
+ } else if (session?.token) {
124
+ headers['X-AccessToken'] = session.token;
125
+ }
126
+ return query(api, {
98
127
  method,
99
- headers: {
100
- 'User-Agent': userAgent,
101
- 'Content-Type': 'application/json',
102
- 'X-AccessToken': session ? session.token : '',
103
- },
128
+ headers,
104
129
  body: JSON.stringify(body),
105
130
  });
131
+ };
106
132
  }
107
133
 
108
134
  export const get = queryWithoutBody('GET');
package/src/locales/en.ts CHANGED
@@ -2,6 +2,18 @@ export default {
2
2
  addedToGitignore: 'Added {{line}} to .gitignore',
3
3
  androidCrunchPngsWarning:
4
4
  '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',
5
+ aabOpenApksFailed: 'Failed to open generated .apks file',
6
+ aabReadUniversalApkFailed: 'Failed to read universal.apk',
7
+ aabUniversalApkNotFound: 'universal.apk not found in generated .apks',
8
+ aabBundletoolDownloadHint:
9
+ 'bundletool not found. Downloading node-bundletool via npx (first run may take a while).',
10
+ aabManifestNotFound:
11
+ "AndroidManifest.xml can't be found in AAB base/manifest/",
12
+ aabParseResourcesWarning:
13
+ '[Warning] Failed to parse resources.arsc: {{error}}',
14
+ aabParseFailed: 'Failed to parse AAB: {{error}}',
15
+ aabParseManifestError: 'Parse AndroidManifest.xml error: {{error}}',
16
+ aabParseResourcesError: 'Parser resources.arsc error: {{error}}',
5
17
  appId: 'App ID',
6
18
  appIdMismatchApk:
7
19
  'App ID mismatch! Current APK: {{appIdInPkg}}, current update.json: {{appId}}',
@@ -116,10 +128,14 @@ This can reduce the risk of inconsistent dependencies and supply chain attacks.
116
128
  usageDiff: 'Usage: cresc {{command}} <origin> <next>',
117
129
  usageParseApk: 'Usage: cresc parseApk <apk file>',
118
130
  usageParseAab: 'Usage: cresc parseAab <aab file>',
131
+ usageExtractApk:
132
+ 'Usage: cresc extractApk <aab file> [--output <apk file>] [--includeAllSplits] [--splits <split names>]',
119
133
  usageParseApp: 'Usage: cresc parseApp <app file>',
120
134
  usageParseIpa: 'Usage: cresc parseIpa <ipa file>',
121
135
  usageUnderDevelopment: 'Usage is under development now.',
122
136
  usageUploadApk: 'Usage: cresc uploadApk <apk file>',
137
+ usageUploadAab:
138
+ 'Usage: cresc uploadAab <aab file> [--includeAllSplits] [--splits <split names>]',
123
139
  usageUploadApp: 'Usage: cresc uploadApp <app file>',
124
140
  usageUploadIpa: 'Usage: cresc uploadIpa <ipa file>',
125
141
  versionBind:
@@ -147,4 +163,5 @@ This can reduce the risk of inconsistent dependencies and supply chain attacks.
147
163
  'This function needs "node-bsdiff". Please run "{{scriptName}} install node-bsdiff" to install',
148
164
  nodeHdiffpatchRequired:
149
165
  'This function needs "node-hdiffpatch". Please run "{{scriptName}} install node-hdiffpatch" to install',
166
+ apkExtracted: 'APK extracted to {{output}}',
150
167
  };
package/src/locales/zh.ts CHANGED
@@ -2,6 +2,16 @@ export default {
2
2
  addedToGitignore: '已将 {{line}} 添加到 .gitignore',
3
3
  androidCrunchPngsWarning:
4
4
  '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',
5
+ aabOpenApksFailed: '无法打开生成的 .apks 文件',
6
+ aabReadUniversalApkFailed: '无法读取 universal.apk',
7
+ aabUniversalApkNotFound: '在生成的 .apks 中未找到 universal.apk',
8
+ aabBundletoolDownloadHint:
9
+ '未找到 bundletool,正在通过 npx 下载 node-bundletool(首次下载可能需要一些时间)。',
10
+ aabManifestNotFound: '在 AAB 的 base/manifest/ 中找不到 AndroidManifest.xml',
11
+ aabParseResourcesWarning: '[警告] 解析 resources.arsc 失败:{{error}}',
12
+ aabParseFailed: '解析 AAB 失败:{{error}}',
13
+ aabParseManifestError: '解析 AndroidManifest.xml 出错:{{error}}',
14
+ aabParseResourcesError: '解析 resources.arsc 出错:{{error}}',
5
15
  appId: '应用 id',
6
16
  appIdMismatchApk:
7
17
  'appId不匹配!当前apk: {{appIdInPkg}}, 当前update.json: {{appId}}',
@@ -110,9 +120,13 @@ export default {
110
120
  usageDiff: '用法:pushy {{command}} <origin> <next>',
111
121
  usageParseApk: '使用方法: pushy parseApk apk后缀文件',
112
122
  usageParseAab: '使用方法: pushy parseAab aab后缀文件',
123
+ usageExtractApk:
124
+ '使用方法: pushy extractApk aab后缀文件 [--output apk文件] [--includeAllSplits] [--splits 分包名列表]',
113
125
  usageParseApp: '使用方法: pushy parseApp app后缀文件',
114
126
  usageParseIpa: '使用方法: pushy parseIpa ipa后缀文件',
115
127
  usageUploadApk: '使用方法: pushy uploadApk apk后缀文件',
128
+ usageUploadAab:
129
+ '使用方法: pushy uploadAab aab后缀文件 [--includeAllSplits] [--splits 分包名列表]',
116
130
  usageUploadApp: '使用方法: pushy uploadApp app后缀文件',
117
131
  usageUploadIpa: '使用方法: pushy uploadIpa ipa后缀文件',
118
132
  versionBind:
@@ -138,4 +152,5 @@ export default {
138
152
  '此功能需要 "node-bsdiff"。请运行 "{{scriptName}} install node-bsdiff" 来安装',
139
153
  nodeHdiffpatchRequired:
140
154
  '此功能需要 "node-hdiffpatch"。请运行 "{{scriptName}} install node-hdiffpatch" 来安装',
155
+ apkExtracted: 'APK 已提取到 {{output}}',
141
156
  };
@@ -1,4 +1,4 @@
1
- import type { CLIModule} from '../types';
1
+ import type { CLIModule } from '../types';
2
2
 
3
3
  export const versionModule: CLIModule = {
4
4
  name: 'version',
package/src/package.ts CHANGED
@@ -1,17 +1,25 @@
1
- import { get, getAllPackages, post, uploadFile, doDelete } from './api';
2
- import { question, saveToLocal } from './utils';
3
- import { t } from './utils/i18n';
4
-
5
- import { getPlatform, getSelectedApp } from './app';
6
-
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
7
4
  import Table from 'tty-table';
5
+ import { doDelete, getAllPackages, post, uploadFile } from './api';
6
+ import { getPlatform, getSelectedApp } from './app';
8
7
  import type { Platform } from './types';
9
- import { getApkInfo, getAppInfo, getIpaInfo, getAabInfo } from './utils';
8
+ import {
9
+ getAabInfo,
10
+ getApkInfo,
11
+ getAppInfo,
12
+ getIpaInfo,
13
+ question,
14
+ saveToLocal,
15
+ } from './utils';
16
+ import { AabParser } from './utils/app-info-parser/aab';
10
17
  import { depVersions } from './utils/dep-versions';
11
18
  import { getCommitInfo } from './utils/git';
19
+ import { t } from './utils/i18n';
12
20
 
13
21
  export async function listPackage(appId: string) {
14
- const allPkgs = await getAllPackages(appId);
22
+ const allPkgs = (await getAllPackages(appId)) || [];
15
23
 
16
24
  const header = [
17
25
  { value: t('nativePackageId') },
@@ -49,7 +57,7 @@ export async function choosePackage(appId: string) {
49
57
 
50
58
  while (true) {
51
59
  const id = await question(t('enterNativePackageId'));
52
- const app = list.find((v) => v.id.toString() === id);
60
+ const app = list?.find((v) => v.id.toString() === id);
53
61
  if (app) {
54
62
  return app;
55
63
  }
@@ -143,6 +151,48 @@ export const packageCommands = {
143
151
  saveToLocal(fn, `${appId}/package/${id}.apk`);
144
152
  console.log(t('apkUploadSuccess', { id, version: versionName, buildTime }));
145
153
  },
154
+ uploadAab: async ({
155
+ args,
156
+ options,
157
+ }: {
158
+ args: string[];
159
+ options: Record<string, any>;
160
+ }) => {
161
+ const source = args[0];
162
+ if (!source || !source.endsWith('.aab')) {
163
+ throw new Error(t('usageUploadAab'));
164
+ }
165
+
166
+ const output = path.join(
167
+ os.tmpdir(),
168
+ `${path.basename(source, path.extname(source))}-${Date.now()}.apk`,
169
+ );
170
+
171
+ const includeAllSplits =
172
+ options.includeAllSplits === true || options.includeAllSplits === 'true';
173
+ const splits = options.splits
174
+ ? String(options.splits)
175
+ .split(',')
176
+ .map((item) => item.trim())
177
+ .filter(Boolean)
178
+ : null;
179
+
180
+ const parser = new AabParser(source);
181
+ try {
182
+ await parser.extractApk(output, {
183
+ includeAllSplits,
184
+ splits,
185
+ });
186
+ await packageCommands.uploadApk({
187
+ args: [output],
188
+ options,
189
+ });
190
+ } finally {
191
+ if (await fs.pathExists(output)) {
192
+ await fs.remove(output);
193
+ }
194
+ }
195
+ },
146
196
  uploadApp: async ({
147
197
  args,
148
198
  options,
@@ -214,6 +264,42 @@ export const packageCommands = {
214
264
  }
215
265
  console.log(await getAabInfo(fn));
216
266
  },
267
+ extractApk: async ({
268
+ args,
269
+ options,
270
+ }: {
271
+ args: string[];
272
+ options: Record<string, any>;
273
+ }) => {
274
+ const source = args[0];
275
+ if (!source || !source.endsWith('.aab')) {
276
+ throw new Error(t('usageExtractApk'));
277
+ }
278
+
279
+ const output =
280
+ options.output ||
281
+ path.join(
282
+ path.dirname(source),
283
+ `${path.basename(source, path.extname(source))}.apk`,
284
+ );
285
+
286
+ const includeAllSplits =
287
+ options.includeAllSplits === true || options.includeAllSplits === 'true';
288
+ const splits = options.splits
289
+ ? String(options.splits)
290
+ .split(',')
291
+ .map((item) => item.trim())
292
+ .filter(Boolean)
293
+ : null;
294
+
295
+ const parser = new AabParser(source);
296
+ await parser.extractApk(output, {
297
+ includeAllSplits,
298
+ splits,
299
+ });
300
+
301
+ console.log(t('apkExtracted', { output }));
302
+ },
217
303
  packages: async ({ options }: { options: { platform: Platform } }) => {
218
304
  const platform = await getPlatform(options.platform);
219
305
  const { appId } = await getSelectedApp(platform);
@@ -224,12 +310,17 @@ export const packageCommands = {
224
310
  options,
225
311
  }: {
226
312
  args: string[];
227
- options: { appId?: string; packageId?: string; packageVersion?: string };
313
+ options: {
314
+ appId?: string;
315
+ packageId?: string;
316
+ packageVersion?: string;
317
+ platform?: Platform;
318
+ };
228
319
  }) => {
229
320
  let { appId, packageId, packageVersion } = options;
230
321
 
231
322
  if (!appId) {
232
- const platform = await getPlatform();
323
+ const platform = await getPlatform(options.platform);
233
324
  appId = (await getSelectedApp(platform)).appId as string;
234
325
  }
235
326
 
package/src/provider.ts CHANGED
@@ -122,6 +122,9 @@ export class CLIProviderImpl implements CLIProvider {
122
122
  case 'apk':
123
123
  await packageCommands.uploadApk(context);
124
124
  break;
125
+ case 'aab':
126
+ await packageCommands.uploadAab(context);
127
+ break;
125
128
  case 'app':
126
129
  await packageCommands.uploadApp(context);
127
130
  break;
@@ -0,0 +1,240 @@
1
+ import { spawn } from 'child_process';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+ import { open as openZipFile } from 'yauzl';
6
+ import { t } from '../i18n';
7
+ import { ResourceFinder } from './resource-finder';
8
+ import { mapInfoResource } from './utils';
9
+ import { ManifestParser } from './xml-parser/manifest';
10
+ import { Zip } from './zip';
11
+
12
+ export class AabParser extends Zip {
13
+ file: string | File;
14
+
15
+ constructor(file: string | File) {
16
+ super(file);
17
+ this.file = file;
18
+ }
19
+
20
+ async extractApk(
21
+ outputPath: string,
22
+ {
23
+ includeAllSplits,
24
+ splits,
25
+ }: { includeAllSplits?: boolean; splits?: string[] | null },
26
+ ) {
27
+ const normalizedSplits = Array.isArray(splits)
28
+ ? splits.map((item) => item.trim()).filter(Boolean)
29
+ : [];
30
+ const modules = includeAllSplits
31
+ ? null
32
+ : Array.from(new Set(['base', ...normalizedSplits]));
33
+ const modulesArgs = modules ? [`--modules=${modules.join(',')}`] : [];
34
+
35
+ const runCommand = (
36
+ command: string,
37
+ args: string[],
38
+ options: { stdio?: 'inherit'; env?: NodeJS.ProcessEnv } = {},
39
+ ) =>
40
+ new Promise<void>((resolve, reject) => {
41
+ const inheritStdio = options.stdio === 'inherit';
42
+ const child = spawn(command, args, {
43
+ stdio: inheritStdio ? 'inherit' : ['ignore', 'pipe', 'pipe'],
44
+ env: options.env,
45
+ });
46
+ let stderr = '';
47
+ if (!inheritStdio) {
48
+ child.stderr?.on('data', (chunk) => {
49
+ stderr += chunk.toString();
50
+ });
51
+ }
52
+ child.on('error', reject);
53
+ child.on('close', (code) => {
54
+ if (code === 0) {
55
+ resolve();
56
+ return;
57
+ }
58
+ reject(
59
+ new Error(
60
+ stderr.trim() || `Command failed: ${command} (code ${code})`,
61
+ ),
62
+ );
63
+ });
64
+ });
65
+
66
+ // Create a temp file for the .apks output
67
+ const tempDir = os.tmpdir();
68
+ const tempApksPath = path.join(tempDir, `temp-${Date.now()}.apks`);
69
+
70
+ const needsNpxDownload = async () => {
71
+ try {
72
+ await runCommand('npx', [
73
+ '--no-install',
74
+ 'node-bundletool',
75
+ '--version',
76
+ ]);
77
+ return false;
78
+ } catch {
79
+ return true;
80
+ }
81
+ };
82
+
83
+ try {
84
+ // 1. Build APKS (universal mode)
85
+ // We assume bundletool is in the path.
86
+ // User might need keystore to sign it properly but for simple extraction we stick to default debug key if possible or unsigned?
87
+ // actually bundletool build-apks signs with debug key by default if no keystore provided.
88
+
89
+ try {
90
+ await runCommand('bundletool', [
91
+ 'build-apks',
92
+ '--mode=universal',
93
+ `--bundle=${this.file}`,
94
+ `--output=${tempApksPath}`,
95
+ '--overwrite',
96
+ ...modulesArgs,
97
+ ]);
98
+ } catch (e) {
99
+ // Fallback to npx node-bundletool if bundletool is not in PATH
100
+ // We use -y to avoid interactive prompt for installation
101
+ if (await needsNpxDownload()) {
102
+ console.log(t('aabBundletoolDownloadHint'));
103
+ }
104
+ await runCommand(
105
+ 'npx',
106
+ [
107
+ '-y',
108
+ 'node-bundletool',
109
+ 'build-apks',
110
+ '--mode=universal',
111
+ `--bundle=${this.file}`,
112
+ `--output=${tempApksPath}`,
113
+ '--overwrite',
114
+ ...modulesArgs,
115
+ ],
116
+ {
117
+ stdio: 'inherit',
118
+ env: {
119
+ ...process.env,
120
+ npm_config_progress: 'true',
121
+ },
122
+ },
123
+ );
124
+ }
125
+
126
+ // 2. Extract universal.apk from the .apks (zip) file
127
+ await new Promise<void>((resolve, reject) => {
128
+ openZipFile(tempApksPath, { lazyEntries: true }, (err, zipfile) => {
129
+ if (err || !zipfile) {
130
+ reject(err || new Error(t('aabOpenApksFailed')));
131
+ return;
132
+ }
133
+
134
+ let found = false;
135
+ zipfile.readEntry();
136
+ zipfile.on('entry', (entry) => {
137
+ if (entry.fileName === 'universal.apk') {
138
+ found = true;
139
+ zipfile.openReadStream(entry, (err, readStream) => {
140
+ if (err || !readStream) {
141
+ reject(err || new Error(t('aabReadUniversalApkFailed')));
142
+ return;
143
+ }
144
+ const writeStream = fs.createWriteStream(outputPath);
145
+ readStream.pipe(writeStream);
146
+ writeStream.on('close', () => {
147
+ zipfile.close();
148
+ resolve();
149
+ });
150
+ writeStream.on('error', reject);
151
+ });
152
+ } else {
153
+ zipfile.readEntry();
154
+ }
155
+ });
156
+
157
+ zipfile.on('end', () => {
158
+ if (!found) reject(new Error(t('aabUniversalApkNotFound')));
159
+ });
160
+ zipfile.on('error', reject);
161
+ });
162
+ });
163
+ } finally {
164
+ // Cleanup
165
+ if (await fs.pathExists(tempApksPath)) {
166
+ await fs.remove(tempApksPath);
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * 解析 AAB 文件信息(类似 APK parser 的 parse 方法)
173
+ * 注意:AAB 中的 AndroidManifest.xml 在 base/manifest/AndroidManifest.xml
174
+ */
175
+ async parse() {
176
+ const manifestPath = 'base/manifest/AndroidManifest.xml';
177
+ const ResourceName = /^base\/resources\.arsc$/;
178
+
179
+ try {
180
+ const manifestBuffer = await this.getEntry(
181
+ new RegExp(`^${escapeRegExp(manifestPath)}$`),
182
+ );
183
+
184
+ if (!manifestBuffer) {
185
+ throw new Error(t('aabManifestNotFound'));
186
+ }
187
+
188
+ let apkInfo = this._parseManifest(manifestBuffer as Buffer);
189
+
190
+ try {
191
+ const resourceBuffer = await this.getEntry(ResourceName);
192
+ if (resourceBuffer) {
193
+ const resourceMap = this._parseResourceMap(resourceBuffer as Buffer);
194
+ apkInfo = mapInfoResource(apkInfo, resourceMap);
195
+ }
196
+ } catch (e: any) {
197
+ console.warn(t('aabParseResourcesWarning', { error: e?.message ?? e }));
198
+ }
199
+
200
+ return apkInfo;
201
+ } catch (error: any) {
202
+ throw new Error(t('aabParseFailed', { error: error.message ?? error }));
203
+ }
204
+ }
205
+ /**
206
+ * Parse manifest
207
+ * @param {Buffer} buffer // manifest file's buffer
208
+ */
209
+ private _parseManifest(buffer: Buffer) {
210
+ try {
211
+ const parser = new ManifestParser(buffer, {
212
+ ignore: [
213
+ 'application.activity',
214
+ 'application.service',
215
+ 'application.receiver',
216
+ 'application.provider',
217
+ 'permission-group',
218
+ ],
219
+ });
220
+ return parser.parse();
221
+ } catch (e: any) {
222
+ throw new Error(t('aabParseManifestError', { error: e?.message ?? e }));
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Parse resourceMap
228
+ * @param {Buffer} buffer // resourceMap file's buffer
229
+ */
230
+ private _parseResourceMap(buffer: Buffer) {
231
+ try {
232
+ return new ResourceFinder().processResourceTable(buffer);
233
+ } catch (e: any) {
234
+ throw new Error(t('aabParseResourcesError', { error: e?.message ?? e }));
235
+ }
236
+ }
237
+ }
238
+
239
+ const escapeRegExp = (value: string) =>
240
+ value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');