Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<ScreenshotDisplayType, AppScreenshotSet>();
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,
Expand Down
14 changes: 14 additions & 0 deletions packages/eas-cli/src/metadata/apple/tasks/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}}`);
Expand Down
74 changes: 74 additions & 0 deletions packages/eas-cli/src/metadata/utils/__tests__/png.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
80 changes: 80 additions & 0 deletions packages/eas-cli/src/metadata/utils/png.ts
Original file line number Diff line number Diff line change
@@ -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)
)
);
}
Loading