Compare commits

..
Author SHA1 Message Date
evanbaconandClaude Opus 4.6 608bb669c2 feat(metadata): allow up to 3 previews per device type
Apple supports up to 3 video previews per device type, but the
metadata:push/pull pipeline previously hard-coded a single preview per
PreviewType. Lift that limitation by accepting an array of preview
configs alongside the existing single-config form (which still works
for backwards compat). Sync semantics now mirror screenshots: match by
filename + size, upload only changed entries, delete removed ones, and
reorder the set to match config order.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:36:03 -07:00
11 changed files with 547 additions and 660 deletions

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Allow up to 3 video previews per device type in `metadata:push` and `metadata:pull`. Each preview type now accepts an array of preview configs in addition to the existing single-config form (which remains supported for backwards compatibility). ([@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
+26 -41
View File
@@ -328,7 +328,7 @@
},
"previews": {
"type": "object",
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129). Each device type accepts a single preview config (string or object) or an array of up to 3 preview configs.",
"additionalProperties": {
"oneOf": [
{
@@ -348,6 +348,31 @@
},
"required": ["path"],
"additionalProperties": false
},
{
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Video file path (relative to project root)"
},
"previewFrameTimeCode": {
"type": "string",
"description": "Optional preview frame time code (e.g., '00:05:00' for 5 seconds)"
}
},
"required": ["path"],
"additionalProperties": false
}
]
}
}
]
}
@@ -800,39 +825,6 @@
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"AppleInAppPurchase": {
"type": "object",
"additionalProperties": false,
"description": "Basic listing of an In-App Purchase. Localizations, pricing, and review screenshots are intentionally out of scope for this schema iteration.",
"required": ["productId", "referenceName", "type"],
"properties": {
"productId": {
"type": "string",
"description": "Unique product identifier for the IAP. Acts as the primary key when reconciling against App Store Connect.",
"minLength": 1
},
"referenceName": {
"type": "string",
"description": "Internal reference name shown in App Store Connect.",
"minLength": 1
},
"type": {
"enum": [
"CONSUMABLE",
"NON_CONSUMABLE",
"NON_RENEWING_SUBSCRIPTION",
"AUTO_RENEWABLE_SUBSCRIPTION",
"AUTOMATICALLY_RENEWABLE_SUBSCRIPTION",
"FREE_SUBSCRIPTION"
],
"description": "In-App Purchase type."
},
"state": {
"type": "string",
"description": "Read-only review state from App Store Connect. Only populated by `metadata:pull`; ignored on `metadata:push`."
}
}
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
@@ -1067,13 +1059,6 @@
"appClip": {
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"inAppPurchases": {
"type": "array",
"description": "Basic listing of In-App Purchases. Currently limited to a declarative round-trip of productId / referenceName / type. Pull is supported; push only reports diffs because the underlying apple-utils API is read-only for now.",
"items": {
"$ref": "#/definitions/apple/AppleInAppPurchase"
}
}
}
}
@@ -20,7 +20,6 @@ import {
AppleAppClip,
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleInAppPurchase,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -258,14 +257,4 @@ export class AppleConfigReader {
public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null {
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
}
/**
* Get the desired In-App Purchase listing from config, or null if the
* key is missing entirely. An empty array is preserved as an empty array
* (the user explicitly cleared their list) so callers can distinguish
* "unconfigured" from "intentionally empty".
*/
public getInAppPurchases(): AppleInAppPurchase[] | null {
return this.schema.inAppPurchases ?? null;
}
}
@@ -17,7 +17,6 @@ import {
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAppClipReviewDetail,
AppleInAppPurchase,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -212,11 +211,42 @@ export class AppleConfigWriter {
Object.keys(screenshots).length > 0 ? screenshots : undefined;
}
/** Set video previews for a specific locale */
/**
* Set video previews for a specific locale.
*
* Accepts either the legacy single-config form or the multi-preview array form
* for each preview type. When an array contains exactly one entry, it is
* unwrapped to the legacy single-object form for stable, minimal config diffs.
*/
public setPreviews(locale: string, previews: ApplePreviews): void {
this.schema.info = this.schema.info ?? {};
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
if (Object.keys(previews).length === 0) {
this.schema.info[locale].previews = undefined;
return;
}
const normalized: ApplePreviews = {};
for (const [previewType, value] of Object.entries(previews)) {
if (value == null) {
continue;
}
if (Array.isArray(value)) {
if (value.length === 0) {
continue;
}
if (value.length === 1) {
normalized[previewType as keyof ApplePreviews] = value[0];
} else {
normalized[previewType as keyof ApplePreviews] = value;
}
} else {
normalized[previewType as keyof ApplePreviews] = value;
}
}
this.schema.info[locale].previews = Object.keys(normalized).length > 0 ? normalized : undefined;
}
/** Set the App Clip default experience attributes (action, releaseWithAppStoreVersion). */
@@ -242,25 +272,6 @@ export class AppleConfigWriter {
}
}
/**
* Set the In-App Purchase listing. Sorts by `productId` for stable
* `metadata:pull` round-trips and drops the key entirely when there are
* no IAPs to avoid spamming `store.config.json` with empty arrays.
*/
public setInAppPurchases(entries: AppleInAppPurchase[]): void {
if (!entries || entries.length === 0) {
delete this.schema.inAppPurchases;
return;
}
const sorted = [...entries].sort((a, b) => a.productId.localeCompare(b.productId));
this.schema.inAppPurchases = sorted.map(entry => ({
productId: entry.productId,
referenceName: entry.referenceName,
type: entry.type,
...(entry.state ? { state: entry.state } : {}),
}));
}
/** Set per-locale App Clip info (subtitle + header image). */
public setAppClipLocalizedInfo(locale: string, info: AppleAppClipLocalizedInfo): void {
this.schema.appClip = this.schema.appClip ?? {};
+1 -3
View File
@@ -5,7 +5,6 @@ import type { AppClipData } from './tasks/app-clip';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
import type { InAppPurchasesData } from './tasks/in-app-purchases';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
@@ -19,8 +18,7 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
AppReviewData &
ScreenshotsData &
PreviewsData &
AppClipData &
InAppPurchasesData;
AppClipData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
@@ -1,284 +0,0 @@
import { App, InAppPurchase, InAppPurchaseState, InAppPurchaseType } from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData, PartialAppleData } from '../../data';
import { InAppPurchasesTask } from '../in-app-purchases';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makeIap(
id: string,
attrs: {
productId: string;
referenceName: string;
inAppPurchaseType?: InAppPurchaseType;
state?: InAppPurchaseState;
}
): InAppPurchase {
return new InAppPurchase(requestContext, id, {
productId: attrs.productId,
referenceName: attrs.referenceName,
inAppPurchaseType: attrs.inAppPurchaseType ?? InAppPurchaseType.NON_CONSUMABLE,
state: attrs.state ?? InAppPurchaseState.APPROVED,
} as any);
}
const IAPS_EMPTY_RESPONSE = {
data: [],
links: {
self: 'https://appstoreconnect.apple.com/iris/v1/apps/stub-id/inAppPurchases',
},
meta: { paging: { total: 0, limit: 50 } },
};
describe(InAppPurchasesTask, () => {
afterEach(() => {
nock.cleanAll();
});
describe('prepareAsync', () => {
it('initializes an empty map when the app has no IAPs', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(/\/apps\/stub-id\/inAppPurchases/)
.reply(200, IAPS_EMPTY_RESPONSE);
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new InAppPurchasesTask().prepareAsync({ context });
expect(context.inAppPurchases).toBeDefined();
expect(context.inAppPurchases?.size).toBe(0);
expect(scope.isDone()).toBeTruthy();
});
it('warns and falls back to empty map when ASC errors out', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(/\/apps\/stub-id\/inAppPurchases/)
.reply(500, { errors: [{ status: '500', title: 'boom' }] });
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new InAppPurchasesTask().prepareAsync({ context });
expect(context.inAppPurchases?.size).toBe(0);
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('writes nothing when the inventory is empty', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: { inAppPurchases: new Map() } as AppleData,
});
expect(writer.setInAppPurchases).toBeCalledWith([]);
});
it('writes a single IAP entry when one is registered', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const iap = makeIap('iap-1', {
productId: 'com.example.coins',
referenceName: 'Coins',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
state: InAppPurchaseState.APPROVED,
});
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: {
inAppPurchases: new Map([[iap.attributes.productId, iap]]),
} as AppleData,
});
expect(writer.setInAppPurchases).toBeCalledWith([
{
productId: 'com.example.coins',
referenceName: 'Coins',
type: 'CONSUMABLE',
state: 'APPROVED',
},
]);
});
it('writes multiple IAP entries when several are registered', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const a = makeIap('iap-a', {
productId: 'com.example.a',
referenceName: 'A',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
});
const b = makeIap('iap-b', {
productId: 'com.example.b',
referenceName: 'B',
inAppPurchaseType: InAppPurchaseType.NON_CONSUMABLE,
});
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: {
inAppPurchases: new Map([
['com.example.a', a],
['com.example.b', b],
]),
} as AppleData,
});
expect(writer.setInAppPurchases).toBeCalledTimes(1);
const arg = writer.setInAppPurchases.mock.calls[0][0];
expect(arg).toHaveLength(2);
expect(arg.map((entry: any) => entry.productId).sort()).toEqual([
'com.example.a',
'com.example.b',
]);
});
});
describe('uploadAsync', () => {
it('skips when no IAPs are configured', async () => {
const reader = new AppleConfigReader({});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: { inAppPurchases: new Map() } as AppleData,
});
// No HTTP calls should occur — the task is read-only.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('treats an existing matching IAP as a no-op', async () => {
const existing = makeIap('iap-1', {
productId: 'com.example.coins',
referenceName: 'Coins',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
});
const reader = new AppleConfigReader({
inAppPurchases: [
{
productId: 'com.example.coins',
referenceName: 'Coins',
type: 'CONSUMABLE',
},
],
});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: {
inAppPurchases: new Map([['com.example.coins', existing]]),
} as AppleData,
});
// Read-only push: no HTTP calls regardless of state.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('reports a would-create when config has a new IAP', async () => {
const reader = new AppleConfigReader({
inAppPurchases: [
{
productId: 'com.example.new',
referenceName: 'New IAP',
type: 'NON_CONSUMABLE',
},
],
});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: { inAppPurchases: new Map() } as AppleData,
});
// No HTTP calls — current implementation only warns.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('reports a would-rename when referenceName differs', async () => {
const existing = makeIap('iap-1', {
productId: 'com.example.coins',
referenceName: 'Coins',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
});
const reader = new AppleConfigReader({
inAppPurchases: [
{
productId: 'com.example.coins',
referenceName: 'Gold Coins',
type: 'CONSUMABLE',
},
],
});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: {
inAppPurchases: new Map([['com.example.coins', existing]]),
} as AppleData,
});
// No HTTP calls — current implementation only warns.
expect(nock.pendingMocks()).toHaveLength(0);
});
});
describe('round-trip', () => {
it('preserves productId / referenceName / type from pull to push', async () => {
// Pull: fetch from ASC and write to a real (un-mocked) writer.
jest.unmock('../../config/writer');
const { AppleConfigWriter: RealWriter } = jest.requireActual(
'../../config/writer'
);
const writer = new RealWriter();
const iap = makeIap('iap-1', {
productId: 'com.example.gem',
referenceName: 'Gem',
inAppPurchaseType: InAppPurchaseType.NON_CONSUMABLE,
state: InAppPurchaseState.APPROVED,
});
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: {
inAppPurchases: new Map([[iap.attributes.productId, iap]]),
} as AppleData,
});
const schema = writer.toSchema();
expect(schema.apple.inAppPurchases).toEqual([
{
productId: 'com.example.gem',
referenceName: 'Gem',
type: 'NON_CONSUMABLE',
state: 'APPROVED',
},
]);
// Push: feed the schema back through the reader and confirm the
// existing IAP is treated as already in sync (no warnings, no calls).
const reader = new AppleConfigReader(schema.apple);
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: {
inAppPurchases: new Map([[iap.attributes.productId, iap]]),
} as AppleData,
});
expect(nock.pendingMocks()).toHaveLength(0);
});
});
});
@@ -354,6 +354,17 @@ describe(PreviewsTask, () => {
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
// Mock AppPreviewSet.infoAsync for the reorder step
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
new AppPreviewSet(requestContext, 'NEW_PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [newPreview],
} as any)
);
const reorderMock = jest.fn().mockResolvedValue([]);
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
const previewSets = new Map();
previewSets.set('en-US', new Map());
@@ -375,6 +386,214 @@ describe(PreviewsTask, () => {
waitForProcessing: true,
})
);
// Single preview, current order matches: reorder should be skipped.
expect(reorderMock).not.toHaveBeenCalled();
});
it('uploads multiple previews when configured as an array', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: [
'./previews/intro.mp4',
{ path: './previews/features.mp4', previewFrameTimeCode: '00:02:00' },
'./previews/outro.mp4',
],
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const intro = new AppPreview(requestContext, 'NEW_PV_INTRO', {
fileName: 'intro.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const features = new AppPreview(requestContext, 'NEW_PV_FEATURES', {
fileName: 'features.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:02:00',
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const outro = new AppPreview(requestContext, 'NEW_PV_OUTRO', {
fileName: 'outro.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
.post(`/v1/${AppPreviewSet.type}`)
.reply(201, {
data: {
id: 'NEW_PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
});
const uploadSpy = jest
.spyOn(AppPreview, 'uploadAsync')
.mockResolvedValueOnce(intro)
.mockResolvedValueOnce(features)
.mockResolvedValueOnce(outro);
// After upload Apple returns the previews in upload order, which already matches config.
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
new AppPreviewSet(requestContext, 'NEW_PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [intro, features, outro],
} as any)
);
const reorderMock = jest.fn().mockResolvedValue([]);
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(uploadSpy).toHaveBeenCalledTimes(3);
expect(uploadSpy).toHaveBeenNthCalledWith(
1,
requestContext,
expect.objectContaining({ filePath: '/test/project/previews/intro.mp4' })
);
expect(uploadSpy).toHaveBeenNthCalledWith(
2,
requestContext,
expect.objectContaining({
filePath: '/test/project/previews/features.mp4',
previewFrameTimeCode: '00:02:00',
})
);
expect(uploadSpy).toHaveBeenNthCalledWith(
3,
requestContext,
expect.objectContaining({ filePath: '/test/project/previews/outro.mp4' })
);
// Order already matches config order, no reorder call expected.
expect(reorderMock).not.toHaveBeenCalled();
});
it('reorders previews when ASC order differs from config order', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: ['./previews/a.mp4', './previews/b.mp4'],
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
// Both previews already exist (matching filename + size), so no upload happens.
const previewA = new AppPreview(requestContext, 'PV_A', {
fileName: 'a.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const previewB = new AppPreview(requestContext, 'PV_B', {
fileName: 'b.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
// ASC reports them in [b, a] order — config wants [a, b].
previewTypeMap.set(
PreviewType.IPHONE_67,
new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [previewB, previewA],
} as any)
);
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(previewA);
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [previewB, previewA],
} as any)
);
const reorderMock = jest.fn().mockResolvedValue([]);
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(AppPreview.uploadAsync).not.toHaveBeenCalled();
expect(reorderMock).toHaveBeenCalledWith({ appPreviews: ['PV_A', 'PV_B'] });
});
it('skips a preview type whose array is empty', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: [],
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
jest.spyOn(AppPreview, 'uploadAsync');
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(AppPreview.uploadAsync).not.toHaveBeenCalled();
});
it('uploads new preview with object config including previewFrameTimeCode', async () => {
@@ -420,6 +639,16 @@ describe(PreviewsTask, () => {
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
new AppPreviewSet(requestContext, 'NEW_PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [newPreview],
} as any)
);
const reorderMock = jest.fn().mockResolvedValue([]);
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
const previewSets = new Map();
previewSets.set('en-US', new Map());
@@ -444,4 +673,67 @@ describe(PreviewsTask, () => {
);
});
});
describe('downloadAsync (multi-preview)', () => {
it('emits an array when there are multiple previews in a set', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const previews = [
new AppPreview(requestContext, 'PV_1', {
fileName: 'intro.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
videoUrl: 'https://example.com/intro.mp4',
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any),
new AppPreview(requestContext, 'PV_2', {
fileName: 'features.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:01:00',
videoUrl: 'https://example.com/features.mp4',
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any),
];
jest.spyOn(previews[0], 'getVideoUrl').mockReturnValue('https://example.com/intro.mp4');
jest.spyOn(previews[1], 'getVideoUrl').mockReturnValue('https://example.com/features.mp4');
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
previewTypeMap.set(
PreviewType.IPHONE_67,
new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: previews,
} as any)
);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
});
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets: new Map([['en-US', previewTypeMap]]),
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: [
'store/apple/preview/en-US/IPHONE_67/intro.mp4',
{
path: 'store/apple/preview/en-US/IPHONE_67/features.mp4',
previewFrameTimeCode: '00:01:00',
},
],
});
});
});
});
@@ -1,151 +0,0 @@
import { InAppPurchase } from '@expo/apple-utils';
import chalk from 'chalk';
import Log from '../../../log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
// TODO(expo/third-party#148): Once @expo/apple-utils is bumped to include the
// InAppPurchaseV2 and InAppPurchaseLocalization models, switch from v1
// read-only to v2 CRUD:
// - Use App.getInAppPurchasesV2Async() for listing instead of getInAppPurchasesAsync()
// - Use InAppPurchaseV2.createAsync() for creation
// - Use InAppPurchaseV2.updateAsync() for referenceName changes
// - Use InAppPurchaseLocalization for localized name/description round-trip
// (schema should add an optional `localizations` array per IAP entry)
//
// Note: auto-renewable subscriptions are a separate Apple resource
// (`subscriptionGroups` / `subscriptions`) and are intentionally out of scope
// for this task. The v2 InAppPurchaseV2Type enum only has CONSUMABLE,
// NON_CONSUMABLE, and NON_RENEWING_SUBSCRIPTION.
export type InAppPurchasesData = {
/**
* The list of in-app purchases registered for the app, keyed by `productId`.
* The map is empty when the app has no IAPs (or none are visible to the
* authenticated account).
*/
inAppPurchases: Map<string, InAppPurchase>;
};
/**
* Task for managing the basic listing of In-App Purchases (declarative
* round-trip of `productId`, `referenceName`, and `inAppPurchaseType`).
*
* Scope (intentional, see PR description):
* - Pull (download): writes the existing IAPs to `store.config.json`.
* - Push (upload): no-op for now. `@expo/apple-utils` only exposes
* read access to the deprecated v1 `inAppPurchases` resource — there is
* no create/update/delete and no v2 (`inAppPurchasesV2`) wrapper yet.
* When the user has IAPs in their config that don't exist (or differ) in
* ASC, we emit a warning so the round-trip is still informative without
* silently dropping intent.
*
* Explicitly out of scope for this iteration:
* - Localizations (no `InAppPurchaseLocalization` model in apple-utils)
* - Pricing, review screenshots, content hosting, family sharing
* - Deletes — we never delete IAPs that exist in ASC but are missing from
* config. Too dangerous for a first iteration.
*/
export class InAppPurchasesTask extends AppleTask {
public name = (): string => 'in-app purchases';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.inAppPurchases = new Map();
try {
const purchases = await context.app.getInAppPurchasesAsync();
for (const purchase of purchases) {
const productId = purchase.attributes.productId;
if (productId) {
context.inAppPurchases.set(productId, purchase);
}
}
} catch (error: any) {
// The IAP endpoint is on the iris API and may be unavailable for some
// accounts/apps (e.g. apps that have never had an IAP, or accounts
// without the right entitlements). Treat as empty rather than fatal.
Log.warn(
chalk`{yellow Skipped in-app purchases - failed to load from App Store Connect: ${error?.message ?? error}}`
);
}
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
const entries = Array.from(context.inAppPurchases.values()).map(purchase => ({
productId: purchase.attributes.productId,
referenceName: purchase.attributes.referenceName,
type: purchase.attributes.inAppPurchaseType as unknown as string,
state: (purchase.attributes.state as unknown as string) ?? undefined,
}));
config.setInAppPurchases(entries);
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const desired = config.getInAppPurchases();
if (!desired || desired.length === 0) {
Log.log(chalk`{dim - Skipped in-app purchases, none configured}`);
return;
}
// Match desired entries against the existing ASC inventory by productId.
// We never delete; we only report what would change. Apple's v1
// inAppPurchases endpoint exposed by @expo/apple-utils is read-only,
// so we can't actually create or patch records here.
const existing = context.inAppPurchases;
const toCreate: typeof desired = [];
const toUpdate: { productId: string; from: string; to: string }[] = [];
let unchanged = 0;
for (const entry of desired) {
const current = existing.get(entry.productId);
if (!current) {
toCreate.push(entry);
continue;
}
if (
entry.referenceName &&
current.attributes.referenceName !== entry.referenceName
) {
toUpdate.push({
productId: entry.productId,
from: current.attributes.referenceName,
to: entry.referenceName,
});
} else {
unchanged++;
}
}
if (unchanged > 0) {
Log.log(
chalk`{dim - In-app purchases: ${unchanged} already in sync}`
);
}
if (toCreate.length === 0 && toUpdate.length === 0) {
return;
}
// We intentionally only warn here. See class doc-comment.
Log.warn(
chalk`{yellow In-app purchase mutations are not yet supported by EAS metadata.}`
);
Log.warn(
chalk`{yellow IAP mutations require @expo/apple-utils with InAppPurchaseV2 support (see expo/third-party#148).}`
);
for (const entry of toCreate) {
Log.warn(
chalk` {yellow Would create IAP ${chalk.bold(entry.productId)} (${entry.type}) — ${entry.referenceName}}`
);
}
for (const entry of toUpdate) {
Log.warn(
chalk` {yellow Would rename IAP ${chalk.bold(entry.productId)}: ${entry.from} → ${entry.to}}`
);
}
Log.warn(
chalk`{yellow Apply these changes manually in App Store Connect for now.}`
);
}
}
@@ -3,7 +3,6 @@ import { AppClipTask } from './app-clip';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionOptions, AppVersionTask } from './app-version';
import { InAppPurchasesTask } from './in-app-purchases';
import { PreviewsTask } from './previews';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
@@ -24,6 +23,5 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new ScreenshotsTask(),
new PreviewsTask(),
new AppClipTask(),
new InAppPurchasesTask(),
];
}
@@ -22,19 +22,35 @@ export type PreviewsData = {
previewSets: PreviewSetsMap;
};
/**
* Normalize preview config to always return an object with path and optional previewFrameTimeCode.
*/
function normalizePreviewConfig(config: ApplePreviewConfig): {
/** Normalized preview entry used internally by the upload pipeline. */
type NormalizedPreviewConfig = {
path: string;
previewFrameTimeCode?: string;
} {
};
/**
* Normalize a single preview config into an object form with path and optional previewFrameTimeCode.
*/
function normalizePreviewConfig(config: ApplePreviewConfig): NormalizedPreviewConfig {
if (typeof config === 'string') {
return { path: config };
}
return config;
}
/**
* Normalize the value stored in `ApplePreviews[previewType]` (which can be a single
* config OR an array of configs) into an array of normalized configs.
*/
function normalizePreviewConfigs(
value: ApplePreviewConfig | ApplePreviewConfig[]
): NormalizedPreviewConfig[] {
if (Array.isArray(value)) {
return value.map(normalizePreviewConfig);
}
return [normalizePreviewConfig(value)];
}
/**
* Task for managing App Store video previews.
* Downloads existing previews and uploads new ones based on store configuration.
@@ -86,34 +102,44 @@ export class PreviewsTask extends AppleTask {
continue;
}
// For now, we only handle the first preview per set (App Store allows up to 3)
// We can extend this later to support multiple previews
const preview = previewModels[0];
const downloaded = await downloadPreviewAsync(
context.projectDir,
localeCode,
previewType,
preview,
0
);
// Download all previews in this set, preserving order. Mirrors how
// screenshots are handled: when a preview is in a broken state
// (AWAITING_UPLOAD with no rendered videoUrl) the download will fail,
// but we still preserve the entry pointing at its expected local path
// so users can either drop in a replacement file or remove the entry
// to delete the broken ASC record.
const entries: ApplePreviewConfig[] = [];
for (let i = 0; i < previewModels.length; i++) {
const preview = previewModels[i];
const downloaded = await downloadPreviewAsync(
context.projectDir,
localeCode,
previewType,
preview,
i
);
// When the download succeeds, write the real path. When it fails
// (e.g. the preview is in a broken AWAITING_UPLOAD state with no
// rendered videoUrl), preserve the entry in config so the user can
// either drop in a replacement file or remove the entry to delete
// the broken ASC record.
const fileName = preview.attributes.fileName || 'preview.mp4';
const relativePath =
downloaded || path.join('store', 'apple', 'preview', localeCode, previewType, fileName);
const fileName = preview.attributes.fileName || `${String(i + 1).padStart(2, '0')}.mp4`;
const relativePath =
downloaded || path.join('store', 'apple', 'preview', localeCode, previewType, fileName);
if (preview.attributes.previewFrameTimeCode) {
previews[previewType] = {
path: relativePath,
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
};
} else {
previews[previewType] = relativePath;
if (preview.attributes.previewFrameTimeCode) {
entries.push({
path: relativePath,
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
});
} else {
entries.push(relativePath);
}
}
if (entries.length === 0) {
continue;
}
// For backwards compatibility with existing single-preview configs,
// emit the legacy single-object form when there's exactly one entry.
previews[previewType] = entries.length === 1 ? entries[0] : entries;
}
if (Object.keys(previews).length > 0) {
@@ -146,8 +172,13 @@ export class PreviewsTask extends AppleTask {
continue;
}
for (const [previewType, previewConfig] of Object.entries(previews)) {
if (!previewConfig) {
for (const [previewType, previewValue] of Object.entries(previews)) {
if (!previewValue) {
continue;
}
const normalized = normalizePreviewConfigs(previewValue);
if (normalized.length === 0) {
continue;
}
@@ -155,7 +186,7 @@ export class PreviewsTask extends AppleTask {
context.projectDir,
localization,
previewType as PreviewType,
normalizePreviewConfig(previewConfig),
normalized,
context.previewSets.get(localeCode)
);
}
@@ -164,23 +195,20 @@ export class PreviewsTask extends AppleTask {
}
/**
* Sync a preview set - upload new preview, delete old one if changed.
* Sync a preview set against the configured previews.
*
* Mirrors the screenshots task: keeps unchanged previews (matched by filename
* + size + completion state), uploads any new/changed previews, deletes
* removed entries, and finally reorders the set to match config order.
*/
async function syncPreviewSetAsync(
projectDir: string,
localization: AppStoreVersionLocalization,
previewType: PreviewType,
previewConfig: { path: string; previewFrameTimeCode?: string },
previewConfigs: NormalizedPreviewConfig[],
existingSets: Map<PreviewType, AppPreviewSet> | undefined
): Promise<void> {
const locale = localization.attributes.locale;
const absolutePath = path.resolve(projectDir, previewConfig.path);
const fileName = path.basename(absolutePath);
if (!fs.existsSync(absolutePath)) {
Log.warn(chalk`{yellow Video preview not found: ${absolutePath}}`);
return;
}
// Get or create the preview set
let previewSet = existingSets?.get(previewType);
@@ -201,62 +229,127 @@ async function syncPreviewSetAsync(
const existingPreviews = previewSet.attributes.appPreviews || [];
// Check if we need to update (different filename, size, or no existing preview)
const existingPreview = existingPreviews.find(p => p.attributes.fileName === fileName);
const localSize = fs.statSync(absolutePath).size;
if (
existingPreview &&
existingPreview.isComplete() &&
existingPreview.attributes.fileSize === localSize
) {
// Preview with same filename exists, check if we need to update preview frame time code
if (
previewConfig.previewFrameTimeCode &&
existingPreview.attributes.previewFrameTimeCode !== previewConfig.previewFrameTimeCode
) {
await logAsync(
() =>
existingPreview.updateAsync({
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
}),
{
pending: `Updating preview frame time code for ${chalk.bold(fileName)} (${locale})...`,
success: `Updated preview frame time code for ${chalk.bold(fileName)} (${locale})`,
failure: `Failed updating preview frame time code for ${chalk.bold(fileName)} (${locale})`,
}
);
}
Log.log(chalk`{dim Preview ${fileName} already exists, skipping upload}`);
return;
}
// Delete existing previews that don't match
// Build a map of existing previews by filename for comparison.
const existingByFilename = new Map<string, AppPreview>();
for (const preview of existingPreviews) {
if (preview.attributes.fileName !== fileName) {
await logAsync(() => preview.deleteAsync(), {
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
});
}
existingByFilename.set(preview.attributes.fileName, preview);
}
// Upload new preview
await logAsync(
() =>
AppPreview.uploadAsync(localization.context, {
id: previewSet!.id,
filePath: absolutePath,
waitForProcessing: true,
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
}),
{
pending: `Uploading video preview ${chalk.bold(fileName)} (${locale})...`,
success: `Uploaded video preview ${chalk.bold(fileName)} (${locale})`,
failure: `Failed uploading video preview ${chalk.bold(fileName)} (${locale})`,
// Track which previews to keep (by id), and which configs need uploading.
const keptPreviewIds = new Set<string>();
type UploadEntry = {
absolutePath: string;
fileName: string;
previewFrameTimeCode?: string;
};
const toUpload: UploadEntry[] = [];
for (const previewConfig of previewConfigs) {
const absolutePath = path.resolve(projectDir, previewConfig.path);
const fileName = path.basename(absolutePath);
const existing = existingByFilename.get(fileName);
const localSize = fs.existsSync(absolutePath) ? fs.statSync(absolutePath).size : null;
if (
existing &&
existing.isComplete() &&
(localSize === null || existing.attributes.fileSize === localSize)
) {
keptPreviewIds.add(existing.id);
existingByFilename.delete(fileName);
// If only the previewFrameTimeCode changed, patch it in place.
if (
previewConfig.previewFrameTimeCode &&
existing.attributes.previewFrameTimeCode !== previewConfig.previewFrameTimeCode
) {
await logAsync(
() =>
existing.updateAsync({
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
}),
{
pending: `Updating preview frame time code for ${chalk.bold(fileName)} (${locale})...`,
success: `Updated preview frame time code for ${chalk.bold(fileName)} (${locale})`,
failure: `Failed updating preview frame time code for ${chalk.bold(fileName)} (${locale})`,
}
);
} else {
Log.log(chalk`{dim Preview ${fileName} already exists, skipping upload}`);
}
continue;
}
);
toUpload.push({
absolutePath,
fileName,
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
});
}
// Delete previews that are no longer in config.
for (const preview of existingByFilename.values()) {
await logAsync(() => preview.deleteAsync(), {
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
});
}
// Upload new previews.
for (const entry of toUpload) {
if (!fs.existsSync(entry.absolutePath)) {
Log.warn(chalk`{yellow Video preview not found: ${entry.absolutePath}}`);
continue;
}
const newPreview = await logAsync(
() =>
AppPreview.uploadAsync(localization.context, {
id: previewSet!.id,
filePath: entry.absolutePath,
waitForProcessing: true,
previewFrameTimeCode: entry.previewFrameTimeCode,
}),
{
pending: `Uploading video preview ${chalk.bold(entry.fileName)} (${locale})...`,
success: `Uploaded video preview ${chalk.bold(entry.fileName)} (${locale})`,
failure: `Failed uploading video preview ${chalk.bold(entry.fileName)} (${locale})`,
}
);
keptPreviewIds.add(newPreview.id);
}
// Reorder previews to match config order (mirrors screenshots).
if (keptPreviewIds.size > 0) {
const refreshedSet = await AppPreviewSet.infoAsync(localization.context, {
id: previewSet.id,
});
const refreshedPreviews = refreshedSet.attributes.appPreviews || [];
const previewsByFilename = new Map<string, AppPreview>();
for (const p of refreshedPreviews) {
previewsByFilename.set(p.attributes.fileName, p);
}
const orderedIds: string[] = [];
for (const previewConfig of previewConfigs) {
const fileName = path.basename(previewConfig.path);
const preview = previewsByFilename.get(fileName);
if (preview) {
orderedIds.push(preview.id);
}
}
const currentIds = refreshedPreviews.map(p => p.id);
if (
orderedIds.length > 0 &&
(orderedIds.length !== currentIds.length || orderedIds.some((id, i) => id !== currentIds[i]))
) {
await previewSet.reorderPreviewsAsync({ appPreviews: orderedIds });
}
}
}
/**
+7 -52
View File
@@ -36,11 +36,16 @@ export type ApplePreviewConfig =
/**
* Video previews organized by display type.
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config.
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config or an array of preview configs.
* Apple supports up to 3 previews per device type. A single object/string is still
* accepted for backwards compatibility with existing store configs.
* @example { "IPHONE_67": "./previews/demo.mp4" }
* @example { "IPHONE_67": { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" } }
* @example { "IPHONE_67": ["./previews/intro.mp4", "./previews/features.mp4"] }
*/
export type ApplePreviews = Partial<Record<ApplePreviewType, ApplePreviewConfig>>;
export type ApplePreviews = Partial<
Record<ApplePreviewType, ApplePreviewConfig | ApplePreviewConfig[]>
>;
export interface AppleMetadata {
version?: string;
@@ -54,56 +59,6 @@ export interface AppleMetadata {
review?: AppleReview;
/** App Clip metadata. Only applies to apps that ship an App Clip target. */
appClip?: AppleAppClip;
/**
* In-App Purchase listing. Currently limited to a declarative round-trip
* of `productId`, `referenceName`, and `type`. Localizations, pricing,
* and review screenshots are intentionally out of scope for now.
*
* Note: auto-renewable subscriptions are a separate Apple resource
* (`subscriptionGroups` / `subscriptions`) and are intentionally out of
* scope for this task. They may appear here as legacy v1 entries on pull
* but cannot be created/managed via IAP APIs.
*
* TODO: Once apple-utils is bumped with InAppPurchaseLocalization
* (expo/third-party#148), add an optional `localizations` array per IAP
* entry for localized name/description round-trip.
*/
inAppPurchases?: AppleInAppPurchase[];
}
/**
* In-App Purchase type values from App Store Connect API.
*
* The v2 `inAppPurchasesV2` resource only supports CONSUMABLE, NON_CONSUMABLE,
* and NON_RENEWING_SUBSCRIPTION. Auto-renewable subscriptions are a completely
* separate Apple resource (`subscriptionGroups` / `subscriptions`) and are NOT
* part of the IAP v2 API. See expo/third-party#148 for details.
*
* The legacy v1 types are preserved so that `metadata:pull` can still surface
* existing auto-renewable and free subscription IAPs that were created before
* the v2 split.
*/
export type AppleInAppPurchaseType =
/** v2 IAP types (supported for CRUD once apple-utils is bumped) */
| 'CONSUMABLE'
| 'NON_CONSUMABLE'
| 'NON_RENEWING_SUBSCRIPTION'
/** @deprecated v1 legacy — auto-renewable subscriptions live on `subscriptionGroups`/`subscriptions` in v2 */
| 'AUTO_RENEWABLE_SUBSCRIPTION'
/** @deprecated v1 legacy — same as AUTO_RENEWABLE_SUBSCRIPTION, apple-utils v1 spelling */
| 'AUTOMATICALLY_RENEWABLE_SUBSCRIPTION'
/** @deprecated v1 legacy */
| 'FREE_SUBSCRIPTION';
export interface AppleInAppPurchase {
/** Product identifier (primary key, must be unique within the app). */
productId: string;
/** Internal reference name shown in App Store Connect. */
referenceName: string;
/** IAP type. */
type: AppleInAppPurchaseType | string;
/** Read-only review/state from App Store Connect. Only populated on pull. */
state?: string;
}
/** App Clip action enum values from App Store Connect API */