@@ -2,34 +2,10 @@ import fs from 'fs-extra';
22import path from 'path' ;
33import os from 'os' ;
44import {
5- type Entry ,
6- fromBuffer as openZipFromBuffer ,
75 open as openZipFile ,
86} from 'yauzl' ;
9- import { ZipFile as YazlZipFile } from 'yazl' ;
107import Zip from './zip' ;
118
12- interface ExtractApkOptions {
13- includeAllSplits ?: boolean ;
14- splits ?: string [ ] | null ;
15- }
16-
17- type BufferedEntry = {
18- apkPath : string ;
19- buffer : Buffer ;
20- compress ?: boolean ;
21- } ;
22-
23- type SplitEntry = {
24- name : string ;
25- buffer : Buffer ;
26- } ;
27-
28- /**
29- * 纯 JS 的 AAB 解析器,参考 https://github.com/accrescent/android-bundle
30- * 将 base/ 内容重打包为一个通用 APK,并在需要时合并 split APK。
31- * 生成的 APK 使用 AAB 中的 proto manifest/resources(不可用于安装,但可被本工具解析)。
32- */
339class AabParser extends Zip {
3410 file : string | File ;
3511
@@ -41,33 +17,75 @@ class AabParser extends Zip {
4117 /**
4218 * 从 AAB 提取 APK(不依赖 bundletool)
4319 */
44- async extractApk (
45- outputPath : string ,
46- options : ExtractApkOptions = { } ,
47- ) : Promise < string > {
48- if ( typeof this . file !== 'string' ) {
49- throw new Error ( 'AAB file path must be a string in Node.js environment' ) ;
50- }
20+ async extractApk ( outputPath : string ) {
21+ const { exec } = require ( 'child_process' ) ;
22+ const util = require ( 'util' ) ;
23+ const execAsync = util . promisify ( exec ) ;
5124
52- const { includeAllSplits = false , splits = null } = options ;
53- const { baseEntries , splitEntries , metaInfEntries } =
54- await this . collectBundleEntries ( ) ;
25+ // Create a temp file for the .apks output
26+ const tempDir = os . tmpdir ( ) ;
27+ const tempApksPath = path . join ( tempDir , `temp- ${ Date . now ( ) } .apks` ) ;
5528
56- const entryMap = new Map < string , BufferedEntry > ( ) ;
57- for ( const entry of baseEntries ) {
58- entryMap . set ( entry . apkPath , entry ) ;
59- }
60- for ( const entry of metaInfEntries ) {
61- entryMap . set ( entry . apkPath , entry ) ;
62- }
29+ try {
30+ // 1. Build APKS (universal mode)
31+ // We assume bundletool is in the path.
32+ // User might need keystore to sign it properly but for simple extraction we stick to default debug key if possible or unsigned?
33+ // actually bundletool build-apks signs with debug key by default if no keystore provided.
6334
64- const selectedSplits = this . pickSplits ( splitEntries , includeAllSplits , splits ) ;
65- for ( const split of selectedSplits ) {
66- await this . mergeSplitApk ( entryMap , split . buffer ) ;
67- }
35+ let cmd = `bundletool build-apks --mode=universal --bundle="${ this . file } " --output="${ tempApksPath } " --overwrite` ;
36+ try {
37+ await execAsync ( cmd ) ;
38+ } catch ( e ) {
39+ // Fallback to npx node-bundletool if bundletool is not in PATH
40+ // We use -y to avoid interactive prompt for installation
41+ cmd = `npx -y node-bundletool build-apks --mode=universal --bundle="${ this . file } " --output="${ tempApksPath } " --overwrite` ;
42+ await execAsync ( cmd ) ;
43+ }
6844
69- await this . writeApk ( entryMap , outputPath ) ;
70- return outputPath ;
45+ // 2. Extract universal.apk from the .apks (zip) file
46+ await new Promise < void > ( ( resolve , reject ) => {
47+ openZipFile ( tempApksPath , { lazyEntries : true } , ( err , zipfile ) => {
48+ if ( err || ! zipfile ) {
49+ reject ( err || new Error ( 'Failed to open generated .apks file' ) ) ;
50+ return ;
51+ }
52+
53+ let found = false ;
54+ zipfile . readEntry ( ) ;
55+ zipfile . on ( 'entry' , ( entry ) => {
56+ if ( entry . fileName === 'universal.apk' ) {
57+ found = true ;
58+ zipfile . openReadStream ( entry , ( err , readStream ) => {
59+ if ( err || ! readStream ) {
60+ reject ( err || new Error ( 'Failed to read universal.apk' ) ) ;
61+ return ;
62+ }
63+ const writeStream = fs . createWriteStream ( outputPath ) ;
64+ readStream . pipe ( writeStream ) ;
65+ writeStream . on ( 'close' , ( ) => {
66+ zipfile . close ( ) ;
67+ resolve ( ) ;
68+ } ) ;
69+ writeStream . on ( 'error' , reject ) ;
70+ } ) ;
71+ } else {
72+ zipfile . readEntry ( ) ;
73+ }
74+ } ) ;
75+
76+ zipfile . on ( 'end' , ( ) => {
77+ if ( ! found )
78+ reject ( new Error ( 'universal.apk not found in generated .apks' ) ) ;
79+ } ) ;
80+ zipfile . on ( 'error' , reject ) ;
81+ } ) ;
82+ } ) ;
83+ } finally {
84+ // Cleanup
85+ if ( await fs . pathExists ( tempApksPath ) ) {
86+ await fs . remove ( tempApksPath ) ;
87+ }
88+ }
7189 }
7290
7391 /**
@@ -110,195 +128,6 @@ class AabParser extends Zip {
110128 throw new Error ( `Failed to parse AAB: ${ error . message ?? error } ` ) ;
111129 }
112130 }
113-
114- private pickSplits (
115- splitEntries : SplitEntry [ ] ,
116- includeAllSplits : boolean ,
117- splits : string [ ] | null ,
118- ) {
119- if ( splits && splits . length > 0 ) {
120- return splitEntries . filter ( ( { name } ) =>
121- splits . some ( ( s ) => name . includes ( s ) ) ,
122- ) ;
123- }
124- return includeAllSplits ? splitEntries : [ ] ;
125- }
126-
127- private async writeApk (
128- entries : Map < string , BufferedEntry > ,
129- outputPath : string ,
130- ) {
131- await fs . ensureDir ( path . dirname ( outputPath ) ) ;
132-
133- const zipFile = new YazlZipFile ( ) ;
134- for ( const { apkPath, buffer, compress } of entries . values ( ) ) {
135- zipFile . addBuffer ( buffer , apkPath , {
136- compress,
137- } ) ;
138- }
139-
140- await new Promise < void > ( ( resolve , reject ) => {
141- zipFile . outputStream
142- . pipe ( fs . createWriteStream ( outputPath ) )
143- . on ( 'close' , resolve )
144- . on ( 'error' , reject ) ;
145- zipFile . end ( ) ;
146- } ) ;
147- }
148-
149- private async collectBundleEntries ( ) {
150- return new Promise < {
151- baseEntries : BufferedEntry [ ] ;
152- splitEntries : SplitEntry [ ] ;
153- metaInfEntries : BufferedEntry [ ] ;
154- } > ( ( resolve , reject ) => {
155- openZipFile ( this . file as string , { lazyEntries : true } , ( err , zipfile ) => {
156- if ( err || ! zipfile ) {
157- reject ( err ?? new Error ( 'Failed to open AAB file' ) ) ;
158- return ;
159- }
160-
161- const baseEntries : BufferedEntry [ ] = [ ] ;
162- const splitEntries : SplitEntry [ ] = [ ] ;
163- const metaInfEntries : BufferedEntry [ ] = [ ] ;
164- const promises : Promise < void > [ ] = [ ] ;
165-
166- const readNext = ( ) => zipfile . readEntry ( ) ;
167-
168- zipfile . on ( 'entry' , ( entry : Entry ) => {
169- if ( entry . fileName . endsWith ( '/' ) ) {
170- readNext ( ) ;
171- return ;
172- }
173-
174- const promise = this . readEntryBuffer ( zipfile , entry )
175- . then ( ( buffer ) => {
176- if ( entry . fileName . startsWith ( 'base/' ) ) {
177- const apkPath = this . mapBasePath ( entry . fileName ) ;
178- if ( apkPath ) {
179- baseEntries . push ( {
180- apkPath,
181- buffer,
182- compress : entry . compressionMethod !== 0 ,
183- } ) ;
184- }
185- } else if (
186- ( entry . fileName . startsWith ( 'splits/' ) ||
187- entry . fileName . startsWith ( 'split/' ) ) &&
188- entry . fileName . endsWith ( '.apk' )
189- ) {
190- splitEntries . push ( { name : entry . fileName , buffer } ) ;
191- } else if ( entry . fileName . startsWith ( 'META-INF/' ) ) {
192- metaInfEntries . push ( {
193- apkPath : entry . fileName ,
194- buffer,
195- compress : entry . compressionMethod !== 0 ,
196- } ) ;
197- }
198- } )
199- . catch ( ( error ) => {
200- zipfile . close ( ) ;
201- reject ( error ) ;
202- } )
203- . finally ( readNext ) ;
204-
205- promises . push ( promise ) ;
206- } ) ;
207-
208- zipfile . once ( 'error' , reject ) ;
209- zipfile . once ( 'end' , ( ) => {
210- Promise . all ( promises )
211- . then ( ( ) => resolve ( { baseEntries, splitEntries, metaInfEntries } ) )
212- . catch ( reject ) ;
213- } ) ;
214-
215- readNext ( ) ;
216- } ) ;
217- } ) ;
218- }
219-
220- private mapBasePath ( fileName : string ) {
221- const relative = fileName . replace ( / ^ b a s e \/ / , '' ) ;
222- if ( ! relative ) return null ;
223-
224- if ( relative === 'manifest/AndroidManifest.xml' ) {
225- return 'androidmanifest.xml' ;
226- }
227-
228- if ( relative . startsWith ( 'root/' ) ) {
229- return relative . replace ( / ^ r o o t \/ / , '' ) ;
230- }
231-
232- if ( relative === 'resources.pb' ) {
233- return 'resources.pb' ;
234- }
235-
236- if ( relative === 'resources.arsc' ) {
237- return 'resources.arsc' ;
238- }
239-
240- return relative ;
241- }
242-
243- private async mergeSplitApk (
244- entryMap : Map < string , BufferedEntry > ,
245- splitBuffer : Buffer ,
246- ) {
247- await new Promise < void > ( ( resolve , reject ) => {
248- openZipFromBuffer ( splitBuffer , { lazyEntries : true } , ( err , zipfile ) => {
249- if ( err || ! zipfile ) {
250- reject ( err ?? new Error ( 'Failed to open split APK' ) ) ;
251- return ;
252- }
253-
254- const readNext = ( ) => zipfile . readEntry ( ) ;
255- zipfile . on ( 'entry' , ( entry : Entry ) => {
256- if ( entry . fileName . endsWith ( '/' ) ) {
257- readNext ( ) ;
258- return ;
259- }
260- if ( entry . fileName . startsWith ( 'META-INF/' ) ) {
261- readNext ( ) ;
262- return ;
263- }
264-
265- this . readEntryBuffer ( zipfile , entry )
266- . then ( ( buffer ) => {
267- entryMap . set ( entry . fileName , {
268- apkPath : entry . fileName ,
269- buffer,
270- compress : entry . compressionMethod !== 0 ,
271- } ) ;
272- } )
273- . catch ( ( error ) => {
274- zipfile . close ( ) ;
275- reject ( error ) ;
276- } )
277- . finally ( readNext ) ;
278- } ) ;
279-
280- zipfile . once ( 'error' , reject ) ;
281- zipfile . once ( 'end' , resolve ) ;
282- readNext ( ) ;
283- } ) ;
284- } ) ;
285- }
286-
287- private async readEntryBuffer ( zipfile : any , entry : Entry ) : Promise < Buffer > {
288- return new Promise ( ( resolve , reject ) => {
289- zipfile . openReadStream ( entry , ( err : any , readStream : any ) => {
290- if ( err || ! readStream ) {
291- reject ( err ?? new Error ( 'Failed to open entry stream' ) ) ;
292- return ;
293- }
294- const chunks : Buffer [ ] = [ ] ;
295- readStream . on ( 'data' , ( chunk : Buffer ) => chunks . push ( chunk ) ) ;
296- readStream . on ( 'end' , ( ) => resolve ( Buffer . concat ( chunks ) ) ) ;
297- readStream . on ( 'error' , reject ) ;
298- } ) ;
299- } ) ;
300- }
301-
302131 /**
303132 * Parse manifest
304133 * @param {Buffer } buffer // manifest file's buffer
@@ -317,7 +146,7 @@ class AabParser extends Zip {
317146 } ) ;
318147 return parser . parse ( ) ;
319148 } catch ( e : any ) {
320- throw new Error ( ' Parse AndroidManifest.xml error: ' + e . message ) ;
149+ throw new Error ( ` Parse AndroidManifest.xml error: ${ e . message } ` ) ;
321150 }
322151 }
323152
@@ -330,7 +159,7 @@ class AabParser extends Zip {
330159 const ResourceFinder = require ( './resource-finder' ) ;
331160 return new ResourceFinder ( ) . processResourceTable ( buffer ) ;
332161 } catch ( e : any ) {
333- throw new Error ( ' Parser resources.arsc error: ' + e . message ) ;
162+ throw new Error ( ` Parser resources.arsc error: ${ e . message } ` ) ;
334163 }
335164 }
336165}
0 commit comments