From 9f8960a46bb7403f568405367783a5d2594c7e67 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 13:45:22 +0800 Subject: [PATCH 01/20] refactor(zip): share entry stream, first-entry and output-zip helpers - zip-entries gains openEntryStream, openFirstEntry (moved from zip-range), readFirstEntry, extractFirstEntry and createOutputZip (moved from diff) - readEntry / writeEntry / readEntryPrefix / nested .hap extraction use them; readEntryPrefix stops via for-await break instead of hand-managed listeners - diff, native-package (raw copy, repack, nested extract), utils readZipEntry, hermes-base readFirstZipEntry and aab universal.apk extraction drop their own yauzl/yazl stream wrappers; drop two now-unused locale keys - fix: packBundle hung forever when the output file could not be written (no 'error' listener on the write stream); it now rejects and removes the partial file Co-Authored-By: Claude Opus 5.5 --- src/bundle-pack.ts | 62 +++--- src/diff.ts | 62 ++---- src/native-package.ts | 100 +++------- src/utils/app-info-parser/aab.ts | 47 +---- src/utils/hermes-base.ts | 19 +- src/utils/index.ts | 43 +--- src/utils/zip-entries.ts | 323 +++++++++++++++++-------------- src/utils/zip-range.ts | 43 +--- tests/bundle-pack.test.ts | 13 ++ 9 files changed, 270 insertions(+), 442 deletions(-) diff --git a/src/bundle-pack.ts b/src/bundle-pack.ts index c8b963d..24edde8 100644 --- a/src/bundle-pack.ts +++ b/src/bundle-pack.ts @@ -1,8 +1,7 @@ import fs from 'fs'; import path from 'path'; -import { ZipFile as YazlZipFile } from 'yazl'; -import { ensureDirSync } from './utils/fs-helpers'; import { t } from './utils/i18n'; +import { createOutputZip } from './utils/zip-entries'; import { zipOptionsForPayloadFile } from './utils/zip-options'; const ignorePackingExtensions = ['DS_Store', 'txt.map']; @@ -20,43 +19,40 @@ export async function packBundle( ignorePackingFileNames.push(`${bundleName}.map`); } console.log(t('packing')); - ensureDirSync(path.dirname(output)); - await new Promise((resolve, reject) => { - const zipfile = new YazlZipFile(); + const { zipfile, writePromise, abort } = createOutputZip(output); - function addDirectory(root: string, rel: string) { - if (rel) { - zipfile.addEmptyDirectory(rel); + function addDirectory(root: string, rel: string) { + if (rel) { + zipfile.addEmptyDirectory(rel); + } + for (const name of fs.readdirSync(root)) { + if ( + ignorePackingFileNames.includes(name) || + ignorePackingExtensions.some((ext) => name.endsWith(`.${ext}`)) + ) { + continue; } - const children = fs.readdirSync(root); - for (const name of children) { - if ( - ignorePackingFileNames.includes(name) || - ignorePackingExtensions.some((ext) => name.endsWith(`.${ext}`)) - ) { - continue; - } - const fullPath = path.join(root, name); - const stat = fs.statSync(fullPath); - if (stat.isFile()) { - zipfile.addFile( - fullPath, - rel + name, - zipOptionsForPayloadFile(fullPath, rel + name), - ); - } else if (stat.isDirectory()) { - addDirectory(fullPath, `${rel}${name}/`); - } + const fullPath = path.join(root, name); + const stat = fs.statSync(fullPath); + if (stat.isFile()) { + zipfile.addFile( + fullPath, + rel + name, + zipOptionsForPayloadFile(fullPath, rel + name), + ); + } else if (stat.isDirectory()) { + addDirectory(fullPath, `${rel}${name}/`); } } + } + try { addDirectory(dir, ''); - - zipfile.outputStream.on('error', (err: unknown) => reject(err)); - zipfile.outputStream.pipe(fs.createWriteStream(output)).on('close', () => { - resolve(); - }); zipfile.end(); - }); + await writePromise; + } catch (error) { + await abort(); + throw error; + } console.log(t('fileGenerated', { file: output })); } diff --git a/src/diff.ts b/src/diff.ts index 993a89d..163396b 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -4,12 +4,11 @@ import { npm, yarn } from 'global-dirs'; import os from 'os'; import path from 'path'; import type { Entry, ZipFile as YauzlZipFile } from 'yauzl'; -import { ZipFile as YazlZipFile } from 'yazl'; +import type { ZipFile as YazlZipFile } from 'yazl'; import { resolveNativePackageEntry } from './native-package'; import type { CommandContext, Platform } from './types'; import { translateOptions } from './utils'; import { isPPKBundleFileName, scriptName, tempDir } from './utils/constants'; -import { ensureDirSync } from './utils/fs-helpers'; import { type HbcTransformMeta, transformHbcFile, @@ -19,7 +18,9 @@ import { } from './utils/hbcTransform'; import { t } from './utils/i18n'; import { + createOutputZip, enumZipEntries, + openEntryStream, readEntry, readEntryPrefix, writeEntry, @@ -132,38 +133,6 @@ function basename(fn: string): string | undefined { return m?.[1]; } -function createOutputZip(output: string) { - ensureDirSync(path.dirname(output)); - const zipfile = new YazlZipFile(); - const writeStream = fs.createWriteStream(output); - const writePromise = new Promise((resolve, reject) => { - zipfile.outputStream.on('error', reject); - // without this, a full disk / read-only target left the promise pending - writeStream.on('error', reject); - zipfile.outputStream.pipe(writeStream).on('close', () => { - resolve(void 0); - }); - }); - // 失败时释放输出 fd 并删掉半成品,避免残留一个截断的 patch 包 - const abort = async () => { - writePromise.catch(() => {}); - zipfile.outputStream.unpipe(writeStream); - // yazl 的 outputStream 类型声明缺 destroy,运行时是 PassThrough - (zipfile.outputStream as unknown as { destroy(): void }).destroy(); - if (!writeStream.closed) { - // 等 fd 真正关闭再删文件,避免 Windows 上删除被占用文件失败 - await new Promise((resolve) => { - writeStream.once('close', () => resolve()); - if (!writeStream.destroyed) { - writeStream.destroy(); - } - }); - } - await fs.promises.rm(output, { force: true }); - }; - return { zipfile, writePromise, abort }; -} - /** * 打开输出包,执行 build 填充条目并返回 manifest,最后写入 __diff.json * 收尾。build 或收尾任何一步失败都会关闭输出流、删除半成品并重新抛出。 @@ -543,24 +512,15 @@ async function addFileFromZipEntry( nextZipfile, ZIP_ENTRY_SNIFF_BYTES, ); + const readStream = await openEntryStream(entry, nextZipfile); await new Promise((resolve, reject) => { - nextZipfile.openReadStream(entry, (err, readStream) => { - if (err) { - return reject(err); - } - if (!readStream) { - return reject(new Error(`Unable to read zip entry: ${entry.fileName}`)); - } - zipfile.addReadStream( - readStream, - entry.fileName, - zipOptionsForPayloadEntry(entry.fileName, entryPrefix), - ); - readStream.on('error', reject); - readStream.on('end', () => { - resolve(void 0); - }); - }); + zipfile.addReadStream( + readStream, + entry.fileName, + zipOptionsForPayloadEntry(entry.fileName, entryPrefix), + ); + readStream.on('error', reject); + readStream.on('end', () => resolve()); }); } diff --git a/src/native-package.ts b/src/native-package.ts index 668444c..21bb20d 100644 --- a/src/native-package.ts +++ b/src/native-package.ts @@ -6,9 +6,13 @@ import { open as openZipFile, type ZipFile as YauzlZipFile, } from 'yauzl'; -import { ZipFile as YazlZipFile } from 'yazl'; import type { Platform } from './types'; import { ensureDir, remove } from './utils/fs-helpers'; +import { + createOutputZip, + openEntryStream, + writeEntry, +} from './utils/zip-entries'; import { fitsRawZip, RawZipWriter } from './utils/zip-raw-writer'; export type NativePackageEntry = { @@ -87,22 +91,11 @@ class RawCopySink implements SlimSink { constructor(output: string) { this.writer = new RawZipWriter(output); } - copyEntry(sourceZip: YauzlZipFile, entry: Entry): Promise { - return new Promise((resolve, reject) => { - sourceZip.openReadStream( - entry, - { decodeFileData: false }, - (error, readStream) => { - if (error || !readStream) { - reject( - error ?? new Error(`Unable to read zip entry: ${entry.fileName}`), - ); - return; - } - this.writer.addRawEntry(entry, readStream).then(resolve, reject); - }, - ); + async copyEntry(sourceZip: YauzlZipFile, entry: Entry): Promise { + const readStream = await openEntryStream(entry, sourceZip, { + decodeFileData: false, }); + await this.writer.addRawEntry(entry, readStream); } addFile(filePath: string, fileName: string, like: Entry): Promise { // a nested slim archive is a zip of deflated entries: storing it as-is @@ -119,32 +112,18 @@ class RawCopySink implements SlimSink { /** Re-deflates every kept entry through yazl; only for sources that need zip64. */ class RepackSink implements SlimSink { - private readonly outputZip = new YazlZipFile(); - private readonly writePromise: Promise; - constructor(readonly output: string) { - this.writePromise = new Promise((resolve, reject) => { - this.outputZip.outputStream.once('error', reject); - this.outputZip.outputStream - .pipe(fs.createWriteStream(output)) - .once('error', reject) - .once('close', () => resolve()); - }); + private readonly output: ReturnType; + constructor(output: string) { + this.output = createOutputZip(output); } - copyEntry(sourceZip: YauzlZipFile, entry: Entry): Promise { - return new Promise((resolve, reject) => { - sourceZip.openReadStream(entry, (error, readStream) => { - if (error || !readStream) { - reject( - error ?? new Error(`Unable to read zip entry: ${entry.fileName}`), - ); - return; - } - readStream.once('error', reject); - readStream.once('end', () => resolve()); - this.outputZip.addReadStream(readStream, entry.fileName, { - compress: entry.compressionMethod !== 0, - mtime: entry.getLastModDate(), - }); + async copyEntry(sourceZip: YauzlZipFile, entry: Entry): Promise { + const readStream = await openEntryStream(entry, sourceZip); + await new Promise((resolve, reject) => { + readStream.once('error', reject); + readStream.once('end', () => resolve()); + this.output.zipfile.addReadStream(readStream, entry.fileName, { + compress: entry.compressionMethod !== 0, + mtime: entry.getLastModDate(), }); }); } @@ -153,19 +132,17 @@ class RepackSink implements SlimSink { fileName: string, like: Entry, ): Promise { - this.outputZip.addFile(filePath, fileName, { + this.output.zipfile.addFile(filePath, fileName, { compress: like.compressionMethod !== 0, mtime: like.getLastModDate(), }); } async end(): Promise { - this.outputZip.end(); - await this.writePromise; + this.output.zipfile.end(); + await this.output.writePromise; } - async abort(): Promise { - this.outputZip.end(); - await this.writePromise.catch(() => {}); - await remove(this.output).catch(() => {}); + abort(): Promise { + return this.output.abort(); } } @@ -181,31 +158,6 @@ function openZip(source: string): Promise { }); } -function extractEntryToFile( - sourceZip: YauzlZipFile, - entry: Entry, - outputPath: string, -): Promise { - return new Promise((resolve, reject) => { - sourceZip.openReadStream(entry, (error, readStream) => { - if (error) { - reject(error); - return; - } - if (!readStream) { - reject(new Error(`Unable to read zip entry: ${entry.fileName}`)); - return; - } - - const writeStream = fs.createWriteStream(outputPath); - readStream.once('error', reject); - writeStream.once('error', reject); - writeStream.once('close', () => resolve()); - readStream.pipe(writeStream); - }); - }); -} - export interface SlimPackageOptions { /** re-deflate through yazl even when a raw copy would do (tests) */ repack?: boolean; @@ -270,7 +222,7 @@ async function filterNativePackageArchive( const index = nestedArchiveIndex++; const nestedSource = path.join(scratchDir, `${index}.source.hap`); const nestedOutput = path.join(scratchDir, `${index}.slim.hap`); - await extractEntryToFile(sourceZip, entry, nestedSource); + await writeEntry(entry, sourceZip, nestedSource); const nestedResult = await filterNativePackageArchive( nestedSource, nestedOutput, diff --git a/src/utils/app-info-parser/aab.ts b/src/utils/app-info-parser/aab.ts index fe2f94e..0f22d4d 100644 --- a/src/utils/app-info-parser/aab.ts +++ b/src/utils/app-info-parser/aab.ts @@ -2,9 +2,9 @@ import { spawn } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { open as openZipFile } from 'yauzl'; import { pathExists, remove } from '../fs-helpers'; import { t } from '../i18n'; +import { extractFirstEntry } from '../zip-entries'; import { ResourceFinder } from './resource-finder'; import { mapInfoResource } from './utils'; import { ManifestParser } from './xml-parser/manifest'; @@ -127,43 +127,14 @@ export class AabParser extends Zip { } // 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(t('aabOpenApksFailed'))); - 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(t('aabReadUniversalApkFailed'))); - return; - } - const writeStream = fs.createWriteStream(outputPath); - readStream.pipe(writeStream); - writeStream.on('close', () => { - zipfile.close(); - resolve(); - }); - readStream.on('error', reject); - writeStream.on('error', reject); - }); - } else { - zipfile.readEntry(); - } - }); - - zipfile.on('end', () => { - if (!found) reject(new Error(t('aabUniversalApkNotFound'))); - }); - zipfile.on('error', reject); - }); - }); + const found = await extractFirstEntry( + tempApksPath, + (name) => name === 'universal.apk', + outputPath, + ); + if (!found) { + throw new Error(t('aabUniversalApkNotFound')); + } } finally { // Cleanup if (await pathExists(tempDir)) { diff --git a/src/utils/hermes-base.ts b/src/utils/hermes-base.ts index 16ca52f..35fb3f3 100644 --- a/src/utils/hermes-base.ts +++ b/src/utils/hermes-base.ts @@ -37,7 +37,7 @@ import { auditRawHermesBytecode, readHermesSemanticData } from './hermes-raw'; import { hermesTimeout } from './hermes-timeout'; import { t } from './i18n'; import { webFetch } from './runtime'; -import { enumZipEntries, readEntry } from './zip-entries'; +import { readFirstEntry } from './zip-entries'; import { fetchZipEntryData, fetchZipEntryHead, @@ -412,7 +412,7 @@ export async function extractBundleFromArchive( return fs.promises.readFile(filePath); } if (ext === '.ppk' || ext === '.apk' || ext === '.ipa') { - return readFirstZipEntry( + return readFirstEntry( filePath, bundleEntryMatcher(ext.slice(1) as BaseArtifactType), ); @@ -427,25 +427,12 @@ export async function extractBundleFromArchive( return bundle ?? null; } // unknown extension: try as zip, then as raw file - const fromZip = await readFirstZipEntry(filePath, (name) => + const fromZip = await readFirstEntry(filePath, (name) => BUNDLE_ENTRY_NAMES.includes(name), ).catch(() => null); return fromZip ?? fs.promises.readFile(filePath); } -async function readFirstZipEntry( - zipPath: string, - matches: (name: string) => boolean, -): Promise { - let found: Buffer | null = null; - await enumZipEntries(zipPath, async (entry, zipFile) => { - if (!found && matches(entry.fileName)) { - found = await readEntry(entry, zipFile); - } - }); - return found; -} - // --------------------------------------------------------------------------- // cache (sha256-named bundles) // --------------------------------------------------------------------------- diff --git a/src/utils/index.ts b/src/utils/index.ts index 9f7a3b4..96afa0a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,13 +4,13 @@ import { createHash } from 'crypto'; import path from 'path'; import type { Root as ProtobufRoot } from 'protobufjs'; import { read } from 'read'; -import { open as openZipFile, type Entry as YauzlEntry } from 'yauzl'; import pkg from '../../package.json'; import type AppInfoParserType from './app-info-parser'; import { checkPlugins } from './check-plugin'; import { IS_CRESC } from './constants'; import { getDepVersion } from './dep-versions'; import { t } from './i18n'; +import { readFirstEntry } from './zip-entries'; // app-info-parser (and its zip/plist/protobuf stack) is only needed by the // package commands; load it on first use instead of on every CLI start. @@ -427,44 +427,9 @@ export async function getAabInfo(fn: string) { } async function readZipEntry(fn: string, entryName: string): Promise { - return new Promise((resolve, reject) => { - openZipFile(fn, { lazyEntries: true }, (err, zipfile) => { - if (err || !zipfile) { - reject(err ?? new Error('Failed to open zip file')); - return; - } - let found = false; - zipfile.readEntry(); - zipfile.on('entry', (entry: YauzlEntry) => { - if (entry.fileName === entryName) { - found = true; - zipfile.openReadStream(entry, (streamError, readStream) => { - if (streamError || !readStream) { - zipfile.close(); - reject(streamError ?? new Error('Failed to read zip entry')); - return; - } - const chunks: Buffer[] = []; - readStream.on('data', (chunk: Buffer) => chunks.push(chunk)); - readStream.on('end', () => { - zipfile.close(); - resolve(Buffer.concat(chunks)); - }); - readStream.on('error', (error) => { - zipfile.close(); - reject(error); - }); - }); - } else { - zipfile.readEntry(); - } - }); - zipfile.on('end', () => { - if (!found) reject(new Error(`${entryName} not found in AAB`)); - }); - zipfile.on('error', reject); - }); - }); + const data = await readFirstEntry(fn, (name) => name === entryName); + if (!data) throw new Error(`${entryName} not found in AAB`); + return data; } async function resolveResource( diff --git a/src/utils/zip-entries.ts b/src/utils/zip-entries.ts index a57bf6e..92fb074 100644 --- a/src/utils/zip-entries.ts +++ b/src/utils/zip-entries.ts @@ -1,141 +1,183 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import type { Readable } from 'stream'; import { pipeline } from 'stream/promises'; import { type Entry, open as openZipFile, type ZipFile as YauzlZipFile, } from 'yauzl'; +import { ZipFile as YazlZipFile } from 'yazl'; import { ensureDirSync, remove } from './fs-helpers'; -export function readEntry( +export function openEntryStream( entry: Entry, zipFile: YauzlZipFile, -): Promise { - const buffers: Buffer[] = []; + options: { decodeFileData?: boolean } = {}, +): Promise { return new Promise((resolve, reject) => { - zipFile.openReadStream(entry, (err, stream) => { - if (err) { - return reject(err); - } - if (!stream) { - return reject(new Error(`Unable to read zip entry: ${entry.fileName}`)); + zipFile.openReadStream(entry, options, (err, stream) => { + if (err || !stream) { + reject(err ?? new Error(`Unable to read zip entry: ${entry.fileName}`)); + return; } - stream.on('data', (chunk: Buffer) => { - buffers.push(chunk); - }); - stream.on('end', () => { - // a single chunk (a cached Range read) is handed back as-is, not copied - resolve(buffers.length === 1 ? buffers[0] : Buffer.concat(buffers)); - }); - stream.on('error', (err) => { - reject(err); - }); + resolve(stream); }); }); } -export function writeEntry( +export async function readEntry( + entry: Entry, + zipFile: YauzlZipFile, +): Promise { + const buffers: Buffer[] = []; + for await (const chunk of await openEntryStream(entry, zipFile)) { + buffers.push(chunk); + } + // a single chunk (a cached Range read) is handed back as-is, not copied + return buffers.length === 1 ? buffers[0] : Buffer.concat(buffers); +} + +export async function writeEntry( entry: Entry, zipFile: YauzlZipFile, outputPath: string, ): Promise { ensureDirSync(path.dirname(outputPath)); - return new Promise((resolve, reject) => { - zipFile.openReadStream(entry, (err, stream) => { - if (err) { - reject(err); - return; - } - if (!stream) { - reject(new Error(`Unable to read zip entry: ${entry.fileName}`)); - return; - } - pipeline(stream, fs.createWriteStream(outputPath)).then(resolve, reject); - }); - }); + await pipeline( + await openEntryStream(entry, zipFile), + fs.createWriteStream(outputPath), + ); } -export function readEntryPrefix( +export async function readEntryPrefix( entry: Entry, zipFile: YauzlZipFile, maxBytes: number, ): Promise { if (maxBytes <= 0) { - return Promise.resolve(Buffer.alloc(0)); + return Buffer.alloc(0); } - const buffers: Buffer[] = []; let length = 0; + // leaving the loop early destroys the stream without an error + for await (const chunk of await openEntryStream(entry, zipFile)) { + const slice = chunk.subarray(0, maxBytes - length); + buffers.push(slice); + length += slice.length; + if (length >= maxBytes) { + break; + } + } + return Buffer.concat(buffers, length); +} +/** Open a local zip and stop at the first matching entry (caller closes). */ +export function openFirstEntry( + zipPath: string, + matches: (name: string) => boolean, +): Promise<{ zipFile: YauzlZipFile; entry: Entry } | null> { return new Promise((resolve, reject) => { - zipFile.openReadStream(entry, (err, stream) => { - if (err) { - return reject(err); - } - if (!stream) { - return reject(new Error(`Unable to read zip entry: ${entry.fileName}`)); - } - - let settled = false; - let expectedDestroyError: Error | undefined; - const cleanup = () => { - stream.off('data', onData); - stream.off('end', onEnd); - stream.off('error', onError); - }; - const finish = (destroyStream = false) => { - if (settled) { - return; - } - settled = true; - stream.off('data', onData); - stream.off('end', onEnd); - if (!destroyStream) { - stream.off('error', onError); - } - resolve(Buffer.concat(buffers, length)); + openZipFile( + zipPath, + { lazyEntries: true, autoClose: false }, + (err, zipFile) => { + if (err) return reject(err); + let settled = false; + zipFile.on('error', (error) => { + if (!settled) { + settled = true; + reject(error); + } + }); + zipFile.on('end', () => { + if (!settled) { + settled = true; + zipFile.close(); + resolve(null); + } + }); + zipFile.on('entry', (entry: Entry) => { + if (settled) return; + if (matches(entry.fileName)) { + settled = true; + resolve({ zipFile, entry }); + return; + } + zipFile.readEntry(); + }); + zipFile.readEntry(); + }, + ); + }); +} - if (destroyStream) { - expectedDestroyError = new Error('zip entry prefix read complete'); - stream.destroy(expectedDestroyError); - } - }; - const onData = (chunk: Buffer) => { - const remaining = maxBytes - length; - if (remaining <= 0) { - finish(true); - return; - } +/** Content of the first matching entry of a local zip, or null. */ +export async function readFirstEntry( + zipPath: string, + matches: (name: string) => boolean, +): Promise { + const found = await openFirstEntry(zipPath, matches); + if (!found) return null; + try { + return await readEntry(found.entry, found.zipFile); + } finally { + found.zipFile.close(); + } +} - const slice = - chunk.length > remaining ? chunk.subarray(0, remaining) : chunk; - buffers.push(slice); - length += slice.length; - if (length >= maxBytes) { - finish(true); - } - }; - const onEnd = () => finish(); - const onError = (error: Error) => { - if (settled && error === expectedDestroyError) { - cleanup(); - return; - } - if (settled) { - return; - } - settled = true; - cleanup(); - reject(error); - }; +/** Extract the first matching entry of a local zip to `outputPath`. */ +export async function extractFirstEntry( + zipPath: string, + matches: (name: string) => boolean, + outputPath: string, +): Promise { + const found = await openFirstEntry(zipPath, matches); + if (!found) return false; + try { + await writeEntry(found.entry, found.zipFile, outputPath); + } finally { + found.zipFile.close(); + } + return true; +} - stream.on('data', onData); - stream.once('end', onEnd); - stream.once('error', onError); +/** + * A yazl archive piped into `output`. `writePromise` settles once the file is + * closed; `abort` releases the fd and deletes the partial file. + */ +export function createOutputZip(output: string) { + ensureDirSync(path.dirname(output)); + const zipfile = new YazlZipFile(); + const writeStream = fs.createWriteStream(output); + const writePromise = new Promise((resolve, reject) => { + zipfile.outputStream.on('error', reject); + // without this, a full disk / read-only target left the promise pending + writeStream.on('error', reject); + zipfile.outputStream.pipe(writeStream).on('close', () => { + resolve(void 0); }); }); + // 失败时释放输出 fd 并删掉半成品,避免残留一个截断的包 + const abort = async () => { + writePromise.catch(() => {}); + zipfile.outputStream.unpipe(writeStream); + // yazl 的 outputStream 类型声明缺 destroy,运行时是 PassThrough + (zipfile.outputStream as unknown as { destroy(): void }).destroy(); + if (!writeStream.closed) { + // 等 fd 真正关闭再删文件,避免 Windows 上删除被占用文件失败 + await new Promise((resolve) => { + writeStream.once('close', () => resolve()); + if (!writeStream.destroyed) { + writeStream.destroy(); + } + }); + } + // best effort: a failed cleanup must not mask the original error + await fs.promises.rm(output, { force: true }).catch(() => {}); + }; + return { zipfile, writePromise, abort }; } export async function enumZipEntries( @@ -148,63 +190,46 @@ export async function enumZipEntries( nestedPath = '', ) { return new Promise((resolve, reject) => { - openZipFile( - zipFn, - { lazyEntries: true }, - async (err: any, zipfile: YauzlZipFile) => { - if (err) { - return reject(err); - } - - zipfile.on('end', resolve); - zipfile.on('error', reject); - zipfile.on('entry', async (entry) => { - const fullPath = nestedPath + entry.fileName; - - try { - if ( - !entry.fileName.endsWith('/') && - entry.fileName.toLowerCase().endsWith('.hap') - ) { - const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'nested_zip_'), - ); - const tempZipPath = path.join(tempDir, 'temp.zip'); - try { - await new Promise((res, rej) => { - zipfile.openReadStream(entry, async (err, readStream) => { - if (err) return rej(err); - const writeStream = fs.createWriteStream(tempZipPath); - readStream.on('error', rej); - readStream.pipe(writeStream); - writeStream.on('finish', () => res(void 0)); - writeStream.on('error', rej); - }); - }); - - await enumZipEntries(tempZipPath, callback, `${fullPath}/`); - } finally { - await remove(tempDir); - } - } + openZipFile(zipFn, { lazyEntries: true }, (err: any, zipfile) => { + if (err) { + return reject(err); + } - const result = callback(entry, zipfile, fullPath); - if (result && typeof result.then === 'function') { - await result; + zipfile.on('end', resolve); + zipfile.on('error', reject); + zipfile.on('entry', async (entry) => { + const fullPath = nestedPath + entry.fileName; + + try { + if ( + !entry.fileName.endsWith('/') && + entry.fileName.toLowerCase().endsWith('.hap') + ) { + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'nested_zip_'), + ); + const tempZipPath = path.join(tempDir, 'temp.zip'); + try { + await writeEntry(entry, zipfile, tempZipPath); + await enumZipEntries(tempZipPath, callback, `${fullPath}/`); + } finally { + await remove(tempDir); } - } catch (error) { - // the rejection carries the error; logging it here too would make - // callers report it twice - zipfile.close(); - reject(error); - return; } - zipfile.readEntry(); - }); + await callback(entry, zipfile, fullPath); + } catch (error) { + // the rejection carries the error; logging it here too would make + // callers report it twice + zipfile.close(); + reject(error); + return; + } zipfile.readEntry(); - }, - ); + }); + + zipfile.readEntry(); + }); }); } diff --git a/src/utils/zip-range.ts b/src/utils/zip-range.ts index c9fa369..3efbfc8 100644 --- a/src/utils/zip-range.ts +++ b/src/utils/zip-range.ts @@ -11,13 +11,12 @@ import { PassThrough, Readable } from 'stream'; import { type Entry, fromRandomAccessReader, - open as openZipFile, RandomAccessReader, type ZipFile, } from 'yauzl'; import { inflateRawSync, constants as zlibConstants } from 'zlib'; import { webFetch } from './runtime'; -import { readEntry } from './zip-entries'; +import { openFirstEntry, readEntry } from './zip-entries'; export const ZIP_STORED = 0; export const ZIP_DEFLATED = 8; @@ -117,46 +116,6 @@ async function locationOfEntry( }; } -/** Open a local zip and stop at the first matching entry (caller closes). */ -function openFirstEntry( - zipPath: string, - matches: (name: string) => boolean, -): Promise<{ zipFile: ZipFile; entry: Entry } | null> { - return new Promise((resolve, reject) => { - openZipFile( - zipPath, - { lazyEntries: true, autoClose: false }, - (err, zipFile) => { - if (err) return reject(err); - let settled = false; - zipFile.on('error', (error) => { - if (!settled) { - settled = true; - reject(error); - } - }); - zipFile.on('end', () => { - if (!settled) { - settled = true; - zipFile.close(); - resolve(null); - } - }); - zipFile.on('entry', (entry: Entry) => { - if (settled) return; - if (matches(entry.fileName)) { - settled = true; - resolve({ zipFile, entry }); - return; - } - zipFile.readEntry(); - }); - zipFile.readEntry(); - }, - ); - }); -} - /** * The `bundleOffset` / `bundleCompressedSize` / `bundleCompression` fields for * version/create and package/create, or nothing when the entry cannot be diff --git a/tests/bundle-pack.test.ts b/tests/bundle-pack.test.ts index a88178e..6659c6e 100644 --- a/tests/bundle-pack.test.ts +++ b/tests/bundle-pack.test.ts @@ -130,4 +130,17 @@ describe('bundle-pack', () => { 'custom.bundle.map', ); }); + + test('rejects instead of hanging when the output cannot be written', async () => { + const sourceDir = path.join(tempRoot, 'bundle'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(sourceDir, 'index.bundlejs'), 'bundle'); + // a directory where the zip file should go: the write stream fails + const output = path.join(tempRoot, 'out.ppk'); + fs.mkdirSync(output); + + await expect(packBundle(sourceDir, output)).rejects.toMatchObject({ + code: 'EISDIR', + }); + }); }); From 505409038cce06dfb43a5e65df31999cb9953279 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 13:51:18 +0800 Subject: [PATCH 02/20] refactor(app-info-parser): parse only what the CLI reads The vendored apk/ipa/aab readers parsed icons, provisioning profiles, activities, services, permissions etc. that nothing consumed, and AabParser.parse() was never called (getAabInfo goes through protobuf). - binary XML reader keeps the element tree and the int/string/reference/ boolean value types; manifest parsing reduces to parseApkManifest() returning versionName and application meta-data - resource-finder drops DEBUG output, the unused type/key string pools, type-spec decoding and entryMap - Zip/AppInfoParser/AppParser classes become readZipEntries(); ipa is parsePlist(); AabParser is extractApk() - getApkInfo / getIpaInfo read every entry (manifest and resources, or the plist) in a single zip scan instead of two, sharing one result helper - drop the cgbi-to-png dependency and 8 unused locale keys getApkInfo and the full resources.arsc map are byte-identical to the old parser on four real release APKs. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 19 - package.json | 1 - src/locales/en.ts | 11 - src/locales/zh.ts | 8 - src/package.ts | 14 +- src/utils/app-info-parser/aab.ts | 295 +++--- src/utils/app-info-parser/apk.ts | 119 +-- src/utils/app-info-parser/app.ts | 7 - src/utils/app-info-parser/index.ts | 54 -- src/utils/app-info-parser/ipa.ts | 83 +- src/utils/app-info-parser/resource-finder.ts | 273 +----- src/utils/app-info-parser/utils.ts | 161 ---- .../app-info-parser/xml-parser/binary.ts | 836 ++++-------------- .../app-info-parser/xml-parser/manifest.ts | 228 ----- src/utils/app-info-parser/zip.ts | 126 +-- src/utils/hermes-base.ts | 7 +- src/utils/index.ts | 248 +++--- tests/app-info-zip.test.ts | 376 ++++---- 18 files changed, 709 insertions(+), 2157 deletions(-) delete mode 100644 src/utils/app-info-parser/app.ts delete mode 100644 src/utils/app-info-parser/index.ts delete mode 100644 src/utils/app-info-parser/utils.ts delete mode 100644 src/utils/app-info-parser/xml-parser/manifest.ts diff --git a/bun.lock b/bun.lock index 4619b9a..bf5d93b 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,6 @@ "name": "react-native-update-cli", "dependencies": { "bplist-parser": "^0.3.2", - "cgbi-to-png": "^1.0.7", "chalk": "4", "cli-arguments": "^0.2.1", "compare-versions": "^6.1.1", @@ -150,20 +149,14 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], "breakword": ["breakword@1.0.6", "", { "dependencies": { "wcwidth": "^1.0.1" } }, "sha512-yjxDAYyK/pBvws9H4xKYpLDpYKEH6CzrBPAuXq3x18I+c/2MkVtT3qAr7Oloi6Dss9qNhPVueAAVU1CSeNDIXw=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], - "bufferpack": ["bufferpack@0.0.6", "", {}, "sha512-MTWvLHElqczrIVhge9qHtqgNigJFyh0+tCDId5yCbFAfuekHWIG+uAgvoHVflwrDPuY/e47JE1ki5qcM7w4uLg=="], - "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], @@ -174,8 +167,6 @@ "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "cgbi-to-png": ["cgbi-to-png@1.0.7", "", { "dependencies": { "bufferpack": "0.0.6", "crc": "^3.3.0", "stream-to-buffer": "^0.1.0", "streamifier": "^0.1.1" } }, "sha512-YR80kxTPuq9oRpZUdQmNEQWrmTKLINk1cfLVfyrV7Rfr9KLtLJdcockPKbreIr4JYAq+DhHBR7w+WA/tF5VDaQ=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cli-arguments": ["cli-arguments@0.2.1", "", {}, "sha512-vaoTjiREjxKlpTNMiaJUkQnYRhgui8r+huhB6mMHcGQyz5F7Hd1o1jsW9C/wRKjlNYQ6fTvODLtZe7DxfEIz8g=="], @@ -192,8 +183,6 @@ "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], - "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], - "csv": ["csv@5.5.3", "", { "dependencies": { "csv-generate": "^3.4.3", "csv-parse": "^4.16.3", "csv-stringify": "^5.6.5", "stream-transform": "^2.1.3" } }, "sha512-QTaY0XjjhTQOdguARF0lGKm5/mEq9PD9/VhZZegHDIBq2tQwgNpHc3dneD4mGo2iJs+fTKv5Bp0fZ+BRuY3Z0g=="], "csv-generate": ["csv-generate@3.4.3", "", {}, "sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw=="], @@ -292,8 +281,6 @@ "i18next": ["i18next@26.4.0", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], @@ -436,14 +423,8 @@ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - "stream-to": ["stream-to@0.2.2", "", {}, "sha512-Kg1BSDTwgGiVMtTCJNlo7kk/xzL33ZuZveEBRt6rXw+f1WLK/8kmz2NVCT/Qnv0JkV85JOHcLhD82mnXsR3kPw=="], - - "stream-to-buffer": ["stream-to-buffer@0.1.0", "", { "dependencies": { "stream-to": "~0.2.0" } }, "sha512-Da4WoKaZyu3nf+bIdIifh7IPkFjARBnBK+pYqn0EUJqksjV9afojjaCCHUemH30Jmu7T2qcKvlZm2ykN38uzaw=="], - "stream-transform": ["stream-transform@2.1.3", "", { "dependencies": { "mixme": "^0.5.1" } }, "sha512-9GHUiM5hMiCi6Y03jD2ARC1ettBXkQBoQAe7nJsPknnI0ow10aXjTnew8QtYQmLjzn974BnmWEAJgCY6ZP1DeQ=="], - "streamifier": ["streamifier@0.1.1", "", {}, "sha512-zDgl+muIlWzXNsXeyUfOk9dChMjlpkq0DRsxujtYPgyJ676yQ8jEm6zzaaWHFDg5BNcLuif0eD2MTyJdZqXpdg=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], diff --git a/package.json b/package.json index cc31374..479bf88 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "homepage": "https://github.com/reactnativecn/react-native-update-cli", "dependencies": { "bplist-parser": "^0.3.2", - "cgbi-to-png": "^1.0.7", "chalk": "4", "cli-arguments": "^0.2.1", "compare-versions": "^6.1.1", diff --git a/src/locales/en.ts b/src/locales/en.ts index 34654f2..ead06da 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -5,23 +5,12 @@ export default { accountNoExpiry: 'No expiry specified', accountRenewalWarning: '[Notice] Your Pushy paid plan expires in {{days}} days. Renew soon to avoid service disruption: {{url}}', - accountLookupFailed: - '[Warning] Unable to retrieve account information. Continuing with the command.', 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', 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}}', - 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 {{- source}}: {{appId}}', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 69c2a89..142c8bb 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -5,20 +5,12 @@ export default { accountNoExpiry: '未设置有效期', accountRenewalWarning: '[注意] 您的 Pushy 付费套餐将在 {{days}} 天内到期,请及时续费,以免影响服务:{{url}}', - accountLookupFailed: '[警告] 暂时无法获取账号信息,将继续执行命令。', 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', aabBundletoolDownloadHint: '未找到 bundletool,正在通过 npx 下载 node-bundletool(首次下载可能需要一些时间)。', - 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}}, 当前{{- source}}: {{appId}}', diff --git a/src/package.ts b/src/package.ts index 09d75c0..4b4b4c9 100644 --- a/src/package.ts +++ b/src/package.ts @@ -69,10 +69,10 @@ type NativeUploadConfig = { }; // the AAB parser drags in protobufjs; load it only for the aab commands -function createAabParser(source: string) { - const { AabParser } = - require('./utils/app-info-parser/aab') as typeof import('./utils/app-info-parser/aab'); - return new AabParser(source); +function loadExtractApk() { + return ( + require('./utils/app-info-parser/aab') as typeof import('./utils/app-info-parser/aab') + ).extractApk; } export function normalizeUploadBuildTime(value: unknown): string { @@ -333,9 +333,8 @@ export const packageCommands = { const includeAllSplits = parseBooleanOption(options.includeAllSplits); const splits = parseCsvOption(options.splits); - const parser = createAabParser(source); try { - await parser.extractApk(output, { + await loadExtractApk()(source, output, { includeAllSplits, splits, }); @@ -412,8 +411,7 @@ export const packageCommands = { const includeAllSplits = parseBooleanOption(options.includeAllSplits); const splits = parseCsvOption(options.splits); - const parser = createAabParser(source); - await parser.extractApk(output, { + await loadExtractApk()(source, output, { includeAllSplits, splits, }); diff --git a/src/utils/app-info-parser/aab.ts b/src/utils/app-info-parser/aab.ts index 0f22d4d..df6454c 100644 --- a/src/utils/app-info-parser/aab.ts +++ b/src/utils/app-info-parser/aab.ts @@ -5,211 +5,134 @@ import path from 'path'; import { pathExists, remove } from '../fs-helpers'; import { t } from '../i18n'; import { extractFirstEntry } from '../zip-entries'; -import { ResourceFinder } from './resource-finder'; -import { mapInfoResource } from './utils'; -import { ManifestParser } from './xml-parser/manifest'; -import { Zip } from './zip'; -export class AabParser extends Zip { - async extractApk( - outputPath: string, - { - includeAllSplits, - splits, - }: { includeAllSplits?: boolean; splits?: string[] | null }, - ) { - const normalizedSplits = Array.isArray(splits) - ? splits.map((item) => item.trim()).filter(Boolean) - : []; - const modules = includeAllSplits - ? null - : Array.from(new Set(['base', ...normalizedSplits])); - const modulesArgs = modules ? [`--modules=${modules.join(',')}`] : []; +/** Build a universal APK from an .aab with bundletool (or npx node-bundletool). */ +export async function extractApk( + aabPath: string, + outputPath: string, + { + includeAllSplits, + splits, + }: { includeAllSplits?: boolean; splits?: string[] | null }, +) { + const bundlePath = path.resolve(aabPath); + const normalizedSplits = Array.isArray(splits) + ? splits.map((item) => item.trim()).filter(Boolean) + : []; + const modules = includeAllSplits + ? null + : Array.from(new Set(['base', ...normalizedSplits])); + const modulesArgs = modules ? [`--modules=${modules.join(',')}`] : []; - 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: inheritStdio ? 'inherit' : ['ignore', 'pipe', 'pipe'], - env: options.env, + 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: inheritStdio ? 'inherit' : ['ignore', 'pipe', 'pipe'], + env: options.env, + }); + let stderr = ''; + if (!inheritStdio) { + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); }); - let stderr = ''; - if (!inheritStdio) { - child.stderr?.on('data', (chunk) => { - stderr += chunk.toString(); - }); + } + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; } - child.on('error', reject); - child.on('close', (code) => { - if (code === 0) { - resolve(); - return; - } - reject( - new Error( - stderr.trim() || `Command failed: ${command} (code ${code})`, - ), - ); - }); + reject( + new Error( + stderr.trim() || `Command failed: ${command} (code ${code})`, + ), + ); }); + }); - // Create a temp file for the .apks output - const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'bundletool-'), - ); - const tempApksPath = path.join(tempDir, 'output.apks'); - - const needsNpxDownload = async () => { - try { - await runCommand('npx', [ - '--no-install', - 'node-bundletool', - '--version', - ]); - return false; - } catch { - return true; - } - }; + // Create a temp file for the .apks output + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'bundletool-'), + ); + const tempApksPath = path.join(tempDir, 'output.apks'); + const needsNpxDownload = async () => { 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. + await runCommand('npx', ['--no-install', 'node-bundletool', '--version']); + return false; + } catch { + return true; + } + }; - try { - await runCommand('bundletool', [ + 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. + + try { + await runCommand('bundletool', [ + 'build-apks', + '--mode=universal', + `--bundle=${bundlePath}`, + `--output=${tempApksPath}`, + '--overwrite', + ...modulesArgs, + ]); + } catch (e) { + // Only fall back when bundletool itself is missing from PATH (spawn + // ENOENT). Any other failure (corrupt AAB, Java issues, bad args, + // non-zero exit) must surface as-is instead of being masked by an + // online npx install. + if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw e; + } + // Fallback to npx node-bundletool if bundletool is not in PATH + // We use -y to avoid interactive prompt for installation + if (await needsNpxDownload()) { + console.log(t('aabBundletoolDownloadHint')); + } + await runCommand( + 'npx', + [ + '-y', + 'node-bundletool', 'build-apks', '--mode=universal', - `--bundle=${this.file}`, + `--bundle=${bundlePath}`, `--output=${tempApksPath}`, '--overwrite', ...modulesArgs, - ]); - } catch (e) { - // Only fall back when bundletool itself is missing from PATH (spawn - // ENOENT). Any other failure (corrupt AAB, Java issues, bad args, - // non-zero exit) must surface as-is instead of being masked by an - // online npx install. - if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') { - throw e; - } - // Fallback to npx node-bundletool if bundletool is not in PATH - // We use -y to avoid interactive prompt for installation - 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', - }, + ], + { + stdio: 'inherit', + env: { + ...process.env, + npm_config_progress: 'true', }, - ); - } - - // 2. Extract universal.apk from the .apks (zip) file - const found = await extractFirstEntry( - tempApksPath, - (name) => name === 'universal.apk', - outputPath, + }, ); - if (!found) { - throw new Error(t('aabUniversalApkNotFound')); - } - } finally { - // Cleanup - if (await pathExists(tempDir)) { - await remove(tempDir); - } } - } - - /** - * 解析 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(t('aabManifestNotFound')); - } - - let apkInfo = this._parseManifest(manifestBuffer as Buffer); - - try { - const resourceBuffer = await this.getEntry(ResourceName); - if (resourceBuffer) { - const resourceMap = this._parseResourceMap(resourceBuffer as Buffer); - apkInfo = mapInfoResource(apkInfo, resourceMap); - } - } catch (e: any) { - console.warn(t('aabParseResourcesWarning', { error: e?.message ?? e })); - } - return apkInfo; - } catch (error: any) { - throw new Error(t('aabParseFailed', { error: error.message ?? error })); - } - } - /** - * Parse manifest - * @param {Buffer} buffer // manifest file's buffer - */ - private _parseManifest(buffer: Buffer) { - try { - const parser = new ManifestParser(buffer, { - ignore: [ - 'application.activity', - 'application.service', - 'application.receiver', - 'application.provider', - 'permission-group', - ], - }); - return parser.parse(); - } catch (e: any) { - throw new Error(t('aabParseManifestError', { error: e?.message ?? e })); + // 2. Extract universal.apk from the .apks (zip) file + const found = await extractFirstEntry( + tempApksPath, + (name) => name === 'universal.apk', + outputPath, + ); + if (!found) { + throw new Error(t('aabUniversalApkNotFound')); } - } - - /** - * 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(t('aabParseResourcesError', { error: e?.message ?? e })); + } finally { + // Cleanup + if (await pathExists(tempDir)) { + await remove(tempDir); } } } - -const escapeRegExp = (value: string) => - value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); diff --git a/src/utils/app-info-parser/apk.ts b/src/utils/app-info-parser/apk.ts index 5b9a7ca..57bfb2d 100644 --- a/src/utils/app-info-parser/apk.ts +++ b/src/utils/app-info-parser/apk.ts @@ -1,92 +1,47 @@ import { ResourceFinder } from './resource-finder'; -import { findApkIconPath, getBase64FromBuffer, mapInfoResource } from './utils'; -import { ManifestParser } from './xml-parser/manifest'; -import { Zip } from './zip'; +import { parseBinaryXml, type XmlElement } from './xml-parser/binary'; -const ManifestName = /^androidmanifest\.xml$/; -const ResourceName = /^resources\.arsc$/; - -export class ApkParser extends Zip { - parse(): Promise { - const manifestKey = ManifestName.toString(); - const resourceKey = ResourceName.toString(); - - return new Promise((resolve, reject) => { - this.getEntries([ManifestName, ResourceName]) - .then((buffers: Record) => { - const manifestBuffer = buffers[manifestKey]; - 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[resourceKey]) { - resolve(apkInfo); - } else { - resourceMap = this._parseResourceMap( - buffers[resourceKey] as Buffer, - ); - apkInfo = mapInfoResource(apkInfo, resourceMap); - - const iconPath = findApkIconPath(apkInfo); - if (iconPath) { - this.getEntry(iconPath) - .then((iconBuffer: Buffer | Blob | undefined) => { - apkInfo.icon = Buffer.isBuffer(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); - }); - }); - } +export interface ApkManifestInfo { + versionName?: unknown; + /** ``'s `` entries, resource references resolved */ + metaData: Array<{ name?: unknown; value?: unknown }>; +} - /** - * Parse manifest - * @param {Buffer} buffer // manifest file's buffer - */ - private _parseManifest(buffer: Buffer) { - try { - const parser = new ManifestParser(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}`); - } +/** + * The fields of a binary AndroidManifest.xml the CLI reads, with + * `@` references resolved through resources.arsc when given. + */ +export function parseApkManifest( + manifest: Buffer, + resources?: Buffer, +): ApkManifestInfo { + let root: XmlElement; + try { + root = parseBinaryXml(manifest); + } 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) { + let resourceMap: Record = {}; + if (resources) { try { - return new ResourceFinder().processResourceTable(buffer); + resourceMap = new ResourceFinder().processResourceTable(resources); } catch (e: any) { throw new Error(`Parser resources.arsc error: ${e}`); } } + const resolve = (value: unknown) => + typeof value === 'string' && value.startsWith('resourceId:') + ? resourceMap[`@${value.replace('resourceId:0x', '').toUpperCase()}`] + : value; + + const application = root.children.find((el) => el.name === 'application'); + return { + versionName: resolve(root.attributes.versionName), + metaData: (application?.children ?? []) + .filter((el) => el.name === 'meta-data') + .map(({ attributes }) => ({ + name: resolve(attributes.name), + value: resolve(attributes.value), + })), + }; } diff --git a/src/utils/app-info-parser/app.ts b/src/utils/app-info-parser/app.ts deleted file mode 100644 index cd8c191..0000000 --- a/src/utils/app-info-parser/app.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Zip } from './zip'; - -export class AppParser extends Zip { - async parse(): Promise> { - return {}; - } -} diff --git a/src/utils/app-info-parser/index.ts b/src/utils/app-info-parser/index.ts deleted file mode 100644 index a1159e1..0000000 --- a/src/utils/app-info-parser/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { AabParser } from './aab'; -import { ApkParser } from './apk'; -import { AppParser } from './app'; -import { IpaParser } from './ipa'; - -const supportFileTypes = ['ipa', 'apk', 'app', 'aab'] as const; -type SupportedFileType = (typeof supportFileTypes)[number]; -type AppInfoInnerParser = AabParser | ApkParser | AppParser | IpaParser; - -class AppInfoParser { - file: string | File; - parser: AppInfoInnerParser; - /** - * parser for parsing .ipa or .apk file - * @param {String | File} file // file's path in Node, instance of File in Browser - */ - constructor(file: string | File) { - if (!file) { - throw new Error( - "Param miss: file(file's path in Node, instance of File in browser).", - ); - } - const splits = (typeof file === 'string' ? file : file.name).split('.'); - const fileType = splits[splits.length - 1].toLowerCase(); - if (!supportFileTypes.includes(fileType as SupportedFileType)) { - throw new Error( - 'Unsupported file type, only support .ipa, .apk, .app, or .aab file.', - ); - } - this.file = file; - - switch (fileType as SupportedFileType) { - case 'ipa': - this.parser = new IpaParser(this.file); - break; - case 'apk': - this.parser = new ApkParser(this.file); - break; - case 'app': - this.parser = new AppParser(this.file); - break; - case 'aab': - this.parser = new AabParser(this.file); - break; - default: - throw new Error('Unsupported parser file type.'); - } - } - parse(): Promise { - return this.parser.parse() as Promise; - } -} - -export default AppInfoParser; diff --git a/src/utils/app-info-parser/ipa.ts b/src/utils/app-info-parser/ipa.ts index 96e232d..5e2879b 100644 --- a/src/utils/app-info-parser/ipa.ts +++ b/src/utils/app-info-parser/ipa.ts @@ -1,11 +1,4 @@ const parseBplist = require('bplist-parser').parseBuffer; -const cgbiToPng = require('cgbi-to-png'); - -import { findIpaIconPath, getBase64FromBuffer, isBrowser } from './utils'; -import { Zip } from './zip'; - -const PlistName = /payload\/[^/]+?.app\/info.plist$/i; -const ProvisionName = /payload\/.+?\.app\/embedded.mobileprovision/; type ParsePlist = (content: string) => Record; @@ -36,74 +29,14 @@ function loadParsePlist(): Promise { return parsePlistPromise; } -export class IpaParser extends Zip { - async parse(): Promise { - const parsePlist = await loadParsePlist(); - const buffers = await this.getEntries([PlistName, ProvisionName]); - if (!buffers[PlistName as any]) { - throw new Error("Info.plist can't be found."); - } - const plistInfo = this._parsePlist( - buffers[PlistName as any] as Buffer, - parsePlist, - ); - const provisionInfo = this._parseProvision( - buffers[ProvisionName as any] as Buffer | undefined, - parsePlist, - ); - plistInfo.mobileProvision = provisionInfo; - - const iconRegex = new RegExp(findIpaIconPath(plistInfo).toLowerCase()); - const iconBuffer = await this.getEntry(iconRegex); - try { - plistInfo.icon = iconBuffer - ? getBase64FromBuffer(cgbiToPng.revert(iconBuffer)) - : null; - } catch (err) { - if (isBrowser()) { - plistInfo.icon = iconBuffer - ? getBase64FromBuffer( - window.btoa(String.fromCharCode(...(iconBuffer as Buffer))), - ) - : null; - } else { - plistInfo.icon = null; - console.warn('[Warning] failed to parse icon: ', err); - } - } - return plistInfo; - } - /** - * Parse plist - * @param {Buffer} buffer // plist file's buffer - */ - private _parsePlist(buffer: Buffer, parsePlist: ParsePlist) { - let result: any; - const bufferType = buffer[0]; - if (bufferType === 60 || bufferType === 239) { - result = parsePlist(buffer.toString()); - } else if (bufferType === 98) { - result = parseBplist(buffer)[0]; - } else { - throw new Error('Unknown plist buffer type.'); - } - return result; +/** Parse an Info.plist in XML or binary form. */ +export async function parsePlist(buffer: Buffer): Promise> { + const bufferType = buffer[0]; + if (bufferType === 60 || bufferType === 239) { + return (await loadParsePlist())(buffer.toString()); } - /** - * parse provision - * @param {Buffer} buffer // provision file's buffer - */ - private _parseProvision(buffer: Buffer | undefined, parsePlist: ParsePlist) { - let info: Record = {}; - if (buffer) { - let content = buffer.toString('utf-8'); - const firstIndex = content.indexOf(''); - content = content.slice(firstIndex, endIndex + 8); - if (content) { - info = parsePlist(content); - } - } - return info; + if (bufferType === 98) { + return parseBplist(buffer)[0]; } + throw new Error('Unknown plist buffer type.'); } diff --git a/src/utils/app-info-parser/resource-finder.ts b/src/utils/app-info-parser/resource-finder.ts index a9b9a05..68e1a60 100644 --- a/src/utils/app-info-parser/resource-finder.ts +++ b/src/utils/app-info-parser/resource-finder.ts @@ -52,6 +52,10 @@ class Reader { return this.buffer.readInt32LE(this.advance(4)); } + skip(len: number) { + this.advance(len); + } + /** The next `len` bytes as a new reader (a view, not a copy). */ readBytes(len: number) { const at = this.advance(len); @@ -64,32 +68,24 @@ function chunkAt(bb: Reader, pos: number, size: number) { return new Reader(bb.buffer.subarray(pos, pos + size)); } -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; +const FLAG_COMPLEX = 0x0001; + 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 = {}; - /** - * - * @param {Reader} bb - * @return {Map>} - */ + /** Map of `@` to the values of that resource. */ processResourceTable(resourceBuffer: Buffer) { const bb = new Reader(resourceBuffer); @@ -114,32 +110,22 @@ export class ResourceFinder { while (true) { let pos = 0; let t = 0; - let _hs = 0; let s = 0; try { pos = bb.offset; t = bb.readShort(); - _hs = bb.readShort(); + bb.readShort(); // header size s = bb.readInt(); } catch (_e) { break; } if (t === RES_STRING_POOL_TYPE) { if (realStringPoolCount === 0) { - if (DEBUG) { - console.log('Processing the string pool ...'); - } - this.valueStringPool = this.processStringPool(chunkAt(bb, pos, s)); } realStringPoolCount++; } else if (t === RES_TABLE_PACKAGE_TYPE) { - if (DEBUG) { - console.log(`Processing the package ${realPackageCount} ...`); - } - this.processPackage(chunkAt(bb, pos, s)); - realPackageCount++; } else { throw new Error('Unsupported type'); @@ -158,32 +144,17 @@ export class ResourceFinder { return this.responseMap; } - /** - * - * @param {Reader} bb - */ private processPackage(bb: Reader) { - const type = bb.readShort(); + bb.readShort(); // type 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(); - } + bb.readInt(); // size + this.packageId = bb.readInt(); + bb.skip(256); // package name const typeStrings = bb.readInt(); - const lastPublicType = bb.readInt(); + bb.readInt(); // last public type const keyStrings = bb.readInt(); - const lastPublicKey = bb.readInt(); - - void lastPublicType; - void lastPublicKey; + bb.readInt(); // last public key if (typeStrings !== headerSize) { throw new Error( @@ -191,62 +162,22 @@ export class ResourceFinder { ); } - if (DEBUG) { - console.log('Type strings:'); - } - - let lastPosition = bb.offset; - bb.offset = typeStrings; - const bbTypeStrings = bb.readBytes(bb.limit - bb.offset); - bb.offset = lastPosition; - this.typeStringPool = this.processStringPool(bbTypeStrings); - - if (DEBUG) { - console.log('Key strings:'); - } - + // the type and key string pools only name entries: skip both, and the + // type specs (only flags); types carry the values 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 = bb.readBytes(bb.limit - bb.offset); - bb.offset = lastPosition; - this.keyStringPool = this.processStringPool(bbKeyStrings); - - let typeSpecCount = 0; - let typeCount = 0; - - bb.offset = keyStrings + keySize; - - let bb2: Reader; - + bb.readShort(); // key pool type + bb.readShort(); // key pool header size + bb.offset = keyStrings + bb.readInt(); while (true) { const pos = bb.offset; try { const t = bb.readShort(); - const hs = bb.readShort(); + bb.readShort(); // header size const s = bb.readInt(); - void hs; - - if (t === RES_TABLE_TYPE_SPEC_TYPE) { + if (t === RES_TABLE_TYPE_TYPE) { bb.offset = pos; - bb2 = bb.readBytes(s); - this.processTypeSpec(bb2); - - typeSpecCount++; - } else if (t === RES_TABLE_TYPE_TYPE) { - bb.offset = pos; - bb2 = bb.readBytes(s); - this.processType(bb2); - - typeCount++; + this.processType(bb.readBytes(s)); } if (s === 0) { @@ -262,36 +193,19 @@ export class ResourceFinder { break; } } - - void typeSpecCount; - void typeCount; } - /** - * - * @param {Reader} bb - */ private processType(bb: Reader) { - const type = bb.readShort(); + bb.readShort(); // type const headerSize = bb.readShort(); - const size = bb.readInt(); + bb.readInt(); // size const id = bb.readByte(); - const res0 = bb.readByte(); - const res1 = bb.readShort(); + bb.skip(3); // res0, res1 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) { @@ -308,86 +222,38 @@ export class ResourceFinder { const resourceId = (this.packageId << 24) | (id << 16) | i; - let entrySize = 0; let entryFlag = 0; - let entryKey = 0; try { - entrySize = bb.readShort(); + bb.readShort(); // entry size entryFlag = bb.readShort(); - entryKey = bb.readInt(); + bb.readInt(); // key } 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(); + bb.readShort(); // value size + bb.readUint8(); // res0 + const valueDataType = bb.readUint8(); 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.`, - ); - } + bb.readInt(); // parent + const valueCount = bb.readInt(); + // name (4) + Res_value (8) per value; maps are not resolved + bb.skip(valueCount * 12); } } @@ -404,26 +270,15 @@ export class ResourceFinder { } } - /** - * - * @param {Reader} bb - * @return {Array} - */ private processStringPool(bb: Reader) { - const type = bb.readShort(); - const headerSize = bb.readShort(); - const size = bb.readInt(); + bb.readShort(); // type + bb.readShort(); // header size + bb.readInt(); // size const stringCount = bb.readInt(); - const styleCount = bb.readInt(); + bb.readInt(); // style count const flags = bb.readInt(); const stringsStart = bb.readInt(); - const stylesStart = bb.readInt(); - - void type; - void headerSize; - void size; - void styleCount; - void stylesStart; + bb.readInt(); // styles start const isUtf8 = (flags & 256) !== 0; @@ -435,77 +290,35 @@ export class ResourceFinder { const strings = new Array(stringCount); for (let i = 0; i < stringCount; ++i) { - const pos = stringsStart + offsets[i]; - bb.offset = pos; - + bb.offset = stringsStart + offsets[i]; strings[i] = ''; if (isUtf8) { - let u16len = bb.readUint8(); - - if ((u16len & 0x80) !== 0) { - u16len = ((u16len & 0x7f) << 8) + bb.readUint8(); + // UTF-16 length, then UTF-8 byte length; each 1 or 2 bytes + if ((bb.readUint8() & 0x80) !== 0) { + bb.readUint8(); } - let u8len = bb.readUint8(); if ((u8len & 0x80) !== 0) { u8len = ((u8len & 0x7f) << 8) + bb.readUint8(); } - if (u8len > 0) { strings[i] = bb.readBytes(u8len).buffer.toString('utf8'); - } else { - strings[i] = ''; } } else { let u16len = bb.readUint16(); if ((u16len & 0x8000) !== 0) { u16len = ((u16len & 0x7fff) << 16) + bb.readUint16(); } - if (u16len > 0) { strings[i] = bb.readBytes(u16len * 2).buffer.toString('utf16le'); } } - - if (DEBUG) { - console.log('Parsed value: {0}', strings[i]); - } } return strings; } - /** - * - * @param {Reader} bb - */ - private processTypeSpec(bb: Reader) { - 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) { diff --git a/src/utils/app-info-parser/utils.ts b/src/utils/app-info-parser/utils.ts deleted file mode 100644 index f7599f3..0000000 --- a/src/utils/app-info-parser/utils.ts +++ /dev/null @@ -1,161 +0,0 @@ -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 window !== 'undefined' && typeof window.document !== 'undefined'; - -/** - * 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?.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: strips null unicode padding from decoded strings - return value.replace(/\u0000/g, ''); - } - return value; -}; - -export { - decodeNullUnicode, - findApkIconPath, - findIpaIconPath, - getBase64FromBuffer, - isArray, - isBrowser, - isObject, - isPrimitive, - mapInfoResource, -}; diff --git a/src/utils/app-info-parser/xml-parser/binary.ts b/src/utils/app-info-parser/xml-parser/binary.ts index 18aa6ea..d1e1033 100644 --- a/src/utils/app-info-parser/xml-parser/binary.ts +++ b/src/utils/app-info-parser/xml-parser/binary.ts @@ -1,693 +1,179 @@ -// From https://github.com/openstf/adbkit-apkreader -const NodeType = { - ELEMENT_NODE: 1, - ATTRIBUTE_NODE: 2, - CDATA_SECTION_NODE: 4, -}; - -const ChunkType = { - NULL: 0x0000, - STRING_POOL: 0x0001, - TABLE: 0x0002, - XML: 0x0003, - XML_FIRST_CHUNK: 0x0100, - XML_START_NAMESPACE: 0x0100, - XML_END_NAMESPACE: 0x0101, - XML_START_ELEMENT: 0x0102, - XML_END_ELEMENT: 0x0103, - XML_CDATA: 0x0104, - XML_LAST_CHUNK: 0x017f, - XML_RESOURCE_MAP: 0x0180, - TABLE_PACKAGE: 0x0200, - TABLE_TYPE: 0x0201, - TABLE_TYPE_SPEC: 0x0202, -}; - -const StringFlags = { - SORTED: 1 << 0, - UTF8: 1 << 8, -}; - -// Taken from android.util.TypedValue -const TypedValue = { - COMPLEX_MANTISSA_MASK: 0x00ffffff, - COMPLEX_MANTISSA_SHIFT: 0x00000008, - COMPLEX_RADIX_0p23: 0x00000003, - COMPLEX_RADIX_16p7: 0x00000001, - COMPLEX_RADIX_23p0: 0x00000000, - COMPLEX_RADIX_8p15: 0x00000002, - COMPLEX_RADIX_MASK: 0x00000003, - COMPLEX_RADIX_SHIFT: 0x00000004, - COMPLEX_UNIT_DIP: 0x00000001, - COMPLEX_UNIT_FRACTION: 0x00000000, - COMPLEX_UNIT_FRACTION_PARENT: 0x00000001, - COMPLEX_UNIT_IN: 0x00000004, - COMPLEX_UNIT_MASK: 0x0000000f, - COMPLEX_UNIT_MM: 0x00000005, - COMPLEX_UNIT_PT: 0x00000003, - COMPLEX_UNIT_PX: 0x00000000, - COMPLEX_UNIT_SHIFT: 0x00000000, - COMPLEX_UNIT_SP: 0x00000002, - DENSITY_DEFAULT: 0x00000000, - DENSITY_NONE: 0x0000ffff, - TYPE_ATTRIBUTE: 0x00000002, - TYPE_DIMENSION: 0x00000005, - TYPE_FIRST_COLOR_INT: 0x0000001c, - TYPE_FIRST_INT: 0x00000010, - TYPE_FLOAT: 0x00000004, - TYPE_FRACTION: 0x00000006, - TYPE_INT_BOOLEAN: 0x00000012, - TYPE_INT_COLOR_ARGB4: 0x0000001e, - TYPE_INT_COLOR_ARGB8: 0x0000001c, - TYPE_INT_COLOR_RGB4: 0x0000001f, - TYPE_INT_COLOR_RGB8: 0x0000001d, - TYPE_INT_DEC: 0x00000010, - TYPE_INT_HEX: 0x00000011, - TYPE_LAST_COLOR_INT: 0x0000001f, - TYPE_LAST_INT: 0x0000001f, - TYPE_NULL: 0x00000000, - TYPE_REFERENCE: 0x00000001, - TYPE_STRING: 0x00000003, -}; - -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 = []; - this.resources = []; - this.document = null; - this.parent = null; - this.stack = []; - this.debug = options.debug || false; - } - - readU8() { - this.debug && console.group('readU8'); - this.debug && console.debug('cursor:', this.cursor); - const val = this.buffer[this.cursor]; - this.debug && console.debug('value:', val); - this.cursor += 1; - this.debug && console.groupEnd(); - return val; - } - - readU16() { - this.debug && console.group('readU16'); - this.debug && console.debug('cursor:', this.cursor); - const val = this.buffer.readUInt16LE(this.cursor); - this.debug && console.debug('value:', val); - this.cursor += 2; - this.debug && console.groupEnd(); - return val; - } - - readS32() { - this.debug && console.group('readS32'); - this.debug && console.debug('cursor:', this.cursor); - const val = this.buffer.readInt32LE(this.cursor); - this.debug && console.debug('value:', val); - this.cursor += 4; - this.debug && console.groupEnd(); - return val; - } - - readU32() { - this.debug && console.group('readU32'); - this.debug && console.debug('cursor:', this.cursor); - const val = this.buffer.readUInt32LE(this.cursor); - this.debug && console.debug('value:', val); - this.cursor += 4; - this.debug && console.groupEnd(); - return val; - } - - readLength8() { - this.debug && console.group('readLength8'); - let len = this.readU8(); - if (len & 0x80) { - len = (len & 0x7f) << 8; - len += this.readU8(); - } - this.debug && console.debug('length:', len); - this.debug && console.groupEnd(); - return len; - } - - readLength16() { - this.debug && console.group('readLength16'); - let len = this.readU16(); - if (len & 0x8000) { - len = (len & 0x7fff) << 16; - len += this.readU16(); - } - this.debug && console.debug('length:', len); - this.debug && console.groupEnd(); - return len; - } - - readDimension() { - this.debug && console.group('readDimension'); - - const dimension: { - value: number | null; - unit: string | null; - rawUnit: number | null; - } = { - value: null, - unit: null, - rawUnit: null, - }; - - const value = this.readU32(); - const unit = value & 0xff; - - dimension.value = value >> 8; - dimension.rawUnit = unit; - - switch (unit) { - case TypedValue.COMPLEX_UNIT_MM: - dimension.unit = 'mm'; - break; - case TypedValue.COMPLEX_UNIT_PX: - dimension.unit = 'px'; - break; - case TypedValue.COMPLEX_UNIT_DIP: - dimension.unit = 'dp'; - break; - case TypedValue.COMPLEX_UNIT_SP: - dimension.unit = 'sp'; - break; - case TypedValue.COMPLEX_UNIT_PT: - dimension.unit = 'pt'; - break; - case TypedValue.COMPLEX_UNIT_IN: - dimension.unit = 'in'; - break; - } - - this.debug && console.groupEnd(); - - return dimension; - } - - readFraction() { - this.debug && console.group('readFraction'); - - const fraction: { - value: number | null; - type: string | null; - rawType: number | null; - } = { - value: null, - type: null, - rawType: null, - }; - - const value = this.readU32(); - const type = value & 0xf; - - fraction.value = this.convertIntToFloat(value >> 4); - fraction.rawType = type; - - switch (type) { - case TypedValue.COMPLEX_UNIT_FRACTION: - fraction.type = '%'; - break; - case TypedValue.COMPLEX_UNIT_FRACTION_PARENT: - fraction.type = '%p'; - break; - } - - this.debug && console.groupEnd(); - - return fraction; - } - - readHex24() { - this.debug && console.group('readHex24'); - const val = (this.readU32() & 0xffffff).toString(16); - this.debug && console.groupEnd(); - return val; - } - - readHex32() { - this.debug && console.group('readHex32'); - const val = this.readU32().toString(16); - this.debug && console.groupEnd(); - return val; - } - - readTypedValue() { - this.debug && console.group('readTypedValue'); - - const typedValue: { - value: any; - type: string | null; - rawType: number | null; - } = { - value: null, - type: null, - rawType: null, - }; - - const start = this.cursor; - - let size = this.readU16(); - /* const zero = */ this.readU8(); - const dataType = this.readU8(); - - if (size === 0) { - size = 8; - } - - typedValue.rawType = dataType; +// Binary (AAPT) XML reader, trimmed from +// https://github.com/openstf/adbkit-apkreader to the element tree and the +// attribute value types an AndroidManifest.xml needs. + +const CHUNK_STRING_POOL = 0x0001; +const CHUNK_XML = 0x0003; +const CHUNK_XML_START_ELEMENT = 0x0102; +const CHUNK_XML_END_ELEMENT = 0x0103; +const STRING_POOL_UTF8 = 1 << 8; + +// android.util.TypedValue +const TYPE_NULL = 0x00; +const TYPE_REFERENCE = 0x01; +const TYPE_STRING = 0x03; +const TYPE_INT_DEC = 0x10; +const TYPE_INT_HEX = 0x11; +const TYPE_INT_BOOLEAN = 0x12; + +export interface XmlElement { + name: string; + /** typed attribute values; references read as `resourceId:0x` */ + attributes: Record; + children: XmlElement[]; +} - switch (dataType) { - case TypedValue.TYPE_INT_DEC: - typedValue.value = this.readS32(); - typedValue.type = 'int_dec'; - break; - case TypedValue.TYPE_INT_HEX: - typedValue.value = this.readS32(); - typedValue.type = 'int_hex'; - break; - case TypedValue.TYPE_STRING: { - const ref = this.readS32(); - typedValue.value = ref > 0 ? this.strings[ref] : ''; - typedValue.type = 'string'; - break; - } - case TypedValue.TYPE_REFERENCE: { - const id = this.readU32(); - typedValue.value = `resourceId:0x${id.toString(16)}`; - typedValue.type = 'reference'; - break; +export function parseBinaryXml(buffer: Buffer): XmlElement { + let cursor = 0; + const strings: string[] = []; + + const u8 = () => buffer[cursor++]; + const u16 = () => { + const value = buffer.readUInt16LE(cursor); + cursor += 2; + return value; + }; + const u32 = () => { + const value = buffer.readUInt32LE(cursor); + cursor += 4; + return value; + }; + const s32 = () => { + const value = buffer.readInt32LE(cursor); + cursor += 4; + return value; + }; + + const readString = (utf8: boolean) => { + let byteLength: number; + if (utf8) { + // character count, then byte count; each 1 or 2 bytes + if (u8() & 0x80) u8(); + byteLength = u8(); + if (byteLength & 0x80) { + byteLength = ((byteLength & 0x7f) << 8) + u8(); } - case TypedValue.TYPE_INT_BOOLEAN: - typedValue.value = this.readS32() !== 0; - typedValue.type = 'boolean'; - break; - case TypedValue.TYPE_NULL: - this.readU32(); - typedValue.value = null; - typedValue.type = 'null'; - break; - case TypedValue.TYPE_INT_COLOR_RGB8: - typedValue.value = this.readHex24(); - typedValue.type = 'rgb8'; - break; - case TypedValue.TYPE_INT_COLOR_RGB4: - typedValue.value = this.readHex24(); - typedValue.type = 'rgb4'; - break; - case TypedValue.TYPE_INT_COLOR_ARGB8: - typedValue.value = this.readHex32(); - typedValue.type = 'argb8'; - break; - case TypedValue.TYPE_INT_COLOR_ARGB4: - typedValue.value = this.readHex32(); - typedValue.type = 'argb4'; - break; - case TypedValue.TYPE_DIMENSION: - typedValue.value = this.readDimension(); - typedValue.type = 'dimension'; - break; - case TypedValue.TYPE_FRACTION: - typedValue.value = this.readFraction(); - typedValue.type = 'fraction'; - break; - default: { - const type = dataType.toString(16); - console.debug( - `Not sure what to do with typed value of type 0x${type}, falling back to reading an uint32.`, - ); - typedValue.value = this.readU32(); - typedValue.type = 'unknown'; - } - } - - const end = start + size; - if (this.cursor !== end) { - const type = dataType.toString(16); - const diff = end - this.cursor; - console.debug(`Cursor is off by ${diff} bytes at ${this.cursor} at supposed end \ -of typed value of type 0x${type}. The typed value started at offset ${start} \ -and is supposed to end at offset ${end}. Ignoring the rest of the value.`); - this.cursor = end; - } - - this.debug && console.groupEnd(); - - return typedValue; - } - - // https://twitter.com/kawasima/status/427730289201139712 - convertIntToFloat(int: number) { - const buf = new ArrayBuffer(4); - new Int32Array(buf)[0] = int; - return new Float32Array(buf)[0]; - } - - readString(encoding: string) { - this.debug && console.group('readString', encoding); - switch (encoding) { - case 'utf-8': { - const stringLength = this.readLength8(); - this.debug && console.debug('stringLength:', stringLength); - const byteLength = this.readLength8(); - this.debug && console.debug('byteLength:', byteLength); - const value = this.buffer.toString( - encoding, - this.cursor, - (this.cursor += byteLength), - ); - this.debug && console.debug('value:', value); - this.debug && console.groupEnd(); - return value; + } else { + byteLength = u16(); + if (byteLength & 0x8000) { + byteLength = ((byteLength & 0x7fff) << 16) + u16(); } - case 'ucs2': { - const stringLength = this.readLength16(); - this.debug && console.debug('stringLength:', stringLength); - const byteLength = stringLength * 2; - this.debug && console.debug('byteLength:', byteLength); - const value = this.buffer.toString( - encoding, - this.cursor, - (this.cursor += byteLength), - ); - this.debug && console.debug('value:', value); - this.debug && console.groupEnd(); - return value; + byteLength *= 2; + } + const start = cursor; + cursor += byteLength; + return buffer.toString(utf8 ? 'utf-8' : 'ucs2', start, cursor); + }; + + const readStringPool = (chunkStart: number) => { + const count = u32(); + u32(); // style count + const flags = u32(); + const stringsStart = chunkStart + u32(); + u32(); // styles start + const offsets: number[] = []; + for (let i = 0; i < count; i++) { + offsets.push(u32()); + } + for (const offset of offsets) { + cursor = stringsStart + offset; + strings.push(readString((flags & STRING_POOL_UTF8) !== 0)); + } + }; + + const readTypedValue = (): unknown => { + const start = cursor; + const size = u16() || 8; + u8(); // res0 + const type = u8(); + const data = start + 4; + cursor = start + size; + switch (type) { + case TYPE_INT_DEC: + case TYPE_INT_HEX: + return buffer.readInt32LE(data); + case TYPE_STRING: { + const ref = buffer.readInt32LE(data); + return ref > 0 ? strings[ref] : ''; } + case TYPE_REFERENCE: + return `resourceId:0x${buffer.readUInt32LE(data).toString(16)}`; + case TYPE_INT_BOOLEAN: + return buffer.readInt32LE(data) !== 0; + case TYPE_NULL: + return null; default: - throw new Error(`Unsupported encoding '${encoding}'`); - } - } - - readChunkHeader() { - this.debug && console.group('readChunkHeader'); - const header = { - startOffset: this.cursor, - chunkType: this.readU16(), - headerSize: this.readU16(), - chunkSize: this.readU32(), - }; - this.debug && console.debug('startOffset:', header.startOffset); - this.debug && console.debug('chunkType:', header.chunkType); - this.debug && console.debug('headerSize:', header.headerSize); - this.debug && console.debug('chunkSize:', header.chunkSize); - this.debug && console.groupEnd(); - return header; - } - - readStringPool(header: any) { - this.debug && console.group('readStringPool'); - - header.stringCount = this.readU32(); - this.debug && console.debug('stringCount:', header.stringCount); - header.styleCount = this.readU32(); - this.debug && console.debug('styleCount:', header.styleCount); - header.flags = this.readU32(); - this.debug && console.debug('flags:', header.flags); - header.stringsStart = this.readU32(); - this.debug && console.debug('stringsStart:', header.stringsStart); - header.stylesStart = this.readU32(); - this.debug && console.debug('stylesStart:', header.stylesStart); - - if (header.chunkType !== ChunkType.STRING_POOL) { - throw new Error('Invalid string pool header'); - } - - const offsets: number[] = []; - for (let i = 0, l = header.stringCount; i < l; ++i) { - this.debug && console.debug('offset:', i); - offsets.push(this.readU32()); - } - - const sorted = (header.flags & StringFlags.SORTED) === StringFlags.SORTED; - this.debug && console.debug('sorted:', sorted); - const encoding = - (header.flags & StringFlags.UTF8) === StringFlags.UTF8 ? 'utf-8' : 'ucs2'; - this.debug && console.debug('encoding:', encoding); - - const stringsStart = header.startOffset + header.stringsStart; - this.cursor = stringsStart; - for (let i = 0, l = header.stringCount; i < l; ++i) { - this.debug && console.debug('string:', i); - this.debug && console.debug('offset:', offsets[i]); - this.cursor = stringsStart + offsets[i]; - this.strings.push(this.readString(encoding)); - } - - this.cursor = header.startOffset + header.chunkSize; - - this.debug && console.groupEnd(); - - return null; - } - - readResourceMap(header: any) { - this.debug && console.group('readResourceMap'); - const count = Math.floor((header.chunkSize - header.headerSize) / 4); - for (let i = 0; i < count; ++i) { - this.resources.push(this.readU32()); - } - this.debug && console.groupEnd(); - return null; - } - - readXmlNamespaceStart() { - this.debug && console.group('readXmlNamespaceStart'); - - /* const line = */ this.readU32(); - /* const commentRef = */ this.readU32(); - /* const prefixRef = */ this.readS32(); - /* const uriRef = */ this.readS32(); - - this.debug && console.groupEnd(); - - return null; - } - - readXmlNamespaceEnd() { - this.debug && console.group('readXmlNamespaceEnd'); - - /* const line = */ this.readU32(); - /* const commentRef = */ this.readU32(); - /* const prefixRef = */ this.readS32(); - /* const uriRef = */ this.readS32(); - - this.debug && console.groupEnd(); - - return null; - } - - readXmlElementStart() { - this.debug && console.group('readXmlElementStart'); - - const node: any = { - namespaceURI: null, - nodeType: NodeType.ELEMENT_NODE, - nodeName: null, - attributes: [], - childNodes: [], - }; - - /* const line = */ this.readU32(); - /* const commentRef = */ this.readU32(); - const nsRef = this.readS32(); - const nameRef = this.readS32(); - - if (nsRef > 0) { - node.namespaceURI = this.strings[nsRef]; - } - - node.nodeName = this.strings[nameRef]; - - /* const attrStart = */ this.readU16(); - /* const attrSize = */ this.readU16(); - const attrCount = this.readU16(); - /* const idIndex = */ this.readU16(); - /* const classIndex = */ this.readU16(); - /* const styleIndex = */ this.readU16(); - - for (let i = 0; i < attrCount; ++i) { - node.attributes.push(this.readXmlAttribute()); - } - - if (this.document) { - this.parent.childNodes.push(node); - this.parent = node; - } else { - this.document = this.parent = node; - } - - this.stack.push(node); - - this.debug && console.groupEnd(); - - return node; - } - - readXmlAttribute() { - this.debug && console.group('readXmlAttribute'); - - const attr: any = { - namespaceURI: null, - nodeType: NodeType.ATTRIBUTE_NODE, - nodeName: null, - name: null, - value: null, - typedValue: null, + // floats, dimensions, colors...: not needed, keep the raw bits + return buffer.readUInt32LE(data); + } + }; + + const readElement = (): XmlElement => { + u32(); // line + u32(); // comment + s32(); // namespace + const element: XmlElement = { + name: strings[s32()], + attributes: Object.create(null), + children: [], }; - - const nsRef = this.readS32(); - const nameRef = this.readS32(); - const valueRef = this.readS32(); - - if (nsRef > 0) { - attr.namespaceURI = this.strings[nsRef]; - } - - attr.nodeName = attr.name = this.strings[nameRef]; - - if (valueRef > 0) { - if (attr.name === 'versionName') { + u16(); // attribute start + u16(); // attribute size + const attributeCount = u16(); + cursor += 6; // id, class and style indexes + for (let i = 0; i < attributeCount; i++) { + s32(); // namespace + const name = strings[s32()]; + s32(); // raw value + let value = readTypedValue(); + if (name === 'versionName' && typeof value === 'string') { // strip UTF-16 null padding only; keep spaces and non-ASCII chars // biome-ignore lint/suspicious/noControlCharactersInRegex: removing NUL padding is the point - this.strings[valueRef] = this.strings[valueRef].replace(/\u0000/g, ''); + value = value.replace(/\u0000/g, ''); } - attr.value = this.strings[valueRef]; - } - - attr.typedValue = this.readTypedValue(); - - this.debug && console.groupEnd(); - - return attr; - } - - readXmlElementEnd() { - this.debug && console.group('readXmlCData'); - - /* const line = */ this.readU32(); - /* const commentRef = */ this.readU32(); - /* const nsRef = */ this.readS32(); - /* const nameRef = */ this.readS32(); - - this.stack.pop(); - this.parent = this.stack[this.stack.length - 1]; - - this.debug && console.groupEnd(); - - return null; - } - - readXmlCData() { - this.debug && console.group('readXmlCData'); - - const cdata: any = { - namespaceURI: null, - nodeType: NodeType.CDATA_SECTION_NODE, - nodeName: '#cdata', - data: null, - typedValue: null, - }; - - /* const line = */ this.readU32(); - /* const commentRef = */ this.readU32(); - const dataRef = this.readS32(); - - if (dataRef > 0) { - cdata.data = this.strings[dataRef]; + element.attributes[name] = value; } + return element; + }; - cdata.typedValue = this.readTypedValue(); - - this.parent.childNodes.push(cdata); - - this.debug && console.groupEnd(); - - return cdata; - } - - readNull(header: any) { - this.debug && console.group('readNull'); - this.cursor += header.chunkSize - header.headerSize; - this.debug && console.groupEnd(); - return null; + if (u16() !== CHUNK_XML) { + throw new Error('Invalid XML header'); } + cursor = 8; // past the XML chunk header - parse() { - this.debug && console.group('BinaryXmlParser.parse'); - - const xmlHeader = this.readChunkHeader(); - if (xmlHeader.chunkType !== ChunkType.XML) { - throw new Error('Invalid XML header'); + let root: XmlElement | undefined; + const stack: XmlElement[] = []; + while (cursor < buffer.length) { + const start = cursor; + const type = u16(); + u16(); // header size + const size = u32(); + if (size < 8) { + throw new Error(`Invalid chunk size ${size} at offset ${start}`); } - - while (this.cursor < this.buffer.length) { - this.debug && console.group('chunk'); - const start = this.cursor; - const header = this.readChunkHeader(); - switch (header.chunkType) { - case ChunkType.STRING_POOL: - this.readStringPool(header); - break; - case ChunkType.XML_RESOURCE_MAP: - this.readResourceMap(header); - break; - case ChunkType.XML_START_NAMESPACE: - this.readXmlNamespaceStart(); - break; - case ChunkType.XML_END_NAMESPACE: - this.readXmlNamespaceEnd(); - break; - case ChunkType.XML_START_ELEMENT: - this.readXmlElementStart(); - break; - case ChunkType.XML_END_ELEMENT: - this.readXmlElementEnd(); - break; - case ChunkType.XML_CDATA: - this.readXmlCData(); - break; - case ChunkType.NULL: - this.readNull(header); - break; - default: - throw new Error(`Unsupported chunk type '${header.chunkType}'`); - } - - const end = start + header.chunkSize; - if (this.cursor !== end) { - const diff = end - this.cursor; - const type = header.chunkType.toString(16); - console.debug(`Cursor is off by ${diff} bytes at ${this.cursor} at supposed \ -end of chunk of type 0x${type}. The chunk started at offset ${start} and is \ -supposed to end at offset ${end}. Ignoring the rest of the chunk.`); - this.cursor = end; + switch (type) { + case CHUNK_STRING_POOL: + readStringPool(start); + break; + case CHUNK_XML_START_ELEMENT: { + const element = readElement(); + if (stack.length) { + stack[stack.length - 1].children.push(element); + } else { + root ??= element; + } + stack.push(element); + break; } - - this.debug && console.groupEnd(); + case CHUNK_XML_END_ELEMENT: + stack.pop(); + break; + // namespaces, resource map, CDATA: not needed } - - this.debug && console.groupEnd(); - - return this.document; + cursor = start + size; + } + if (!root) { + throw new Error('No root element'); } + return root; } diff --git a/src/utils/app-info-parser/xml-parser/manifest.ts b/src/utils/app-info-parser/xml-parser/manifest.ts deleted file mode 100644 index a2cc2a4..0000000 --- a/src/utils/app-info-parser/xml-parser/manifest.ts +++ /dev/null @@ -1,228 +0,0 @@ -// From https://github.com/openstf/adbkit-apkreader -import { BinaryXmlParser } from './binary'; - -const INTENT_MAIN = 'android.intent.action.MAIN'; -const CATEGORY_LAUNCHER = 'android.intent.category.LAUNCHER'; - -export class ManifestParser { - private buffer: Buffer; - private xmlParser: BinaryXmlParser; - - constructor(buffer: Buffer, options: Record = {}) { - this.buffer = buffer; - this.xmlParser = new BinaryXmlParser(this.buffer, options); - } - - private collapseAttributes(element: any) { - const collapsed: Record = Object.create(null); - const attributes = element.attributes as Array<{ - name: string; - typedValue: { value: any }; - }>; - for (let i = 0, len = attributes.length; i < len; i++) { - const attr = attributes[i]; - collapsed[attr.name] = attr.typedValue.value; - } - return collapsed; - } - - private parseIntents(element: any, target: any) { - target.intentFilters = []; - target.metaData = []; - - for (const child of element.childNodes) { - switch (child.nodeName) { - case 'intent-filter': { - const intentFilter = this.collapseAttributes(child); - - intentFilter.actions = []; - intentFilter.categories = []; - intentFilter.data = []; - - for (const item of child.childNodes) { - switch (item.nodeName) { - case 'action': - intentFilter.actions.push(this.collapseAttributes(item)); - break; - case 'category': - intentFilter.categories.push(this.collapseAttributes(item)); - break; - case 'data': - intentFilter.data.push(this.collapseAttributes(item)); - break; - } - } - - target.intentFilters.push(intentFilter); - break; - } - case 'meta-data': - target.metaData.push(this.collapseAttributes(child)); - break; - } - } - } - - private parseApplication(element: any) { - const app = this.collapseAttributes(element); - - app.activities = []; - app.activityAliases = []; - app.launcherActivities = []; - app.services = []; - app.receivers = []; - app.providers = []; - app.usesLibraries = []; - app.metaData = []; - - for (const child of element.childNodes) { - switch (child.nodeName) { - case 'activity': { - const activity = this.collapseAttributes(child); - this.parseIntents(child, activity); - app.activities.push(activity); - if (this.isLauncherActivity(activity)) { - app.launcherActivities.push(activity); - } - break; - } - case 'activity-alias': { - const activityAlias = this.collapseAttributes(child); - this.parseIntents(child, activityAlias); - app.activityAliases.push(activityAlias); - if (this.isLauncherActivity(activityAlias)) { - app.launcherActivities.push(activityAlias); - } - break; - } - case 'service': { - const service = this.collapseAttributes(child); - this.parseIntents(child, service); - app.services.push(service); - break; - } - case 'receiver': { - const receiver = this.collapseAttributes(child); - this.parseIntents(child, receiver); - app.receivers.push(receiver); - break; - } - case 'provider': { - const provider = this.collapseAttributes(child); - - provider.grantUriPermissions = []; - provider.metaData = []; - provider.pathPermissions = []; - - for (const item of child.childNodes) { - switch (item.nodeName) { - case 'grant-uri-permission': - provider.grantUriPermissions.push( - this.collapseAttributes(item), - ); - break; - case 'meta-data': - provider.metaData.push(this.collapseAttributes(item)); - break; - case 'path-permission': - provider.pathPermissions.push(this.collapseAttributes(item)); - break; - } - } - - app.providers.push(provider); - break; - } - case 'uses-library': - app.usesLibraries.push(this.collapseAttributes(child)); - break; - case 'meta-data': - app.metaData.push(this.collapseAttributes(child)); - break; - } - } - - return app; - } - - private isLauncherActivity(activity: any) { - return activity.intentFilters.some((filter: any) => { - const hasMain = filter.actions.some( - (action: any) => action.name === INTENT_MAIN, - ); - if (!hasMain) { - return false; - } - return filter.categories.some( - (category: any) => category.name === CATEGORY_LAUNCHER, - ); - }); - } - - parse() { - const document = this.xmlParser.parse(); - const manifest = this.collapseAttributes(document); - - manifest.usesPermissions = []; - manifest.usesPermissionsSDK23 = []; - manifest.permissions = []; - manifest.permissionTrees = []; - manifest.permissionGroups = []; - manifest.instrumentation = null; - manifest.usesSdk = null; - manifest.usesConfiguration = null; - manifest.usesFeatures = []; - manifest.supportsScreens = null; - manifest.compatibleScreens = []; - manifest.supportsGlTextures = []; - manifest.application = Object.create(null); - - for (const element of document.childNodes) { - switch (element.nodeName) { - case 'uses-permission': - manifest.usesPermissions.push(this.collapseAttributes(element)); - break; - case 'uses-permission-sdk-23': - manifest.usesPermissionsSDK23.push(this.collapseAttributes(element)); - break; - case 'permission': - manifest.permissions.push(this.collapseAttributes(element)); - break; - case 'permission-tree': - manifest.permissionTrees.push(this.collapseAttributes(element)); - break; - case 'permission-group': - manifest.permissionGroups.push(this.collapseAttributes(element)); - break; - case 'instrumentation': - manifest.instrumentation = this.collapseAttributes(element); - break; - case 'uses-sdk': - manifest.usesSdk = this.collapseAttributes(element); - break; - case 'uses-configuration': - manifest.usesConfiguration = this.collapseAttributes(element); - break; - case 'uses-feature': - manifest.usesFeatures.push(this.collapseAttributes(element)); - break; - case 'supports-screens': - manifest.supportsScreens = this.collapseAttributes(element); - break; - case 'compatible-screens': - for (const screen of element.childNodes) { - manifest.compatibleScreens.push(this.collapseAttributes(screen)); - } - break; - case 'supports-gl-texture': - manifest.supportsGlTextures.push(this.collapseAttributes(element)); - break; - case 'application': - manifest.application = this.parseApplication(element); - break; - } - } - - return manifest; - } -} diff --git a/src/utils/app-info-parser/zip.ts b/src/utils/app-info-parser/zip.ts index c3937e8..b3c251f 100644 --- a/src/utils/app-info-parser/zip.ts +++ b/src/utils/app-info-parser/zip.ts @@ -1,108 +1,28 @@ -import path from 'path'; import { enumZipEntries, readEntry } from '../zip-entries'; -import { decodeNullUnicode } from './utils'; -export class Zip { - file: string | File; - - constructor(file: string | File) { - this.file = typeof file === 'string' ? path.resolve(file) : 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 this.readEntries(decoded, type); - } - - /** - * 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 this.readEntries([decoded], type).then( - (buffers) => buffers[decoded as any], - ); - } - - async getEntryFromHarmonyApp( - regex: RegExp, - ): Promise { - return (await this.getEntriesFromHarmonyApp([regex]))[0]; - } - - /** - * Read multiple entries in a single pass over the archive (including - * nested .hap extraction), instead of one full scan per entry. - * Returns buffers in the same order as the given regexps. - */ - async getEntriesFromHarmonyApp( - regexps: RegExp[], - ): Promise<(Buffer | undefined)[]> { - const results: (Buffer | undefined)[] = new Array(regexps.length); - try { - if (typeof this.file !== 'string') { - throw new Error('Param error: [file] must be file path in Node.'); - } - await enumZipEntries(this.file, async (entry, zipFile) => { - for (let i = 0; i < regexps.length; i++) { - if (regexps[i].test(entry.fileName)) { - results[i] = await readEntry(entry, zipFile); - } - } - }); - } catch (error) { - console.error('Error in getEntriesFromHarmonyApp:', error); +/** + * Read the entries matching each pattern in a single pass over the archive + * (nested .hap archives included). Each entry name is tried as-is and + * lowercased, with NUL padding stripped. Returns buffers in pattern order. + */ +export async function readZipEntries( + file: string, + patterns: RegExp[], +): Promise<(Buffer | undefined)[]> { + const results: (Buffer | undefined)[] = new Array(patterns.length); + await enumZipEntries(file, async (entry, zipFile) => { + if (entry.fileName.endsWith('/')) { + return; } - return results; - } - - private async readEntries( - decoded: Array, - type: 'buffer' | 'blob', - ): Promise> { - if (typeof this.file !== 'string') { - throw new Error('Param error: [file] must be file path in Node.'); + // biome-ignore lint/suspicious/noControlCharactersInRegex: strips NUL padding from entry names + const name = entry.fileName.replace(/\u0000/g, ''); + const lowerName = name.toLowerCase(); + const index = patterns.findIndex( + (pattern) => pattern.test(name) || pattern.test(lowerName), + ); + if (index !== -1) { + results[index] = await readEntry(entry, zipFile); } - - const buffers: Record = {}; - await enumZipEntries(this.file, async (entry, zipFile) => { - if (entry.fileName.endsWith('/')) { - return; - } - - const entryName = decodeNullUnicode(entry.fileName).toString(); - const lowerEntryName = entryName.toLowerCase(); - const matched = decoded.find((pattern) => { - if (typeof pattern === 'string') { - return pattern === entryName || pattern === lowerEntryName; - } - pattern.lastIndex = 0; - if (pattern.test(entryName)) { - return true; - } - pattern.lastIndex = 0; - return pattern.test(lowerEntryName); - }); - - if (!matched) { - return; - } - - const buffer = await readEntry(entry, zipFile); - buffers[matched as any] = - type === 'blob' ? new Blob([buffer as any]) : buffer; - }); - - return buffers; - } + }); + return results; } diff --git a/src/utils/hermes-base.ts b/src/utils/hermes-base.ts index 35fb3f3..9e7c779 100644 --- a/src/utils/hermes-base.ts +++ b/src/utils/hermes-base.ts @@ -418,10 +418,9 @@ export async function extractBundleFromArchive( ); } if (ext === '.app') { - // harmony .app wraps .hap archives; reuse the existing parser - const { default: AppInfoParser } = await import('./app-info-parser'); - const parser = new AppInfoParser(filePath); - const [bundle] = await parser.parser.getEntriesFromHarmonyApp([ + // harmony .app wraps .hap archives; readZipEntries walks into them + const { readZipEntries } = await import('./app-info-parser/zip'); + const [bundle] = await readZipEntries(filePath, [ /^resources\/rawfile\/bundle\.harmony\.js$/, ]); return bundle ?? null; diff --git a/src/utils/index.ts b/src/utils/index.ts index 96afa0a..9efd3e7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,7 +5,6 @@ import path from 'path'; import type { Root as ProtobufRoot } from 'protobufjs'; import { read } from 'read'; import pkg from '../../package.json'; -import type AppInfoParserType from './app-info-parser'; import { checkPlugins } from './check-plugin'; import { IS_CRESC } from './constants'; import { getDepVersion } from './dep-versions'; @@ -14,10 +13,12 @@ import { readFirstEntry } from './zip-entries'; // app-info-parser (and its zip/plist/protobuf stack) is only needed by the // package commands; load it on first use instead of on every CLI start. -function createAppInfoParser(fn: string): AppInfoParserType { - const { default: AppInfoParser } = - require('./app-info-parser') as typeof import('./app-info-parser'); - return new AppInfoParser(fn); +function loadAppInfoParser() { + return { + ...(require('./app-info-parser/apk') as typeof import('./app-info-parser/apk')), + ...(require('./app-info-parser/ipa') as typeof import('./app-info-parser/ipa')), + ...(require('./app-info-parser/zip') as typeof import('./app-info-parser/zip')), + }; } /** @@ -29,22 +30,6 @@ export function loadTtyTable(): typeof import('tty-table') { return (mod.default ?? mod) as typeof import('tty-table'); } -type ApkMetaEntry = { - name?: string; - value?: string | number | Array; -}; - -type ParsedApkInfo = { - versionName: string; - application: { - metaData?: ApkMetaEntry[]; - }; -}; - -type ParsedIpaInfo = { - CFBundleShortVersionString: string; -}; - type ParsedAppMetaInfo = { versionName?: string; pushy_build_time?: number | string; @@ -168,159 +153,142 @@ export function translateOptions>( // stay byte-identical with what the client hashes at runtime (its embedded // bundle, which is also the pdiff source) — the server compares the two to // decide pdiff applicability. Raw bytes, no normalization of any kind. -// getEntries is typed Buffer | Blob (app-info-parser's browser/node duality); -// under Node it is always a Buffer, but handle both. -async function sha256(data: Buffer | Blob): Promise { - const buffer = Buffer.isBuffer(data) - ? data - : Buffer.from(await data.arrayBuffer()); - return createHash('sha256').update(buffer).digest('hex'); +function sha256(data: Buffer): string { + return createHash('sha256').update(data).digest('hex'); } -// Anchored exact paths: unanchored/unescaped patterns also matched entries -// like index.android.bundle.map or .backup, and a later match would silently -// overwrite the real bundle, registering a wrong bundleHash. -export const ApkBundleFileName = /^assets\/index\.android\.bundle$/; -const ApkUpdateJsonName = /^res\/raw\/update\.json$/; - -export async function getApkInfo(fn: string) { - const appInfoParser = createAppInfoParser(fn); - // read both entries in a single scan over the archive - const entries = await appInfoParser.parser.getEntries([ - ApkBundleFileName, - ApkUpdateJsonName, - ]); - const bundleFile = entries[String(ApkBundleFileName)]; +function requireBundle( + bundleFile: Buffer | undefined, + packageType: string, + entryFile: string, +): Buffer { if (!bundleFile) { - throw new Error( - t('bundleNotFound', { - packageType: 'apk', - entryFile: 'index.android.bundle', - }), - ); - } - const updateJsonFile = entries[String(ApkUpdateJsonName)]; - let appCredential = {}; - if (updateJsonFile) { - appCredential = JSON.parse(updateJsonFile.toString()).android; - } - const { versionName, application } = - await appInfoParser.parse(); - let buildTime = 0; - if (Array.isArray(application.metaData)) { - for (const meta of application.metaData) { - if (meta.name === 'pushy_build_time') { - if (Array.isArray(meta.value)) { - const firstValue = meta.value[0]; - buildTime = Number(firstValue); - } else if (meta.value !== undefined) { - buildTime = Number(meta.value); - } - } - } + throw new Error(t('bundleNotFound', { packageType, entryFile })); } + return bundleFile; +} + +function requireBuildTime(value: unknown): number { + const buildTime = Number(value); if (!Number.isFinite(buildTime) || buildTime === 0) { throw new Error(t('buildTimeNotFound')); } + return buildTime; +} + +/** The common shape of getApkInfo / getAppInfo / getIpaInfo. */ +function nativePackageInfo( + versionName: unknown, + buildTime: number | string, + bundleFile: Buffer, + updateJson: Buffer | undefined, + platform: 'android' | 'harmony' | 'ios', +) { + const appCredential = updateJson + ? JSON.parse(updateJson.toString())[platform] + : {}; return { versionName, buildTime, - bundleHash: await sha256(bundleFile), + bundleHash: sha256(bundleFile), bundleFile, ...appCredential, }; } -export async function getAppInfo(fn: string) { - const appInfoParser = createAppInfoParser(fn); - // single scan (and single nested .hap extraction) for all three entries - const [bundleFile, updateJsonFile, metaJsonFile] = - await appInfoParser.parser.getEntriesFromHarmonyApp([ - /^resources\/rawfile\/bundle\.harmony\.js$/, - /^resources\/rawfile\/update\.json$/, - /^resources\/rawfile\/meta\.json$/, - ]); - if (!bundleFile) { - throw new Error( - t('bundleNotFound', { - packageType: 'app', - entryFile: 'bundle.harmony.js', - }), - ); - } - let appCredential = {}; - if (updateJsonFile) { - appCredential = JSON.parse(updateJsonFile.toString()).harmony; - } - let metaData: ParsedAppMetaInfo = {}; - if (metaJsonFile) { - metaData = JSON.parse(metaJsonFile.toString()) as ParsedAppMetaInfo; - } - const { versionName, pushy_build_time } = metaData; - let buildTime = 0; - if (pushy_build_time) { - buildTime = Number(pushy_build_time); +// Anchored exact paths: unanchored/unescaped patterns also matched entries +// like index.android.bundle.map or .backup, and a later match would silently +// overwrite the real bundle, registering a wrong bundleHash. +export const ApkBundleFileName = /^assets\/index\.android\.bundle$/; +const ApkUpdateJsonName = /^res\/raw\/update\.json$/; +const ApkManifestName = /^androidmanifest\.xml$/; +const ApkResourcesName = /^resources\.arsc$/; + +export async function getApkInfo(fn: string) { + const { parseApkManifest, readZipEntries } = loadAppInfoParser(); + // read every entry in a single scan over the archive + const [bundleFile, updateJson, manifest, resources] = await readZipEntries( + fn, + [ApkBundleFileName, ApkUpdateJsonName, ApkManifestName, ApkResourcesName], + ); + const bundle = requireBundle(bundleFile, 'apk', 'index.android.bundle'); + if (!manifest) { + throw new Error("AndroidManifest.xml can't be found."); } - if (!Number.isFinite(buildTime) || buildTime === 0) { - throw new Error(t('buildTimeNotFound')); + const { versionName, metaData } = parseApkManifest(manifest, resources); + let buildTime: unknown; + for (const meta of metaData) { + if (meta.name === 'pushy_build_time') { + buildTime = Array.isArray(meta.value) ? meta.value[0] : meta.value; + } } - return { + return nativePackageInfo( versionName, - buildTime, - bundleHash: await sha256(bundleFile), - bundleFile, - ...appCredential, - }; + requireBuildTime(buildTime), + bundle, + updateJson, + 'android', + ); +} + +export async function getAppInfo(fn: string) { + const { readZipEntries } = loadAppInfoParser(); + // single scan (and single nested .hap extraction) for all three entries + const [bundleFile, updateJson, metaJson] = await readZipEntries(fn, [ + /^resources\/rawfile\/bundle\.harmony\.js$/, + /^resources\/rawfile\/update\.json$/, + /^resources\/rawfile\/meta\.json$/, + ]); + const bundle = requireBundle(bundleFile, 'app', 'bundle.harmony.js'); + const { versionName, pushy_build_time }: ParsedAppMetaInfo = metaJson + ? JSON.parse(metaJson.toString()) + : {}; + return nativePackageInfo( + versionName, + requireBuildTime(pushy_build_time), + bundle, + updateJson, + 'harmony', + ); } // lowercase because the zip reader also matches against the lowercased entry // name (real IPAs use "Payload/") export const IpaBundleFileName = /^payload\/[^/]+\.app\/main\.jsbundle$/; const IpaUpdateJsonName = /^payload\/[^/]+\.app\/assets\/update\.json$/; +const IpaPlistName = /payload\/[^/]+?.app\/info.plist$/i; const IpaBuildTimeName = /^payload\/[^/]+\.app\/pushy_build_time\.txt$/; // Not in root bundle when use `use_frameworks` const IpaBuildTimeFrameworkName = /^payload\/[^/]+\.app\/frameworks\/react_native_update\.framework\/pushy_build_time\.txt$/; export async function getIpaInfo(fn: string) { - const appInfoParser = createAppInfoParser(fn); - // read all four entries in a single scan over the archive - const entries = await appInfoParser.parser.getEntries([ - IpaBundleFileName, - IpaUpdateJsonName, - IpaBuildTimeName, - IpaBuildTimeFrameworkName, - ]); - const bundleFile = entries[String(IpaBundleFileName)]; - if (!bundleFile) { - throw new Error( - t('bundleNotFound', { - packageType: 'ipa', - entryFile: 'main.jsbundle', - }), - ); - } - const updateJsonFile = entries[String(IpaUpdateJsonName)]; - let appCredential = {}; - if (updateJsonFile) { - appCredential = JSON.parse(updateJsonFile.toString()).ios; + const { parsePlist, readZipEntries } = loadAppInfoParser(); + // read every entry in a single scan over the archive + const [bundleFile, updateJson, plist, buildTimeTxt, frameworkBuildTimeTxt] = + await readZipEntries(fn, [ + IpaBundleFileName, + IpaUpdateJsonName, + IpaPlistName, + IpaBuildTimeName, + IpaBuildTimeFrameworkName, + ]); + const bundle = requireBundle(bundleFile, 'ipa', 'main.jsbundle'); + if (!plist) { + throw new Error("Info.plist can't be found."); } - const { CFBundleShortVersionString: versionName } = - await appInfoParser.parse(); - const buildTimeTxtBuffer = - entries[String(IpaBuildTimeName)] ?? - entries[String(IpaBuildTimeFrameworkName)]; + const { CFBundleShortVersionString: versionName } = await parsePlist(plist); + const buildTimeTxtBuffer = buildTimeTxt ?? frameworkBuildTimeTxt; if (!buildTimeTxtBuffer) { throw new Error(t('buildTimeNotFound')); } - const buildTime = buildTimeTxtBuffer.toString().trim(); - return { + return nativePackageInfo( versionName, - buildTime, - bundleHash: await sha256(bundleFile), - bundleFile, - ...appCredential, - }; + buildTimeTxtBuffer.toString().trim(), + bundle, + updateJson, + 'ios', + ); } export async function getAabInfo(fn: string) { @@ -411,7 +379,7 @@ export async function getAabInfo(fn: string) { // through extractApk → getApkInfo which enforces the bundle's presence. let bundleHash: string | undefined; try { - bundleHash = await sha256( + bundleHash = sha256( await readZipEntry(fn, 'base/assets/index.android.bundle'), ); } catch { diff --git a/tests/app-info-zip.test.ts b/tests/app-info-zip.test.ts index 8aff25d..47b5807 100644 --- a/tests/app-info-zip.test.ts +++ b/tests/app-info-zip.test.ts @@ -4,8 +4,9 @@ import os from 'os'; import path from 'path'; import { ZipFile as YazlZipFile } from 'yazl'; import { ApkBundleFileName, IpaBundleFileName } from '../src/utils'; -import { IpaParser } from '../src/utils/app-info-parser/ipa'; -import { Zip } from '../src/utils/app-info-parser/zip'; +import { parseApkManifest } from '../src/utils/app-info-parser/apk'; +import { parsePlist } from '../src/utils/app-info-parser/ipa'; +import { readZipEntries } from '../src/utils/app-info-parser/zip'; function mkTempDir(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -31,7 +32,162 @@ async function writeZip( }); } -describe('app-info-parser Zip', () => { +// Minimal binary (AAPT) XML writer: a UTF-16 string pool plus start/end +// element chunks whose attributes carry typed values. +type Attr = [name: string, type: number, data: number | string]; +type Node = { name: string; attrs: Attr[]; children?: Node[] }; + +function binaryXml(root: Node): Buffer { + const strings: string[] = []; + const ref = (s: string) => { + if (!strings.includes(s)) strings.push(s); + return strings.indexOf(s); + }; + const u16 = (n: number) => { + const b = Buffer.alloc(2); + b.writeUInt16LE(n); + return b; + }; + const u32 = (n: number) => { + const b = Buffer.alloc(4); + b.writeInt32LE(n); + return b; + }; + const chunk = (type: number, headerSize: number, body: Buffer[]) => { + const data = Buffer.concat(body); + return Buffer.concat([ + u16(type), + u16(headerSize), + u32(8 + data.length), + data, + ]); + }; + const elements: Buffer[] = []; + const walk = (node: Node) => { + const name = ref(node.name); + const attrs = node.attrs.map(([attrName, type, data]) => + Buffer.concat([ + u32(-1), + u32(ref(attrName)), + u32(typeof data === 'string' ? ref(data) : -1), + u16(8), + Buffer.from([0, type]), + u32(typeof data === 'string' ? ref(data) : data), + ]), + ); + elements.push( + chunk(0x0102, 16, [ + u32(1), + u32(-1), + u32(-1), + u32(name), + u16(20), + u16(20), + u16(attrs.length), + u16(0), + u16(0), + u16(0), + ...attrs, + ]), + ); + for (const child of node.children ?? []) walk(child); + elements.push(chunk(0x0103, 16, [u32(1), u32(-1), u32(-1), u32(name)])); + }; + walk(root); + const encoded = strings.map((s) => + Buffer.concat([u16(s.length), Buffer.from(s, 'utf16le'), u16(0)]), + ); + const offsets: Buffer[] = []; + let at = 0; + for (const e of encoded) { + offsets.push(u32(at)); + at += e.length; + } + const pool = chunk(0x0001, 28, [ + u32(strings.length), + u32(0), + u32(0), + u32(28 + 4 * strings.length), + u32(0), + ...offsets, + ...encoded, + ]); + return chunk(0x0003, 8, [pool, ...elements]); +} + +const TYPE_REFERENCE = 0x01; +const TYPE_STRING = 0x03; +const TYPE_INT_DEC = 0x10; +const TYPE_INT_BOOLEAN = 0x12; + +describe('parseApkManifest', () => { + test('reads versionName and application meta-data', () => { + const manifest = binaryXml({ + name: 'manifest', + attrs: [['versionName', TYPE_STRING, '1.2.3\u0000\u0000']], + children: [ + { name: 'uses-permission', attrs: [['name', TYPE_STRING, 'x']] }, + { + name: 'application', + attrs: [['label', TYPE_REFERENCE, 0x7f010000]], + children: [ + { + name: 'meta-data', + attrs: [ + ['name', TYPE_STRING, 'pushy_build_time'], + ['value', TYPE_INT_DEC, 1700000000], + ], + }, + { + name: 'meta-data', + attrs: [ + ['name', TYPE_STRING, 'enabled'], + ['value', TYPE_INT_BOOLEAN, 0], + ], + }, + { + name: 'activity', + attrs: [['name', TYPE_STRING, '.Main']], + children: [ + { + name: 'meta-data', + attrs: [['name', TYPE_STRING, 'nested']], + }, + ], + }, + ], + }, + ], + }); + + expect(parseApkManifest(manifest)).toEqual({ + versionName: '1.2.3', + metaData: [ + { name: 'pushy_build_time', value: 1700000000 }, + { name: 'enabled', value: false }, + ], + }); + }); + + test('leaves an unresolvable reference undefined without resources.arsc', () => { + const manifest = binaryXml({ + name: 'manifest', + attrs: [['versionName', TYPE_REFERENCE, 0x7f0f0001]], + }); + expect(parseApkManifest(manifest)).toEqual({ + versionName: undefined, + metaData: [], + }); + }); + + test('rejects a buffer that is not binary XML', () => { + expect(() => parseApkManifest(Buffer.alloc(16))).toThrow( + 'Parse AndroidManifest.xml error: Invalid XML header', + ); + }); +}); + +describe('readZipEntries', () => { let tempRoot = ''; beforeEach(() => { @@ -44,24 +200,23 @@ describe('app-info-parser Zip', () => { } }); - test('reads entries by regex and string with compatible keys', async () => { + test('reads entries in pattern order, matching lowercased names too', async () => { const zipPath = path.join(tempRoot, 'app.zip'); await writeZip(zipPath, { 'AndroidManifest.xml': 'manifest', + 'base/manifest/AndroidManifest.xml': 'aab manifest', 'res/icon.png': 'icon', }); - const manifestRegex = /^androidmanifest\.xml$/; - const buffers = await new Zip(zipPath).getEntries([ - manifestRegex, - 'res/icon.png', + const [manifest, aabManifest, missing] = await readZipEntries(zipPath, [ + /^androidmanifest\.xml$/, + // case-sensitive patterns still match the original name + /^base\/manifest\/AndroidManifest\.xml$/, + /^nothing$/, ]); - - expect(Buffer.isBuffer(buffers[manifestRegex as any])).toBe(true); - expect((buffers[manifestRegex as any] as Buffer).toString()).toBe( - 'manifest', - ); - expect((buffers['res/icon.png'] as Buffer).toString()).toBe('icon'); + expect(manifest?.toString()).toBe('manifest'); + expect(aabManifest?.toString()).toBe('aab manifest'); + expect(missing).toBeUndefined(); }); test('bundle patterns only match the exact bundle entry, not .map/.backup siblings', async () => { @@ -74,10 +229,8 @@ describe('app-info-parser Zip', () => { 'foo/assets/index.android.bundle.tmp': 'TMP', }); - const apkEntries = await new Zip(apkPath).getEntries([ApkBundleFileName]); - expect((apkEntries[String(ApkBundleFileName)] as Buffer).toString()).toBe( - 'REAL_BUNDLE', - ); + const [apkBundle] = await readZipEntries(apkPath, [ApkBundleFileName]); + expect(apkBundle?.toString()).toBe('REAL_BUNDLE'); const ipaPath = path.join(tempRoot, 'app-bundle.ipa'); await writeZip(ipaPath, { @@ -86,16 +239,43 @@ describe('app-info-parser Zip', () => { 'Payload/Test.app/mainXjsbundle': 'NOT_A_BUNDLE', }); - const ipaEntries = await new Zip(ipaPath).getEntries([IpaBundleFileName]); - expect((ipaEntries[String(IpaBundleFileName)] as Buffer).toString()).toBe( - 'REAL_IPA_BUNDLE', - ); + const [ipaBundle] = await readZipEntries(ipaPath, [IpaBundleFileName]); + expect(ipaBundle?.toString()).toBe('REAL_IPA_BUNDLE'); }); - test('parses ipa plist with current plist package exports', async () => { - const ipaPath = path.join(tempRoot, 'app.ipa'); - await writeZip(ipaPath, { - 'Payload/Test.app/Info.plist': ` + test('walks into nested .hap archives', async () => { + const hapPath = path.join(tempRoot, 'entry.hap'); + await writeZip(hapPath, { + 'resources/rawfile/bundle.harmony.js': 'bundle', + 'resources/rawfile/update.json': '{"harmony":{}}', + }); + const appPath = path.join(tempRoot, 'app.app'); + await writeZip(appPath, { + 'entry.hap': fs.readFileSync(hapPath), + 'pack.info': '{}', + }); + + const [bundle, updateJson, meta] = await readZipEntries(appPath, [ + /^resources\/rawfile\/bundle\.harmony\.js$/, + /^resources\/rawfile\/update\.json$/, + /^resources\/rawfile\/meta\.json$/, + ]); + expect(bundle?.toString()).toBe('bundle'); + expect(updateJson?.toString()).toBe('{"harmony":{}}'); + expect(meta).toBeUndefined(); + }); + + test('rejects a file that is not a zip', async () => { + const notZip = path.join(tempRoot, 'broken.app'); + fs.writeFileSync(notZip, 'not a zip'); + await expect(readZipEntries(notZip, [/x/])).rejects.toThrow(); + }); +}); + +describe('parsePlist', () => { + test('parses an XML plist with the current plist package exports', async () => { + const info = await parsePlist( + Buffer.from(` CFBundleIdentifier @@ -103,150 +283,16 @@ describe('app-info-parser Zip', () => { CFBundleShortVersionString 1.2.3 -`, - 'Payload/Test.app/embedded.mobileprovision': `prefix - - - - TeamName - ReactNativeCN - - -suffix`, - }); - - const info = await new IpaParser(ipaPath).parse(); +`), + ); expect(info.CFBundleIdentifier).toBe('cn.reactnative.test'); expect(info.CFBundleShortVersionString).toBe('1.2.3'); - expect(info.mobileProvision.TeamName).toBe('ReactNativeCN'); - expect(info.icon).toBe(null); - }); - - test('getEntry reads a single entry by string and regex', async () => { - const zipPath = path.join(tempRoot, 'app.zip'); - await writeZip(zipPath, { - 'test.txt': 'hello world', - 'foo.bar': 'baz', - }); - - const zip = new Zip(zipPath); - - // By string - const buf1 = await zip.getEntry('test.txt'); - expect(Buffer.isBuffer(buf1)).toBe(true); - expect((buf1 as Buffer).toString()).toBe('hello world'); - - // By regex - const buf2 = await zip.getEntry(/foo.bar/); - expect(Buffer.isBuffer(buf2)).toBe(true); - expect((buf2 as Buffer).toString()).toBe('baz'); - }); - - test('getEntry and getEntries handle blob return type', async () => { - const zipPath = path.join(tempRoot, 'app.zip'); - await writeZip(zipPath, { - 'test.txt': 'hello world', - }); - - const zip = new Zip(zipPath); - - const blob1 = await zip.getEntry('test.txt', 'blob'); - expect(blob1).toBeInstanceOf(Blob); - expect(await (blob1 as Blob).text()).toBe('hello world'); - - const buffers = await zip.getEntries(['test.txt'], 'blob'); - expect(buffers['test.txt']).toBeInstanceOf(Blob); - expect(await (buffers['test.txt'] as Blob).text()).toBe('hello world'); }); - test('getEntryFromHarmonyApp extracts matching file', async () => { - const zipPath = path.join(tempRoot, 'app.zip'); - await writeZip(zipPath, { - 'config.json': '{"harmony": true}', - 'other.txt': 'ignore', - }); - - const zip = new Zip(zipPath); - - const buf = await zip.getEntryFromHarmonyApp(/config.json/); - expect(Buffer.isBuffer(buf)).toBe(true); - expect((buf as Buffer).toString()).toBe('{"harmony": true}'); - }); - - test('getEntriesFromHarmonyApp reads multiple entries in one pass', async () => { - const zipPath = path.join(tempRoot, 'app.zip'); - await writeZip(zipPath, { - 'rawfile/bundle.harmony.js': 'bundle', - 'rawfile/update.json': '{"harmony":{}}', - 'other.txt': 'ignore', - }); - - const zip = new Zip(zipPath); - - const [bundle, updateJson, missing] = await zip.getEntriesFromHarmonyApp([ - /rawfile\/bundle.harmony.js/, - /rawfile\/update.json/, - /rawfile\/meta.json/, - ]); - expect((bundle as Buffer).toString()).toBe('bundle'); - expect((updateJson as Buffer).toString()).toBe('{"harmony":{}}'); - expect(missing).toBeUndefined(); - }); - - test('getEntry matches case-sensitive regex against original entry name', async () => { - const zipPath = path.join(tempRoot, 'app.zip'); - await writeZip(zipPath, { - 'base/manifest/AndroidManifest.xml': 'aab manifest', - }); - - // regex with uppercase letters used to never match because only the - // lowercased entry name was tested - const buf = await new Zip(zipPath).getEntry( - /^base\/manifest\/AndroidManifest\.xml$/, - ); - expect((buf as Buffer).toString()).toBe('aab manifest'); - }); - - test('constructor resolves path for string input', () => { - const relPath = 'some/relative/path.zip'; - const zip = new Zip(relPath); - expect(zip.file).toBe(path.resolve(relPath)); - }); - - test('throws error when reading entries and file is not a string', async () => { - // Simulate a File object which is not a string - const mockFile = new Blob(['dummy']) as File; - const zip = new Zip(mockFile); - - expect(zip.file).toBe(mockFile); - - try { - await zip.getEntry('any'); - expect(true).toBe(false); // Should not reach here - } catch (e: any) { - expect(e.message).toBe('Param error: [file] must be file path in Node.'); - } - - try { - await zip.getEntries(['any']); - expect(true).toBe(false); // Should not reach here - } catch (e: any) { - expect(e.message).toBe('Param error: [file] must be file path in Node.'); - } - - // getEntryFromHarmonyApp catches the error and logs to console, returning undefined - const originalError = console.error; - let consoleOutput = ''; - console.error = (_msg: string, err: any) => { - consoleOutput += err.message; - }; - const res = await zip.getEntryFromHarmonyApp(/any/); - console.error = originalError; - - expect(res).toBeUndefined(); - expect(consoleOutput).toBe( - 'Param error: [file] must be file path in Node.', + test('rejects an unknown plist format', async () => { + await expect(parsePlist(Buffer.from('garbage'))).rejects.toThrow( + 'Unknown plist buffer type.', ); }); }); From 834155ea7d6b130b4e2d10eb670dce49cf0e4532 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 13:54:34 +0800 Subject: [PATCH 03/20] refactor: trim the registry version check, drop the properties dependency - utils/latest-version (a 581-line copy of @badisi/latest-version) becomes a ~190-line getLatestVersions(): only the `latest` dist-tag is read, so the package.json input, installed-version lookups (three sync requires and a node_modules walk per package on every check), wanted/next/range logic and the public types go; cache location and file format are unchanged - userCacheDir() in fs-helpers replaces the two copies of the platform cache directory logic (auto-update, latest-version) - gradle.properties' hermesEnabled is read with a regex (same results as the properties package on the usual forms: separators, comments, last wins), so the properties dependency goes Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 - package.json | 1 - src/auto-update.ts | 18 +- src/bundle-runner.ts | 52 +-- src/utils/fs-helpers.ts | 23 ++ src/utils/index.ts | 16 +- src/utils/latest-version.ts | 188 ++++++++++ src/utils/latest-version/index.ts | 581 ------------------------------ 8 files changed, 245 insertions(+), 637 deletions(-) create mode 100644 src/utils/latest-version.ts delete mode 100644 src/utils/latest-version/index.ts diff --git a/bun.lock b/bun.lock index bf5d93b..911d024 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,6 @@ "i18next": "^26.4.0", "plist": "^5.0.0", "progress": "^2.0.3", - "properties": "^1.2.1", "protobufjs": "^8.7.2", "read": "^4.1.0", "registry-auth-token": "^5.1.1", @@ -377,8 +376,6 @@ "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], - "properties": ["properties@1.2.1", "", {}, "sha512-qYNxyMj1JeW54i/EWEFsM1cVwxJbtgPp8+0Wg9XjNaK6VE/c4oRi6PNu5p7w1mNXEIQIjV5Wwn8v8Gz82/QzdQ=="], - "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], "protobufjs": ["protobufjs@8.7.2", "", { "dependencies": { "long": "^5.3.2" } }, "sha512-oTVHV+oelUBtiu5iTuTNNZ0eLYsXSMxry4cgr30mayNkgIZL6qZ0IOQVPuSWGcyAaXKl/XgqwWHIC3a0khYVBA=="], diff --git a/package.json b/package.json index 479bf88..6b099b0 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,6 @@ "i18next": "^26.4.0", "plist": "^5.0.0", "progress": "^2.0.3", - "properties": "^1.2.1", "protobufjs": "^8.7.2", "read": "^4.1.0", "registry-auth-token": "^5.1.1", diff --git a/src/auto-update.ts b/src/auto-update.ts index 26486a4..975aee5 100644 --- a/src/auto-update.ts +++ b/src/auto-update.ts @@ -11,8 +11,8 @@ import { statSync, writeFileSync, } from 'fs'; -import { homedir } from 'os'; import path from 'path'; +import { userCacheDir } from './utils/fs-helpers'; export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; @@ -61,21 +61,7 @@ const PERMISSION_RETRY_MS = 7 * 24 * 60 * 60 * 1000; const OUTPUT_LIMIT = 32 * 1024; function cacheDir(): string { - if (process.env.XDG_CACHE_HOME) { - return path.join(process.env.XDG_CACHE_HOME, PACKAGE_NAME); - } - switch (process.platform) { - case 'darwin': - return path.join(homedir(), 'Library', 'Caches', PACKAGE_NAME); - case 'win32': - return path.join( - process.env.LOCALAPPDATA ?? path.join(homedir(), 'AppData', 'Local'), - PACKAGE_NAME, - 'Cache', - ); - default: - return path.join(homedir(), '.cache', PACKAGE_NAME); - } + return userCacheDir(PACKAGE_NAME); } function stateFile(): string { diff --git a/src/bundle-runner.ts b/src/bundle-runner.ts index 808211c..47a412e 100644 --- a/src/bundle-runner.ts +++ b/src/bundle-runner.ts @@ -31,7 +31,6 @@ import { } from './utils/runtime'; const g2js = require('gradle-to-js/lib/parser'); -const properties = require('properties'); export interface BundleCliOptions { taro?: boolean; @@ -545,11 +544,11 @@ export async function detectHermesEnabled( return true; } if (platform === 'android') { - const gradleProperties = await readGradleProperties( + const hermesEnabled = await readGradleHermesEnabled( path.join(projectRoot, 'android', 'gradle.properties'), ); - if (typeof gradleProperties.hermesEnabled === 'boolean') { - return gradleProperties.hermesEnabled; + if (hermesEnabled !== undefined) { + return hermesEnabled; } if (typeof gradleConfig.enableHermes === 'boolean') { return gradleConfig.enableHermes; @@ -562,29 +561,30 @@ export async function detectHermesEnabled( return false; } -/** parsed gradle.properties; a missing file is simply empty */ -function readGradleProperties( +/** + * `hermesEnabled` from gradle.properties (the last assignment wins, as in + * Gradle); undefined when the file, the key or a boolean value is missing. + */ +async function readGradleHermesEnabled( file: string, -): Promise<{ hermesEnabled?: boolean }> { - return new Promise((resolve) => { - properties.parse( - file, - { path: true }, - ( - error: (Error & { code?: string }) | null, - props: { hermesEnabled?: boolean } = {}, - ) => { - if (error) { - if (error.code !== 'ENOENT') { - console.warn(`${file}: ${error.message ?? error}`); - } - resolve({}); - return; - } - resolve(props); - }, - ); - }); +): Promise { + let content: string; + try { + content = await fs.promises.readFile(file, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn(`${file}: ${(error as Error).message ?? error}`); + } + return undefined; + } + let value: boolean | undefined; + for (const match of content.matchAll( + /^[ \t\f]*hermesEnabled[ \t\f]*[=: \t\f][ \t\f]*(.*?)[ \t\f]*\r?$/gm, + )) { + value = + match[1] === 'true' ? true : match[1] === 'false' ? false : undefined; + } + return value; } /** React Native's gradle plugin (0.71+) enables Hermes unless a property says otherwise */ diff --git a/src/utils/fs-helpers.ts b/src/utils/fs-helpers.ts index 52053d3..9bd3106 100644 --- a/src/utils/fs-helpers.ts +++ b/src/utils/fs-helpers.ts @@ -1,8 +1,31 @@ // The handful of fs-extra conveniences the CLI used, on plain node:fs. import fs from 'fs'; +import { homedir } from 'os'; import path from 'path'; +/** + * The per-user cache directory for `name`: $XDG_CACHE_HOME when set (on every + * platform; also lets tests isolate), else the platform's cache location. + */ +export function userCacheDir(name: string): string { + if (process.env.XDG_CACHE_HOME) { + return path.join(process.env.XDG_CACHE_HOME, name); + } + switch (process.platform) { + case 'darwin': + return path.join(homedir(), 'Library', 'Caches', name); + case 'win32': + return path.join( + process.env.LOCALAPPDATA ?? path.join(homedir(), 'AppData', 'Local'), + name, + 'Cache', + ); + default: + return path.join(homedir(), '.cache', name); + } +} + /** `rm -rf`: no error when the path is already gone. */ export function remove(target: string): Promise { return fs.promises.rm(target, { recursive: true, force: true }); diff --git a/src/utils/index.ts b/src/utils/index.ts index 9efd3e7..de06836 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -464,19 +464,15 @@ const VERSION_CHECK_PACKAGES = [ async function getLatestVersions( background: boolean, ): Promise> { - // the registry client pulls in global-dirs/registry-auth-token/semver: only - // pay for them when actually checking - const { default: latestVersion } = + // the registry client pulls in registry-auth-token: only pay for it when + // actually checking + const { getLatestVersions: fetchLatestVersions } = require('./latest-version') as typeof import('./latest-version'); - return latestVersion(VERSION_CHECK_PACKAGES, { + return fetchLatestVersions(VERSION_CHECK_PACKAGES, { useCache: true, ...(background ? { unref: true } : { cacheMaxAge: 0 }), - requestOptions: { - timeout: 2000, - }, - }) - .then((pkgs) => pkgs.map((pkg) => pkg.latest)) - .catch(() => VERSION_CHECK_PACKAGES.map(() => undefined)); + timeout: 2000, + }); } function isNewer(latest: string | undefined, current: string | undefined) { diff --git a/src/utils/latest-version.ts b/src/utils/latest-version.ts new file mode 100644 index 0000000..6dadab9 --- /dev/null +++ b/src/utils/latest-version.ts @@ -0,0 +1,188 @@ +// Latest dist-tag lookup on the npm registry, with a per-package metadata +// cache. Trimmed from @badisi/latest-version to what the version check needs; +// the cache location and file format are unchanged. +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import type { IncomingMessage, RequestOptions } from 'http'; +import { dirname, join } from 'path'; +import registryAuthToken from 'registry-auth-token'; +import getRegistryUrl from 'registry-auth-token/registry-url'; +import { userCacheDir } from './fs-helpers'; +import { proxyAgentFor } from './runtime'; + +interface PackageMetadata { + name: string; + lastUpdateDate: number; + distTags: Record; + /** registry the metadata came from; a cache from another registry is not reused */ + registryUrl?: string; +} + +export interface LatestVersionOptions { + /** + * Answer from a cache younger than `cacheMaxAge` (same registry) without + * any request; otherwise query the registry and refresh the cache, falling + * back to a stale cache when the query fails. + */ + useCache?: boolean; + /** default one day; 0 always refreshes (a stale cache stays the fallback) */ + cacheMaxAge?: number; + /** + * Do not let the registry request keep the process alive: its socket is + * unref'd, so the process may exit before the promise settles. + */ + unref?: boolean; + /** request timeout in milliseconds */ + timeout?: number; +} + +const ONE_DAY = 1000 * 60 * 60 * 24; + +const cacheFile = (pkgName: string) => + join(userCacheDir('@badisi/latest-version'), `${pkgName}.json`); + +const resolveRegistryUrl = (pkgName: string): string => { + const i = pkgName.indexOf('/'); + return getRegistryUrl(i !== -1 ? pkgName.slice(0, i) : ''); +}; + +function downloadMetadata( + pkgName: string, + options: LatestVersionOptions, +): Promise { + return new Promise((resolve, reject) => { + const registryUrl = resolveRegistryUrl(pkgName); + const pkgUrl = new URL( + encodeURIComponent(pkgName).replace(/^%40/, '@'), + registryUrl, + ); + const headers: Record = { + accept: + 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*', + }; + const authInfo = registryAuthToken(pkgUrl.toString(), { recursive: true }); + if (authInfo) { + headers.authorization = `${authInfo.type} ${authInfo.token}`; + } + const requestOptions: RequestOptions = { + headers, + host: pkgUrl.hostname, + path: pkgUrl.pathname, + port: pkgUrl.port, + timeout: options.timeout, + // HTTP(S)_PROXY / NO_PROXY, like every other request of the CLI + agent: proxyAgentFor(pkgUrl.toString()), + }; + + const { get } = require(pkgUrl.protocol === 'https:' ? 'https' : 'http'); + const request = get(requestOptions, (res: IncomingMessage) => { + if (res.statusCode !== 200) { + res.resume(); // consume response data to free up memory + reject(new Error(`Request error (${res.statusCode}): ${pkgUrl}`)); + return; + } + let rawData = ''; + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + rawData += chunk; + }); + res.once('error', reject); + res.once('end', () => { + try { + resolve({ + name: pkgName, + lastUpdateDate: Date.now(), + distTags: JSON.parse(rawData)['dist-tags'], + registryUrl, + }); + } catch (err) { + reject(err); + } + }); + }); + request.once('timeout', () => { + request.destroy(new Error(`Request timed out: ${pkgUrl}`)); + }); + request.once('error', reject); + if (options.unref) { + // A background check must never delay the process exit: drop the + // socket's ref (also for a pooled keep-alive socket, which the agent + // ref's again right before handing it out). + request.once('socket', (socket: { unref?: () => void }) => { + socket.unref?.(); + }); + } + }); +} + +function saveMetadataToCache(pkg: PackageMetadata): void { + try { + const filePath = cacheFile(pkg.name); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(pkg)); + } catch { + // a read-only or missing cache dir must not turn into an error + } +} + +/** + * The cached metadata of a package (from the same registry), and whether it is + * still younger than `cacheMaxAge`. A stale entry is returned as well so it can + * serve as a fallback when the registry is unreachable. + */ +function getMetadataFromCache( + pkgName: string, + maxAge: number, +): { metadata: PackageMetadata; fresh: boolean } | undefined { + try { + const metadata = JSON.parse( + readFileSync(cacheFile(pkgName)).toString(), + ) as PackageMetadata; + if ( + !metadata || + typeof metadata.lastUpdateDate !== 'number' || + !metadata.distTags || + metadata.registryUrl !== resolveRegistryUrl(pkgName) + ) { + return undefined; + } + const age = Date.now() - metadata.lastUpdateDate; + return { metadata, fresh: maxAge !== 0 && age >= 0 && age < maxAge }; + } catch { + return undefined; // missing / unreadable / corrupt cache: no cache + } +} + +async function getLatestVersion( + pkgName: string, + options: LatestVersionOptions, +): Promise { + if (!options.useCache) { + return (await downloadMetadata(pkgName, options)).distTags.latest; + } + const cached = getMetadataFromCache(pkgName, options.cacheMaxAge ?? ONE_DAY); + if (cached?.fresh) { + return cached.metadata.distTags.latest; + } + try { + const metadata = await downloadMetadata(pkgName, options); + saveMetadataToCache(metadata); + return metadata.distTags.latest; + } catch (err) { + if (!cached) { + throw err; + } + return cached.metadata.distTags.latest; // stale beats nothing + } +} + +/** The `latest` dist-tag of each package, `undefined` where unknown. */ +export function getLatestVersions( + pkgNames: string[], + options: LatestVersionOptions = {}, +): Promise> { + return Promise.all( + pkgNames.map((name) => + getLatestVersion(name, options).catch(() => undefined), + ), + ); +} diff --git a/src/utils/latest-version/index.ts b/src/utils/latest-version/index.ts deleted file mode 100644 index ec827d1..0000000 --- a/src/utils/latest-version/index.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { compare, compareVersions, satisfies } from 'compare-versions'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import { npm, yarn } from 'global-dirs'; -import type { - Agent, - RequestOptions as HttpRequestOptions, - IncomingMessage, -} from 'http'; -import type { RequestOptions as HttpsRequestOptions } from 'https'; -import { homedir } from 'os'; -import { dirname, join, parse, resolve as pathResolve } from 'path'; -import registryAuthToken from 'registry-auth-token'; -import getRegistryUrl from 'registry-auth-token/registry-url'; -import { URL } from 'url'; -import { proxyAgentFor } from '../runtime'; - -interface RegistryVersions { - /** - * The latest version of the package found on the registry (if found). - */ - latest?: string; - /** - * The next version of the package found on the registry (if found). - */ - next?: string; - /** - * The latest version of the package found on the registry and satisfied by the wanted tag or version range. - */ - wanted?: string; -} - -interface InstalledVersions { - /** - * The current local installed version of the package (if installed). - */ - local?: string; - /** - * The current npm global installed version of the package (if installed). - */ - globalNpm?: string; - /** - * The current yarn global installed version of the package (if installed). - */ - globalYarn?: string; -} - -interface LatestVersionPackage extends InstalledVersions, RegistryVersions { - /** - * The name of the package. - */ - name: string; - /** - * The tag or version range that was provided (if provided). - * @default "latest" - */ - wantedTagOrRange?: string; - /** - * Whether the local or global installed versions (if any) could be upgraded or not, based on the wanted version. - */ - updatesAvailable: - | { - local: string | false; - globalNpm: string | false; - globalYarn: string | false; - } - | false; - /** - * Any error that might have occurred during the process. - */ - error?: Error; -} - -interface RequestOptions { - readonly ca?: string | Buffer | Array; - readonly rejectUnauthorized?: boolean; - readonly agent?: Agent | boolean; - readonly timeout?: number; -} - -interface LatestVersionOptions { - /** - * Awaiting the api to return might take time, depending on the network, and might impact your package loading performance. - * You can use the cache mechanism to improve load performance and reduce unnecessary network requests. - * If `useCache` is not supplied, the api will always check for updates and wait for every requests to return before returning itself. - * If `useCache` is used (for each provided package): - * 1) a fresh cache (younger than `cacheMaxAge`, from the same registry) answers without any network request - * 2) otherwise the registry is queried and the cache refreshed; when that query fails, a stale cache (if any) is used instead. - * Combine with `unref` so a slow refresh never keeps the process alive. - * @default false - */ - readonly useCache?: boolean; - - /** - * How long the cache for the provided packages should be used before being refreshed (in milliseconds). - * If `useCache` is not supplied, this option has no effect. - * If `0` is used, the cache is always refreshed (a stale cache still serves as a fallback when the refresh fails). - * @default ONE_DAY - */ - readonly cacheMaxAge?: number; - - /** - * Do not let the registry request keep the process alive: its socket is `unref`'d, so the process may exit before the - * request completes (the returned promise then simply never settles). Use for opportunistic background checks. - * @default false - */ - readonly unref?: boolean; - - /** - * A JavaScript package registry url that implements the CommonJS Package Registry specification. - * @default "Looks at any registry urls in the .npmrc file or fallback to the default npm registry instead" - * @example .npmrc - * registry = 'https://custom-registry.com/' - * @pkgscope:registry = 'https://custom-registry.com/' - */ - readonly registryUrl?: string; - - /** - * Set of options to be passed down to Node.js http/https request. - * @example Behind a proxy with self-signed certificate - * { ca: [ fs.readFileSync('proxy-cert.pem') ] } - * @example Bypassing certificate validation - * { rejectUnauthorized: false } - */ - readonly requestOptions?: RequestOptions; -} - -interface LatestVersion { - /** - * Get latest versions of packages from of a package json like object. - * @param {PackageJson} item - A package json like object (with dependencies, devDependencies and peerDependencies attributes). - * @example { dependencies: { 'npm': 'latest' }, devDependencies: { 'npm': '1.3.2' }, peerDependencies: { '@scope/name': '^5.0.2' } } - * @param {LatestVersionOptions} [options] - An object optionally specifying the use of the cache, the max age of the cache, the registry url and the http or https options. - * If `useCache` is not supplied, the default of `false` is used. - * If `cacheMaxAge` is not supplied, the default of `one day` is used. - * If `registryUrl` is not supplied, the default from `.npmrc` is used or a fallback to the `npm registry url` instead. - * @returns {Promise} - */ - ( - item: PackageJson, - options?: LatestVersionOptions, - ): Promise; - - /** - * Get latest version of a single package. - * @param {Package} item - A single package object (represented by a string that should match the following format: `${'@' | ''}${string}@${string}`) - * @example 'npm', 'npm@1.3.2', '@scope/name@^5.0.2' - * @param {LatestVersionOptions} [options] - An object optionally specifying the use of the cache, the max age of the cache, the registry url and the http or https options. - * If `useCache` is not supplied, the default of `false` is used. - * If `cacheMaxAge` is not supplied, the default of `one day` is used. - * If `registryUrl` is not supplied, the default from `.npmrc` is used or a fallback to the npm registry url instead. - * @returns {Promise} - */ - ( - item: Package, - options?: LatestVersionOptions, - ): Promise; - - /** - * Get latest versions of a collection of packages. - * @param {Package[]} items - A collection of package object (represented by a string that should match the following format: `${'@' | ''}${string}@${string}`) - * @example ['npm', 'npm@1.3.2', '@scope/name@^5.0.2'] - * @param {LatestVersionOptions} [options] - An object optionally specifying the use of the cache, the max age of the cache, the registry url and the http or https options. - * If `useCache` is not supplied, the default of `false` is used. - * If `cacheMaxAge` is not supplied, the default of `one day` is used. - * If `registryUrl` is not supplied, the default from `.npmrc` is used or a fallback to the npm registry url instead. - * @returns {Promise} - */ - ( - items: Package[], - options?: LatestVersionOptions, - ): Promise; // eslint-disable-line @typescript-eslint/unified-signatures -} -type PackageRange = `${'@' | ''}${string}@${string}`; -type Package = PackageRange | string; // eslint-disable-line @typescript-eslint/no-redundant-type-constituents -type PackageJsonDependencies = Record; -type PackageJson = Record & - ( - | { - dependencies: PackageJsonDependencies; - } - | { - devDependencies: PackageJsonDependencies; - } - | { - peerDependencies: PackageJsonDependencies; - } - ); - -/** - * @internal - */ -interface PackageMetadata { - name: string; - lastUpdateDate: number; - versions: string[]; - distTags: Record; - /** registry the metadata came from; a cache from another registry is not reused */ - registryUrl?: string; -} - -export const ONE_DAY = 1000 * 60 * 60 * 24; // eslint-disable-line @typescript-eslint/naming-convention - -const isPackageJson = (obj: any): obj is PackageJson => { - return ( - (obj as PackageJson).dependencies !== undefined || - (obj as PackageJson).devDependencies !== undefined || - (obj as PackageJson).peerDependencies !== undefined - ); -}; - -const resolveRegistryUrl = ( - pkgName: string, - options?: LatestVersionOptions, -): string => { - const i = pkgName.indexOf('/'); - const pkgScope = i !== -1 ? pkgName.slice(0, i) : ''; - return options?.registryUrl ?? getRegistryUrl(pkgScope); -}; - -const downloadMetadata = ( - pkgName: string, - options?: LatestVersionOptions, -): Promise => { - return new Promise((resolve, reject) => { - const registryUrl = resolveRegistryUrl(pkgName, options); - const pkgUrl = new URL( - encodeURIComponent(pkgName).replace(/^%40/, '@'), - registryUrl, - ); - - let requestOptions: HttpRequestOptions | HttpsRequestOptions = { - headers: { - accept: - 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*', - }, - host: pkgUrl.hostname, - path: pkgUrl.pathname, - port: pkgUrl.port, - }; - const authInfo = registryAuthToken(pkgUrl.toString(), { recursive: true }); - if (authInfo && requestOptions.headers) { - (requestOptions.headers as any).authorization = - `${authInfo.type} ${authInfo.token}`; - } - if (options?.requestOptions) { - requestOptions = { ...requestOptions, ...options.requestOptions }; - } - if (requestOptions.agent === undefined) { - // HTTP(S)_PROXY / NO_PROXY, like every other request of the CLI - const agent = proxyAgentFor(pkgUrl.toString()); - if (agent) { - requestOptions = { ...requestOptions, agent }; - } - } - - const { get } = require(pkgUrl.protocol === 'https:' ? 'https' : 'http'); - const request = get(requestOptions, (res: IncomingMessage) => { - if (res.statusCode === 200) { - let rawData = ''; - res.setEncoding('utf8'); - res.on('data', (chunk: string) => (rawData += chunk)); - res.once('error', (err) => { - res.removeAllListeners(); - reject(`Request error (${err.message}): ${pkgUrl}`); - }); - res.once('end', () => { - res.removeAllListeners(); - try { - const pkgMetadata = JSON.parse(rawData); - resolve({ - name: pkgName, - lastUpdateDate: Date.now(), - versions: Object.keys(pkgMetadata.versions as string[]), - distTags: pkgMetadata['dist-tags'], - registryUrl, - }); - return; - } catch (err) { - reject(err); - return; - } - }); - } else { - res.removeAllListeners(); - res.resume(); // consume response data to free up memory - reject(`Request error (${res.statusCode}): ${pkgUrl}`); - return; - } - }); - const abort = (error: Error | string): void => { - request.destroy(); - reject(error); - }; - request.once('timeout', () => { - abort(`Request timed out: ${pkgUrl}`); - }); - request.once('error', (err: Error) => { - abort(err); - }); - request.on('close', () => { - request.removeAllListeners(); - }); - if (options?.unref) { - // A background check must never delay the process exit: drop the - // socket's ref (also for a pooled keep-alive socket, which the agent - // ref's again right before handing it out). - request.once('socket', (socket: { unref?: () => void }) => { - socket.unref?.(); - }); - } - }); -}; - -const getCacheDir = (name = '@badisi/latest-version'): string => { - const homeDir = homedir(); - if (process.env.XDG_CACHE_HOME) { - // an explicit override wins on every platform (also lets tests isolate) - return join(process.env.XDG_CACHE_HOME, name); - } - switch (process.platform) { - case 'darwin': - return join(homeDir, 'Library', 'Caches', name); - case 'win32': - return join( - process.env.LOCALAPPDATA ?? join(homeDir, 'AppData', 'Local'), - name, - 'Cache', - ); - default: - return join(process.env.XDG_CACHE_HOME ?? join(homeDir, '.cache'), name); - } -}; - -const saveMetadataToCache = (pkg: PackageMetadata): void => { - try { - const filePath = join(getCacheDir(), `${pkg.name}.json`); - if (!existsSync(dirname(filePath))) { - mkdirSync(dirname(filePath), { recursive: true }); - } - writeFileSync(filePath, JSON.stringify(pkg)); - } catch { - // a read-only or missing cache dir must not turn into an error - } -}; - -/** - * The cached metadata of a package (from the same registry), and whether it is - * still younger than `cacheMaxAge`. A stale entry is returned as well so it can - * serve as a fallback when the registry is unreachable. - */ -const getMetadataFromCache = ( - pkgName: string, - options?: LatestVersionOptions, -): { metadata: PackageMetadata; fresh: boolean } | undefined => { - const maxAge = options?.cacheMaxAge ?? ONE_DAY; - try { - const pkgCacheFilePath = join(getCacheDir(), `${pkgName}.json`); - if (!existsSync(pkgCacheFilePath)) { - return undefined; - } - const metadata = JSON.parse( - readFileSync(pkgCacheFilePath).toString(), - ) as PackageMetadata; - if ( - !metadata || - typeof metadata.lastUpdateDate !== 'number' || - !metadata.distTags || - metadata.registryUrl !== resolveRegistryUrl(pkgName, options) - ) { - return undefined; - } - const age = Date.now() - metadata.lastUpdateDate; - return { metadata, fresh: maxAge !== 0 && age >= 0 && age < maxAge }; - } catch { - return undefined; // unreadable / corrupt cache is the same as no cache - } -}; - -const getRegistryVersions = async ( - pkgName: string, - tagOrRange?: string, - options?: LatestVersionOptions, -): Promise => { - let pkgMetadata: PackageMetadata | undefined; - if (pkgName.length && options?.useCache) { - const cached = getMetadataFromCache(pkgName, options); - if (cached?.fresh) { - pkgMetadata = cached.metadata; - } else { - try { - pkgMetadata = await downloadMetadata(pkgName, options); - saveMetadataToCache(pkgMetadata); - } catch (err) { - if (!cached) { - throw err; - } - pkgMetadata = cached.metadata; // stale beats nothing - } - } - } else if (pkgName.length) { - pkgMetadata = await downloadMetadata(pkgName, options); - } - - const versions: RegistryVersions = { - latest: pkgMetadata?.distTags.latest, - next: pkgMetadata?.distTags.next, - }; - if (tagOrRange && pkgMetadata?.distTags[tagOrRange]) { - versions.wanted = pkgMetadata.distTags[tagOrRange]; - } else if (tagOrRange && pkgMetadata?.versions.length) { - versions.wanted = maxSatisfying(pkgMetadata.versions, tagOrRange); - } - return versions; -}; - -/** the highest of `versions` within `range` (npm range syntax), if any */ -const maxSatisfying = ( - versions: string[], - range: string, -): string | undefined => { - const matching = versions.filter((version) => { - try { - return satisfies(version, range); - } catch { - return false; - } - }); - if (!matching.length) return undefined; - return matching.sort(compareVersions)[matching.length - 1]; -}; - -/** `a > b` for two versions; invalid input counts as "not newer" */ -const gt = (a: string, b: string): boolean => { - try { - return compare(a, b, '>'); - } catch { - return false; - } -}; - -const getInstalledVersion = ( - pkgName: string, - location: keyof InstalledVersions = 'local', -): string | undefined => { - try { - if (location === 'globalNpm') { - return require(join(npm.packages, pkgName, 'package.json')) - ?.version as string; - } - if (location === 'globalYarn') { - // Make sure package is globally installed by Yarn - const yarnGlobalPkg = require( - pathResolve(yarn.packages, '..', 'package.json'), - ); - if (!yarnGlobalPkg?.dependencies?.[pkgName]) { - return undefined; - } - return require(join(yarn.packages, pkgName, 'package.json')) - ?.version as string; - } - /** - * Compute the local paths manually as require.resolve() and require.resolve.paths() - * cannot be trusted anymore. - * @see https://github.com/nodejs/node/issues/33460 - * @see https://github.com/nodejs/loaders/issues/26 - */ - const { root } = parse(process.cwd()); - let path = process.cwd(); - const localPaths = [join(path, 'node_modules')]; - while (path !== root) { - path = dirname(path); - localPaths.push(join(path, 'node_modules')); - } - for (const localPath of localPaths) { - const pkgPath = join(localPath, pkgName, 'package.json'); - if (existsSync(pkgPath)) { - return require(pkgPath)?.version as string; - } - } - return undefined; - } catch { - return undefined; - } -}; - -const getInfo = async ( - pkg: Package, - options?: LatestVersionOptions, -): Promise => { - const i = pkg.lastIndexOf('@'); - let pkgInfo: LatestVersionPackage = { - name: i > 1 ? pkg.slice(0, i) : pkg, - wantedTagOrRange: i > 1 ? pkg.slice(i + 1) : 'latest', - updatesAvailable: false, - }; - - try { - pkgInfo = { - ...pkgInfo, - local: getInstalledVersion(pkgInfo.name, 'local'), - globalNpm: getInstalledVersion(pkgInfo.name, 'globalNpm'), - globalYarn: getInstalledVersion(pkgInfo.name, 'globalYarn'), - ...(await getRegistryVersions( - pkgInfo.name, - pkgInfo.wantedTagOrRange, - options, - )), - }; - const local = - pkgInfo.local && pkgInfo.wanted - ? gt(pkgInfo.wanted, pkgInfo.local) - ? pkgInfo.wanted - : false - : false; - const globalNpm = - pkgInfo.globalNpm && pkgInfo.wanted - ? gt(pkgInfo.wanted, pkgInfo.globalNpm) - ? pkgInfo.wanted - : false - : false; - const globalYarn = - pkgInfo.globalYarn && pkgInfo.wanted - ? gt(pkgInfo.wanted, pkgInfo.globalYarn) - ? pkgInfo.wanted - : false - : false; - pkgInfo.updatesAvailable = - local || globalNpm || globalYarn - ? { local, globalNpm, globalYarn } - : false; - } catch (err: any) { - pkgInfo.error = err?.message ?? err; - } - - return pkgInfo; -}; - -const latestVersion: LatestVersion = async ( - arg: Package | Package[] | PackageJson, - options?: LatestVersionOptions, -): Promise => { - const pkgs: Package[] = []; - if (typeof arg === 'string') { - pkgs.push(arg); - } else if (Array.isArray(arg)) { - pkgs.push(...arg); - } else if (isPackageJson(arg)) { - const addDeps = (deps?: PackageJsonDependencies): void => { - if (deps) { - pkgs.push( - ...Object.keys(deps).map((key: string) => `${key}@${deps[key]}`), - ); - } - }; - addDeps(arg.dependencies as PackageJsonDependencies | undefined); - addDeps(arg.devDependencies as PackageJsonDependencies | undefined); - addDeps(arg.peerDependencies as PackageJsonDependencies | undefined); - } - - const jobs = await Promise.allSettled( - pkgs.map((pkg) => getInfo(pkg, options)), - ); - const results = jobs.map( - (jobResult: PromiseSettledResult) => - (jobResult as PromiseFulfilledResult).value, - ); - return typeof arg === 'string' ? results[0] : results; -}; - -export type { - LatestVersion, - LatestVersionOptions, - LatestVersionPackage, - Package, - PackageJson, - PackageJsonDependencies, - PackageRange, - RegistryVersions, - RequestOptions, -}; -export default latestVersion; From b6e7233196eb4fd830a076b79082b1ffcbf62411 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:02:04 +0800 Subject: [PATCH 04/20] refactor: remove test-only helpers and dead branches - promiseAny (Promise.any exists), toObjectState, getCurrentCommit: used only by their own tests - translateOptions' key-renaming `map` branch: no caller passes a map - uploadFile's `key` parameter: every caller passed undefined - commented-out code in api, http-helper, i18n and package (and the confirmDeletePackage locale key it referenced) Co-Authored-By: Claude Opus 5.5 --- src/api.ts | 17 +++++------- src/locales/en.ts | 2 -- src/locales/zh.ts | 1 - src/package.ts | 15 +---------- src/utils/git.ts | 10 +------- src/utils/http-helper.ts | 22 ---------------- src/utils/i18n.ts | 2 -- src/utils/index.ts | 50 ++++++++++++++---------------------- src/utils/options.ts | 10 -------- src/versions.ts | 12 ++++----- tests/git.test.ts | 15 +++-------- tests/http-helper.test.ts | 31 +--------------------- tests/options.test.ts | 10 -------- tests/upload-chunked.test.ts | 2 +- tests/utils.test.ts | 37 -------------------------- tests/versions.test.ts | 2 +- 16 files changed, 38 insertions(+), 200 deletions(-) diff --git a/src/api.ts b/src/api.ts index 7ede8dc..a745f9c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -428,11 +428,7 @@ async function sendChunks( } } -export async function uploadFile( - fn: string, - key?: string, - appId?: string | number, -) { +export async function uploadFile(fn: string, appId?: string | number) { const fileSize = fs.statSync(fn).size; // appId 用于服务端路由:绑定了自托管节点(rnu-node)的应用, // 上传指令会指向节点或其对象存储。chunked + size 让支持的服务端(GCS) @@ -440,7 +436,8 @@ export async function uploadFile( const resp = await post('/upload', { ext: path.extname(fn), ...(appId ? { appId: Number(appId) } : {}), - ...(key ? {} : { chunked: true, size: fileSize }), + chunked: true, + size: fileSize, }); const { url, backupUrl, formData, maxSize } = resp; let realUrl = url; @@ -459,7 +456,6 @@ export async function uploadFile( realUrl = backupUrl; } } - // console.log({realUrl}); } if (maxSize && fileSize > filesizeParser(maxSize)) { @@ -528,7 +524,7 @@ export async function uploadFile( stream: fileStream, }); - if (resp.chunked && !key) { + if (resp.chunked) { try { await sendChunks(fn, realUrl, fileSize, bar, resp.chunked, postForm); } catch (error) { @@ -551,7 +547,7 @@ export async function uploadFile( realUrl, fileSize, bar, - postForm(key ? { ...formData, key } : formData, fileSize), + postForm(formData, fileSize), ); } catch (error) { return rethrowUploadError(error); @@ -564,8 +560,7 @@ export async function uploadFile( ); } - // const body = await response.json(); - return { hash: key || formData.key }; + return { hash: formData.key }; } /** diff --git a/src/locales/en.ts b/src/locales/en.ts index ead06da..e3f092d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -176,8 +176,6 @@ This can reduce the risk of inconsistent dependencies and supply chain attacks. unnamed: '(Unnamed)', dryRun: 'Below is the dry-run result, no actual operation will be performed:', usingCustomVersion: 'Using custom version: {{version}}', - confirmDeletePackage: - 'Confirm delete native package {{packageId}}? This operation cannot be undone (Y/N):', deletePackageSuccess: 'Native package {{packageId}} deleted successfully', deletePackagesSuccess: 'Deleted {{count}} native packages: {{packageIds}}', deletePackageError: diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 142c8bb..6d26bff 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -162,7 +162,6 @@ export default { unnamed: '(未命名)', dryRun: '以下是 dry-run 模拟运行结果,不会实际执行任何操作:', usingCustomVersion: '使用自定义版本:{{version}}', - confirmDeletePackage: '确认删除原生包 {{packageId}}? 此操作不可撤销 (Y/N):', deletePackageSuccess: '原生包 {{packageId}} 删除成功', deletePackagesSuccess: '已删除 {{count}} 个原生包:{{packageIds}}', deletePackageError: '删除原生包 {{packageId}} 失败: {{error}}', diff --git a/src/package.ts b/src/package.ts index 4b4b4c9..0df70c6 100644 --- a/src/package.ts +++ b/src/package.ts @@ -168,7 +168,7 @@ async function uploadNativePackage( const slimPackagePath = path.join(tempRoot, `package${config.extension}`); try { await createSlimNativePackage(filePath, slimPackagePath, config.platform); - const { hash } = await uploadFile(slimPackagePath, undefined, appId); + const { hash } = await uploadFile(slimPackagePath, appId); // where the bundle sits inside the uploaded (slim) archive, so a later // `bundle` that falls back to this package as hermes base fetches only // the bundle with one HTTP Range request. Harmony .app nests a second @@ -453,19 +453,6 @@ export const packageCommands = { }); } - // Confirm deletion - // const confirmDelete = await question( - // t('confirmDeletePackage', { packageId }), - // ); - - // if ( - // confirmDelete.toLowerCase() !== 'y' && - // confirmDelete.toLowerCase() !== 'yes' - // ) { - // console.log(t('cancelled')); - // return; - // } - try { if (packageIds.length === 1) { const [packageId] = packageIds; diff --git a/src/utils/git.ts b/src/utils/git.ts index 16766c5..d8a2014 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,4 +1,4 @@ -import { execFile as execFileCallback, spawnSync } from 'child_process'; +import { execFile as execFileCallback } from 'child_process'; import { promisify } from 'util'; export interface CommitInfo { @@ -11,14 +11,6 @@ export interface CommitInfo { const execFile = promisify(execFileCallback); -export function getCurrentCommit() { - const result = spawnSync('git', ['rev-parse', 'HEAD']); - if (result.status !== 0) { - throw new Error('Not a git repository'); - } - return result.stdout.toString().trim(); -} - async function gitOutput(args: string[], cwd = process.cwd()): Promise { const { stdout } = await execFile('git', args, { cwd, diff --git a/src/utils/http-helper.ts b/src/utils/http-helper.ts index 31c3033..314fcf2 100644 --- a/src/utils/http-helper.ts +++ b/src/utils/http-helper.ts @@ -1,27 +1,6 @@ import { defaultEndpoints } from './constants'; import { runtimeFetch } from './runtime'; -// const baseUrl = `http://localhost:9000`; -// let baseUrl = SERVER.main[0]; -// const baseUrl = `https://p.reactnative.cn/api`; - -export function promiseAny(promises: Promise[]) { - return new Promise((resolve, reject) => { - let count = 0; - - for (const promise of promises) { - Promise.resolve(promise) - .then(resolve) - .catch(() => { - count++; - if (count === promises.length) { - reject(new Error('All promises were rejected')); - } - }); - } - }); -} - export const ping = async (url: string, signal?: AbortSignal) => { let timer: ReturnType | undefined; try { @@ -135,7 +114,6 @@ async function resolveBaseUrl(): Promise { // remove /status baseUrl = ret.replace('/status', ''); } - // console.log('baseUrl', baseUrl); return baseUrl; } diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 484be4e..27e88de 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -19,8 +19,6 @@ export function resolveLanguage( i18next.init({ lng: resolveLanguage(), - // debug: process.env.NODE_ENV !== 'production', - // debug: true, resources: { en: { translation: en, diff --git a/src/utils/index.ts b/src/utils/index.ts index de06836..47c15c5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -111,42 +111,30 @@ export async function question(query: string, password?: boolean) { }); } +/** Expand `${name}` placeholders in string options from other options or the environment. */ export function translateOptions>( options: T, - map?: Record, ): T & Record { - if (!map) { - // Existing logic for template replacement if no map is provided - const ret: Record = {}; - for (const key in options) { - const value = options[key]; - if (typeof value === 'string') { - ret[key] = value.replace(/\$\{(\w+)\}/g, (placeholder, name) => { - const replacement = options[name] ?? process.env[name]; - if ( - typeof replacement === 'string' || - typeof replacement === 'number' || - typeof replacement === 'boolean' - ) { - return String(replacement); - } - return placeholder; - }); - } else { - ret[key] = value; - } - } - return ret as T & Record; - } - - const result: Record = { ...options }; - for (const [key, value] of Object.entries(map)) { - if (result[key] !== undefined) { - result[value] = result[key]; - delete result[key]; + const ret: Record = {}; + for (const key in options) { + const value = options[key]; + if (typeof value === 'string') { + ret[key] = value.replace(/\$\{(\w+)\}/g, (placeholder, name) => { + const replacement = options[name] ?? process.env[name]; + if ( + typeof replacement === 'string' || + typeof replacement === 'number' || + typeof replacement === 'boolean' + ) { + return String(replacement); + } + return placeholder; + }); + } else { + ret[key] = value; } } - return result as T; + return ret as T & Record; } // bundleHash: sha256 of the raw bundle bytes extracted from the package. Must diff --git a/src/utils/options.ts b/src/utils/options.ts index d54234a..3dec195 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -66,13 +66,3 @@ export function getStringListOption( return result.length > 0 ? result : undefined; } - -export function toObjectState>( - value: unknown, - fallback: T, -): T { - if (value && typeof value === 'object') { - return value as T; - } - return fallback; -} diff --git a/src/versions.ts b/src/versions.ts index 8308f09..385dc43 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -581,16 +581,14 @@ export const versionCommands = { // fail the publish, only lose the archive. let sourceMapUploadError: unknown; const [{ hash }, bundleMeta, commit, sourceMapUpload] = await Promise.all([ - uploadFile(fn, undefined, appId), + uploadFile(fn, appId), describePpkBundle(fn, options.hermesBase), getCommitInfo(), uploadSourcemapPath - ? uploadFile(uploadSourcemapPath, undefined, appId).catch( - (error: unknown) => { - sourceMapUploadError = error; - return undefined; - }, - ) + ? uploadFile(uploadSourcemapPath, appId).catch((error: unknown) => { + sourceMapUploadError = error; + return undefined; + }) : Promise.resolve(undefined), ]).finally(() => { // the packed copy only exists for the upload; a failed ppk upload must diff --git a/tests/git.test.ts b/tests/git.test.ts index 92af2c2..2c8a6cb 100644 --- a/tests/git.test.ts +++ b/tests/git.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { getCommitInfo, getCurrentCommit } from '../src/utils/git'; +import { getCommitInfo } from '../src/utils/git'; const originalCwd = process.cwd(); @@ -32,13 +32,6 @@ describe('utils/git', () => { tempRoot = ''; }); - test('throws for current commit outside of a git repository', () => { - tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-update-no-git-')); - process.chdir(tempRoot); - - expect(() => getCurrentCommit()).toThrow('Not a git repository'); - }); - test('returns undefined commit info outside of a git repository', async () => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-update-no-git-')); process.chdir(tempRoot); @@ -46,7 +39,7 @@ describe('utils/git', () => { await expect(getCommitInfo()).resolves.toBeUndefined(); }); - test('reads current commit and commit info using git executable', async () => { + test('reads commit info using git executable', async () => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-update-git-')); runGit(['init'], tempRoot); runGit( @@ -58,11 +51,9 @@ describe('utils/git', () => { runGit(['commit', '-m', 'Initial commit'], tempRoot); process.chdir(tempRoot); - const currentCommit = getCurrentCommit(); const commit = await getCommitInfo(); - expect(currentCommit).toMatch(/^[0-9a-f]{40}$/); - expect(commit?.hash).toBe(currentCommit); + expect(commit?.hash).toMatch(/^[0-9a-f]{40}$/); expect(commit?.message).toBe('Initial commit'); expect(commit?.author).toBe('Test Author'); expect(commit?.timestamp).toMatch(/^\d+$/); diff --git a/tests/http-helper.test.ts b/tests/http-helper.test.ts index a0f1a15..052d8c9 100644 --- a/tests/http-helper.test.ts +++ b/tests/http-helper.test.ts @@ -5,36 +5,7 @@ mock.module('../src/utils/runtime', () => ({ runtimeFetch: runtimeFetchMock, })); -import { promiseAny, testUrls } from '../src/utils/http-helper'; - -describe('promiseAny', () => { - test('resolves with the first resolved promise', async () => { - const result = await promiseAny([ - new Promise((_, reject) => setTimeout(() => reject('slow'), 50)), - Promise.resolve('fast'), - new Promise((resolve) => setTimeout(() => resolve('medium'), 30)), - ]); - expect(result).toBe('fast'); - }); - - test('rejects when all promises reject', async () => { - await expect( - promiseAny([ - Promise.reject(new Error('fail1')), - Promise.reject(new Error('fail2')), - Promise.reject(new Error('fail3')), - ]), - ).rejects.toThrow('All promises were rejected'); - }); - - test('resolves with the first even if others fail', async () => { - const result = await promiseAny([ - Promise.reject(new Error('fail')), - Promise.resolve('success'), - ]); - expect(result).toBe('success'); - }); -}); +import { testUrls } from '../src/utils/http-helper'; describe('testUrls', () => { test('returns null for empty array', async () => { diff --git a/tests/options.test.ts b/tests/options.test.ts index 8303509..e1db451 100644 --- a/tests/options.test.ts +++ b/tests/options.test.ts @@ -4,7 +4,6 @@ import { getOptionalStringOption, getStringListOption, getStringOption, - toObjectState, } from '../src/utils/options'; describe('utils/options', () => { @@ -49,13 +48,4 @@ describe('utils/options', () => { expect(getStringListOption({ ids: '' }, 'ids')).toBeUndefined(); expect(getStringListOption({}, 'ids')).toBeUndefined(); }); - - test('toObjectState returns object value or fallback', () => { - const fallback = { ok: true }; - const source = { name: 'app' }; - - expect(toObjectState(source, fallback)).toEqual(source); - expect(toObjectState(null, fallback)).toEqual(fallback); - expect(toObjectState('invalid', fallback)).toEqual(fallback); - }); }); diff --git a/tests/upload-chunked.test.ts b/tests/upload-chunked.test.ts index 0431aed..4c992f5 100644 --- a/tests/upload-chunked.test.ts +++ b/tests/upload-chunked.test.ts @@ -104,7 +104,7 @@ describe('chunked upload', () => { test('announces the size, posts every byte range once, then completes', async () => { const apiCalls = setup(chunkedInstruction); - const result = await uploadFile(tmp, undefined, 9); + const result = await uploadFile(tmp, 9); expect(apiCalls[0].url).toBe('https://api.test/upload'); expect(apiCalls[0].body).toEqual({ diff --git a/tests/utils.test.ts b/tests/utils.test.ts index a02a8ed..93dc87c 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -123,43 +123,6 @@ describe('translateOptions', () => { const result = translateOptions(options); expect(result.output).toBe('test-test.ppk'); }); - - describe('translateOptions with map', () => { - test('maps keys according to the provided map', () => { - const options = { oldKey: 'value', keepKey: 'keep' }; - const map = { oldKey: 'newKey' }; - const result = translateOptions(options, map); - expect(result).toEqual({ newKey: 'value', keepKey: 'keep' }); - }); - - test('ignores missing keys in options', () => { - const options = { existingKey: 'value' }; - const map = { missingKey: 'newKey' }; - const result = translateOptions(options, map); - expect(result).toEqual({ existingKey: 'value' }); - }); - - test('maps multiple keys', () => { - const options = { a: 1, b: 2, c: 3 }; - const map = { a: 'alpha', b: 'beta' }; - const result = translateOptions(options, map); - expect(result).toEqual({ alpha: 1, beta: 2, c: 3 }); - }); - - test('handles mapping to existing keys by overwriting', () => { - const options = { a: 1, b: 2 }; - const map = { a: 'b' }; - const result = translateOptions(options, map); - expect(result).toEqual({ b: 1 }); - }); - - test('handles undefined values', () => { - const options = { a: undefined, b: null }; - const map = { a: 'newA', b: 'newB' }; - const result = translateOptions(options, map); - expect(result).toEqual({ a: undefined, newB: null }); - }); - }); }); describe('isNonInteractive', () => { diff --git a/tests/versions.test.ts b/tests/versions.test.ts index 2db67ef..f3ed4a3 100644 --- a/tests/versions.test.ts +++ b/tests/versions.test.ts @@ -241,7 +241,7 @@ describe('versionCommands.publish', () => { expect(getSelectedAppSpy).not.toHaveBeenCalled(); expect(appGetSpy).toHaveBeenCalledWith('/app/777'); - expect(uploadFileSpy).toHaveBeenCalledWith('bundle.ppk', undefined, '777'); + expect(uploadFileSpy).toHaveBeenCalledWith('bundle.ppk', '777'); expect(postSpy).toHaveBeenCalledWith( '/app/777/version/create', expect.any(Object), From 0e492112800ace5256b8a5a855660faa02d9e8d6 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:04:03 +0800 Subject: [PATCH 05/20] refactor: one sha256 helper and one HBC header reader - utils/hash (sha256Hex, sha256File) replaces five sha256 one-liners in account, diff, failure-fingerprint, utils and hermes-base - hbcTransform exports HBC_MAGIC, HBC_HEADER_SIZE and readHbcFileHeader; hermes-base, hermes-literals and zip-options drop their own copies of the magic, the 128-byte size and the open/read/close header dance Co-Authored-By: Claude Opus 5.5 --- src/account.ts | 8 +++--- src/diff.ts | 14 ++-------- src/utils/failure-fingerprint.ts | 4 +-- src/utils/hash.ts | 13 +++++++++ src/utils/hbcTransform.ts | 8 ++++-- src/utils/hermes-base.ts | 48 ++++++++------------------------ src/utils/hermes-literals.ts | 4 +-- src/utils/index.ts | 6 +--- src/utils/zip-options.ts | 8 ++---- src/versions.ts | 2 +- tests/hermes-base.test.ts | 2 +- tests/zip-range.test.ts | 2 +- 12 files changed, 45 insertions(+), 74 deletions(-) create mode 100644 src/utils/hash.ts diff --git a/src/account.ts b/src/account.ts index 7f4ca1e..b01581b 100644 --- a/src/account.ts +++ b/src/account.ts @@ -1,11 +1,11 @@ import chalk from 'chalk'; import { fork } from 'child_process'; -import { createHash } from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { getApiToken, getSession } from './api'; import { IS_CRESC, pricingPageUrl } from './utils/constants'; +import { sha256Hex } from './utils/hash'; import { t } from './utils/i18n'; export interface AccountInfo { @@ -62,9 +62,9 @@ interface AccountCache { } export function accountCacheFile(token: string, apiToken = '') { - const key = createHash('sha256') - .update(JSON.stringify([IS_CRESC, process.cwd(), token, apiToken])) - .digest('hex'); + const key = sha256Hex( + JSON.stringify([IS_CRESC, process.cwd(), token, apiToken]), + ); return path.join( os.homedir(), '.cache', diff --git a/src/diff.ts b/src/diff.ts index 163396b..f6543bd 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import fs from 'fs'; import { npm, yarn } from 'global-dirs'; import os from 'os'; @@ -9,6 +8,7 @@ import { resolveNativePackageEntry } from './native-package'; import type { CommandContext, Platform } from './types'; import { translateOptions } from './utils'; import { isPPKBundleFileName, scriptName, tempDir } from './utils/constants'; +import { sha256File } from './utils/hash'; import { type HbcTransformMeta, transformHbcFile, @@ -211,14 +211,6 @@ async function bundleSourceFile( return outputPath; } -async function hashFile(filePath: string): Promise { - const hash = createHash('sha256'); - for await (const chunk of fs.createReadStream(filePath)) { - hash.update(chunk as Buffer); - } - return hash.digest('hex'); -} - async function filesEqual(leftPath: string, rightPath: string) { const [leftStat, rightStat] = await Promise.all([ fs.promises.stat(leftPath), @@ -228,8 +220,8 @@ async function filesEqual(leftPath: string, rightPath: string) { return false; } const [leftHash, rightHash] = await Promise.all([ - hashFile(leftPath), - hashFile(rightPath), + sha256File(leftPath), + sha256File(rightPath), ]); return leftHash === rightHash; } diff --git a/src/utils/failure-fingerprint.ts b/src/utils/failure-fingerprint.ts index 78ad809..f78cf8a 100644 --- a/src/utils/failure-fingerprint.ts +++ b/src/utils/failure-fingerprint.ts @@ -14,9 +14,7 @@ * leaves the machine. The local console keeps the unredacted text: that is * where the name is actually useful. */ -import { createHash } from 'node:crypto'; - -const sha = (value: string) => createHash('sha256').update(value).digest('hex'); +import { sha256Hex as sha } from './hash'; /** stable stand-in for one redacted value; the same input always yields it */ const token = (kind: string, value: string) => diff --git a/src/utils/hash.ts b/src/utils/hash.ts new file mode 100644 index 0000000..7a0660b --- /dev/null +++ b/src/utils/hash.ts @@ -0,0 +1,13 @@ +import { createHash } from 'crypto'; +import fs from 'fs'; + +export function sha256Hex(data: Buffer | string): string { + return createHash('sha256').update(data).digest('hex'); +} + +/** sha256 of a file, streamed (a bundle or cached base can be tens of MB) */ +export async function sha256File(file: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of fs.createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} diff --git a/src/utils/hbcTransform.ts b/src/utils/hbcTransform.ts index 7b7411f..43f205f 100644 --- a/src/utils/hbcTransform.ts +++ b/src/utils/hbcTransform.ts @@ -17,9 +17,11 @@ import { copyFile, open as openFile, rm, stat } from 'node:fs/promises'; -const HBC_MAGIC = Buffer.from([0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f]); +export const HBC_MAGIC = Buffer.from([ + 0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f, +]); const HEADER_COUNTS_OFFSET = 32; // magic(8) + version(4) + sourceHash(20) -const HBC_HEADER_SIZE = 128; +export const HBC_HEADER_SIZE = 128; /** 差分字段:条目内 byte 偏移处按小端读 u32,取 [bit, bit+bits) 位。 */ type DeltaField = { byte: number; bit: number; bits: number }; @@ -502,7 +504,7 @@ function applyDeltaSections( type HbcFileHeader = { header: Buffer; size: number }; /** 读取文件头 128 字节与总长;文件过短或非 HBC 时返回 null。 */ -async function readHbcFileHeader( +export async function readHbcFileHeader( filePath: string, ): Promise { const { size } = await stat(filePath); diff --git a/src/utils/hermes-base.ts b/src/utils/hermes-base.ts index 9e7c779..60c5812 100644 --- a/src/utils/hermes-base.ts +++ b/src/utils/hermes-base.ts @@ -11,7 +11,6 @@ import { StringDecoder } from 'node:string_decoder'; import { spawn, spawnSync } from 'child_process'; -import { createHash } from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -26,7 +25,12 @@ import { remove, removeSync, } from './fs-helpers'; -import { getHbcVersion } from './hbcTransform'; +import { sha256File, sha256Hex } from './hash'; +import { + getHbcVersion, + HBC_HEADER_SIZE, + readHbcFileHeader, +} from './hbcTransform'; import { normalizeCachedObjectInstruction } from './hermes-cached-object'; import { type LiteralBuffers, @@ -93,9 +97,6 @@ const describeBytecodeVersion = (version: number | null) => ? 'plain JS' : `HBC ${version ?? 'n/a'}`; -/** bytes `getHbcVersion` needs to read the version */ -const HBC_HEAD_BYTES = 128; - /** time allowed for a response to start (headers) */ const FETCH_HEADERS_TIMEOUT_MS = 30_000; /** a download that receives nothing for this long is abandoned */ @@ -248,29 +249,10 @@ export function classifyHermesCommand(hermesCommand: string): { // HBC helpers // --------------------------------------------------------------------------- -export function sha256Hex(data: Buffer): string { - return createHash('sha256').update(data).digest('hex'); -} - -/** sha256 of a file, streamed (a cached base can be tens of MB) */ -export async function sha256File(file: string): Promise { - const hash = createHash('sha256'); - for await (const chunk of fs.createReadStream(file)) hash.update(chunk); - return hash.digest('hex'); -} - /** HBC version from the first bytes of a file, without reading the rest */ export async function readFileHbcVersion(file: string): Promise { - const header = Buffer.alloc(128); - const handle = await fs.promises.open(file, 'r'); - try { - const { bytesRead } = await handle.read(header, 0, 128, 0); - return getHbcVersion(header.subarray(0, bytesRead)); - } catch { - return null; - } finally { - await handle.close(); - } + const info = await readHbcFileHeader(file); + return info && getHbcVersion(info.header); } const PROBE_CACHE_FILE = 'hbc-versions.json'; @@ -355,14 +337,8 @@ function compileProbe(hermesCommand: string): number | null { if (result.status !== 0 || !fs.existsSync(output)) { return null; } - const header = Buffer.alloc(128); - const fd = fs.openSync(output, 'r'); - try { - fs.readSync(fd, header, 0, 128, 0); - } finally { - fs.closeSync(fd); - } - return getHbcVersion(header); + // the probe output is a few hundred bytes + return getHbcVersion(fs.readFileSync(output)); } catch { return null; } finally { @@ -754,7 +730,7 @@ export async function fetchBaseBundle( const headMatches = expectHbcVersion === undefined || getHbcVersion( - await fetchZipEntryHead(record.url, location, HBC_HEAD_BYTES), + await fetchZipEntryHead(record.url, location, HBC_HEADER_SIZE), ) === expectHbcVersion; if (!headMatches) throw new StaleHeadError(); const { data, fetchedBytes, totalBytes } = await fetchZipEntryData( @@ -793,7 +769,7 @@ export async function fetchBaseBundle( expectHbcVersion === undefined ? undefined : { - bytes: HBC_HEAD_BYTES, + bytes: HBC_HEADER_SIZE, check: (head) => { const version = getHbcVersion(head); if (version !== expectHbcVersion) { diff --git a/src/utils/hermes-literals.ts b/src/utils/hermes-literals.ts index 68c3901..6bbb8cf 100644 --- a/src/utils/hermes-literals.ts +++ b/src/utils/hermes-literals.ts @@ -31,9 +31,7 @@ * `NewObjectWithBufferAndParent dst, parent, shapeIndex, valueOffset`. */ import { open as openFile } from 'node:fs/promises'; -import { resolveHbcSections } from './hbcTransform'; - -const HBC_HEADER_SIZE = 128; +import { HBC_HEADER_SIZE, resolveHbcSections } from './hbcTransform'; const TAG_TYPE_MASK = 0x70; const TAG_LENGTH_CONTINUES = 0x80; diff --git a/src/utils/index.ts b/src/utils/index.ts index 47c15c5..27f0481 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,5 @@ import chalk from 'chalk'; import { compare, satisfies } from 'compare-versions'; -import { createHash } from 'crypto'; import path from 'path'; import type { Root as ProtobufRoot } from 'protobufjs'; import { read } from 'read'; @@ -8,6 +7,7 @@ import pkg from '../../package.json'; import { checkPlugins } from './check-plugin'; import { IS_CRESC } from './constants'; import { getDepVersion } from './dep-versions'; +import { sha256Hex as sha256 } from './hash'; import { t } from './i18n'; import { readFirstEntry } from './zip-entries'; @@ -141,10 +141,6 @@ export function translateOptions>( // stay byte-identical with what the client hashes at runtime (its embedded // bundle, which is also the pdiff source) — the server compares the two to // decide pdiff applicability. Raw bytes, no normalization of any kind. -function sha256(data: Buffer): string { - return createHash('sha256').update(data).digest('hex'); -} - function requireBundle( bundleFile: Buffer | undefined, packageType: string, diff --git a/src/utils/zip-options.ts b/src/utils/zip-options.ts index 3ab308f..c09e2a5 100644 --- a/src/utils/zip-options.ts +++ b/src/utils/zip-options.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import path from 'path'; +import { HBC_MAGIC } from './hbcTransform'; export type ZipEntryOptions = { compress?: boolean; @@ -39,9 +40,6 @@ const alreadyCompressedExtensions = new Set([ '.zst', ]); -const HERMES_MAGIC = Buffer.from([ - 0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f, -]); const HERMES_DELTA_MAGIC = Buffer.from([ 0x39, 0xe0, 0x43, 0xfc, 0x3e, 0xfc, 0xe6, 0xe0, ]); @@ -54,9 +52,7 @@ function startsWith(bytes: Buffer, signature: Buffer): boolean { } function hasHermesBytecodeMagic(bytes: Buffer): boolean { - return ( - startsWith(bytes, HERMES_MAGIC) || startsWith(bytes, HERMES_DELTA_MAGIC) - ); + return startsWith(bytes, HBC_MAGIC) || startsWith(bytes, HERMES_DELTA_MAGIC); } function hasAlreadyCompressedMagic(bytes: Buffer): boolean { diff --git a/src/versions.ts b/src/versions.ts index 385dc43..78fbf8e 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -10,13 +10,13 @@ import type { Package, Platform, Version } from './types'; import { isNonInteractive, loadTtyTable, question } from './utils'; import { getDepVersions } from './utils/dep-versions'; import { getCommitInfo } from './utils/git'; +import { sha256Hex } from './utils/hash'; import { BUNDLE_ENTRY_NAMES, cachePut, extractBundleFromArchive, type HermesBaseMeta, reportedBytecodeVersion, - sha256Hex, truncateHermesBaseDetail, } from './utils/hermes-base'; import { t } from './utils/i18n'; diff --git a/tests/hermes-base.test.ts b/tests/hermes-base.test.ts index 5ee5523..2f5522f 100644 --- a/tests/hermes-base.test.ts +++ b/tests/hermes-base.test.ts @@ -5,6 +5,7 @@ import os from 'os'; import path from 'path'; import { ZipFile } from 'yazl'; import { ensureDirSync, removeSync } from '../src/utils/fs-helpers'; +import { sha256Hex } from '../src/utils/hash'; import { cacheDir, cacheLookup, @@ -21,7 +22,6 @@ import { normalizeDisassemblyLine, probeHbcVersion, resolveHermesBase, - sha256Hex, tmpDir, truncateHermesBaseDetail, verifyHermesBaseEquivalence, diff --git a/tests/zip-range.test.ts b/tests/zip-range.test.ts index 49d1650..b63fabf 100644 --- a/tests/zip-range.test.ts +++ b/tests/zip-range.test.ts @@ -6,12 +6,12 @@ import { fromRandomAccessReader, type ZipFile as YauzlZipFile } from 'yauzl'; import { ZipFile } from 'yazl'; import { deflateRawSync, inflateRawSync } from 'zlib'; import { removeSync } from '../src/utils/fs-helpers'; +import { sha256Hex } from '../src/utils/hash'; import { BundleHashMismatchError, bundleEntryMatcher, fetchBaseBundle, HbcVersionMismatchError, - sha256Hex, } from '../src/utils/hermes-base'; import { bundleLocationFields, From 2959a5fab62605a1cfdea2562a849a2be2c2eda1 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:23:15 +0800 Subject: [PATCH 06/20] refactor: one bulk delete for versions and packages deleteVersion and deletePackage carried the same one-id / many-ids DELETE, success message and error wrapping, and two copies of toNumericIds; both now call deleteByIds(appId, kind, ids). Co-Authored-By: Claude Opus 5.5 --- src/package.ts | 72 +++++++++++++++++++++++++++++++------------------ src/versions.ts | 39 +++------------------------ 2 files changed, 49 insertions(+), 62 deletions(-) diff --git a/src/package.ts b/src/package.ts index 0df70c6..a45fc0b 100644 --- a/src/package.ts +++ b/src/package.ts @@ -105,7 +105,7 @@ function parseCsvOption(value: unknown): string[] | null { return parsed.length > 0 ? parsed : null; } -function toNumericIds(ids: string[]) { +export function toNumericIds(ids: string[]) { return ids.map((id) => { const numericId = Number(id); if (!Number.isInteger(numericId) || numericId <= 0) { @@ -115,6 +115,50 @@ function toNumericIds(ids: string[]) { }); } +const deleteMessages = { + version: { + one: 'deleteVersionSuccess', + many: 'deleteVersionsSuccess', + error: 'deleteVersionError', + }, + package: { + one: 'deletePackageSuccess', + many: 'deletePackagesSuccess', + error: 'deletePackageError', + }, +} as const; + +/** + * Delete versions or packages of an app: one id through + * `DELETE /app/:appId//:id`, several in one `DELETE /app/:appId/`. + */ +export async function deleteByIds( + appId: string | number, + kind: 'version' | 'package', + ids: string[], +) { + const messages = deleteMessages[kind]; + try { + if (ids.length === 1) { + const [id] = ids; + await doDelete(`/app/${appId}/${kind}/${id}`); + console.log(t(messages.one, { [`${kind}Id`]: id })); + } else { + await doDelete(`/app/${appId}/${kind}`, { + [`${kind}Ids`]: toNumericIds(ids), + }); + console.log( + t(messages.many, { count: ids.length, [`${kind}Ids`]: ids.join(', ') }), + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + t(messages.error, { [`${kind}Id`]: ids.join(', '), error: message }), + ); + } +} + function getVersionBinding(version: unknown): PackageVersionRef | undefined { if (!version || typeof version !== 'object') { return undefined; @@ -453,30 +497,6 @@ export const packageCommands = { }); } - try { - if (packageIds.length === 1) { - const [packageId] = packageIds; - await doDelete(`/app/${appId}/package/${packageId}`); - console.log(t('deletePackageSuccess', { packageId })); - } else { - await doDelete(`/app/${appId}/package`, { - packageIds: toNumericIds(packageIds), - }); - console.log( - t('deletePackagesSuccess', { - count: packageIds.length, - packageIds: packageIds.join(', '), - }), - ); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - t('deletePackageError', { - packageId: packageIds.join(', '), - error: message, - }), - ); - } + await deleteByIds(appId, 'package', packageIds); }, }; diff --git a/src/versions.ts b/src/versions.ts index 78fbf8e..63a14fa 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -3,9 +3,9 @@ import { compare, satisfies } from 'compare-versions'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { doDelete, get, getAllPackages, post, put, uploadFile } from './api'; +import { get, getAllPackages, post, put, uploadFile } from './api'; import { getPlatform, resolveAppId } from './app'; -import { choosePackage } from './package'; +import { choosePackage, deleteByIds, toNumericIds } from './package'; import type { Package, Platform, Version } from './types'; import { isNonInteractive, loadTtyTable, question } from './utils'; import { getDepVersions } from './utils/dep-versions'; @@ -149,16 +149,6 @@ function getDepsChanges(oldDeps?: Deps, newDeps?: Deps): DepChange[] { return rows; } -function toNumericIds(ids: string[]) { - return ids.map((id) => { - const numericId = Number(id); - if (!Number.isInteger(numericId) || numericId <= 0) { - throw new Error(t('invalidId', { id })); - } - return numericId; - }); -} - function getDepsChangeSummary(changes: DepChange[]): DepsChangeSummary { return changes.reduce( (acc, item) => { @@ -872,29 +862,6 @@ export const versionCommands = { versionIds = [String((await chooseVersion(appId)).id)]; } - try { - if (versionIds.length === 1) { - const [versionId] = versionIds; - await doDelete(`/app/${appId}/version/${versionId}`); - console.log(t('deleteVersionSuccess', { versionId })); - } else { - await doDelete(`/app/${appId}/version`, { - versionIds: toNumericIds(versionIds), - }); - console.log( - t('deleteVersionsSuccess', { - count: versionIds.length, - versionIds: versionIds.join(', '), - }), - ); - } - } catch (error: any) { - throw new Error( - t('deleteVersionError', { - versionId: versionIds.join(', '), - error: error.message, - }), - ); - } + await deleteByIds(appId, 'version', versionIds); }, }; From 21dc2266b1813c20c78b746e5efbe616d6f94a9e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:23:15 +0800 Subject: [PATCH 07/20] test(e2e): read the upload body before answering in the mock store The mock /oss/upload replied 204 + connection: close before reading the multipart body; under load the client's write then failed with EPIPE, it retried and printed "Upload interrupted", failing the empty-stderr check (reproduced on master: 2 of 16 loaded runs). Co-Authored-By: Claude Opus 5.5 --- tests/cli-e2e.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cli-e2e.test.ts b/tests/cli-e2e.test.ts index 2093971..d263f74 100644 --- a/tests/cli-e2e.test.ts +++ b/tests/cli-e2e.test.ts @@ -97,6 +97,9 @@ function createMockServer(requests: string[], options: MockServerOptions = {}) { } if (url === '/oss/upload') { + // read the form like a real store: answering mid-body made the + // client's write fail with EPIPE (and retry) under load + await readBody(request); response.writeHead(204, { connection: 'close' }); response.end(); return; From 7d534ad329cbd03948326185188341ad0a38c100 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:24:43 +0800 Subject: [PATCH 08/20] refactor: one paged version search and one pager - findVersion(appId, predicate) replaces findVersionDeps (versions) and findVersionByHash (symbolicate), two copies of the same newest-first page walk over /version/list - listVersions and chooseVersion share pageOffset() for U/D/B paging (and use versionPageLimit instead of a hardcoded 10) Co-Authored-By: Claude Opus 5.5 --- src/symbolicate.ts | 25 ++--------- src/versions.ts | 110 +++++++++++++++++++++------------------------ 2 files changed, 54 insertions(+), 81 deletions(-) diff --git a/src/symbolicate.ts b/src/symbolicate.ts index d89837a..907fbc8 100644 --- a/src/symbolicate.ts +++ b/src/symbolicate.ts @@ -2,11 +2,11 @@ import fs from 'fs'; import { SourceMapConsumer } from 'source-map'; import { get } from './api'; import { getPlatform, getSelectedApp } from './app'; -import type { Platform, Version } from './types'; +import type { Platform } from './types'; import { t } from './utils/i18n'; import { webFetch } from './utils/runtime'; import { unpackSourceMap } from './utils/slim-sourcemap'; -import { fetchVersions } from './versions'; +import { findVersion } from './versions'; // A frame location inside a shipped bundle: `::`. Covers // `index.bundlejs`, `index.android.bundle`, `main.jsbundle`, `bundle.harmony.js` @@ -63,25 +63,6 @@ export function symbolicateStack( return { text, mapped }; } -async function findVersionByHash( - appId: string, - hash: string, -): Promise { - const limit = 100; - // The list is newest-first; crashes are usually about recent versions. - for (let offset = 0; offset < 100 * limit; offset += limit) { - const page = await fetchVersions(appId, offset, limit); - const found = page.find((version) => version.hash === hash); - if (found) { - return found; - } - if (page.length < limit) { - return undefined; - } - } - return undefined; -} - async function readStack(source: string | undefined): Promise { if (!source || source === '-') { // No file and an interactive terminal: nothing is piped in, so waiting on @@ -128,7 +109,7 @@ export const symbolicateCommands = { let versionId = versionIdOption; if (!versionId) { - const version = await findVersionByHash(appId, hash); + const version = await findVersion(appId, (item) => item.hash === hash); if (!version) { throw new Error(t('symbolicateVersionNotFound', { hash })); } diff --git a/src/versions.ts b/src/versions.ts index 63a14fa..40b3464 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -235,31 +235,6 @@ function printDepsChangesForPackage({ return true; } -async function findVersionDeps(appId: string, versionId: string) { - const targetId = String(versionId); - const limit = 100; - let offset = 0; - - while (true) { - const { data, count } = await get( - `/app/${appId}/version/list?offset=${offset}&limit=${limit}`, - ); - const versions: Version[] = Array.isArray(data) ? data : []; - const version = versions.find((item) => String(item.id) === targetId); - - if (version) { - return normalizeDeps(version.deps); - } - - offset += versions.length; - if (versions.length === 0 || offset >= Number(count || 0)) { - break; - } - } - - return undefined; -} - async function printDepsChangesForPublish({ appId, versionId, @@ -278,7 +253,11 @@ async function printDepsChangesForPublish({ let versionDeps = normalizeDeps(providedVersionDeps); if (!versionDeps) { try { - versionDeps = await findVersionDeps(appId, versionId); + const version = await findVersion( + appId, + (item) => String(item.id) === String(versionId), + ); + versionDeps = normalizeDeps(version?.deps); } catch (error: any) { console.warn( chalk.yellow( @@ -319,6 +298,38 @@ export async function fetchVersions( return Array.isArray(data) ? data : []; } +/** + * The first version matching `predicate`, paging through the list newest + * first (crashes and binds are usually about recent versions). + */ +export async function findVersion( + appId: string, + predicate: (version: Version) => boolean, +): Promise { + const limit = 100; + for (let offset = 0; offset < 100 * limit; offset += limit) { + const page = await fetchVersions(appId, offset, limit); + const found = page.find(predicate); + if (found || page.length < limit) { + return found; + } + } + return undefined; +} + +/** The offset after a U(p) / D(own) / B(egin) paging command, else undefined. */ +function pageOffset(cmd: string, offset: number): number | undefined { + switch (cmd.toLowerCase()) { + case 'u': + return Math.max(0, offset - versionPageLimit); + case 'd': + return offset + versionPageLimit; + case 'b': + return 0; + } + return undefined; +} + async function showVersion(appId: string, offset: number) { const data = await fetchVersions(appId, offset, versionPageLimit); console.log(t('offset', { offset })); @@ -355,21 +366,10 @@ async function listVersions(appId: string, interactive = true) { return data; } const cmd = await question('page Up/page Down/Begin/Quit(U/D/B/Q)'); - switch (cmd.toLowerCase()) { - case 'u': - offset = Math.max(0, offset - 10); - break; - case 'd': - offset += 10; - break; - case 'b': - offset = 0; - break; - case 'q': - return data; - case '': - return data; + if (cmd === '' || cmd.toLowerCase() === 'q') { + return data; } + offset = pageOffset(cmd, offset) ?? offset; } } @@ -383,25 +383,17 @@ async function chooseVersion(appId: string) { const cmd = await question( 'Enter versionId or page Up/page Down/Begin(U/D/B)', ); - switch (cmd.toUpperCase()) { - case 'U': - offset = Math.max(0, offset - 10); - break; - case 'D': - offset += 10; - break; - case 'B': - offset = 0; - break; - default: { - const versionId = Number.parseInt(cmd, 10); - const v = data.find( - (version: Version) => String(version.id) === String(versionId), - ); - if (v) { - return v; - } - } + const nextOffset = pageOffset(cmd, offset); + if (nextOffset !== undefined) { + offset = nextOffset; + continue; + } + const versionId = Number.parseInt(cmd, 10); + const v = data.find( + (version: Version) => String(version.id) === String(versionId), + ); + if (v) { + return v; } } } From 98a749b791e38ab07631bf79330dff272eaaf920 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:27:45 +0800 Subject: [PATCH 09/20] refactor(sentry): probe sentry-cli once, resolve the explicit release once - probeSentryCli() replaces two helpers that each spawned `sentry-cli sourcemaps upload --help` (one process less per upload) - explicitSentryRelease() replaces two copies of option-then-env release resolution in uploadSourcemapForSentry - drop an existsSync on the path require.resolve just returned, and fold the per-platform SENTRY_PROPERTIES branches - tests: uploadSourcemapForSentry against a fake sentry-cli (Debug ID, SENTRY_RELEASE fallback, legacy releases command, failed Debug ID retry) Co-Authored-By: Claude Opus 5.5 --- src/bundle-runner.ts | 86 ++++++++++-------------- tests/bundle-runner.test.ts | 129 ++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 52 deletions(-) diff --git a/src/bundle-runner.ts b/src/bundle-runner.ts index 47a412e..7d3d241 100644 --- a/src/bundle-runner.ts +++ b/src/bundle-runner.ts @@ -297,15 +297,14 @@ export async function runReactNativeBundleCommand({ } if (isSentry) { - if (platform === 'ios') { - process.env.SENTRY_PROPERTIES = 'ios/sentry.properties'; - } else if (platform === 'android') { - process.env.SENTRY_PROPERTIES = 'android/sentry.properties'; - } else if ( - platform === 'harmony' && - fs.existsSync('harmony/sentry.properties') + // optional only on harmony + const sentryProperties = `${platform}/sentry.properties`; + if ( + platform === 'ios' || + platform === 'android' || + (platform === 'harmony' && fs.existsSync(sentryProperties)) ) { - process.env.SENTRY_PROPERTIES = 'harmony/sentry.properties'; + process.env.SENTRY_PROPERTIES = sentryProperties; } } @@ -1575,28 +1574,34 @@ export function buildSentrySourcemapsUploadArgs({ ]; } -function supportsStandaloneSentrySourcemapsUpload(sentryCliPath: string) { +/** + * What the installed sentry-cli supports, from one `sourcemaps upload --help` + * run: the standalone `sourcemaps upload` command (else the legacy + * `releases files upload-sourcemaps`), and its `--debug-id-reference` flag. + */ +function probeSentryCli(sentryCliPath: string) { const result = spawnJavaScriptSync( [sentryCliPath, 'sourcemaps', 'upload', '--help'], - { - stdio: 'ignore', - }, + { encoding: 'utf8' }, ); - return !result.error && result.status === 0; + const standalone = !result.error && result.status === 0; + return { + standalone, + debugIdReference: + standalone && + typeof result.stdout === 'string' && + result.stdout.includes('--debug-id-reference'), + }; } -function supportsSentryDebugIdReference(sentryCliPath: string) { - const result = spawnJavaScriptSync( - [sentryCliPath, 'sourcemaps', 'upload', '--help'], - { - encoding: 'utf8', - }, - ); +/** --sentry-release/--sentry-dist, else SENTRY_RELEASE/SENTRY_DIST */ +function explicitSentryRelease(options: SentryUploadOptions) { return ( - !result.error && - result.status === 0 && - typeof result.stdout === 'string' && - result.stdout.includes('--debug-id-reference') + resolveSentryReleaseFromValues(options.sentryRelease, options.sentryDist) ?? + resolveSentryReleaseFromValues( + process.env.SENTRY_RELEASE, + process.env.SENTRY_DIST, + ) ); } @@ -1664,10 +1669,6 @@ export async function uploadSourcemapForSentry( return; } - if (!fs.existsSync(sentryCliPath)) { - return; - } - const { bundlePath, sourcemapPath } = await prepareSentryUploadArtifacts( bundleName, outputFolder, @@ -1677,8 +1678,8 @@ export async function uploadSourcemapForSentry( sourcemapPath, sentryOptions, ); - const useStandaloneSourcemapsCommand = - supportsStandaloneSentrySourcemapsUpload(sentryCliPath); + const sentryCli = probeSentryCli(sentryCliPath); + const useStandaloneSourcemapsCommand = sentryCli.standalone; if (uploadMode.type === 'release') { uploadSourcemapsWithRelease({ @@ -1693,19 +1694,8 @@ export async function uploadSourcemapForSentry( } console.log(t('uploadingSourcemap')); - if ( - !useStandaloneSourcemapsCommand || - !supportsSentryDebugIdReference(sentryCliPath) - ) { - const explicitRelease = - resolveSentryReleaseFromValues( - sentryOptions.sentryRelease, - sentryOptions.sentryDist, - ) ?? - resolveSentryReleaseFromValues( - process.env.SENTRY_RELEASE, - process.env.SENTRY_DIST, - ); + if (!sentryCli.debugIdReference) { + const explicitRelease = explicitSentryRelease(sentryOptions); if (!explicitRelease) { throw new Error( '[pushy/sentry] sentry-cli does not support Debug ID source map upload. ' + @@ -1740,15 +1730,7 @@ export async function uploadSourcemapForSentry( return; } - const explicitRelease = - resolveSentryReleaseFromValues( - sentryOptions.sentryRelease, - sentryOptions.sentryDist, - ) ?? - resolveSentryReleaseFromValues( - process.env.SENTRY_RELEASE, - process.env.SENTRY_DIST, - ); + const explicitRelease = explicitSentryRelease(sentryOptions); if (!explicitRelease) { assertSuccessfulSyncProcess(debugIdResult, sentryCliPath); return; diff --git a/tests/bundle-runner.test.ts b/tests/bundle-runner.test.ts index 610731e..92fa1f8 100644 --- a/tests/bundle-runner.test.ts +++ b/tests/bundle-runner.test.ts @@ -15,6 +15,7 @@ import { resolveHermesCommand, resolveSentryUploadMode, summarizeHermescStderr, + uploadSourcemapForSentry, } from '../src/bundle-runner'; function mkTempDir(prefix: string): string { @@ -745,3 +746,131 @@ describe('detectHermesEnabled', () => { } }); }); + +describe('uploadSourcemapForSentry', () => { + // A fake @sentry/cli: logs each invocation and answers `--help` according + // to FAKE_SENTRY_HELP (full | legacy-flags | missing). + const FAKE_CLI = ` +const fs = require('fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.FAKE_SENTRY_LOG, JSON.stringify(args) + '\\n'); +if (args.includes('--help')) { + if (process.env.FAKE_SENTRY_HELP === 'missing') process.exit(1); + console.log(process.env.FAKE_SENTRY_HELP === 'full' + ? 'Usage: --release --dist --debug-id-reference' + : 'Usage: --release --dist'); + process.exit(0); +} +if (args.includes('--debug-id-reference') && process.env.FAKE_SENTRY_DEBUG_FAIL) { + process.exit(2); +} +`; + let root = ''; + let cwd = ''; + const savedEnv: Record = {}; + const envKeys = [ + 'FAKE_SENTRY_LOG', + 'FAKE_SENTRY_HELP', + 'FAKE_SENTRY_DEBUG_FAIL', + 'SENTRY_RELEASE', + 'SENTRY_DIST', + ]; + let logSpy: ReturnType; + let warnSpy: ReturnType; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-sentry-')); + const bin = path.join(root, 'node_modules', '@sentry', 'cli', 'bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'sentry-cli'), FAKE_CLI); + fs.mkdirSync(path.join(root, 'out')); + fs.writeFileSync(path.join(root, 'out', 'index.bundlejs'), 'code'); + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + process.env.FAKE_SENTRY_LOG = path.join(root, 'calls.log'); + cwd = process.cwd(); + process.chdir(root); + logSpy = spyOn(console, 'log').mockImplementation(() => {}); + warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + process.chdir(cwd); + for (const key of envKeys) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + logSpy.mockRestore(); + warnSpy.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + const writeMap = (extra: Record = {}) => + fs.writeFileSync( + path.join(root, 'out', 'index.bundlejs.map'), + JSON.stringify({ version: 3, sources: [], mappings: '', ...extra }), + ); + const calls = () => + fs + .readFileSync(path.join(root, 'calls.log'), 'utf8') + .trim() + .split('\n') + .map((line) => (JSON.parse(line) as string[]).slice(0, 4).join(' ')); + const upload = (options = {}) => + uploadSourcemapForSentry( + 'index.bundlejs', + path.join(root, 'out'), + 'index.bundlejs.map', + 'ios', + options, + ); + + test('uploads by Debug ID after a single --help probe', async () => { + writeMap({ debugId: 'abc' }); + process.env.FAKE_SENTRY_HELP = 'full'; + await upload(); + expect(calls()).toEqual([ + 'sourcemaps upload --help', + 'sourcemaps upload --debug-id-reference --strip-prefix', + ]); + }); + + test('falls back to SENTRY_RELEASE when --debug-id-reference is unsupported', async () => { + writeMap({ debugId: 'abc' }); + process.env.FAKE_SENTRY_HELP = 'legacy-flags'; + process.env.SENTRY_RELEASE = 'r1'; + await upload(); + expect(calls()).toEqual([ + 'sourcemaps upload --help', + 'releases set-commits r1 --auto', + 'sourcemaps upload --release r1', + ]); + }); + + test('uses releases files upload-sourcemaps without the standalone command', async () => { + writeMap(); + process.env.FAKE_SENTRY_HELP = 'missing'; + await upload({ sentryRelease: 'r2' }); + expect(calls()).toEqual([ + 'sourcemaps upload --help', + 'releases set-commits r2 --auto', + 'releases files r2 upload-sourcemaps', + ]); + }); + + test('retries a failed Debug ID upload with SENTRY_RELEASE', async () => { + writeMap({ debugId: 'abc' }); + process.env.FAKE_SENTRY_HELP = 'full'; + process.env.FAKE_SENTRY_DEBUG_FAIL = '1'; + process.env.SENTRY_RELEASE = 'r3'; + await upload(); + expect(calls()).toEqual([ + 'sourcemaps upload --help', + 'sourcemaps upload --debug-id-reference --strip-prefix', + 'releases set-commits r3 --auto', + 'sourcemaps upload --release r3', + ]); + }); +}); From aafcc6a971f3ea782a711573e4379295a4b84649 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:30:06 +0800 Subject: [PATCH 10/20] refactor: one truthy-env check and one PackageManager type isTruthyEnv (utils/options) replaces the copies in bin, auto-update and isNonInteractive; auto-update uses runtime's PackageManager type instead of declaring its own. Co-Authored-By: Claude Opus 5.5 --- src/auto-update.ts | 12 +++++------- src/bin.ts | 5 +---- src/utils/index.ts | 5 ++--- src/utils/options.ts | 5 +++++ 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/auto-update.ts b/src/auto-update.ts index 975aee5..2f0cd94 100644 --- a/src/auto-update.ts +++ b/src/auto-update.ts @@ -13,8 +13,8 @@ import { } from 'fs'; import path from 'path'; import { userCacheDir } from './utils/fs-helpers'; - -export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; +import { isTruthyEnv } from './utils/options'; +import type { PackageManager } from './utils/runtime'; interface AutoUpdateCandidate { currentVersion: string; @@ -304,13 +304,11 @@ function inferNpmPrefix( function isDisabled(env: NodeJS.ProcessEnv): boolean { const disabled = (value: string | undefined) => value === '0' || value?.toLowerCase() === 'false'; - const enabled = (value: string | undefined) => - value === '1' || value?.toLowerCase() === 'true'; return ( disabled(env.RNU_AUTO_UPDATE) || - enabled(env.RNU_DISABLE_AUTO_UPDATE) || - enabled(env.NO_UPDATE_NOTIFIER) || - enabled(env.CI) + isTruthyEnv(env.RNU_DISABLE_AUTO_UPDATE) || + isTruthyEnv(env.NO_UPDATE_NOTIFIER) || + isTruthyEnv(env.CI) ); } diff --git a/src/bin.ts b/src/bin.ts index ebc8982..fba5b7e 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -5,6 +5,7 @@ import { loadSession } from './api'; import { commandNames, loadCommandHandler } from './commands'; import { printVersionCommand } from './utils'; import { t } from './utils/i18n'; +import { isTruthyEnv } from './utils/options'; interface CliArgv { command: string; @@ -12,10 +13,6 @@ interface CliArgv { options: Record; } -function isTruthyEnv(value: string | undefined): boolean { - return value === '1' || value?.toLowerCase() === 'true'; -} - function printUsage(exitCode = 1) { console.log('React Native Update CLI'); console.log(''); diff --git a/src/utils/index.ts b/src/utils/index.ts index 27f0481..2e0f533 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,6 +9,7 @@ import { IS_CRESC } from './constants'; import { getDepVersion } from './dep-versions'; import { sha256Hex as sha256 } from './hash'; import { t } from './i18n'; +import { isTruthyEnv } from './options'; import { readFirstEntry } from './zip-entries'; // app-info-parser (and its zip/plist/protobuf stack) is only needed by the @@ -91,11 +92,9 @@ export function isNonInteractive( stdinIsTTY: boolean | undefined = process.stdin.isTTY, globalFlag: boolean | undefined = global.NO_INTERACTIVE, ) { - const envValue = env.NO_INTERACTIVE?.toLowerCase(); return ( globalFlag === true || - envValue === 'true' || - envValue === '1' || + isTruthyEnv(env.NO_INTERACTIVE) || stdinIsTTY !== true ); } diff --git a/src/utils/options.ts b/src/utils/options.ts index 3dec195..f668119 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -1,3 +1,8 @@ +/** `1` or `true` (any case), the usual spelling of an enabled env flag */ +export function isTruthyEnv(value: string | undefined): boolean { + return value === '1' || value?.toLowerCase() === 'true'; +} + export function getBooleanOption( options: Record, key: string, From c028acbce895e11d8eb4276af6237352c759a5ba Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:32:42 +0800 Subject: [PATCH 11/20] refactor(package): use the shared option parsers, inline buildTime String() - uploadAab / extractApk read includeAllSplits and splits through getBooleanOption / getStringListOption instead of local copies - normalizeUploadBuildTime and NativeUploadConfig.normalizeBuildTime were both String(); the upload now sends String(buildTime) directly - drop tests that re-implemented those helpers locally and so tested nothing Co-Authored-By: Claude Opus 5.5 --- src/package.ts | 41 ++--------- tests/package-buildtime.test.ts | 24 ------ tests/package.test.ts | 126 +------------------------------- 3 files changed, 8 insertions(+), 183 deletions(-) delete mode 100644 tests/package-buildtime.test.ts diff --git a/src/package.ts b/src/package.ts index a45fc0b..e4f4b29 100644 --- a/src/package.ts +++ b/src/package.ts @@ -24,7 +24,7 @@ import { reportedBytecodeVersion, } from './utils/hermes-base'; import { t } from './utils/i18n'; -import { getStringListOption } from './utils/options'; +import { getBooleanOption, getStringListOption } from './utils/options'; import { bundleLocationFields, locateZipEntry } from './utils/zip-range'; type PackageCommandOptions = Record & { @@ -63,9 +63,6 @@ type NativeUploadConfig = { appKeyMismatchKey: string; successKey: string; getInfo: (filePath: string) => Promise; - normalizeBuildTime?: ( - buildTime: NativePackageInfo['buildTime'], - ) => string | number | undefined; }; // the AAB parser drags in protobufjs; load it only for the aab commands @@ -75,10 +72,6 @@ function loadExtractApk() { ).extractApk; } -export function normalizeUploadBuildTime(value: unknown): string { - return String(value); -} - function ensureFileByExt( filePath: string | undefined, extension: NativeUploadConfig['extension'] | '.aab', @@ -90,21 +83,6 @@ function ensureFileByExt( return filePath; } -function parseBooleanOption(value: unknown): boolean { - return value === true || value === 'true'; -} - -function parseCsvOption(value: unknown): string[] | null { - if (typeof value !== 'string') { - return null; - } - const parsed = value - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - return parsed.length > 0 ? parsed : null; -} - export function toNumericIds(ids: string[]) { return ids.map((id) => { const numericId = Number(id); @@ -232,15 +210,11 @@ async function uploadNativePackage( // already hashed the bundle, so pass that hash instead of hashing twice await cachePut(bundleFile, undefined, bundleHash).catch(() => {}); } - const normalizedBuildTime = config.normalizeBuildTime - ? config.normalizeBuildTime(buildTime) - : buildTime; - const uploadBuildTime = normalizeUploadBuildTime(normalizedBuildTime); const { id } = await post(`/app/${appId}/package/create`, { name: versionName, hash, - buildTime: uploadBuildTime, + buildTime: String(buildTime), // Content identity of the embedded JS bundle; the server matches it // against the client-reported bundleHash to decide pdiff applicability. // Old servers strip unknown fields, so this is forward-compatible. @@ -258,7 +232,7 @@ async function uploadNativePackage( t(config.successKey, { id, version: versionName, - buildTime: uploadBuildTime, + buildTime: String(buildTime), }), ); } finally { @@ -374,8 +348,8 @@ export const packageCommands = { ); const output = path.join(tempRoot, 'universal.apk'); - const includeAllSplits = parseBooleanOption(options.includeAllSplits); - const splits = parseCsvOption(options.splits); + const includeAllSplits = getBooleanOption(options, 'includeAllSplits'); + const splits = getStringListOption(options, 'splits'); try { await loadExtractApk()(source, output, { @@ -405,7 +379,6 @@ export const packageCommands = { appKeyMismatchKey: 'appKeyMismatchApp', successKey: 'appUploadSuccess', getInfo: (filePath) => getAppInfo(filePath), - normalizeBuildTime: (buildTime) => String(buildTime), }); }, parseApp: async ({ args }: { args: string[] }) => { @@ -452,8 +425,8 @@ export const packageCommands = { `${path.basename(source, path.extname(source))}.apk`, ); - const includeAllSplits = parseBooleanOption(options.includeAllSplits); - const splits = parseCsvOption(options.splits); + const includeAllSplits = getBooleanOption(options, 'includeAllSplits'); + const splits = getStringListOption(options, 'splits'); await loadExtractApk()(source, output, { includeAllSplits, diff --git a/tests/package-buildtime.test.ts b/tests/package-buildtime.test.ts deleted file mode 100644 index aeb4dd4..0000000 --- a/tests/package-buildtime.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { normalizeUploadBuildTime } from '../src/package'; - -describe('package buildTime normalization', () => { - test('converts number to string', () => { - expect(normalizeUploadBuildTime(29538230)).toBe('29538230'); - expect(normalizeUploadBuildTime(0)).toBe('0'); - }); - - test('keeps string value', () => { - expect(normalizeUploadBuildTime('29538230')).toBe('29538230'); - expect(normalizeUploadBuildTime(' 29538230 ')).toBe(' 29538230 '); - }); - - test('always returns a string for any input', () => { - expect(normalizeUploadBuildTime(undefined)).toBe('undefined'); - expect(normalizeUploadBuildTime(null)).toBe('null'); - expect(normalizeUploadBuildTime(Number.NaN)).toBe('NaN'); - expect(normalizeUploadBuildTime(Number.POSITIVE_INFINITY)).toBe('Infinity'); - expect(normalizeUploadBuildTime(Number.NEGATIVE_INFINITY)).toBe( - '-Infinity', - ); - }); -}); diff --git a/tests/package.test.ts b/tests/package.test.ts index a53cf4c..3e06ae4 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -4,11 +4,7 @@ import os from 'os'; import path from 'path'; import { ZipFile as YazlZipFile } from 'yazl'; import * as api from '../src/api'; -import { - choosePackage, - normalizeUploadBuildTime, - packageCommands, -} from '../src/package'; +import { choosePackage, packageCommands } from '../src/package'; import * as utils from '../src/utils'; import { enumZipEntries } from '../src/utils/zip-entries'; @@ -30,126 +26,6 @@ async function createZip( }); } -describe('normalizeUploadBuildTime', () => { - test('converts number to string', () => { - expect(normalizeUploadBuildTime(1234567890)).toBe('1234567890'); - }); - - test('keeps string as-is', () => { - expect(normalizeUploadBuildTime('1234567890')).toBe('1234567890'); - }); - - test('converts undefined to string', () => { - expect(normalizeUploadBuildTime(undefined)).toBe('undefined'); - }); - - test('converts null to string', () => { - expect(normalizeUploadBuildTime(null)).toBe('null'); - }); - - test('converts 0 to string', () => { - expect(normalizeUploadBuildTime(0)).toBe('0'); - }); -}); - -// Test the internal helper functions by re-implementing and verifying the logic -describe('package helper logic', () => { - // parseBooleanOption equivalent - function parseBooleanOption(value: unknown): boolean { - return value === true || value === 'true'; - } - - test('parseBooleanOption returns true for boolean true', () => { - expect(parseBooleanOption(true)).toBe(true); - }); - - test('parseBooleanOption returns true for string "true"', () => { - expect(parseBooleanOption('true')).toBe(true); - }); - - test('parseBooleanOption returns false for false', () => { - expect(parseBooleanOption(false)).toBe(false); - }); - - test('parseBooleanOption returns false for "false"', () => { - expect(parseBooleanOption('false')).toBe(false); - }); - - test('parseBooleanOption returns false for undefined', () => { - expect(parseBooleanOption(undefined)).toBe(false); - }); - - test('parseBooleanOption returns false for 1', () => { - expect(parseBooleanOption(1)).toBe(false); - }); - - // parseCsvOption equivalent - function parseCsvOption(value: unknown): string[] | null { - if (typeof value !== 'string') return null; - const parsed = value - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - return parsed.length > 0 ? parsed : null; - } - - test('parseCsvOption parses simple CSV', () => { - expect(parseCsvOption('a,b,c')).toEqual(['a', 'b', 'c']); - }); - - test('parseCsvOption trims whitespace', () => { - expect(parseCsvOption(' a , b , c ')).toEqual(['a', 'b', 'c']); - }); - - test('parseCsvOption filters empty values', () => { - expect(parseCsvOption('a,,b,,c')).toEqual(['a', 'b', 'c']); - }); - - test('parseCsvOption returns null for empty string', () => { - expect(parseCsvOption('')).toBeNull(); - }); - - test('parseCsvOption returns null for non-string', () => { - expect(parseCsvOption(123)).toBeNull(); - expect(parseCsvOption(undefined)).toBeNull(); - expect(parseCsvOption(null)).toBeNull(); - }); - - test('parseCsvOption handles single item', () => { - expect(parseCsvOption('foo')).toEqual(['foo']); - }); - - // ensureFileByExt equivalent - function ensureFileByExt( - filePath: string | undefined, - extension: string, - ): string { - if (!filePath?.endsWith(extension)) { - throw new Error(`Usage: expected ${extension} file`); - } - return filePath; - } - - test('ensureFileByExt accepts matching extension', () => { - expect(ensureFileByExt('app.ipa', '.ipa')).toBe('app.ipa'); - expect(ensureFileByExt('debug.apk', '.apk')).toBe('debug.apk'); - expect(ensureFileByExt('release.aab', '.aab')).toBe('release.aab'); - expect(ensureFileByExt('bundle.app', '.app')).toBe('bundle.app'); - }); - - test('ensureFileByExt rejects wrong extension', () => { - expect(() => ensureFileByExt('app.apk', '.ipa')).toThrow(); - }); - - test('ensureFileByExt rejects undefined', () => { - expect(() => ensureFileByExt(undefined, '.ipa')).toThrow(); - }); - - test('ensureFileByExt rejects empty string', () => { - expect(() => ensureFileByExt('', '.apk')).toThrow(); - }); -}); - describe('packageCommands.deletePackage', () => { let consoleSpy: ReturnType; let deleteSpy: ReturnType; From 779f07e4e2be70fb395a23e70eac4255ddd68890 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:35:19 +0800 Subject: [PATCH 12/20] refactor(versions): one non-interactive check, one version id prompt isNonInteractiveFor(options) replaces five copies of the `--no-interactive || isNonInteractive()` expression, and versionIdOrChoose() the three "--versionId, else pick one, else throw versionIdRequired" blocks of update, updateVersionInfo and deleteVersion. Co-Authored-By: Claude Opus 5.5 --- src/versions.ts | 77 ++++++++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/src/versions.ts b/src/versions.ts index 40b3464..f24a7dc 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -398,6 +398,27 @@ async function chooseVersion(appId: string) { } } +function isNonInteractiveFor(options: VersionCommandOptions) { + return ( + getBooleanOption(options, 'no-interactive', false) || isNonInteractive() + ); +} + +/** `versionId` when given, else one picked from the list (interactive only). */ +async function versionIdOrChoose( + appId: string, + versionId: string | undefined, + options: VersionCommandOptions, +): Promise { + if (versionId) { + return versionId; + } + if (isNonInteractiveFor(options)) { + throw new Error(t('versionIdRequired')); + } + return String((await chooseVersion(appId)).id); +} + export const bindVersionToPackages = async ({ appId, versionId, @@ -518,8 +539,7 @@ export const versionCommands = { const platform = await getPlatform(options.platform); const appId = await resolveAppId({ ...options, platform }); - const nonInteractive = - getBooleanOption(options, 'no-interactive', false) || isNonInteractive(); + const nonInteractive = isNonInteractiveFor(options); // The source map is archived with the version so `pushy symbolicate` can // map crashes back to source. An explicit path that does not exist is a @@ -670,23 +690,16 @@ export const versionCommands = { }, versions: async ({ options }: { options: VersionCommandOptions }) => { const appId = await resolveAppId(options); - const interactive = !( - getBooleanOption(options, 'no-interactive', false) || isNonInteractive() - ); - await listVersions(appId, interactive); + await listVersions(appId, !isNonInteractiveFor(options)); }, update: async ({ options }: { options: VersionCommandOptions }) => { - const nonInteractive = - getBooleanOption(options, 'no-interactive', false) || isNonInteractive(); const appId = await resolveAppId(options); - let versionId: string | null | undefined = options.versionId; - if (!versionId) { - if (nonInteractive) { - throw new Error(t('versionIdRequired')); - } - versionId = String((await chooseVersion(appId)).id); - } + let versionId: string | null = await versionIdOrChoose( + appId, + options.versionId, + options, + ); if (versionId === 'null') { versionId = null; } @@ -777,7 +790,7 @@ export const versionCommands = { } } else { if (!pkgId) { - if (nonInteractive) { + if (isNonInteractiveFor(options)) { throw new Error(t('packageIdRequired')); } // the package list was already fetched above: no second round trip @@ -818,17 +831,12 @@ export const versionCommands = { }: { options: VersionCommandOptions; }) => { - const nonInteractive = - getBooleanOption(options, 'no-interactive', false) || isNonInteractive(); const appId = await resolveAppId(options); - - let versionId = options.versionId; - if (!versionId) { - if (nonInteractive) { - throw new Error(t('versionIdRequired')); - } - versionId = String((await chooseVersion(appId)).id); - } + const versionId = await versionIdOrChoose( + appId, + options.versionId, + options, + ); const updateParams: Record = {}; if (options.name) updateParams.name = options.name; @@ -839,20 +847,11 @@ export const versionCommands = { console.log(t('operationSuccess')); }, deleteVersion: async ({ options }: { options: VersionCommandOptions }) => { - const nonInteractive = - getBooleanOption(options, 'no-interactive', false) || isNonInteractive(); const appId = await resolveAppId(options); - - const parsedVersionIds = - getStringListOption(options, 'versionIds') ?? - getStringListOption(options, 'versionId'); - let versionIds = parsedVersionIds; - if (!versionIds) { - if (nonInteractive) { - throw new Error(t('versionIdRequired')); - } - versionIds = [String((await chooseVersion(appId)).id)]; - } + const versionIds = getStringListOption(options, 'versionIds') ?? + getStringListOption(options, 'versionId') ?? [ + await versionIdOrChoose(appId, undefined, options), + ]; await deleteByIds(appId, 'version', versionIds); }, From 30e4db74e279b9aade60281056d2e89fea687042 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:36:23 +0800 Subject: [PATCH 13/20] refactor: simpler deps diff, one update.json reader - getDepsChanges builds each row once (added / removed / changed derived from which side is missing); getDepsChangeSummary is a plain count - app: readUpdateInfo() is shared by getSelectedApp and selectApp; a broken update.json now fails selectApp with the same failedToParseUpdateJson error getSelectedApp gives; drop an id re-check after parsePositiveIntegerId / chooseApp and deleteApp's unreachable `!id` branch Co-Authored-By: Claude Opus 5.5 --- src/app.ts | 53 +++++++++++++++++------------------------- src/versions.ts | 61 ++++++++++++++----------------------------------- 2 files changed, 38 insertions(+), 76 deletions(-) diff --git a/src/app.ts b/src/app.ts index 6707965..c6b7fcd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -50,32 +50,37 @@ function parsePositiveIntegerId(value: string): number { return id; } -/** Read the selected app for a platform from the requested config file. */ -export async function getSelectedApp( - platform: Platform, - configPath?: string, -): Promise<{ appId: string; appKey: string; platform: Platform }> { - assertPlatform(platform); +type UpdateInfo = Partial>; - const resolvedConfigPath = configPath || updateJson; +/** The parsed config file, or undefined when it does not exist. */ +async function readUpdateInfo( + configPath: string, +): Promise { let raw: string; try { - raw = await fs.promises.readFile(resolvedConfigPath, 'utf8'); + raw = await fs.promises.readFile(configPath, 'utf8'); } catch (e: any) { if (e.code === 'ENOENT') { - throw new AppNotSelectedError(platform); + return undefined; } throw e; } - let updateInfo: Partial>; try { - updateInfo = JSON.parse(raw); + return JSON.parse(raw); } catch { - throw new Error( - t('failedToParseUpdateJson', { configPath: resolvedConfigPath }), - ); + throw new Error(t('failedToParseUpdateJson', { configPath })); } - const info = updateInfo[platform]; +} + +/** Read the selected app for a platform from the requested config file. */ +export async function getSelectedApp( + platform: Platform, + configPath?: string, +): Promise<{ appId: string; appKey: string; platform: Platform }> { + assertPlatform(platform); + + const updateInfo = await readUpdateInfo(configPath || updateJson); + const info = updateInfo?.[platform]; if (!info) { throw new AppNotSelectedError(platform); } @@ -183,9 +188,6 @@ async function selectApp({ const id = args[0] ? parsePositiveIntegerId(args[0]) : (await chooseApp(platform)).id; - if (!Number.isSafeInteger(id) || id <= 0) { - throw new Error(t('invalidId', { id: args[0] })); - } const app = await assertAppPlatform(String(id), platform); if (typeof app.appKey !== 'string' || !app.appKey) { throw new Error(t('appKeyMissing', { appId: id })); @@ -193,16 +195,7 @@ async function selectApp({ const appKey = app.appKey; const configPath = options.config || updateJson; - let updateInfo: Partial> = - {}; - try { - updateInfo = JSON.parse(await fs.promises.readFile(configPath, 'utf8')); - } catch (e: any) { - if (e.code !== 'ENOENT') { - console.error(t('failedToParseUpdateJson', { configPath })); - throw e; - } - } + const updateInfo = (await readUpdateInfo(configPath)) ?? {}; updateInfo[platform] = { appId: id, appKey, @@ -250,10 +243,6 @@ export function getAppCommands() { const id = args[0] ? parsePositiveIntegerId(args[0]) : (await chooseApp(platform)).id; - if (!id) { - console.log(t('cancelled')); - return; - } await doDelete(`/app/${id}`); console.log(t('operationSuccess')); }, diff --git a/src/versions.ts b/src/versions.ts index f24a7dc..13e9fe8 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -111,58 +111,31 @@ function getDepsChanges(oldDeps?: Deps, newDeps?: Deps): DepChange[] { for (const key of keys) { const oldVersion = oldDeps[key]; const newVersion = newDeps[key]; - - if (oldVersion === undefined && newVersion !== undefined) { - rows.push({ - dependency: key, - oldVersion: '-', - newVersion, - type: 'added', - }); - continue; - } - - if (oldVersion !== undefined && newVersion === undefined) { - rows.push({ - dependency: key, - oldVersion, - newVersion: '-', - type: 'removed', - }); + if (oldVersion === newVersion) { continue; } - - if ( - oldVersion !== undefined && - newVersion !== undefined && - oldVersion !== newVersion - ) { - rows.push({ - dependency: key, - oldVersion, - newVersion, - type: 'changed', - }); - } + rows.push({ + dependency: key, + oldVersion: oldVersion ?? '-', + newVersion: newVersion ?? '-', + type: + oldVersion === undefined + ? 'added' + : newVersion === undefined + ? 'removed' + : 'changed', + }); } return rows; } function getDepsChangeSummary(changes: DepChange[]): DepsChangeSummary { - return changes.reduce( - (acc, item) => { - if (item.type === 'added') { - acc.added += 1; - } else if (item.type === 'removed') { - acc.removed += 1; - } else { - acc.changed += 1; - } - return acc; - }, - { added: 0, removed: 0, changed: 0 }, - ); + const summary = { added: 0, removed: 0, changed: 0 }; + for (const { type } of changes) { + summary[type] += 1; + } + return summary; } function renderVersionChange(change: DepChange) { From eaf285b01957de0e43594c4de2742ca3ea444ac9 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:36:58 +0800 Subject: [PATCH 14/20] refactor(api): one query factory, one proxy-aware network error - queryWith(method, withBody) replaces the two header-building factories - networkError() replaces the proxy-hint wrapping copied in query() and uploadFile() - provider: runMessageCommand is runDataCommand plus a message Co-Authored-By: Claude Opus 5.5 --- src/api.ts | 67 ++++++++++++++++++------------------------------- src/provider.ts | 8 +++--- 2 files changed, 27 insertions(+), 48 deletions(-) diff --git a/src/api.ts b/src/api.ts index a745f9c..b6c8d7d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -165,6 +165,18 @@ function isProxyRelatedError(error: unknown): boolean { return PROXY_ERROR_PATTERNS.some((p) => lower.includes(p.toLowerCase())); } +/** A request error for `url`, with the proxy hints when a proxy is the likely cause. */ +function networkError(error: unknown, url: string): Error { + const baseError = createRequestError(error, url); + if (isProxyRelatedError(error)) { + return new Error( + `${baseError.message}\n\n${t('proxyNetworkError')}\n${t('proxyNetworkErrorTips')}`, + { cause: baseError }, + ); + } + return baseError; +} + async function query(url: string, options: RuntimeRequestInit) { const baseUrl = await getBaseUrl(); const fullUrl = `${baseUrl}${url}`; @@ -172,14 +184,7 @@ async function query(url: string, options: RuntimeRequestInit) { try { resp = await runtimeFetch(fullUrl, options); } catch (error) { - const baseError = createRequestError(error, fullUrl); - if (isProxyRelatedError(error)) { - throw new Error( - `${baseError.message}\n\n${t('proxyNetworkError')}\n${t('proxyNetworkErrorTips')}`, - { cause: baseError }, - ); - } - throw baseError; + throw networkError(error, fullUrl); } const text = await resp.text(); let json: any; @@ -207,29 +212,12 @@ async function query(url: string, options: RuntimeRequestInit) { return json; } -function queryWithoutBody(method: string) { - return (api: string) => { - const headers: Record = { - 'User-Agent': userAgent, - }; - if (apiToken) { - headers['x-api-token'] = apiToken; - } else if (session?.token) { - headers['X-AccessToken'] = session.token; - } - return query(api, { - method, - headers, - }); - }; -} - -function queryWithBody(method: string) { +function queryWith(method: string, withBody: boolean) { return (api: string, body?: Record) => { - const headers: Record = { - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - }; + const headers: Record = { 'User-Agent': userAgent }; + if (withBody) { + headers['Content-Type'] = 'application/json'; + } if (apiToken) { headers['x-api-token'] = apiToken; } else if (session?.token) { @@ -238,15 +226,15 @@ function queryWithBody(method: string) { return query(api, { method, headers, - body: JSON.stringify(body), + ...(withBody ? { body: JSON.stringify(body) } : {}), }); }; } -export const get = queryWithoutBody('GET'); -export const post = queryWithBody('POST'); -export const put = queryWithBody('PUT'); -export const doDelete = queryWithBody('DELETE'); +export const get: (api: string) => Promise = queryWith('GET', false); +export const post = queryWith('POST', true); +export const put = queryWith('PUT', true); +export const doDelete = queryWith('DELETE', true); // Upload deadline: generous for a slow link (5 s/MB, i.e. ~200 KB/s, on top // of a 30 s base, never below 60 s) but bounded, so a stalled connection cannot @@ -480,14 +468,7 @@ export async function uploadFile(fn: string, appId?: string | number) { }); const rethrowUploadError = (error: unknown): never => { - const baseError = createRequestError(error, realUrl); - if (isProxyRelatedError(error)) { - throw new Error( - `${baseError.message}\n\n${t('proxyNetworkError')}\n${t('proxyNetworkErrorTips')}`, - { cause: baseError }, - ); - } - throw baseError; + throw networkError(error, realUrl); }; // 自托管节点的 s3 直传:服务端下发预签名 PUT,字节直达用户的对象存储 diff --git a/src/provider.ts b/src/provider.ts index 30a6e01..79cca29 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -52,15 +52,13 @@ export class CLIProviderImpl implements CLIProvider { successMessage: string, requireSession = true, ): Promise { - return runAsCommandResult( + return this.runDataCommand( async () => { - if (requireSession) { - await this.ensureInitialized(); - } await task(); + return { message: successMessage }; }, fallbackError, - () => ({ message: successMessage }), + requireSession, ); } From 0a6ae2f795b55b874d8b72dc371e53a3a1f8af4f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:37:25 +0800 Subject: [PATCH 15/20] refactor(diff): one stream-threshold check, one entry materializer addBundlePatch and buildBundlePatch shared the stream-threshold test, and addBundlePatch and readOriginBundleSource the mkdtemp + writeEntry + timing block that turns a zip entry into a file-backed BundleSource. Co-Authored-By: Claude Opus 5.5 --- src/diff.ts | 78 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/src/diff.ts b/src/diff.ts index f6543bd..668d642 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -356,6 +356,41 @@ async function buildStreamBundlePatch( }; } +/** origin 与 new 中较大者达到流式阈值时走流式 diff */ +function exceedsStreamThreshold( + options: BundleDiffOptions, + originSource: BundleSource | undefined, + newSize: number, +): originSource is BundleSource { + return ( + !!options.streamThresholdBytes && + !!originSource && + Math.max(bundleSourceSize(originSource), newSize) >= + options.streamThresholdBytes + ); +} + +/** 把 zip 条目落盘到 workRoot 下的临时目录,作为文件型 BundleSource */ +async function materializeEntry( + entry: Entry, + zipFile: YauzlZipFile, + workRoot: string, + prefix: string, +): Promise { + const filePath = path.join( + fs.mkdtempSync(path.join(workRoot, prefix)), + 'bundle.bin', + ); + const startedAt = performance.now(); + await writeEntry(entry, zipFile, filePath); + return { + kind: 'file', + path: filePath, + size: entry.uncompressedSize, + materializeDurationMs: performance.now() - startedAt, + }; +} + /** * 生成 bundle patch:默认现状路径;开启 hbcTransform 时对 Hermes bundle * 做双候选择优(baseline vs 变换域 + 元数据开销),且变换候选必须通过 @@ -369,10 +404,7 @@ async function buildBundlePatch( workRoot: string, ): Promise { if ( - options.streamThresholdBytes && - originSource && - Math.max(bundleSourceSize(originSource), bundleSourceSize(newSource)) >= - options.streamThresholdBytes + exceedsStreamThreshold(options, originSource, bundleSourceSize(newSource)) ) { return buildStreamBundlePatch(originSource, newSource, options, workRoot); } @@ -434,26 +466,13 @@ async function addBundlePatch( hbcTransformMetas: Record, workRoot: string, ) { - const shouldStream = - !!bundleOptions.streamThresholdBytes && - !!originSource && - Math.max(bundleSourceSize(originSource), entry.uncompressedSize) >= - bundleOptions.streamThresholdBytes; - let newSource: BundleSource; - if (shouldStream) { - const nextRoot = fs.mkdtempSync(path.join(workRoot, 'next-')); - const nextPath = path.join(nextRoot, 'bundle.bin'); - const startedAt = performance.now(); - await writeEntry(entry, nextZipfile, nextPath); - newSource = { - kind: 'file', - path: nextPath, - size: entry.uncompressedSize, - materializeDurationMs: performance.now() - startedAt, - }; - } else { - newSource = { kind: 'buffer', data: await readEntry(entry, nextZipfile) }; - } + const newSource: BundleSource = exceedsStreamThreshold( + bundleOptions, + originSource, + entry.uncompressedSize, + ) + ? await materializeEntry(entry, nextZipfile, workRoot, 'next-') + : { kind: 'buffer', data: await readEntry(entry, nextZipfile) }; const { patch, hbcTransform } = await buildBundlePatch( diffFn, @@ -482,16 +501,7 @@ async function readOriginBundleSource( if (!bundleOptions.streamThresholdBytes) { return { kind: 'buffer', data: await readEntry(entry, zipFile) }; } - const originRoot = fs.mkdtempSync(path.join(workRoot, 'origin-')); - const originPath = path.join(originRoot, 'bundle.bin'); - const startedAt = performance.now(); - await writeEntry(entry, zipFile, originPath); - return { - kind: 'file', - path: originPath, - size: entry.uncompressedSize, - materializeDurationMs: performance.now() - startedAt, - }; + return materializeEntry(entry, zipFile, workRoot, 'origin-'); } async function addFileFromZipEntry( From fbaa50d6bb2878084bd0a65e178709a9bd6cf7df Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:37:58 +0800 Subject: [PATCH 16/20] refactor(bundle): one publish-and-upload step The --name path and the interactive "upload now?" path each built the same publish payload (appId, hermesBase, the sourcemap ternary) and repeated the Sentry upload call; both now go through publishAndUploadSentry(). Co-Authored-By: Claude Opus 5.5 --- src/bundle.ts | 71 ++++++++++++++++++++++----------------------------- 1 file changed, 31 insertions(+), 40 deletions(-) diff --git a/src/bundle.ts b/src/bundle.ts index eba0c23..d4f62e0 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -294,6 +294,8 @@ export const bundleCommands = { `${Date.now()}`, ); + const sourcemap = normalized.sourcemap || bundleParams.sourcemap; + console.log(t('bundlingWithRN', { version: depVersions['react-native'] })); const hermesResult = await runReactNativeBundleCommand({ @@ -302,8 +304,7 @@ export const bundleCommands = { entryFile: normalized.entryFile, outputFolder: normalized.intermediaDir, platform, - sourcemapOutput: - normalized.sourcemap || bundleParams.sourcemap ? sourcemapOutput : '', + sourcemapOutput: sourcemap ? sourcemapOutput : '', forceHermes: normalized.hermes, hermesBase: hermesBase ? { ...hermesBase, appId } : undefined, resetCache: normalized.resetCache, @@ -327,24 +328,17 @@ export const bundleCommands = { }) : undefined; - if (normalized.name) { + const publishAndUploadSentry = async ( + payload: Omit< + PublishBundlePayload, + 'appId' | 'hermesBase' | 'sourcemap' + > = {}, + ) => { await publishBundleVersion(realOutput, platform, { appId: await getAppId(), - name: normalized.name, - description: normalized.description, - metaInfo: normalized.metaInfo, - packageId: normalized.packageId, - packageVersion: normalized.packageVersion, - minPackageVersion: normalized.minPackageVersion, - maxPackageVersion: normalized.maxPackageVersion, - packageVersionRange: normalized.packageVersionRange, - rollout: normalized.rollout, - dryRun: normalized.dryRun, hermesBase: baseMeta, - sourcemap: - normalized.sourcemap || bundleParams.sourcemap - ? sourcemapOutput - : undefined, + sourcemap: sourcemap ? sourcemapOutput : undefined, + ...payload, }); await uploadSentryArtifactsIfNeeded( bundleParams.sentry, @@ -357,32 +351,29 @@ export const bundleCommands = { sentryDist: normalized.sentryDist, }, ); + }; + + if (normalized.name) { + await publishAndUploadSentry({ + name: normalized.name, + description: normalized.description, + metaInfo: normalized.metaInfo, + packageId: normalized.packageId, + packageVersion: normalized.packageVersion, + minPackageVersion: normalized.minPackageVersion, + maxPackageVersion: normalized.maxPackageVersion, + packageVersionRange: normalized.packageVersionRange, + rollout: normalized.rollout, + dryRun: normalized.dryRun, + }); return; } - if (!getBooleanOption(options, 'no-interactive', false)) { - const v = await question(t('uploadBundlePrompt')); - if (v.toLowerCase() === 'y') { - await publishBundleVersion(realOutput, platform, { - appId: await getAppId(), - hermesBase: baseMeta, - sourcemap: - normalized.sourcemap || bundleParams.sourcemap - ? sourcemapOutput - : undefined, - }); - await uploadSentryArtifactsIfNeeded( - bundleParams.sentry, - normalized.bundleName, - normalized.intermediaDir, - sourcemapOutput, - platform, - { - sentryRelease: normalized.sentryRelease, - sentryDist: normalized.sentryDist, - }, - ); - } + if ( + !getBooleanOption(options, 'no-interactive', false) && + (await question(t('uploadBundlePrompt'))).toLowerCase() === 'y' + ) { + await publishAndUploadSentry(); } }, }; From cde94f15d702e68c808fbf48afead58972433031 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:38:17 +0800 Subject: [PATCH 17/20] refactor(dep-versions): drop the depVersions Proxy Its one remaining user (the bundle banner) reads a single dependency, which getDepVersion() answers with two file reads instead of resolving the whole map; the sorted map is built with Object.fromEntries. Co-Authored-By: Claude Opus 5.5 --- src/bundle.ts | 6 ++++-- src/utils/dep-versions.ts | 40 +++++--------------------------------- tests/dep-versions.test.ts | 5 +---- 3 files changed, 10 insertions(+), 41 deletions(-) diff --git a/src/bundle.ts b/src/bundle.ts index d4f62e0..6d72af1 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -12,7 +12,7 @@ import { checkPlugins, question, translateOptions } from './utils'; import { addGitIgnore } from './utils/add-gitignore'; import { checkLockFiles } from './utils/check-lockfile'; import { tempDir } from './utils/constants'; -import { depVersions } from './utils/dep-versions'; +import { getDepVersion } from './utils/dep-versions'; import { cleanStaleTmp, type HermesBaseMeta, @@ -296,7 +296,9 @@ export const bundleCommands = { const sourcemap = normalized.sourcemap || bundleParams.sourcemap; - console.log(t('bundlingWithRN', { version: depVersions['react-native'] })); + console.log( + t('bundlingWithRN', { version: getDepVersion('react-native') }), + ); const hermesResult = await runReactNativeBundleCommand({ bundleName: normalized.bundleName, diff --git a/src/utils/dep-versions.ts b/src/utils/dep-versions.ts index 8552de9..61f0441 100644 --- a/src/utils/dep-versions.ts +++ b/src/utils/dep-versions.ts @@ -77,15 +77,11 @@ function readDepVersions(cwd: string): Record { } // sorted keys: the object is sent to the server as-is and diffed - return Object.keys(versions) - .sort() - .reduce( - (obj, key) => { - obj[key] = versions[key]; - return obj; - }, - {} as Record, - ); + return Object.fromEntries( + Object.keys(versions) + .sort() + .map((key) => [key, versions[key]]), + ); } /** Installed versions of the cwd project's dependencies, keys sorted. */ @@ -118,29 +114,3 @@ export function getDepVersion( } return readInstalledVersion(name, resolvedCwd); } - -/** - * Same data as `getDepVersions()` but as a lazily populated object, for the - * remaining property-access call sites (`depVersions['react-native']`). - * Nothing is resolved until the first property access. - */ -export const depVersions: Record = new Proxy( - {} as Record, - { - get: (_target, key) => - typeof key === 'string' ? getDepVersions()[key] : undefined, - has: (_target, key) => typeof key === 'string' && key in getDepVersions(), - ownKeys: () => Object.keys(getDepVersions()), - getOwnPropertyDescriptor: (_target, key) => { - if (typeof key !== 'string' || !(key in getDepVersions())) { - return undefined; - } - return { - value: getDepVersions()[key], - writable: false, - enumerable: true, - configurable: true, - }; - }, - }, -); diff --git a/tests/dep-versions.test.ts b/tests/dep-versions.test.ts index 7f9de14..2f1f289 100644 --- a/tests/dep-versions.test.ts +++ b/tests/dep-versions.test.ts @@ -40,7 +40,7 @@ describe('depVersions utility', () => { return module.getDepVersions(); }; - test('resolves lazily, once, and exposes the same data through depVersions', async () => { + test('resolves lazily and once', async () => { fs.writeFileSync( path.join(testDir, 'package.json'), JSON.stringify({ dependencies: { lazy: '^1.0.0' } }), @@ -63,9 +63,6 @@ describe('depVersions utility', () => { expect(cwdSpy).not.toHaveBeenCalled(); expect(module.getDepVersions()).toEqual({ lazy: '1.2.3' }); - expect(module.depVersions.lazy).toBe('1.2.3'); - expect(Object.keys(module.depVersions)).toEqual(['lazy']); - expect({ ...module.depVersions }).toEqual({ lazy: '1.2.3' }); // memoized while the project directory is unchanged expect(module.getDepVersions()).toBe(module.getDepVersions()); From 3048b3e874653eefa9c4eb3a6d122c80ca53e648 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:39:09 +0800 Subject: [PATCH 18/20] refactor(zip-range): one local-header parser, one location check locationOfEntry and remoteEntryHead each validated the local file header signature and summed its name/extra lengths; fetchZipEntryData and fetchZipEntryHead each validated the bundle location by hand. Co-Authored-By: Claude Opus 5.5 --- src/utils/zip-range.ts | 73 ++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/src/utils/zip-range.ts b/src/utils/zip-range.ts index 3efbfc8..eeb47a7 100644 --- a/src/utils/zip-range.ts +++ b/src/utils/zip-range.ts @@ -79,6 +79,34 @@ export async function readZipEntryWithLocation( return { data, location: await locationOfEntry(zipPath, found.entry) }; } +/** Where an entry's data starts, from its local file header at `headerOffset`. */ +function dataOffsetFromLocalHeader(header: Buffer, headerOffset: number) { + if (header.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) { + throw new Error('invalid local file header signature'); + } + // fixed header, then the file name and extra field lengths it declares + return ( + headerOffset + + LOCAL_HEADER_SIZE + + header.readUInt16LE(26) + + header.readUInt16LE(28) + ); +} + +function assertValidLocation({ + dataOffset, + compressedSize, +}: Pick) { + if ( + !Number.isInteger(dataOffset) || + !Number.isInteger(compressedSize) || + dataOffset < 0 || + compressedSize <= 0 + ) { + throw new Error('invalid bundle location'); + } +} + async function locationOfEntry( zipPath: string, entry: Entry, @@ -98,18 +126,12 @@ async function locationOfEntry( } finally { await handle.close(); } - if (header.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) { - throw new Error('invalid local file header signature'); - } - const fileNameLength = header.readUInt16LE(26); - const extraFieldLength = header.readUInt16LE(28); return { fileName: entry.fileName, - dataOffset: - entry.relativeOffsetOfLocalHeader + - LOCAL_HEADER_SIZE + - fileNameLength + - extraFieldLength, + dataOffset: dataOffsetFromLocalHeader( + header, + entry.relativeOffsetOfLocalHeader, + ), compressedSize: entry.compressedSize, uncompressedSize: entry.uncompressedSize, compressionMethod: entry.compressionMethod, @@ -303,14 +325,7 @@ export async function fetchZipEntryData( if (compressionMethod !== ZIP_STORED && compressionMethod !== ZIP_DEFLATED) { throw new Error(`unsupported compression method ${compressionMethod}`); } - if ( - !Number.isInteger(dataOffset) || - !Number.isInteger(compressedSize) || - dataOffset < 0 || - compressedSize <= 0 - ) { - throw new Error('invalid bundle location'); - } + assertValidLocation(location); const { data, total } = await fetchRangeBuffer( url, dataOffset, @@ -368,14 +383,7 @@ export async function fetchZipEntryHead( options: RangeOptions = {}, ): Promise { const { dataOffset, compressedSize, compressionMethod } = location; - if ( - !Number.isInteger(dataOffset) || - !Number.isInteger(compressedSize) || - dataOffset < 0 || - compressedSize <= 0 - ) { - throw new Error('invalid bundle location'); - } + assertValidLocation(location); const size = headProbeSize(compressedSize, compressionMethod, bytes); const { data } = await fetchRangeBuffer(url, dataOffset, dataOffset + size, { timeoutMs: timeoutFor(size, options), @@ -737,15 +745,10 @@ async function remoteEntryHead( bytes: number, ): Promise { const start = entry.relativeOffsetOfLocalHeader; - const header = await readAt(reader, start, LOCAL_HEADER_SIZE); - if (header.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) { - throw new Error('invalid local file header signature'); - } - const dataOffset = - start + - LOCAL_HEADER_SIZE + - header.readUInt16LE(26) + - header.readUInt16LE(28); + const dataOffset = dataOffsetFromLocalHeader( + await readAt(reader, start, LOCAL_HEADER_SIZE), + start, + ); const size = headProbeSize( entry.compressedSize, entry.compressionMethod, From aa4e6116e6b9b9836b322d208020e4574e05f097 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:41:03 +0800 Subject: [PATCH 19/20] refactor: small simplifications across utils - zip-options: the 15-branch already-compressed magic check becomes a signature table plus the three offset/bit checks (identical results on ~200k sampled inputs) - check-plugin: the one-entry plugin registry (plugin-config) becomes a direct Sentry check; drop the pluginDetectionError key it no longer needs - bundle-runner: assertSafeToEmpty reuses its own contains() helper - drop a commented-out import and a redundant access() before readFile in scripts/prepublish Co-Authored-By: Claude Opus 5.5 --- scripts/prepublish.ts | 8 +-- src/bundle-runner.ts | 7 +-- src/locales/en.ts | 1 - src/locales/zh.ts | 1 - src/utils/add-gitignore.ts | 1 - src/utils/check-plugin.ts | 50 ++++++------------ src/utils/plugin-config.ts | 32 ----------- src/utils/zip-options.ts | 93 +++++++++++--------------------- tests/check-plugin.test.ts | 98 +++++++++++++--------------------- tests/plugin-config.test.ts | 102 ------------------------------------ 10 files changed, 88 insertions(+), 305 deletions(-) delete mode 100644 src/utils/plugin-config.ts delete mode 100644 tests/plugin-config.test.ts diff --git a/scripts/prepublish.ts b/scripts/prepublish.ts index b79a454..cd6283b 100644 --- a/scripts/prepublish.ts +++ b/scripts/prepublish.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { access, readFile, writeFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { $ } from 'bun'; @@ -11,12 +11,6 @@ async function modifyPackageJson({ }): Promise { const packageJsonPath = path.join(__dirname, '..', 'package.json'); - try { - await access(packageJsonPath); - } catch { - throw new Error(`package.json not found at ${packageJsonPath}`); - } - console.log('Reading package.json...'); const packageJsonContent = await readFile(packageJsonPath, 'utf-8'); const packageJson = JSON.parse(packageJsonContent); diff --git a/src/bundle-runner.ts b/src/bundle-runner.ts index 7d3d241..323a647 100644 --- a/src/bundle-runner.ts +++ b/src/bundle-runner.ts @@ -504,11 +504,8 @@ export function assertSafeToEmpty(dir: string, cwd = process.cwd()): string { [lexicalHome, canonicalHome], [lexicalTmp, canonicalTmp], ] as const) { - const relative = path.relative(lexicalBase, requested); - if ( - relative === '' || - (!relative.startsWith('..') && !path.isAbsolute(relative)) - ) { + if (contains(lexicalBase, requested)) { + const relative = path.relative(lexicalBase, requested); if (!samePath(path.resolve(canonicalBase, relative), target)) { reject(target); } diff --git a/src/locales/en.ts b/src/locales/en.ts index e3f092d..39dde50 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -117,7 +117,6 @@ This can reduce the risk of inconsistent dependencies and supply chain attacks. pausedStatus: '(Paused)', platform: 'Platform', platformQuestion: 'Platform(ios/android/harmony):', - pluginDetectionError: 'error while detecting {{name}} plugin: {{error}}', pluginDetected: 'detected {{name}} plugin', publishUsage: 'Usage: pushy publish --platform ios|android|harmony', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 6d26bff..67da099 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -108,7 +108,6 @@ export default { pausedStatus: '(已暂停)', platform: '平台', platformQuestion: '平台(ios/android/harmony):', - pluginDetectionError: '检测 {{name}} 插件时出错:{{error}}', pluginDetected: '检测到 {{name}} 插件', sourceMapNotFound: '找不到 source map:{{path}}', sourceMapMissingWarning: diff --git a/src/utils/add-gitignore.ts b/src/utils/add-gitignore.ts index 24a6e86..963610b 100644 --- a/src/utils/add-gitignore.ts +++ b/src/utils/add-gitignore.ts @@ -1,5 +1,4 @@ import fs from 'fs'; -// import path from 'path'; import { credentialFile, tempDir } from './constants'; import { t } from './i18n'; diff --git a/src/utils/check-plugin.ts b/src/utils/check-plugin.ts index 08d9c03..c45ec4e 100644 --- a/src/utils/check-plugin.ts +++ b/src/utils/check-plugin.ts @@ -1,38 +1,22 @@ +import { pathExists } from './fs-helpers'; import { t } from './i18n'; -import { plugins } from './plugin-config'; -interface BundleParams { +/** + * Bundle params implied by the project's plugins. Sentry (a sentry.properties + * under ios/ or android/) turns on its source map upload, and with it the + * source map itself. + */ +export async function checkPlugins(): Promise<{ sentry: boolean; sourcemap: boolean; - [key: string]: any; -} - -export async function checkPlugins(): Promise { - const params: BundleParams = { - sentry: false, - sourcemap: false, - }; - - const results = await Promise.all( - plugins.map(async (plugin) => { - try { - const isEnabled = await plugin.detect(); - return { isEnabled, error: null }; - } catch (error) { - return { isEnabled: false, error }; - } - }), - ); - - results.forEach(({ isEnabled, error }, index) => { - const plugin = plugins[index]; - if (error) { - console.warn(t('pluginDetectionError', { name: plugin.name, error })); - } else if (isEnabled && plugin.bundleParams) { - Object.assign(params, plugin.bundleParams); - console.log(t('pluginDetected', { name: plugin.name })); - } - }); - - return params; +}> { + const [ios, android] = await Promise.all([ + pathExists('ios/sentry.properties'), + pathExists('android/sentry.properties'), + ]); + const sentry = ios || android; + if (sentry) { + console.log(t('pluginDetected', { name: 'sentry' })); + } + return { sentry, sourcemap: sentry }; } diff --git a/src/utils/plugin-config.ts b/src/utils/plugin-config.ts deleted file mode 100644 index 7af9d8f..0000000 --- a/src/utils/plugin-config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import fs from 'fs'; - -interface PluginConfig { - name: string; - bundleParams?: { - [key: string]: any; - }; - detect: () => Promise; -} - -export const plugins: PluginConfig[] = [ - { - name: 'sentry', - bundleParams: { - sentry: true, - sourcemap: true, - }, - detect: async () => { - try { - await fs.promises.access('ios/sentry.properties'); - return true; - } catch { - try { - await fs.promises.access('android/sentry.properties'); - return true; - } catch { - return false; - } - } - }, - }, -]; diff --git a/src/utils/zip-options.ts b/src/utils/zip-options.ts index c09e2a5..000f9f9 100644 --- a/src/utils/zip-options.ts +++ b/src/utils/zip-options.ts @@ -55,72 +55,41 @@ function hasHermesBytecodeMagic(bytes: Buffer): boolean { return startsWith(bytes, HBC_MAGIC) || startsWith(bytes, HERMES_DELTA_MAGIC); } +/** leading bytes of formats that are already compressed */ +const COMPRESSED_MAGIC = [ + [0x89, 0x50, 0x4e, 0x47], // PNG + [0xff, 0xd8, 0xff], // JPEG + 'GIF87a', + 'GIF89a', + [0x50, 0x4b, 0x03, 0x04], // zip + [0x50, 0x4b, 0x05, 0x06], // empty zip + [0x50, 0x4b, 0x07, 0x08], // spanned zip + [0x1f, 0x8b], // gzip + 'BZh', + [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00], // xz + [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c], // 7z + [0x28, 0xb5, 0x2f, 0xfd], // zstd + 'OggS', + 'ID3', // mp3 with tags + 'wOFF', + 'wOF2', +].map((magic) => Buffer.from(magic as string)); + function hasAlreadyCompressedMagic(bytes: Buffer): boolean { if (bytes.length < 2) { return false; } - - if (startsWith(bytes, Buffer.from([0x89, 0x50, 0x4e, 0x47]))) { - return true; - } - if (startsWith(bytes, Buffer.from([0xff, 0xd8, 0xff]))) { - return true; - } - if ( - startsWith(bytes, Buffer.from('GIF87a', 'ascii')) || - startsWith(bytes, Buffer.from('GIF89a', 'ascii')) - ) { - return true; - } - if ( - bytes.length >= 12 && - bytes.subarray(0, 4).equals(Buffer.from('RIFF', 'ascii')) && - bytes.subarray(8, 12).equals(Buffer.from('WEBP', 'ascii')) - ) { - return true; - } - if ( - startsWith(bytes, Buffer.from([0x50, 0x4b, 0x03, 0x04])) || - startsWith(bytes, Buffer.from([0x50, 0x4b, 0x05, 0x06])) || - startsWith(bytes, Buffer.from([0x50, 0x4b, 0x07, 0x08])) - ) { - return true; - } - if (startsWith(bytes, Buffer.from([0x1f, 0x8b]))) { - return true; - } - if (startsWith(bytes, Buffer.from('BZh', 'ascii'))) { - return true; - } - if (startsWith(bytes, Buffer.from([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]))) { - return true; - } - if (startsWith(bytes, Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]))) { - return true; - } - if (startsWith(bytes, Buffer.from([0x28, 0xb5, 0x2f, 0xfd]))) { - return true; - } - if (bytes.length >= 12 && bytes.subarray(4, 8).equals(Buffer.from('ftyp'))) { - return true; - } - if (startsWith(bytes, Buffer.from('OggS', 'ascii'))) { - return true; - } - if (startsWith(bytes, Buffer.from('ID3', 'ascii'))) { - return true; - } - if (bytes[0] === 0xff && bytes.length >= 2 && (bytes[1] & 0xe0) === 0xe0) { - return true; - } - if ( - startsWith(bytes, Buffer.from('wOFF', 'ascii')) || - startsWith(bytes, Buffer.from('wOF2', 'ascii')) - ) { - return true; - } - - return false; + const at = (offset: number, ascii: string) => + bytes.subarray(offset, offset + ascii.length).equals(Buffer.from(ascii)); + return ( + COMPRESSED_MAGIC.some((magic) => startsWith(bytes, magic)) || + // WebP: RIFF container with a WEBP form type + (bytes.length >= 12 && at(0, 'RIFF') && at(8, 'WEBP')) || + // ISO base media (mp4, m4a, heic, avif...) + (bytes.length >= 12 && at(4, 'ftyp')) || + // MPEG audio frame sync + (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) + ); } function readFilePrefix(filePath: string): Buffer { diff --git a/tests/check-plugin.test.ts b/tests/check-plugin.test.ts index 965478a..80052de 100644 --- a/tests/check-plugin.test.ts +++ b/tests/check-plugin.test.ts @@ -1,69 +1,45 @@ -import { describe, expect, test } from 'bun:test'; - +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { checkPlugins } from '../src/utils/check-plugin'; -import * as pluginConfig from '../src/utils/plugin-config'; describe('checkPlugins', () => { - test('should detect plugins concurrently (simulated)', async () => { - const mockPlugins = [ - { - name: 'plugin1', - bundleParams: { p1: true }, - detect: async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - return true; - }, - }, - { - name: 'plugin2', - bundleParams: { p2: true }, - detect: async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - return true; - }, - }, - { - name: 'plugin3', - bundleParams: { p3: true }, - detect: async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - return true; - }, - }, - ]; - - // Replacing the plugins array in plugin-config - const originalPlugins = [...pluginConfig.plugins]; - (pluginConfig.plugins as any).splice( - 0, - pluginConfig.plugins.length, - ...mockPlugins, - ); - - const start = Date.now(); - const result = await checkPlugins(); - const end = Date.now(); - const duration = end - start; - - console.log(`Duration with optimized implementation: ${duration}ms`); - - expect(result).toEqual({ - sentry: false, // default - sourcemap: false, // default - p1: true, - p2: true, - p3: true, - } as any); + let tmpDir: string; + let origCwd: string; + let logSpy: ReturnType; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-plugin-test-')); + origCwd = process.cwd(); + process.chdir(tmpDir); + logSpy = spyOn(console, 'log').mockImplementation(() => {}); + }); - // Now it's concurrent, so we expect around 100ms. - // We'll allow some buffer, but it should definitely be less than 250ms. - expect(duration).toBeLessThan(250); + afterEach(() => { + process.chdir(origCwd); + logSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); - // Restore original plugins - (pluginConfig.plugins as any).splice( - 0, - pluginConfig.plugins.length, - ...originalPlugins, + const writeProperties = (platform: string) => { + fs.mkdirSync(path.join(tmpDir, platform), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, platform, 'sentry.properties'), + 'defaults.org=test\n', ); + }; + + test('nothing is enabled without sentry.properties', async () => { + expect(await checkPlugins()).toEqual({ sentry: false, sourcemap: false }); + expect(logSpy).not.toHaveBeenCalled(); }); + + for (const platform of ['ios', 'android']) { + test(`${platform}/sentry.properties enables sentry and source maps`, async () => { + writeProperties(platform); + expect(await checkPlugins()).toEqual({ sentry: true, sourcemap: true }); + expect(logSpy).toHaveBeenCalledTimes(1); + }); + } }); diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts deleted file mode 100644 index c0eb2f6..0000000 --- a/tests/plugin-config.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { ensureDir, remove } from '../src/utils/fs-helpers'; -import { plugins } from '../src/utils/plugin-config'; - -describe('plugin-config - sentry plugin', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'plugin-config-test-'), - ); - }); - - afterEach(async () => { - await remove(tmpDir); - }); - - const sentryPlugin = plugins.find((p) => p.name === 'sentry'); - - test('sentry plugin exists in the plugins array', () => { - expect(sentryPlugin).toBeDefined(); - }); - - test('sentry bundleParams are { sentry: true, sourcemap: true }', () => { - expect(sentryPlugin?.bundleParams).toEqual({ - sentry: true, - sourcemap: true, - }); - }); - - describe('sentry detect', () => { - test('returns false when no sentry.properties exists', async () => { - const origCwd = process.cwd(); - process.chdir(tmpDir); - try { - const result = await sentryPlugin?.detect(); - expect(result).toBe(false); - } finally { - process.chdir(origCwd); - } - }); - - test('returns true when ios/sentry.properties exists', async () => { - await ensureDir(path.join(tmpDir, 'ios')); - await fs.promises.writeFile( - path.join(tmpDir, 'ios', 'sentry.properties'), - 'defaults.org=test\n', - ); - - const origCwd = process.cwd(); - process.chdir(tmpDir); - try { - const result = await sentryPlugin?.detect(); - expect(result).toBe(true); - } finally { - process.chdir(origCwd); - } - }); - - test('returns true when android/sentry.properties exists', async () => { - await ensureDir(path.join(tmpDir, 'android')); - await fs.promises.writeFile( - path.join(tmpDir, 'android', 'sentry.properties'), - 'defaults.org=test\n', - ); - - const origCwd = process.cwd(); - process.chdir(tmpDir); - try { - const result = await sentryPlugin?.detect(); - expect(result).toBe(true); - } finally { - process.chdir(origCwd); - } - }); - - test('returns true when both ios and android sentry.properties exist', async () => { - await ensureDir(path.join(tmpDir, 'ios')); - await ensureDir(path.join(tmpDir, 'android')); - await fs.promises.writeFile( - path.join(tmpDir, 'ios', 'sentry.properties'), - 'defaults.org=test\n', - ); - await fs.promises.writeFile( - path.join(tmpDir, 'android', 'sentry.properties'), - 'defaults.org=test\n', - ); - - const origCwd = process.cwd(); - process.chdir(tmpDir); - try { - const result = await sentryPlugin?.detect(); - expect(result).toBe(true); - } finally { - process.chdir(origCwd); - } - }); - }); -}); From 2af013ba4c0f19ff09181a4cc50b7633e99a957b Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:43:59 +0800 Subject: [PATCH 20/20] fix(zip): handle writePromise rejection from the start A write-stream error (ENOSPC, EACCES, output path is a directory) can come while diff or RepackSink is still building entries, long before anyone awaits writePromise; with no handler attached yet it surfaced as an unhandled rejection and ended the process before abort() could clean up. The no-op handler now goes on at creation; awaiting the promise still rejects. Co-Authored-By: Claude Opus 5.5 --- src/utils/zip-entries.ts | 5 ++++- tests/zip-entries.test.ts | 25 ++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/utils/zip-entries.ts b/src/utils/zip-entries.ts index 92fb074..da931d6 100644 --- a/src/utils/zip-entries.ts +++ b/src/utils/zip-entries.ts @@ -159,9 +159,12 @@ export function createOutputZip(output: string) { resolve(void 0); }); }); + // a write error can come while the caller is still building entries, long + // before it awaits writePromise: mark it handled now (awaiting the original + // promise still rejects) so it never surfaces as an unhandled rejection + writePromise.catch(() => {}); // 失败时释放输出 fd 并删掉半成品,避免残留一个截断的包 const abort = async () => { - writePromise.catch(() => {}); zipfile.outputStream.unpipe(writeStream); // yazl 的 outputStream 类型声明缺 destroy,运行时是 PassThrough (zipfile.outputStream as unknown as { destroy(): void }).destroy(); diff --git a/tests/zip-entries.test.ts b/tests/zip-entries.test.ts index bd819b4..ced919c 100644 --- a/tests/zip-entries.test.ts +++ b/tests/zip-entries.test.ts @@ -4,7 +4,7 @@ import os from 'os'; import path from 'path'; import { ZipFile } from 'yazl'; import { removeSync } from '../src/utils/fs-helpers'; -import { enumZipEntries } from '../src/utils/zip-entries'; +import { createOutputZip, enumZipEntries } from '../src/utils/zip-entries'; function zipBuffer(entries: Record): Promise { const zip = new ZipFile(); @@ -81,3 +81,26 @@ describe('enumZipEntries', () => { expect(nestedTempDirs()).toEqual(before); }); }); + +describe('createOutputZip', () => { + test('a write error before writePromise is awaited is not an unhandled rejection', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-output-zip-')); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + // a directory where the zip should go: the write stream fails at once, + // while the caller would still be adding entries + const output = path.join(dir, 'out.ppk'); + fs.mkdirSync(output); + const { writePromise, abort } = createOutputZip(output); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(unhandled).toEqual([]); + await expect(writePromise).rejects.toMatchObject({ code: 'EISDIR' }); + await abort(); + } finally { + process.off('unhandledRejection', onUnhandled); + removeSync(dir); + } + }); +});