diff --git a/CHANGELOG.md b/CHANGELOG.md index a695366585..5651b348fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes - [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Fix `metadata:pull` rewriting unchanged screenshots on every pull. App Store Connect stamps a unique asset ID into each download's PNG metadata chunks; pulls now skip writing when the local file only differs in those volatile chunks. ([#3804](https://github.com/expo/eas-cli/issues/3804) by [@ahmdshrif](https://github.com/ahmdshrif)) ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/metadata/apple/tasks/__tests__/screenshots.test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/screenshots.test.ts index b281579af4..14c004cd23 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/__tests__/screenshots.test.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/__tests__/screenshots.test.ts @@ -27,6 +27,8 @@ jest.spyOn(fs, 'existsSync').mockReturnValue(true); jest.spyOn(fs, 'statSync').mockReturnValue({ size: 1024 } as any); jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); +// Default: pretend the local file differs so downloads write as before. +jest.spyOn(fs.promises, 'readFile').mockResolvedValue(Buffer.from('existing-local-file')); describe(ScreenshotsTask, () => { beforeEach(() => { @@ -171,6 +173,64 @@ describe(ScreenshotsTask, () => { }); }); + it('skips rewriting a local screenshot that differs only in volatile PNG metadata', async () => { + const writer = jest.mocked(new AppleConfigWriter()); + + const pngChunk = (type: string, data: Buffer): Buffer => { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + return Buffer.concat([length, Buffer.from(type, 'latin1'), data, Buffer.alloc(4)]); + }; + const pngWith = (metadata: string): Buffer => + Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk('IHDR', Buffer.alloc(13, 1)), + pngChunk('iTXt', Buffer.from(metadata)), + pngChunk('IDAT', Buffer.from('same-pixel-data')), + pngChunk('IEND', Buffer.alloc(0)), + ]); + + // Same pixels, different App Store Connect asset ID in the iTXt chunk. + jest.mocked(fs.promises.readFile).mockResolvedValueOnce(pngWith('asset-id-AAAA')); + mockFetch.mockResolvedValue({ + ok: true, + buffer: () => Promise.resolve(pngWith('asset-id-BBBB')), + }); + + const screenshot = new AppScreenshot(requestContext, 'SS_1', { + fileName: 'home.png', + fileSize: 1024, + assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] }, + } as any); + jest.spyOn(screenshot, 'getImageAssetUrl').mockReturnValue('https://example.com/home.png'); + + const displayTypeMap = new Map(); + displayTypeMap.set( + ScreenshotDisplayType.APP_IPHONE_67, + new AppScreenshotSet(requestContext, 'SET_1', { + screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67, + appScreenshots: [screenshot], + } as any) + ); + const screenshotSets = new Map([['en-US', displayTypeMap]]); + const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', { + locale: 'en-US', + } as any); + + await new ScreenshotsTask().downloadAsync({ + config: writer, + context: { screenshotSets, versionLocales: [locale], projectDir: '/test/project' } as any, + }); + + // The file on disk is untouched, but the config entry is still written. + expect(fs.promises.writeFile).not.toBeCalled(); + expect(writer.setScreenshots).toBeCalledWith('en-US', { + [ScreenshotDisplayType.APP_IPHONE_67]: [ + 'store/apple/screenshot/en-US/APP_IPHONE_67/home.png', + ], + }); + }); + it('preserves entries with placeholder paths when imageAsset is null (broken state)', async () => { // Regression test for screenshots stuck in AWAITING_UPLOAD with no // rendered imageAsset. Pull used to drop these from config entirely, diff --git a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts index 6beb3a3cb5..dd7351f029 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts @@ -11,6 +11,7 @@ import path from 'path'; import fetch from '../../../fetch'; import Log from '../../../log'; import { logAsync } from '../../utils/log'; +import { isPngEquivalent } from '../../utils/png'; import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task'; import { AppleScreenshots } from '../types'; @@ -315,6 +316,19 @@ async function downloadScreenshotAsync( } const buffer = await response.buffer(); + + // App Store Connect stamps a unique asset resource ID into the image's + // metadata chunks on every download, so the bytes differ between pulls of + // the same screenshot. Skip the write when the local file only differs in + // those volatile chunks to keep repeated pulls idempotent. + if (fs.existsSync(outputPath)) { + const existing = await fs.promises.readFile(outputPath); + if (isPngEquivalent(existing, buffer)) { + Log.log(chalk`{dim Screenshot unchanged: ${relativePath}}`); + return relativePath; + } + } + await fs.promises.writeFile(outputPath, buffer); Log.log(chalk`{dim Downloaded screenshot: ${relativePath}}`); diff --git a/packages/eas-cli/src/metadata/utils/__tests__/png.test.ts b/packages/eas-cli/src/metadata/utils/__tests__/png.test.ts new file mode 100644 index 0000000000..6dde9f7d0b --- /dev/null +++ b/packages/eas-cli/src/metadata/utils/__tests__/png.test.ts @@ -0,0 +1,74 @@ +import { isPngEquivalent } from '../png'; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function chunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + // The comparison never validates CRCs, so a zeroed CRC keeps fixtures simple. + const crc = Buffer.alloc(4); + return Buffer.concat([length, Buffer.from(type, 'latin1'), data, crc]); +} + +function png(...chunks: Buffer[]): Buffer { + return Buffer.concat([PNG_SIGNATURE, ...chunks]); +} + +const ihdr = chunk('IHDR', Buffer.alloc(13, 1)); +const idat = chunk('IDAT', Buffer.from('pixel-data')); +const otherIdat = chunk('IDAT', Buffer.from('other-pixels')); +const iend = chunk('IEND', Buffer.alloc(0)); + +describe(isPngEquivalent, () => { + it('returns true for byte-identical buffers', () => { + const image = png(ihdr, idat, iend); + expect(isPngEquivalent(image, Buffer.from(image))).toBe(true); + }); + + it('returns true when images differ only in iTXt and eXIf metadata chunks', () => { + // App Store Connect stamps a unique asset resource ID into the XMP (iTXt) + // and EXIF (eXIf) chunks on every download. + const first = png( + ihdr, + chunk('eXIf', Buffer.from('asset-id-AAAAAAAAAAAAAAAAAAAAAAAAAA')), + idat, + chunk('iTXt', Buffer.from('XML:com.adobe.xmp\0\0\0\0\0dc:creator=AAAA')), + iend + ); + const second = png( + ihdr, + chunk('eXIf', Buffer.from('asset-id-BBBBBBBBBBBBBBBBBBBBBBBBBB')), + idat, + chunk('iTXt', Buffer.from('XML:com.adobe.xmp\0\0\0\0\0dc:creator=BBBB')), + iend + ); + expect(isPngEquivalent(first, second)).toBe(true); + }); + + it('returns true when one image has metadata chunks and the other has none', () => { + const withMetadata = png(ihdr, chunk('tEXt', Buffer.from('Comment\0hello')), idat, iend); + const withoutMetadata = png(ihdr, idat, iend); + expect(isPngEquivalent(withMetadata, withoutMetadata)).toBe(true); + }); + + it('returns false when pixel data differs', () => { + expect(isPngEquivalent(png(ihdr, idat, iend), png(ihdr, otherIdat, iend))).toBe(false); + }); + + it('returns false when a non-volatile chunk differs', () => { + const first = png(ihdr, chunk('PLTE', Buffer.from([1, 2, 3])), idat, iend); + const second = png(ihdr, chunk('PLTE', Buffer.from([4, 5, 6])), idat, iend); + expect(isPngEquivalent(first, second)).toBe(false); + }); + + it('falls back to byte equality for non-PNG buffers', () => { + const jpegish = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]); + expect(isPngEquivalent(jpegish, Buffer.from(jpegish))).toBe(true); + expect(isPngEquivalent(jpegish, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 9, 9, 9]))).toBe(false); + }); + + it('returns false for a truncated PNG against a valid one', () => { + const valid = png(ihdr, idat, iend); + expect(isPngEquivalent(valid, valid.subarray(0, valid.length - 6))).toBe(false); + }); +}); diff --git a/packages/eas-cli/src/metadata/utils/png.ts b/packages/eas-cli/src/metadata/utils/png.ts new file mode 100644 index 0000000000..1280d3aab7 --- /dev/null +++ b/packages/eas-cli/src/metadata/utils/png.ts @@ -0,0 +1,80 @@ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** + * Ancillary PNG chunks that carry text or timestamp metadata, but no pixel data. + * App Store Connect embeds a unique asset resource ID into these chunks + * (`eXIf` UserComment and `iTXt` XMP) every time an asset is downloaded, so + * they differ between byte-identical images. + */ +const VOLATILE_CHUNK_TYPES = new Set(['tEXt', 'zTXt', 'iTXt', 'eXIf', 'tIME']); + +type PngChunk = { + type: string; + data: Buffer; +}; + +/** + * Parses the chunk sequence of a PNG buffer. + * Returns null when the buffer is not a structurally valid PNG. + */ +function parsePngChunks(buffer: Buffer): PngChunk[] | null { + if (buffer.length < PNG_SIGNATURE.length + 12) { + return null; + } + if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { + return null; + } + + const chunks: PngChunk[] = []; + let offset = PNG_SIGNATURE.length; + + while (offset < buffer.length) { + // Each chunk is: 4-byte length, 4-byte type, `length` bytes of data, 4-byte CRC. + if (offset + 12 > buffer.length) { + return null; + } + const length = buffer.readUInt32BE(offset); + const type = buffer.toString('latin1', offset + 4, offset + 8); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > buffer.length) { + return null; + } + chunks.push({ type, data: buffer.subarray(dataStart, dataEnd) }); + offset = dataEnd + 4; + if (type === 'IEND') { + break; + } + } + + return chunks; +} + +/** + * Compares two PNG buffers, ignoring volatile metadata chunks (`tEXt`, `zTXt`, + * `iTXt`, `eXIf`, `tIME`). Two PNGs are considered equivalent when their + * remaining chunk sequences โ€” including all pixel data โ€” are identical. + * + * Falls back to plain byte equality when either buffer is not a valid PNG. + */ +export function isPngEquivalent(a: Buffer, b: Buffer): boolean { + if (a.equals(b)) { + return true; + } + + const chunksA = parsePngChunks(a); + const chunksB = parsePngChunks(b); + if (chunksA == null || chunksB == null) { + return false; + } + + const stableA = chunksA.filter(chunk => !VOLATILE_CHUNK_TYPES.has(chunk.type)); + const stableB = chunksB.filter(chunk => !VOLATILE_CHUNK_TYPES.has(chunk.type)); + + return ( + stableA.length === stableB.length && + stableA.every( + (chunk, index) => chunk.type === stableB[index].type && chunk.data.equals(stableB[index].data) + ) + ); +}