Compare commits

..
Author SHA1 Message Date
evanbaconandClaude Opus 4.6 9d93c6ed94 feat(metadata): support privacy nutrition labels (data usage)
Adds a new `privacy.dataUsage` block to the eas-cli metadata schema and
a `DataUsageTask` that pushes/pulls App Privacy Nutrition Labels via the
@expo/apple-utils AppDataUsage* models. After a successful push the
publish state is flipped to published so the changes take effect.

Apple has required every new app submission since 2021 to declare what
data the app collects, so this closes the biggest remaining gap in
`eas metadata`.

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

No files matched your search

-1
View File
@@ -10,7 +10,6 @@ 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
+117 -26
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). Each device type accepts a single preview config (string or object) or an array of up to 3 preview configs.",
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
"additionalProperties": {
"oneOf": [
{
@@ -348,31 +348,6 @@
},
"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
}
]
}
}
]
}
@@ -825,6 +800,118 @@
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"ApplePrivacy": {
"type": "object",
"additionalProperties": false,
"description": "Privacy metadata. Currently only contains App Privacy Nutrition Labels (data usage).",
"properties": {
"dataUsage": {
"$ref": "#/definitions/apple/AppleDataUsage",
"description": "App Privacy Nutrition Labels. Declares what data the app collects, how it is used, and how it is linked to the user. Required for new app submissions since 2021."
}
}
},
"AppleDataUsage": {
"type": "object",
"additionalProperties": false,
"description": "Privacy Nutrition Labels — declarative declaration of data the app collects. When `dataNotCollected` is true, no data is collected and `categories` must be empty.",
"properties": {
"dataNotCollected": {
"type": "boolean",
"description": "Whether the app collects no data at all. Mirrors Apple's \"Data Not Collected\" toggle. When true, `categories` must be empty."
},
"categories": {
"type": "array",
"description": "Per-category data usage declarations.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryEntry"
}
}
}
},
"AppleDataUsageCategoryEntry": {
"type": "object",
"additionalProperties": false,
"required": ["category"],
"description": "A single Privacy Nutrition Labels declaration: pairs a data category with the purposes it is used for and the data protection / linkage classification.",
"properties": {
"category": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryId",
"description": "Apple data category."
},
"purposes": {
"type": "array",
"description": "Purposes the category is used for. At least one entry is required when the category is collected.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsagePurposeId"
}
},
"protections": {
"type": "array",
"description": "Data protection / linkage classification(s). A single category can be both linked to the user and used for tracking.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageDataProtectionId"
}
}
}
},
"AppleDataUsageCategoryId": {
"enum": [
"ADVERTISING_DATA",
"AUDIO",
"BROWSING_HISTORY",
"COARSE_LOCATION",
"CONTACTS",
"CRASH_DATA",
"CREDIT_AND_FRAUD",
"CUSTOMER_SUPPORT",
"DEVICE_ID",
"EMAIL_ADDRESS",
"EMAILS_OR_TEXT_MESSAGES",
"ENVIRONMENTAL_SCANNING",
"FITNESS",
"GAMEPLAY_CONTENT",
"HANDS",
"HEAD_MOVEMENT",
"HEALTH",
"NAME",
"OTHER_CONTACT_INFO",
"OTHER_DATA",
"OTHER_DIAGNOSTIC_DATA",
"OTHER_FINANCIAL_INFO",
"OTHER_USAGE_DATA",
"OTHER_USER_CONTENT",
"PAYMENT_INFORMATION",
"PERFORMANCE_DATA",
"PHONE_NUMBER",
"PHOTOS_OR_VIDEOS",
"PHYSICAL_ADDRESS",
"PRECISE_LOCATION",
"PRODUCT_INTERACTION",
"PURCHASE_HISTORY",
"SEARCH_HISTORY",
"SENSITIVE_INFO",
"USER_ID"
]
},
"AppleDataUsagePurposeId": {
"enum": [
"THIRD_PARTY_ADVERTISING",
"DEVELOPERS_ADVERTISING",
"ANALYTICS",
"PRODUCT_PERSONALIZATION",
"APP_FUNCTIONALITY",
"OTHER_PURPOSES"
]
},
"AppleDataUsageDataProtectionId": {
"enum": [
"DATA_USED_TO_TRACK_YOU",
"DATA_LINKED_TO_YOU",
"DATA_NOT_LINKED_TO_YOU",
"DATA_NOT_COLLECTED"
]
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
@@ -1059,6 +1146,10 @@
"appClip": {
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"privacy": {
"$ref": "#/definitions/apple/ApplePrivacy",
"description": "Privacy metadata, including App Privacy Nutrition Labels (data usage). Required for new app submissions since 2021."
}
}
}
@@ -20,6 +20,7 @@ import {
AppleAppClip,
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -257,4 +258,12 @@ export class AppleConfigReader {
public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null {
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
}
/**
* Get the privacy data usage block (Privacy Nutrition Labels), or `null` if
* not configured. When `null`, the data usage task is a no-op.
*/
public getDataUsage(): AppleDataUsage | null {
return this.schema.privacy?.dataUsage ?? null;
}
}
@@ -17,6 +17,7 @@ import {
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAppClipReviewDetail,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -211,42 +212,29 @@ export class AppleConfigWriter {
Object.keys(screenshots).length > 0 ? screenshots : undefined;
}
/**
* 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.
*/
/** Set video previews for a specific locale */
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;
/**
* Set the privacy data usage block. Pass `null` to clear it (e.g. when the
* remote app has no data usage entries declared yet).
*/
public setDataUsage(dataUsage: AppleDataUsage | null): void {
if (!dataUsage) {
if (this.schema.privacy) {
delete this.schema.privacy.dataUsage;
if (Object.keys(this.schema.privacy).length === 0) {
delete this.schema.privacy;
}
}
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;
this.schema.privacy = this.schema.privacy ?? {};
this.schema.privacy.dataUsage = dataUsage;
}
/** Set the App Clip default experience attributes (action, releaseWithAppStoreVersion). */
+3 -1
View File
@@ -5,6 +5,7 @@ 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 { DataUsageData } from './tasks/data-usage';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
@@ -18,7 +19,8 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
AppReviewData &
ScreenshotsData &
PreviewsData &
AppClipData;
AppClipData &
DataUsageData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
@@ -0,0 +1,403 @@
import {
App,
AppDataUsage,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
AppDataUsagesPublishState,
} from '@expo/apple-utils';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData } from '../../data';
import { DataUsageTask } from '../data-usage';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makeRow(
id: string,
category: AppDataUsageCategoryId | null,
purpose: AppDataUsagePurposeId | null,
protection: AppDataUsageDataProtectionId | null
): AppDataUsage {
const row = new AppDataUsage(requestContext, id, {
category: category ? ({ id: category } as any) : undefined,
purpose: purpose ? ({ id: purpose } as any) : undefined,
dataProtection: protection ? ({ id: protection } as any) : undefined,
} as any);
// Stub deleteAsync so the diff path doesn't actually hit the network.
(row as any).deleteAsync = jest.fn().mockResolvedValue(undefined);
return row;
}
function makeApp(): App {
const app = new App(requestContext, 'app-id', {} as any);
(app as any).getAppDataUsagesAsync = jest.fn().mockResolvedValue([]);
(app as any).getAppDataUsagesPublishStateAsync = jest.fn().mockResolvedValue([]);
(app as any).createAppDataUsageAsync = jest.fn(async (params: any) => {
return makeRow(
`created-${params.appDataUsageCategory ?? 'none'}-${params.appDataUsagePurpose ?? 'none'}-${
params.appDataUsageProtection ?? 'none'
}`,
params.appDataUsageCategory ?? null,
params.appDataUsagePurpose ?? null,
params.appDataUsageProtection ?? null
);
});
return app;
}
function makePublishState(): AppDataUsagesPublishState {
const state = new AppDataUsagesPublishState(requestContext, 'app-id', {
published: false,
lastPublished: '',
lastPublishedBy: '',
} as any);
(state as any).updateAsync = jest.fn().mockResolvedValue(state);
return state;
}
describe(DataUsageTask, () => {
describe('prepareAsync', () => {
it('loads existing data usage rows and publish state', async () => {
const app = makeApp();
const existing = [
makeRow(
'row-1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
];
(app as any).getAppDataUsagesAsync.mockResolvedValue(existing);
const publishState = makePublishState();
(app as any).getAppDataUsagesPublishStateAsync.mockResolvedValue([publishState]);
const context: any = { app, projectDir: '/p' };
await new DataUsageTask().prepareAsync({ context });
expect(context.dataUsages).toBe(existing);
expect(context.dataUsagesPublishState).toBe(publishState);
});
it('treats 404s as empty state', async () => {
const app = makeApp();
(app as any).getAppDataUsagesAsync.mockRejectedValue({ response: { status: 404 } });
(app as any).getAppDataUsagesPublishStateAsync.mockRejectedValue({
response: { status: 404 },
});
const context: any = { app, projectDir: '/p' };
await new DataUsageTask().prepareAsync({ context });
expect(context.dataUsages).toEqual([]);
expect(context.dataUsagesPublishState).toBeNull();
});
});
describe('downloadAsync', () => {
it('writes nothing when there are no rows', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: { dataUsages: [], dataUsagesPublishState: null } as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith(null);
});
it('collapses a single row into a category entry', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [
makeRow(
'row-1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
],
dataUsagesPublishState: null,
} as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith({
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
});
});
it('groups multiple rows by category and dedupes purposes/protections', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [
makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r2',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r3',
AppDataUsageCategoryId.PRECISE_LOCATION,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_NOT_LINKED_TO_YOU
),
],
dataUsagesPublishState: null,
} as any,
});
const arg = (writer.setDataUsage as jest.Mock).mock.calls[0][0];
expect(arg.categories).toHaveLength(2);
const contacts = arg.categories.find((c: any) => c.category === 'CONTACTS');
expect(contacts.purposes.sort()).toEqual(['ANALYTICS', 'APP_FUNCTIONALITY']);
expect(contacts.protections).toEqual(['DATA_LINKED_TO_YOU']);
const location = arg.categories.find((c: any) => c.category === 'PRECISE_LOCATION');
expect(location.purposes).toEqual(['APP_FUNCTIONALITY']);
expect(location.protections).toEqual(['DATA_NOT_LINKED_TO_YOU']);
});
it('emits dataNotCollected when only the sentinel row is present', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [makeRow('r1', null, null, AppDataUsageDataProtectionId.DATA_NOT_COLLECTED)],
dataUsagesPublishState: null,
} as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith({ dataNotCollected: true });
});
});
describe('uploadAsync', () => {
it('skips when no data usage is configured', async () => {
const app = makeApp();
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [],
dataUsagesPublishState: null,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({}),
context: ctx,
});
expect((app as any).createAppDataUsageAsync).not.toHaveBeenCalled();
});
it('creates rows for each (category × purpose × protection) tuple', async () => {
const app = makeApp();
const publishState = makePublishState();
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledTimes(2);
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
it('deletes rows that are no longer in the config and creates new ones', async () => {
const app = makeApp();
const publishState = makePublishState();
const stale = makeRow(
'stale',
AppDataUsageCategoryId.PRECISE_LOCATION,
AppDataUsagePurposeId.OTHER_PURPOSES,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
const kept = makeRow(
'kept',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
(app as any).getAppDataUsagesAsync.mockResolvedValue([stale, kept]);
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [stale, kept],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
{
category: 'EMAIL_ADDRESS',
purposes: ['APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
// stale row deleted, EMAIL_ADDRESS row created, CONTACTS untouched.
expect((stale as any).deleteAsync).toHaveBeenCalled();
expect((kept as any).deleteAsync).not.toHaveBeenCalled();
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledTimes(1);
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledWith({
appDataUsageCategory: 'EMAIL_ADDRESS',
appDataUsagePurpose: 'APP_FUNCTIONALITY',
appDataUsageProtection: 'DATA_LINKED_TO_YOU',
});
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
it('makes no API calls when the config matches existing rows exactly', async () => {
const app = makeApp();
const publishState = makePublishState();
const existing = makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [existing],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
expect((existing as any).deleteAsync).not.toHaveBeenCalled();
expect((app as any).createAppDataUsageAsync).not.toHaveBeenCalled();
// Publish state is still flipped to ensure published.
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
});
describe('round-trip', () => {
it('downloads then uploads without making mutation calls', async () => {
// Use the real writer (not the auto-mock) so we can read back the
// serialized schema and feed it into the upload path.
const RealWriter = jest.requireActual('../../config/writer')
.AppleConfigWriter as typeof AppleConfigWriter;
// Pull side: collapse rows into config.
const writer = new RealWriter();
const downloadApp = makeApp();
const rows = [
makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r2',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
];
await new DataUsageTask().downloadAsync({
config: writer,
context: { app: downloadApp, dataUsages: rows, dataUsagesPublishState: null } as any,
});
// The serialized config should now contain the rows.
expect(writer.schema.privacy?.dataUsage).toEqual({
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
});
// Push side: feed the config back through the reader and verify nothing
// is mutated since the existing rows already match.
const uploadApp = makeApp();
const publishState = makePublishState();
const reader = new AppleConfigReader(writer.schema);
await new DataUsageTask().uploadAsync({
config: reader,
context: {
app: uploadApp,
projectDir: '/p',
dataUsages: rows,
dataUsagesPublishState: publishState,
} as any,
});
expect((uploadApp as any).createAppDataUsageAsync).not.toHaveBeenCalled();
for (const row of rows) {
expect((row as any).deleteAsync).not.toHaveBeenCalled();
}
});
});
});
@@ -354,17 +354,6 @@ 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());
@@ -386,214 +375,6 @@ 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 () => {
@@ -639,16 +420,6 @@ 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());
@@ -673,67 +444,4 @@ 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',
},
],
});
});
});
});
@@ -0,0 +1,322 @@
import {
AppDataUsage,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
AppDataUsagesPublishState,
} from '@expo/apple-utils';
import chalk from 'chalk';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import {
AppleDataUsage,
AppleDataUsageCategoryEntry,
AppleDataUsageCategoryId as AppleDataUsageCategoryIdString,
AppleDataUsageDataProtectionId as AppleDataUsageDataProtectionIdString,
AppleDataUsagePurposeId as AppleDataUsagePurposeIdString,
} from '../types';
export type DataUsageData = {
/**
* Existing AppDataUsage rows on App Store Connect for the current app.
* One row per (category, purpose, dataProtection) tuple. Populated during
* `prepareAsync` and refreshed after `uploadAsync` makes mutations.
*/
dataUsages: AppDataUsage[];
/**
* The publish-state envelope for the data usage section. Apple gates the
* Privacy Nutrition Labels behind a separate `publish` flag — until this is
* set to `published: true`, the values authored above won't take effect on
* the storefront. The model is keyed by the App id (the IRIS endpoint is
* non-standard and there's only ever one).
*/
dataUsagesPublishState: AppDataUsagesPublishState | null;
};
/**
* Sync App Privacy Nutrition Labels (data usage) to/from App Store Connect.
*
* Apple has required every new app submission since 2021 to declare what data
* the app collects, the purposes it's used for, and how it's linked to the
* user. Each declaration is a tuple of (category, purpose, protection) — for
* example "CONTACTS used for ANALYTICS, linked to the user". The local config
* groups these by category for ergonomics; on push we expand each row into the
* cartesian product of (category × purpose × protection) and reconcile against
* the existing rows in App Store Connect.
*
* The task is declarative: any rows in ASC that aren't present in the local
* config are deleted, any rows in the local config that aren't in ASC are
* created, and existing rows are left untouched. After a successful push the
* publish-state envelope is flipped to `published: true` so the changes
* actually appear on the App Store.
*/
export class DataUsageTask extends AppleTask {
public name = (): string => 'data usage (privacy nutrition labels)';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.dataUsages = [];
context.dataUsagesPublishState = null;
try {
context.dataUsages = await context.app.getAppDataUsagesAsync();
} catch (error: any) {
// Apps that have never opened the App Privacy section yet may return
// 404 / NOT_FOUND from the iris endpoint. That's fine — treat it as
// "no rows" and continue. Anything else is unexpected and surfaces.
if (!isNotFoundError(error)) {
throw error;
}
}
try {
const states = await context.app.getAppDataUsagesPublishStateAsync();
context.dataUsagesPublishState = states[0] ?? null;
} catch (error: any) {
if (!isNotFoundError(error)) {
throw error;
}
}
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
const dataUsage = collapseDataUsageRows(context.dataUsages ?? []);
if (!dataUsage) {
// Don't write anything when the app has no rows declared at all — leave
// `privacy.dataUsage` absent in the schema rather than emitting an empty
// object that round-trips into a no-op publish.
config.setDataUsage(null);
return;
}
config.setDataUsage(dataUsage);
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const desired = config.getDataUsage();
if (!desired) {
Log.log(chalk`{dim - Skipped data usage, not configured}`);
return;
}
const desiredRows = expandDataUsageRows(desired);
const existingRows = context.dataUsages ?? [];
// Build a stable key for each (category, purpose, protection) tuple so we
// can diff existing vs desired rows. Categories and protections may be
// null on the existing rows when the relationship isn't included.
const existingByKey = new Map<string, AppDataUsage>();
for (const row of existingRows) {
const key = rowKey(
(row.attributes.category?.id as AppDataUsageCategoryId | undefined) ?? null,
(row.attributes.purpose?.id as AppDataUsagePurposeId | undefined) ?? null,
(row.attributes.dataProtection?.id as AppDataUsageDataProtectionId | undefined) ?? null
);
existingByKey.set(key, row);
}
const desiredByKey = new Map<
string,
{
category: AppDataUsageCategoryId | null;
purpose: AppDataUsagePurposeId | null;
protection: AppDataUsageDataProtectionId | null;
}
>();
for (const row of desiredRows) {
desiredByKey.set(rowKey(row.category, row.purpose, row.protection), row);
}
// Delete rows that exist on ASC but aren't in the local config.
let deleted = 0;
for (const [key, row] of existingByKey) {
if (!desiredByKey.has(key)) {
await row.deleteAsync();
deleted += 1;
}
}
// Create rows that are in the local config but missing from ASC.
let created = 0;
const createdRows: AppDataUsage[] = [];
for (const [key, row] of desiredByKey) {
if (existingByKey.has(key)) {
continue;
}
const newRow = await context.app.createAppDataUsageAsync({
appDataUsageCategory: row.category ?? undefined,
appDataUsageProtection: row.protection ?? undefined,
appDataUsagePurpose: row.purpose ?? undefined,
});
createdRows.push(newRow);
created += 1;
}
if (created === 0 && deleted === 0) {
Log.log(chalk`{dim - Skipped data usage, no changes}`);
} else {
Log.log(
chalk`{dim - Synced data usage: ${String(created)} created, ${String(deleted)} deleted}`
);
// Refresh in-memory state so subsequent invocations see the new rows.
context.dataUsages = [
...existingRows.filter(row => {
const key = rowKey(
(row.attributes.category?.id as AppDataUsageCategoryId | undefined) ?? null,
(row.attributes.purpose?.id as AppDataUsagePurposeId | undefined) ?? null,
(row.attributes.dataProtection?.id as AppDataUsageDataProtectionId | undefined) ?? null
);
return desiredByKey.has(key);
}),
...createdRows,
];
}
// Apple gates the Privacy Nutrition Labels behind a separate publish
// state. Without flipping `published: true` after a write, the rows above
// won't take effect on the storefront. The publish state model is keyed
// by the app id and there's only ever one — refetch it after the writes
// in case it's drifted.
const publishState =
context.dataUsagesPublishState ?? (await context.app.getAppDataUsagesPublishStateAsync())[0];
if (!publishState) {
Log.warn(
chalk`{yellow Could not load data usage publish state — changes may not appear on the App Store. Publish manually from App Store Connect.}`
);
return;
}
context.dataUsagesPublishState = await logAsync(
() => publishState.updateAsync({ published: true }),
{
pending: 'Publishing data usage (privacy nutrition labels)...',
success: 'Published data usage (privacy nutrition labels)',
failure: 'Failed publishing data usage (privacy nutrition labels)',
}
);
}
}
/**
* Build a deterministic key for a (category, purpose, protection) tuple.
* Nulls are folded in so that rows with missing relationships still diff
* correctly against the desired state.
*/
function rowKey(
category: string | null | undefined,
purpose: string | null | undefined,
protection: string | null | undefined
): string {
return `${category ?? ''}::${purpose ?? ''}::${protection ?? ''}`;
}
/**
* Expand a config-shaped {@link AppleDataUsage} into the flat list of
* (category, purpose, protection) tuples that App Store Connect actually
* stores. When `dataNotCollected` is true we emit a single sentinel row that
* declares the app collects no data; this matches Apple's "Data Not Collected"
* toggle, which is just a special row with no category/purpose/protection.
*/
function expandDataUsageRows(dataUsage: AppleDataUsage): {
category: AppDataUsageCategoryId | null;
purpose: AppDataUsagePurposeId | null;
protection: AppDataUsageDataProtectionId | null;
}[] {
if (dataUsage.dataNotCollected) {
return [{ category: null, purpose: null, protection: null }];
}
const rows: {
category: AppDataUsageCategoryId | null;
purpose: AppDataUsagePurposeId | null;
protection: AppDataUsageDataProtectionId | null;
}[] = [];
for (const entry of dataUsage.categories ?? []) {
const purposes = entry.purposes && entry.purposes.length > 0 ? entry.purposes : [null];
const protections =
entry.protections && entry.protections.length > 0 ? entry.protections : [null];
for (const purpose of purposes) {
for (const protection of protections) {
rows.push({
category: entry.category as AppDataUsageCategoryId,
purpose: (purpose as AppDataUsagePurposeId | null) ?? null,
protection: (protection as AppDataUsageDataProtectionId | null) ?? null,
});
}
}
}
return rows;
}
/**
* Collapse the flat list of {@link AppDataUsage} rows from App Store Connect
* back into the grouped {@link AppleDataUsage} schema we serialize to the
* config. Rows are grouped by category; purposes and protections within a
* category are deduped. Returns `null` when there are no rows.
*/
function collapseDataUsageRows(rows: AppDataUsage[]): AppleDataUsage | null {
if (rows.length === 0) {
return null;
}
const byCategory = new Map<string, { purposes: Set<string>; protections: Set<string> }>();
let dataNotCollected = false;
for (const row of rows) {
const categoryId = row.attributes.category?.id;
const purposeId = row.attributes.purpose?.id;
const protectionId = row.attributes.dataProtection?.id;
// A row with no category/purpose/protection at all is the "Data Not
// Collected" sentinel. We don't surface its DATA_NOT_COLLECTED protection
// either — when present we just flip the top-level toggle.
if (!categoryId && !purposeId && protectionId === 'DATA_NOT_COLLECTED') {
dataNotCollected = true;
continue;
}
if (!categoryId) {
continue;
}
let bucket = byCategory.get(categoryId);
if (!bucket) {
bucket = { purposes: new Set(), protections: new Set() };
byCategory.set(categoryId, bucket);
}
if (purposeId) {
bucket.purposes.add(purposeId);
}
if (protectionId) {
bucket.protections.add(protectionId);
}
}
if (dataNotCollected && byCategory.size === 0) {
return { dataNotCollected: true };
}
if (byCategory.size === 0) {
return null;
}
const categories: AppleDataUsageCategoryEntry[] = [];
for (const [category, { purposes, protections }] of byCategory) {
categories.push({
category: category as AppleDataUsageCategoryIdString,
purposes:
purposes.size > 0 ? (Array.from(purposes) as AppleDataUsagePurposeIdString[]) : undefined,
protections:
protections.size > 0
? (Array.from(protections) as AppleDataUsageDataProtectionIdString[])
: undefined,
});
}
// Stable category ordering keeps the generated config diff-friendly.
categories.sort((a, b) => a.category.localeCompare(b.category));
return { categories };
}
function isNotFoundError(error: any): boolean {
if (!error) {
return false;
}
const status = error?.response?.status ?? error?.status;
if (status === 404) {
return true;
}
const message = String(error?.message ?? '');
return /not.?found|404/i.test(message);
}
@@ -3,6 +3,7 @@ import { AppClipTask } from './app-clip';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionOptions, AppVersionTask } from './app-version';
import { DataUsageTask } from './data-usage';
import { PreviewsTask } from './previews';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
@@ -23,5 +24,6 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new ScreenshotsTask(),
new PreviewsTask(),
new AppClipTask(),
new DataUsageTask(),
];
}
@@ -22,35 +22,19 @@ export type PreviewsData = {
previewSets: PreviewSetsMap;
};
/** Normalized preview entry used internally by the upload pipeline. */
type NormalizedPreviewConfig = {
/**
* Normalize preview config to always return an object with path and optional previewFrameTimeCode.
*/
function normalizePreviewConfig(config: ApplePreviewConfig): {
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.
@@ -102,44 +86,34 @@ export class PreviewsTask extends AppleTask {
continue;
}
// 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
);
// 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
);
const fileName = preview.attributes.fileName || `${String(i + 1).padStart(2, '0')}.mp4`;
const relativePath =
downloaded || path.join('store', 'apple', 'preview', localeCode, previewType, fileName);
// 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);
if (preview.attributes.previewFrameTimeCode) {
entries.push({
path: relativePath,
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
});
} else {
entries.push(relativePath);
}
if (preview.attributes.previewFrameTimeCode) {
previews[previewType] = {
path: relativePath,
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
};
} else {
previews[previewType] = 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) {
@@ -172,13 +146,8 @@ export class PreviewsTask extends AppleTask {
continue;
}
for (const [previewType, previewValue] of Object.entries(previews)) {
if (!previewValue) {
continue;
}
const normalized = normalizePreviewConfigs(previewValue);
if (normalized.length === 0) {
for (const [previewType, previewConfig] of Object.entries(previews)) {
if (!previewConfig) {
continue;
}
@@ -186,7 +155,7 @@ export class PreviewsTask extends AppleTask {
context.projectDir,
localization,
previewType as PreviewType,
normalized,
normalizePreviewConfig(previewConfig),
context.previewSets.get(localeCode)
);
}
@@ -195,20 +164,23 @@ export class PreviewsTask extends AppleTask {
}
/**
* 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.
* Sync a preview set - upload new preview, delete old one if changed.
*/
async function syncPreviewSetAsync(
projectDir: string,
localization: AppStoreVersionLocalization,
previewType: PreviewType,
previewConfigs: NormalizedPreviewConfig[],
previewConfig: { path: string; previewFrameTimeCode?: string },
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);
@@ -229,127 +201,62 @@ async function syncPreviewSetAsync(
const existingPreviews = previewSet.attributes.appPreviews || [];
// Build a map of existing previews by filename for comparison.
const existingByFilename = new Map<string, AppPreview>();
// 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
for (const preview of existingPreviews) {
existingByFilename.set(preview.attributes.fileName, preview);
}
// 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 });
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})`,
});
}
}
// 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})`,
}
);
}
/**
+63 -7
View File
@@ -1,6 +1,9 @@
import type {
AgeRatingDeclarationProps,
AppClipAction,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
PreviewType,
ScreenshotDisplayType,
} from '@expo/apple-utils';
@@ -36,16 +39,11 @@ export type ApplePreviewConfig =
/**
* Video previews organized by display type.
* 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.
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config.
* @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 | ApplePreviewConfig[]>
>;
export type ApplePreviews = Partial<Record<ApplePreviewType, ApplePreviewConfig>>;
export interface AppleMetadata {
version?: string;
@@ -59,6 +57,64 @@ export interface AppleMetadata {
review?: AppleReview;
/** App Clip metadata. Only applies to apps that ship an App Clip target. */
appClip?: AppleAppClip;
/** Privacy metadata, including App Privacy Nutrition Labels (data usage). */
privacy?: ApplePrivacy;
}
/** Top-level privacy block. Currently only contains data usage (Privacy Nutrition Labels). */
export interface ApplePrivacy {
/**
* Privacy Nutrition Labels (App Privacy details). Required for new app
* submissions since 2021. When set, the local config is treated as the
* source of truth: existing data usage entries on App Store Connect are
* replaced to match, then published.
*/
dataUsage?: AppleDataUsage;
}
/** Data usage category enum (e.g. CONTACTS, PRECISE_LOCATION). */
export type AppleDataUsageCategoryId = `${AppDataUsageCategoryId}`;
/** Data usage purpose enum (e.g. ANALYTICS, APP_FUNCTIONALITY). */
export type AppleDataUsagePurposeId = `${AppDataUsagePurposeId}`;
/** Data protection / linkage enum (e.g. DATA_LINKED_TO_YOU). */
export type AppleDataUsageDataProtectionId = `${AppDataUsageDataProtectionId}`;
/**
* Privacy Nutrition Labels — declarative declaration of data collected by the
* app. When `dataNotCollected` is true, no data is collected and `categories`
* must be omitted (or empty).
*/
export interface AppleDataUsage {
/**
* Whether the app collects no data at all. When set, `categories` must be
* empty. Mirrors Apple's "Data Not Collected" toggle in App Store Connect.
*/
dataNotCollected?: boolean;
/** Per-category declarations. Order is not significant. */
categories?: AppleDataUsageCategoryEntry[];
}
/**
* One declaration row in the App Privacy details. Each row pairs a data
* category with the purposes it's used for and the protection / linkage
* applied to it.
*/
export interface AppleDataUsageCategoryEntry {
/** Apple data category, e.g. `CONTACTS`, `PRECISE_LOCATION`. */
category: AppleDataUsageCategoryId;
/**
* Purposes the category is used for, e.g. `ANALYTICS`, `APP_FUNCTIONALITY`.
* At least one entry is required when the category is collected.
*/
purposes?: AppleDataUsagePurposeId[];
/**
* Data protection / linkage classification, e.g. `DATA_LINKED_TO_YOU`,
* `DATA_USED_TO_TRACK_YOU`. Multiple values are allowed: a single category
* can be both linked to the user and used for tracking.
*/
protections?: AppleDataUsageDataProtectionId[];
}
/** App Clip action enum values from App Store Connect API */