fix(admin-service): align APK/IPA parser with RWADurian implementation

Replace fragile internal ManifestParser approach with RWADurian's proven pattern:
- APK: write buffer to temp file → ApkReader.open(path) → readManifest() → cleanup
- IPA: unzipper.Open.buffer() → Info.plist → bplist-parser with XML fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 06:08:47 -08:00
parent c70b4bac6a
commit a6cb7add60
1 changed files with 37 additions and 53 deletions

View File

@ -1,6 +1,14 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import * as unzipper from 'unzipper';
import * as bplist from 'bplist-parser';
import { IPackageParser, ParsedPackageInfo } from '../../domain/ports/package-parser.interface';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ApkReader = require('adbkit-apkreader');
@Injectable()
export class PackageParserService implements IPackageParser {
private readonly logger = new Logger(PackageParserService.name);
@ -13,41 +21,12 @@ export class PackageParserService implements IPackageParser {
}
private async parseApk(buffer: Buffer): Promise<ParsedPackageInfo> {
// yauzl.open() 不支持 APK V2/V3 签名格式EOCD 被签名块挤移),
// 改用 yauzl.fromBuffer() + adbkit-apkreader 内部 ManifestParser 直接解析
// adbkit-apkreader only supports file paths, write to temp file first
const tempFile = path.join(os.tmpdir(), `apk-parse-${Date.now()}.apk`);
try {
const yauzl = await import('yauzl');
// ManifestParser(buffer) 内部自带 BinaryXmlParser直接传原始 Buffer 即可
const ManifestParser: any = await import('adbkit-apkreader/lib/apkreader/parser/manifest' as any)
.then((m: any) => m.default || m);
const zipfile = await new Promise<any>((resolve, reject) =>
yauzl.fromBuffer(buffer, { lazyEntries: true }, (err: any, zf: any) =>
err ? reject(err) : resolve(zf),
),
);
const manifestBuffer = await new Promise<Buffer>((resolve, reject) => {
zipfile.readEntry();
zipfile.on('entry', (entry: any) => {
if (entry.fileName === 'AndroidManifest.xml') {
zipfile.openReadStream(entry, (err: any, stream: any) => {
if (err) return reject(err);
const chunks: Buffer[] = [];
stream.on('data', (c: Buffer) => chunks.push(c));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
} else {
zipfile.readEntry();
}
});
zipfile.on('end', () => reject(new Error('AndroidManifest.xml not found')));
zipfile.on('error', reject);
});
const manifest = new ManifestParser(manifestBuffer).parse();
await fs.writeFile(tempFile, buffer);
const reader = await ApkReader.open(tempFile);
const manifest = await reader.readManifest();
return {
packageName: manifest.package || 'unknown',
versionCode: manifest.versionCode || 0,
@ -56,43 +35,48 @@ export class PackageParserService implements IPackageParser {
platform: 'ANDROID',
};
} catch (err: any) {
this.logger.warn(`APK parse failed, using fallback: ${err.message}`);
return {
packageName: 'unknown',
versionCode: 0,
versionName: '0.0.0',
platform: 'ANDROID',
};
this.logger.warn(`APK parse failed: ${err.message}`);
return { packageName: 'unknown', versionCode: 0, versionName: '0.0.0', platform: 'ANDROID' };
} finally {
await fs.unlink(tempFile).catch(() => {});
}
}
private async parseIpa(buffer: Buffer): Promise<ParsedPackageInfo> {
try {
const unzipper = await import('unzipper');
const bplistParser = await import('bplist-parser');
const directory = await unzipper.Open.buffer(buffer);
const plistEntry = directory.files.find((f: any) => /Payload\/[^/]+\.app\/Info\.plist$/.test(f.path));
if (!plistEntry) throw new Error('Info.plist not found in IPA');
const plistBuffer = await plistEntry.buffer();
const parsed = bplistParser.parseBuffer(plistBuffer);
const info = parsed[0] || {};
let info: any = {};
try {
const parsed = bplist.parseBuffer(plistBuffer);
info = parsed[0] || {};
} catch {
info = this.parseXmlPlist(plistBuffer.toString('utf8')) || {};
}
return {
packageName: info.CFBundleIdentifier || 'unknown',
versionCode: parseInt(info.CFBundleVersion || '0', 10),
versionName: info.CFBundleShortVersionString || '0.0.0',
versionName: info.CFBundleShortVersionString || info.CFBundleVersion || '0.0.0',
minSdkVersion: info.MinimumOSVersion,
platform: 'IOS',
};
} catch (err: any) {
this.logger.warn(`IPA parse failed, using fallback: ${err.message}`);
return {
packageName: 'unknown',
versionCode: 0,
versionName: '0.0.0',
platform: 'IOS',
};
this.logger.warn(`IPA parse failed: ${err.message}`);
return { packageName: 'unknown', versionCode: 0, versionName: '0.0.0', platform: 'IOS' };
}
}
private parseXmlPlist(content: string): Record<string, any> | null {
const result: Record<string, any> = {};
const strRegex = /<key>([^<]+)<\/key>\s*<string>([^<]+)<\/string>/g;
const intRegex = /<key>([^<]+)<\/key>\s*<integer>([^<]+)<\/integer>/g;
let match;
while ((match = strRegex.exec(content)) !== null) result[match[1]] = match[2];
while ((match = intRegex.exec(content)) !== null) result[match[1]] = match[2];
return Object.keys(result).length > 0 ? result : null;
}
}