diff --git a/CHANGELOG.md b/CHANGELOG.md index 0753a687fd..9a0f27563b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon)) - Add App Clip bundle identifier registration support for multi-target iOS builds. ([#3300](https://github.com/expo/eas-cli/pull/3300) by [@evanbacon](https://github.com/evanbacon)) - Add `--runtime-version` and `--platform` filters to `eas update:list`. ([#3261](https://github.com/expo/eas-cli/pull/3261) by [@HarelSultan](https://github.com/HarelSultan)) diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 274a9a1c12..3c97fddd1c 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -8,7 +8,7 @@ }, "bugs": "https://github.com/expo/eas-cli/issues", "dependencies": { - "@expo/apple-utils": "2.1.13", + "@expo/apple-utils": "2.1.14", "@expo/code-signing-certificates": "0.0.5", "@expo/config": "10.0.6", "@expo/config-plugins": "9.0.12", diff --git a/packages/eas-cli/schema/metadata-0.json b/packages/eas-cli/schema/metadata-0.json index 1323a0f3c9..eb7912fc6c 100644 --- a/packages/eas-cli/schema/metadata-0.json +++ b/packages/eas-cli/schema/metadata-0.json @@ -315,6 +315,42 @@ "liveEdits": true, "optional": true } + }, + "screenshots": { + "type": "object", + "description": "Screenshots for this locale, organized by display type (e.g., APP_IPHONE_67, APP_IPAD_PRO_129)", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "previews": { + "type": "object", + "description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)", + "additionalProperties": { + "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 + } + ] + } } } }, diff --git a/packages/eas-cli/src/metadata/apple/config/reader.ts b/packages/eas-cli/src/metadata/apple/config/reader.ts index ed96e1b761..1f98a2dff5 100644 --- a/packages/eas-cli/src/metadata/apple/config/reader.ts +++ b/packages/eas-cli/src/metadata/apple/config/reader.ts @@ -16,7 +16,7 @@ import { import uniq from '../../../utils/expodash/uniq'; import { AttributesOf } from '../../utils/asc'; import { removeDatePrecision } from '../../utils/date'; -import { AppleMetadata } from '../types'; +import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types'; type PartialExcept = Pick & Partial>; @@ -211,4 +211,14 @@ export class AppleConfigReader { // TODO: add attachment }; } + + /** Get screenshots configuration for a specific locale */ + public getScreenshots(locale: string): AppleScreenshots | null { + return this.schema.info?.[locale]?.screenshots ?? null; + } + + /** Get video previews configuration for a specific locale */ + public getPreviews(locale: string): ApplePreviews | null { + return this.schema.info?.[locale]?.previews ?? null; + } } diff --git a/packages/eas-cli/src/metadata/apple/config/writer.ts b/packages/eas-cli/src/metadata/apple/config/writer.ts index eb75a9211f..e6fdf1907d 100644 --- a/packages/eas-cli/src/metadata/apple/config/writer.ts +++ b/packages/eas-cli/src/metadata/apple/config/writer.ts @@ -13,7 +13,7 @@ import { } from '@expo/apple-utils'; import { AttributesOf } from '../../utils/asc'; -import { AppleMetadata } from '../types'; +import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types'; /** * Serializes the Apple ASC entities into the metadata configuration schema. @@ -186,6 +186,21 @@ export class AppleConfigWriter { // TODO: add attachment }; } + + /** Set screenshots for a specific locale */ + public setScreenshots(locale: string, screenshots: AppleScreenshots): void { + this.schema.info = this.schema.info ?? {}; + this.schema.info[locale] = this.schema.info[locale] ?? { title: '' }; + this.schema.info[locale].screenshots = + Object.keys(screenshots).length > 0 ? screenshots : undefined; + } + + /** 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; + } } /** Helper function to convert `T | null` to `T | undefined`, required for the entity properties */ diff --git a/packages/eas-cli/src/metadata/apple/data.ts b/packages/eas-cli/src/metadata/apple/data.ts index d249e6815a..eb62ae1872 100644 --- a/packages/eas-cli/src/metadata/apple/data.ts +++ b/packages/eas-cli/src/metadata/apple/data.ts @@ -4,16 +4,23 @@ import type { AgeRatingData } from './tasks/age-rating'; import type { AppInfoData } from './tasks/app-info'; import type { AppReviewData } from './tasks/app-review-detail'; import type { AppVersionData } from './tasks/app-version'; +import type { PreviewsData } from './tasks/previews'; +import type { ScreenshotsData } from './tasks/screenshots'; /** * The fully prepared apple data, used within the `downloadAsync` or `uploadAsync` tasks. * It contains references to each individual models, to either upload or download App Store data. */ -export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData & AppReviewData; +export type AppleData = { app: App; projectDir: string } & AppInfoData & + AppVersionData & + AgeRatingData & + AppReviewData & + ScreenshotsData & + PreviewsData; /** * The unprepared partial apple data, used within the `prepareAsync` tasks. * It contains a reference to the app, each task should populate the necessary data. * If an entity fails to prepare the data, individual tasks should raise errors about the missing data. */ -export type PartialAppleData = { app: App } & Partial>; +export type PartialAppleData = { app: App; projectDir: string } & Partial>; diff --git a/packages/eas-cli/src/metadata/apple/rules/infoRestrictedWords.ts b/packages/eas-cli/src/metadata/apple/rules/infoRestrictedWords.ts index 2f9902c31d..a93a652ea0 100644 --- a/packages/eas-cli/src/metadata/apple/rules/infoRestrictedWords.ts +++ b/packages/eas-cli/src/metadata/apple/rules/infoRestrictedWords.ts @@ -1,8 +1,9 @@ import { truthy } from '../../../utils/expodash/filter'; import { IssueRule } from '../../config/issue'; -import { AppleInfo } from '../types'; -const RESTRICTED_PROPERTIES: (keyof AppleInfo)[] = ['title', 'subtitle', 'description', 'keywords']; +/** Only check text properties that may contain restricted words */ +type AppleInfoTextProperty = 'title' | 'subtitle' | 'description' | 'keywords'; +const RESTRICTED_PROPERTIES: AppleInfoTextProperty[] = ['title', 'subtitle', 'description', 'keywords']; const RESTRICTED_WORDS = { beta: 'Apple restricts the word "beta" and synonyms implying incomplete functionality.', }; diff --git a/packages/eas-cli/src/metadata/apple/tasks/__tests__/age-rating.test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/age-rating.test.ts index 957ba4f137..9c50302ba9 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/__tests__/age-rating.test.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/__tests__/age-rating.test.ts @@ -54,13 +54,13 @@ describe(AgeRatingTask, () => { }); describe('uploadAsync', () => { - it('aborts when age rating is not loaded', async () => { - const promise = new AgeRatingTask().uploadAsync({ - config: new AppleConfigReader({}), - context: { ageRating: undefined } as any, - }); - - await expect(promise).rejects.toThrow('rating not initialized'); + it('skips when age rating is not loaded', async () => { + await expect( + new AgeRatingTask().uploadAsync({ + config: new AppleConfigReader({}), + context: { ageRating: undefined } as any, + }) + ).resolves.not.toThrow(); }); it('skips updating age rating when not configured', async () => { diff --git a/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-info.test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-info.test.ts index b24cdc1662..f5a06ab856 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-info.test.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-info.test.ts @@ -23,6 +23,7 @@ describe(AppInfoTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', }; await new AppInfoTask().prepareAsync({ context }); diff --git a/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-review-detail.test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-review-detail.test.ts index 81c5405d11..6a68864f85 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-review-detail.test.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-review-detail.test.ts @@ -72,20 +72,20 @@ describe(AppReviewDetailTask, () => { nock.cleanAll(); }); - it('aborts when version is not loaded', async () => { - const promise = new AppReviewDetailTask().uploadAsync({ - config: new AppleConfigReader({ - review: { - firstName: 'Evan', - lastName: 'Bacon', - email: 'review@example.com', - phone: '+1 555 555 5555', - }, - }), - context: {} as any, - }); - - await expect(promise).rejects.toThrow('version not init'); + it('skips when version is not loaded', async () => { + await expect( + new AppReviewDetailTask().uploadAsync({ + config: new AppleConfigReader({ + review: { + firstName: 'Evan', + lastName: 'Bacon', + email: 'review@example.com', + phone: '+1 555 555 5555', + }, + }), + context: {} as any, + }) + ).resolves.not.toThrow(); }); it('updates review details when loaded', async () => { diff --git a/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-version.test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-version.test.ts index aed21eac00..a8f9b8e297 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-version.test.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-version.test.ts @@ -44,6 +44,7 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', }; await new AppVersionTask({ editLive: true }).prepareAsync({ context }); @@ -83,6 +84,7 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', }; await new AppVersionTask({ editLive: true }).prepareAsync({ context }); @@ -118,6 +120,7 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', }; await new AppVersionTask().prepareAsync({ context }); @@ -153,6 +156,7 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', }; await new AppVersionTask().prepareAsync({ context }); @@ -194,6 +198,7 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', }; await new AppVersionTask({ version: '2.0' }).prepareAsync({ context }); @@ -243,6 +248,7 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', }; await new AppVersionTask({ version: '3.0' }).prepareAsync({ context }); @@ -371,6 +377,7 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any), }; @@ -399,6 +406,7 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any), versionPhasedRelease: null, // Not enabled yet }; @@ -417,6 +425,7 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any), // Enabled, and not completed yet versionPhasedRelease: new AppStoreVersionPhasedRelease( @@ -440,8 +449,9 @@ describe(AppVersionTask, () => { const context: PartialAppleData = { app: new App(requestContext, 'stub-id', {} as any), + projectDir: '/test/project', version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any), - // Enabled, and not completed yet + // Enabled, and completed versionPhasedRelease: new AppStoreVersionPhasedRelease( requestContext, 'APP_STORE_VERSION_PHASED_RELEASE_1', diff --git a/packages/eas-cli/src/metadata/apple/tasks/age-rating.ts b/packages/eas-cli/src/metadata/apple/tasks/age-rating.ts index 0204000f92..fdd08140e6 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/age-rating.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/age-rating.ts @@ -1,5 +1,4 @@ import { AgeRatingDeclaration } from '@expo/apple-utils'; -import assert from 'assert'; import chalk from 'chalk'; import Log from '../../../log'; @@ -15,7 +14,10 @@ export class AgeRatingTask extends AppleTask { public name = (): string => 'age rating declarations'; public async prepareAsync({ context }: TaskPrepareOptions): Promise { - context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined; + if (!context.version) { + return; + } + context.ageRating = (await context.version.getAgeRatingDeclarationAsync()) ?? undefined; } public async downloadAsync({ config, context }: TaskDownloadOptions): Promise { @@ -25,7 +27,10 @@ export class AgeRatingTask extends AppleTask { } public async uploadAsync({ config, context }: TaskUploadOptions): Promise { - assert(context.ageRating, `Age rating not initialized, can't update age rating`); + if (!context.ageRating) { + Log.log(chalk`{dim - Skipped age rating update, no version available}`); + return; + } const ageRating = config.getAgeRating(); if (!ageRating) { diff --git a/packages/eas-cli/src/metadata/apple/tasks/app-review-detail.ts b/packages/eas-cli/src/metadata/apple/tasks/app-review-detail.ts index 6d2f2437b6..c9ed7882d7 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/app-review-detail.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/app-review-detail.ts @@ -1,5 +1,4 @@ import { AppStoreReviewDetail } from '@expo/apple-utils'; -import assert from 'assert'; import chalk from 'chalk'; import Log from '../../../log'; @@ -16,7 +15,10 @@ export class AppReviewDetailTask extends AppleTask { public name = (): string => 'app review detail'; public async prepareAsync({ context }: TaskPrepareOptions): Promise { - context.reviewDetail = (await context.version!.getAppStoreReviewDetailAsync()) ?? undefined; + if (!context.version) { + return; + } + context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined; } public async downloadAsync({ config, context }: TaskDownloadOptions): Promise { @@ -32,7 +34,10 @@ export class AppReviewDetailTask extends AppleTask { return; } - assert(context.version, `App version not initialized, can't upload store review details`); + if (!context.version) { + Log.log(chalk`{dim - Skipped store review details, no version available}`); + return; + } const { versionString } = context.version.attributes; if (!context.reviewDetail) { diff --git a/packages/eas-cli/src/metadata/apple/tasks/index.ts b/packages/eas-cli/src/metadata/apple/tasks/index.ts index 4f233cc3a0..f0ef5a3935 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/index.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/index.ts @@ -2,6 +2,8 @@ import { AgeRatingTask } from './age-rating'; import { AppInfoTask } from './app-info'; import { AppReviewDetailTask } from './app-review-detail'; import { AppVersionOptions, AppVersionTask } from './app-version'; +import { PreviewsTask } from './previews'; +import { ScreenshotsTask } from './screenshots'; import { AppleTask } from '../task'; type AppleTaskOptions = { @@ -17,5 +19,7 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[ new AppInfoTask(), new AgeRatingTask(), new AppReviewDetailTask(), + new ScreenshotsTask(), + new PreviewsTask(), ]; } diff --git a/packages/eas-cli/src/metadata/apple/tasks/previews.ts b/packages/eas-cli/src/metadata/apple/tasks/previews.ts new file mode 100644 index 0000000000..a3dfb97aab --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/previews.ts @@ -0,0 +1,298 @@ +import { + AppPreview, + AppPreviewSet, + AppStoreVersionLocalization, + PreviewType, +} from '@expo/apple-utils'; +import chalk from 'chalk'; +import fs from 'fs-extra'; +import path from 'path'; + +import fetch from '../../../fetch'; +import Log from '../../../log'; +import { logAsync } from '../../utils/log'; +import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task'; +import { ApplePreviewConfig, ApplePreviews } from '../types'; + +/** Locale -> PreviewType -> AppPreviewSet */ +export type PreviewSetsMap = Map>; + +export type PreviewsData = { + /** Map of locales to their preview sets */ + previewSets: PreviewSetsMap; +}; + +/** + * Normalize preview config to always return an object with path and optional previewFrameTimeCode. + */ +function normalizePreviewConfig(config: ApplePreviewConfig): { + path: string; + previewFrameTimeCode?: string; +} { + if (typeof config === 'string') { + return { path: config }; + } + return config; +} + +/** + * Task for managing App Store video previews. + * Downloads existing previews and uploads new ones based on store configuration. + */ +export class PreviewsTask extends AppleTask { + public name = (): string => 'video previews'; + + public async prepareAsync({ context }: TaskPrepareOptions): Promise { + // Initialize the preview sets map + context.previewSets = new Map(); + + if (!context.versionLocales) { + return; + } + + // Fetch preview sets for each locale + for (const locale of context.versionLocales) { + // Check if the method exists - @expo/apple-utils may not have implemented preview support yet + if (typeof locale.getAppPreviewSetsAsync !== 'function') { + Log.warn( + 'Video preview support requires a newer version of @expo/apple-utils. Skipping previews.' + ); + return; + } + + const sets = await locale.getAppPreviewSetsAsync(); + const previewTypeMap = new Map(); + + for (const set of sets) { + previewTypeMap.set(set.attributes.previewType, set); + } + + context.previewSets.set(locale.attributes.locale, previewTypeMap); + } + } + + public async downloadAsync({ config, context }: TaskDownloadOptions): Promise { + if (!context.previewSets || !context.versionLocales) { + return; + } + + for (const locale of context.versionLocales) { + const localeCode = locale.attributes.locale; + const previewTypeMap = context.previewSets.get(localeCode); + + if (!previewTypeMap || previewTypeMap.size === 0) { + continue; + } + + const previews: ApplePreviews = {}; + + for (const [previewType, set] of previewTypeMap) { + const previewModels = set.attributes.appPreviews; + if (!previewModels || previewModels.length === 0) { + continue; + } + + // For now, we only handle the first preview per set (App Store allows up to 3) + // We can extend this later to support multiple previews + const preview = previewModels[0]; + const relativePath = await downloadPreviewAsync( + context.projectDir, + localeCode, + previewType, + preview + ); + + if (relativePath) { + // Include preview frame time code if available + if (preview.attributes.previewFrameTimeCode) { + previews[previewType] = { + path: relativePath, + previewFrameTimeCode: preview.attributes.previewFrameTimeCode, + }; + } else { + previews[previewType] = relativePath; + } + } + } + + if (Object.keys(previews).length > 0) { + config.setPreviews(localeCode, previews); + } + } + } + + public async uploadAsync({ config, context }: TaskUploadOptions): Promise { + if (!context.previewSets || !context.versionLocales) { + Log.log(chalk`{dim - Skipped video previews, no version available}`); + return; + } + + const locales = config.getLocales(); + if (locales.length <= 0) { + Log.log(chalk`{dim - Skipped video previews, no locales configured}`); + return; + } + + for (const localeCode of locales) { + const previews = config.getPreviews(localeCode); + if (!previews || Object.keys(previews).length === 0) { + continue; + } + + const localization = context.versionLocales.find(l => l.attributes.locale === localeCode); + if (!localization) { + Log.warn(chalk`{yellow Skipping video previews for ${localeCode} - locale not found}`); + continue; + } + + for (const [previewType, previewConfig] of Object.entries(previews)) { + if (!previewConfig) { + continue; + } + + await syncPreviewSetAsync( + context.projectDir, + localization, + previewType as PreviewType, + normalizePreviewConfig(previewConfig), + context.previewSets.get(localeCode) + ); + } + } + } +} + +/** + * Sync a preview set - upload new preview, delete old one if changed. + */ +async function syncPreviewSetAsync( + projectDir: string, + localization: AppStoreVersionLocalization, + previewType: PreviewType, + previewConfig: { path: string; previewFrameTimeCode?: string }, + existingSets: Map | undefined +): Promise { + const locale = localization.attributes.locale; + const absolutePath = path.resolve(projectDir, previewConfig.path); + const fileName = path.basename(absolutePath); + + if (!(await fs.pathExists(absolutePath))) { + Log.warn(chalk`{yellow Video preview not found: ${absolutePath}}`); + return; + } + + // Get or create the preview set + let previewSet = existingSets?.get(previewType); + + if (!previewSet) { + previewSet = await logAsync( + () => + localization.createAppPreviewSetAsync({ + previewType, + }), + { + pending: `Creating preview set for ${chalk.bold(previewType)} (${locale})...`, + success: `Created preview set for ${chalk.bold(previewType)} (${locale})`, + failure: `Failed creating preview set for ${chalk.bold(previewType)} (${locale})`, + } + ); + } + + const existingPreviews = previewSet.attributes.appPreviews || []; + + // Check if we need to update (different filename or no existing preview) + const existingPreview = existingPreviews.find(p => p.attributes.fileName === fileName); + + if (existingPreview && existingPreview.isComplete()) { + // 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) { + 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})`, + } + ); +} + +/** + * Download a video preview to the local filesystem. + * Returns the relative path to the downloaded file. + */ +async function downloadPreviewAsync( + projectDir: string, + locale: string, + previewType: PreviewType, + preview: AppPreview +): Promise { + const videoUrl = preview.getVideoUrl(); + if (!videoUrl) { + Log.warn( + chalk`{yellow Could not get download URL for preview ${preview.attributes.fileName}}` + ); + return null; + } + + // Create directory structure: store/apple/preview/{locale}/{previewType}/ + const previewsDir = path.join(projectDir, 'store', 'apple', 'preview', locale, previewType); + await fs.ensureDir(previewsDir); + + // Use original filename for matching during sync + const fileName = preview.attributes.fileName || '01.mp4'; + const outputPath = path.join(previewsDir, fileName); + const relativePath = path.relative(projectDir, outputPath); + + try { + const response = await fetch(videoUrl); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const buffer = await response.buffer(); + await fs.writeFile(outputPath, buffer); + + Log.log(chalk`{dim Downloaded video preview: ${relativePath}}`); + return relativePath; + } catch (error: any) { + Log.warn(chalk`{yellow Failed to download video preview ${fileName}: ${error.message}}`); + return null; + } +} diff --git a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts new file mode 100644 index 0000000000..ec24ac53d1 --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts @@ -0,0 +1,309 @@ +import { + AppScreenshot, + AppScreenshotSet, + AppStoreVersionLocalization, + ScreenshotDisplayType, +} from '@expo/apple-utils'; +import chalk from 'chalk'; +import fs from 'fs-extra'; +import path from 'path'; + +import fetch from '../../../fetch'; +import Log from '../../../log'; +import { logAsync } from '../../utils/log'; +import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task'; +import { AppleScreenshots } from '../types'; + +/** Locale -> ScreenshotDisplayType -> AppScreenshotSet */ +export type ScreenshotSetsMap = Map>; + +export type ScreenshotsData = { + /** Map of locales to their screenshot sets */ + screenshotSets: ScreenshotSetsMap; +}; + +/** + * Task for managing App Store screenshots. + * Downloads existing screenshots and uploads new ones based on store configuration. + */ +export class ScreenshotsTask extends AppleTask { + public name = (): string => 'screenshots'; + + public async prepareAsync({ context }: TaskPrepareOptions): Promise { + // Initialize the screenshot sets map + context.screenshotSets = new Map(); + + if (!context.versionLocales) { + return; + } + + // Fetch screenshot sets for each locale + for (const locale of context.versionLocales) { + const sets = await locale.getAppScreenshotSetsAsync(); + const displayTypeMap = new Map(); + + for (const set of sets) { + displayTypeMap.set(set.attributes.screenshotDisplayType, set); + } + + context.screenshotSets.set(locale.attributes.locale, displayTypeMap); + } + } + + public async downloadAsync({ config, context }: TaskDownloadOptions): Promise { + if (!context.screenshotSets || !context.versionLocales) { + return; + } + + for (const locale of context.versionLocales) { + const localeCode = locale.attributes.locale; + const displayTypeMap = context.screenshotSets.get(localeCode); + + if (!displayTypeMap || displayTypeMap.size === 0) { + continue; + } + + const screenshots: AppleScreenshots = {}; + + for (const [displayType, set] of displayTypeMap) { + const screenshotModels = set.attributes.appScreenshots; + if (!screenshotModels || screenshotModels.length === 0) { + continue; + } + + // Download screenshots and save to local filesystem + const paths: string[] = []; + for (let i = 0; i < screenshotModels.length; i++) { + const screenshot = screenshotModels[i]; + const relativePath = await downloadScreenshotAsync( + context.projectDir, + localeCode, + displayType, + screenshot, + i + ); + if (relativePath) { + paths.push(relativePath); + } + } + + if (paths.length > 0) { + screenshots[displayType] = paths; + } + } + + if (Object.keys(screenshots).length > 0) { + config.setScreenshots(localeCode, screenshots); + } + } + } + + public async uploadAsync({ config, context }: TaskUploadOptions): Promise { + if (!context.screenshotSets || !context.versionLocales) { + Log.log(chalk`{dim - Skipped screenshots, no version available}`); + return; + } + + const locales = config.getLocales(); + if (locales.length <= 0) { + Log.log(chalk`{dim - Skipped screenshots, no locales configured}`); + return; + } + + for (const localeCode of locales) { + const screenshots = config.getScreenshots(localeCode); + if (!screenshots || Object.keys(screenshots).length === 0) { + continue; + } + + const localization = context.versionLocales.find(l => l.attributes.locale === localeCode); + if (!localization) { + Log.warn(chalk`{yellow Skipping screenshots for ${localeCode} - locale not found}`); + continue; + } + + for (const [displayType, paths] of Object.entries(screenshots)) { + if (!paths || paths.length === 0) { + continue; + } + + await syncScreenshotSetAsync( + context.projectDir, + localization, + displayType as ScreenshotDisplayType, + paths, + context.screenshotSets.get(localeCode) + ); + } + } + } +} + +/** + * Sync a screenshot set - upload new screenshots, delete removed ones, reorder if needed. + */ +async function syncScreenshotSetAsync( + projectDir: string, + localization: AppStoreVersionLocalization, + displayType: ScreenshotDisplayType, + paths: string[], + existingSets: Map | undefined +): Promise { + const locale = localization.attributes.locale; + + // Get or create the screenshot set + let screenshotSet = existingSets?.get(displayType); + + if (!screenshotSet) { + screenshotSet = await logAsync( + () => + localization.createAppScreenshotSetAsync({ + screenshotDisplayType: displayType, + }), + { + pending: `Creating screenshot set for ${chalk.bold(displayType)} (${locale})...`, + success: `Created screenshot set for ${chalk.bold(displayType)} (${locale})`, + failure: `Failed creating screenshot set for ${chalk.bold(displayType)} (${locale})`, + } + ); + } + + const existingScreenshots = screenshotSet.attributes.appScreenshots || []; + + // Build a map of existing screenshots by filename for comparison + const existingByFilename = new Map(); + for (const screenshot of existingScreenshots) { + existingByFilename.set(screenshot.attributes.fileName, screenshot); + } + + // Track which screenshots to keep, upload, and delete + const screenshotIdsToKeep: string[] = []; + const pathsToUpload: string[] = []; + + for (const relativePath of paths) { + const absolutePath = path.resolve(projectDir, relativePath); + const fileName = path.basename(absolutePath); + + // Check if screenshot already exists + const existing = existingByFilename.get(fileName); + if (existing && existing.isComplete()) { + screenshotIdsToKeep.push(existing.id); + existingByFilename.delete(fileName); + } else { + pathsToUpload.push(absolutePath); + } + } + + // Delete screenshots that are no longer in config + for (const screenshot of existingByFilename.values()) { + await logAsync(() => screenshot.deleteAsync(), { + pending: `Deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})...`, + success: `Deleted screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`, + failure: `Failed deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`, + }); + } + + // Upload new screenshots + for (const absolutePath of pathsToUpload) { + const fileName = path.basename(absolutePath); + + if (!(await fs.pathExists(absolutePath))) { + Log.warn(chalk`{yellow Screenshot not found: ${absolutePath}}`); + continue; + } + + const newScreenshot = await logAsync( + () => + AppScreenshot.uploadAsync(localization.context, { + id: screenshotSet!.id, + filePath: absolutePath, + waitForProcessing: true, + }), + { + pending: `Uploading screenshot ${chalk.bold(fileName)} (${locale})...`, + success: `Uploaded screenshot ${chalk.bold(fileName)} (${locale})`, + failure: `Failed uploading screenshot ${chalk.bold(fileName)} (${locale})`, + } + ); + + screenshotIdsToKeep.push(newScreenshot.id); + } + + // Reorder screenshots to match config order + if (screenshotIdsToKeep.length > 0) { + // Build the correct order based on config paths + const orderedIds: string[] = []; + const refreshedSet = await AppScreenshotSet.infoAsync(localization.context, { + id: screenshotSet.id, + }); + const refreshedScreenshots = refreshedSet.attributes.appScreenshots || []; + const screenshotsByFilename = new Map(); + for (const s of refreshedScreenshots) { + screenshotsByFilename.set(s.attributes.fileName, s); + } + + for (const relativePath of paths) { + const fileName = path.basename(relativePath); + const screenshot = screenshotsByFilename.get(fileName); + if (screenshot) { + orderedIds.push(screenshot.id); + } + } + + if (orderedIds.length > 0) { + await screenshotSet.reorderScreenshotsAsync({ appScreenshots: orderedIds }); + } + } +} + +/** + * Download a screenshot to the local filesystem. + * Returns the relative path to the downloaded file. + */ +async function downloadScreenshotAsync( + projectDir: string, + locale: string, + displayType: ScreenshotDisplayType, + screenshot: AppScreenshot, + index: number +): Promise { + const imageUrl = screenshot.getImageAssetUrl({ type: 'png' }); + if (!imageUrl) { + Log.warn( + chalk`{yellow Could not get download URL for screenshot ${screenshot.attributes.fileName}}` + ); + return null; + } + + // Create directory structure: store/apple/screenshot/{locale}/{displayType}/ + const screenshotsDir = path.join( + projectDir, + 'store', + 'apple', + 'screenshot', + locale, + displayType + ); + await fs.ensureDir(screenshotsDir); + + // Use original filename for matching during sync + const fileName = screenshot.attributes.fileName || `${String(index + 1).padStart(2, '0')}.png`; + const outputPath = path.join(screenshotsDir, fileName); + const relativePath = path.relative(projectDir, outputPath); + + try { + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const buffer = await response.buffer(); + await fs.writeFile(outputPath, buffer); + + Log.log(chalk`{dim Downloaded screenshot: ${relativePath}}`); + return relativePath; + } catch (error: any) { + Log.warn(chalk`{yellow Failed to download screenshot ${fileName}: ${error.message}}`); + return null; + } +} diff --git a/packages/eas-cli/src/metadata/apple/types.ts b/packages/eas-cli/src/metadata/apple/types.ts index 427f0a908d..5263745ab7 100644 --- a/packages/eas-cli/src/metadata/apple/types.ts +++ b/packages/eas-cli/src/metadata/apple/types.ts @@ -1,7 +1,46 @@ -import type { AgeRatingDeclarationProps } from '@expo/apple-utils'; +import type { + AgeRatingDeclarationProps, + PreviewType, + ScreenshotDisplayType, +} from '@expo/apple-utils'; export type AppleLocale = string; +/** Screenshot display type enum values from App Store Connect API */ +export type AppleScreenshotDisplayType = `${ScreenshotDisplayType}`; + +/** Preview display type enum values from App Store Connect API */ +export type ApplePreviewType = `${PreviewType}`; + +/** + * Screenshots organized by display type. + * Key is the display type (e.g., 'APP_IPHONE_67'), value is array of file paths. + * @example { "APP_IPHONE_67": ["./screenshots/home.png", "./screenshots/profile.png"] } + */ +export type AppleScreenshots = Partial>; + +/** + * Video preview configuration - either a simple path string or an object with options. + * @example "./previews/demo.mp4" + * @example { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" } + */ +export type ApplePreviewConfig = + | string + | { + /** Video file path (relative to project root) */ + path: string; + /** Optional preview frame time code (e.g., '00:05:00' for 5 seconds) */ + previewFrameTimeCode?: string; + }; + +/** + * Video previews organized by display type. + * 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" } } + */ +export type ApplePreviews = Partial>; + export interface AppleMetadata { version?: string; copyright?: string; @@ -9,6 +48,7 @@ export interface AppleMetadata { categories?: AppleCategory; release?: AppleRelease; advisory?: AppleAdvisory; + /** @deprecated Use screenshots/previews in AppleInfo instead */ preview?: Record; review?: AppleReview; } @@ -40,6 +80,10 @@ export interface AppleInfo { privacyPolicyText?: string; privacyChoicesUrl?: string; supportUrl?: string; + /** Screenshots for this locale, organized by display type */ + screenshots?: AppleScreenshots; + /** Video previews for this locale, organized by display type */ + previews?: ApplePreviews; } export interface AppleReview { diff --git a/packages/eas-cli/src/metadata/download.ts b/packages/eas-cli/src/metadata/download.ts index 2e068112cd..cf502d9669 100644 --- a/packages/eas-cli/src/metadata/download.ts +++ b/packages/eas-cli/src/metadata/download.ts @@ -63,7 +63,7 @@ export async function downloadMetadataAsync({ const errors: Error[] = []; const config = createAppleWriter(); const tasks = createAppleTasks(); - const taskCtx = { app }; + const taskCtx = { app, projectDir }; for (const task of tasks) { try { diff --git a/packages/eas-cli/src/metadata/upload.ts b/packages/eas-cli/src/metadata/upload.ts index 3f22e31f62..80ef454ff2 100644 --- a/packages/eas-cli/src/metadata/upload.ts +++ b/packages/eas-cli/src/metadata/upload.ts @@ -55,7 +55,7 @@ export async function uploadMetadataAsync({ version: config.getVersion()?.versionString, }); - const taskCtx = { app }; + const taskCtx = { app, projectDir }; for (const task of tasks) { try { diff --git a/yarn.lock b/yarn.lock index f73266ec26..632fcd72ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1541,10 +1541,10 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b" integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A== -"@expo/apple-utils@2.1.13": - version "2.1.13" - resolved "https://registry.yarnpkg.com/@expo/apple-utils/-/apple-utils-2.1.13.tgz#b4950a68a54befa8f1581ae0066477b068dd4757" - integrity sha512-nt3efiJhAWTHl9ikKYrHEuv3dhqCdicsHFRE9LmvtcVsPhXl9bAsm0gbACoLPr7ClP8664H/S6SdVJOD/tw0jg== +"@expo/apple-utils@2.1.14": + version "2.1.14" + resolved "https://registry.yarnpkg.com/@expo/apple-utils/-/apple-utils-2.1.14.tgz#69ee7a25ad7a0548b74bfcd80bee88d070a3aa2c" + integrity sha512-6k9KAyk/itPvNgkAI3LactHJyD/S8Jq2V3iEZRm2LMRDx/Gpu4KvqX1dQBon2G7qFM/AEkLO1dToF/dirB7K2Q== "@expo/build-tools@1.0.264": version "1.0.264"