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:
parent
c70b4bac6a
commit
a6cb7add60
|
|
@ -1,6 +1,14 @@
|
||||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
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';
|
import { IPackageParser, ParsedPackageInfo } from '../../domain/ports/package-parser.interface';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const ApkReader = require('adbkit-apkreader');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PackageParserService implements IPackageParser {
|
export class PackageParserService implements IPackageParser {
|
||||||
private readonly logger = new Logger(PackageParserService.name);
|
private readonly logger = new Logger(PackageParserService.name);
|
||||||
|
|
@ -13,41 +21,12 @@ export class PackageParserService implements IPackageParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async parseApk(buffer: Buffer): Promise<ParsedPackageInfo> {
|
private async parseApk(buffer: Buffer): Promise<ParsedPackageInfo> {
|
||||||
// yauzl.open() 不支持 APK V2/V3 签名格式(EOCD 被签名块挤移),
|
// adbkit-apkreader only supports file paths, write to temp file first
|
||||||
// 改用 yauzl.fromBuffer() + adbkit-apkreader 内部 ManifestParser 直接解析
|
const tempFile = path.join(os.tmpdir(), `apk-parse-${Date.now()}.apk`);
|
||||||
try {
|
try {
|
||||||
const yauzl = await import('yauzl');
|
await fs.writeFile(tempFile, buffer);
|
||||||
// ManifestParser(buffer) 内部自带 BinaryXmlParser,直接传原始 Buffer 即可
|
const reader = await ApkReader.open(tempFile);
|
||||||
const ManifestParser: any = await import('adbkit-apkreader/lib/apkreader/parser/manifest' as any)
|
const manifest = await reader.readManifest();
|
||||||
.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();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
packageName: manifest.package || 'unknown',
|
packageName: manifest.package || 'unknown',
|
||||||
versionCode: manifest.versionCode || 0,
|
versionCode: manifest.versionCode || 0,
|
||||||
|
|
@ -56,43 +35,48 @@ export class PackageParserService implements IPackageParser {
|
||||||
platform: 'ANDROID',
|
platform: 'ANDROID',
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`APK parse failed, using fallback: ${err.message}`);
|
this.logger.warn(`APK parse failed: ${err.message}`);
|
||||||
return {
|
return { packageName: 'unknown', versionCode: 0, versionName: '0.0.0', platform: 'ANDROID' };
|
||||||
packageName: 'unknown',
|
} finally {
|
||||||
versionCode: 0,
|
await fs.unlink(tempFile).catch(() => {});
|
||||||
versionName: '0.0.0',
|
|
||||||
platform: 'ANDROID',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async parseIpa(buffer: Buffer): Promise<ParsedPackageInfo> {
|
private async parseIpa(buffer: Buffer): Promise<ParsedPackageInfo> {
|
||||||
try {
|
try {
|
||||||
const unzipper = await import('unzipper');
|
|
||||||
const bplistParser = await import('bplist-parser');
|
|
||||||
const directory = await unzipper.Open.buffer(buffer);
|
const directory = await unzipper.Open.buffer(buffer);
|
||||||
const plistEntry = directory.files.find((f: any) => /Payload\/[^/]+\.app\/Info\.plist$/.test(f.path));
|
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');
|
if (!plistEntry) throw new Error('Info.plist not found in IPA');
|
||||||
|
|
||||||
const plistBuffer = await plistEntry.buffer();
|
const plistBuffer = await plistEntry.buffer();
|
||||||
const parsed = bplistParser.parseBuffer(plistBuffer);
|
let info: any = {};
|
||||||
const info = parsed[0] || {};
|
try {
|
||||||
|
const parsed = bplist.parseBuffer(plistBuffer);
|
||||||
|
info = parsed[0] || {};
|
||||||
|
} catch {
|
||||||
|
info = this.parseXmlPlist(plistBuffer.toString('utf8')) || {};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
packageName: info.CFBundleIdentifier || 'unknown',
|
packageName: info.CFBundleIdentifier || 'unknown',
|
||||||
versionCode: parseInt(info.CFBundleVersion || '0', 10),
|
versionCode: parseInt(info.CFBundleVersion || '0', 10),
|
||||||
versionName: info.CFBundleShortVersionString || '0.0.0',
|
versionName: info.CFBundleShortVersionString || info.CFBundleVersion || '0.0.0',
|
||||||
minSdkVersion: info.MinimumOSVersion,
|
minSdkVersion: info.MinimumOSVersion,
|
||||||
platform: 'IOS',
|
platform: 'IOS',
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`IPA parse failed, using fallback: ${err.message}`);
|
this.logger.warn(`IPA parse failed: ${err.message}`);
|
||||||
return {
|
return { packageName: 'unknown', versionCode: 0, versionName: '0.0.0', platform: 'IOS' };
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue