mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d93c6ed94 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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})`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user