From a10adcff183ba6a0ef5372107cd5246d30fa142f Mon Sep 17 00:00:00 2001 From: evanbacon Date: Fri, 9 Jan 2026 18:24:31 -0800 Subject: [PATCH 1/6] Add support for Apple screenshots and video previews Introduces new tasks for managing App Store screenshots and video previews, including downloading and uploading assets per locale and display type. Updates AppleData and PartialAppleData types to include screenshots and previews, and extends config reader/writer to handle these assets. Also bumps @expo/apple-utils to 2.1.13 and updates related tests and types. --- packages/eas-cli/package.json | 2 +- .../src/metadata/apple/config/reader.ts | 12 +- .../src/metadata/apple/config/writer.ts | 17 +- packages/eas-cli/src/metadata/apple/data.ts | 11 +- .../apple/rules/infoRestrictedWords.ts | 5 +- .../apple/tasks/__tests__/app-info.test.ts | 1 + .../apple/tasks/__tests__/app-version.test.ts | 12 +- .../eas-cli/src/metadata/apple/tasks/index.ts | 4 + .../src/metadata/apple/tasks/previews.ts | 287 ++++++++++++++++ .../src/metadata/apple/tasks/screenshots.ts | 307 ++++++++++++++++++ packages/eas-cli/src/metadata/apple/types.ts | 46 ++- packages/eas-cli/src/metadata/download.ts | 2 +- packages/eas-cli/src/metadata/upload.ts | 2 +- yarn.lock | 8 +- 14 files changed, 701 insertions(+), 15 deletions(-) create mode 100644 packages/eas-cli/src/metadata/apple/tasks/previews.ts create mode 100644 packages/eas-cli/src/metadata/apple/tasks/screenshots.ts diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index b8a9e512fe..274a9a1c12 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.12", + "@expo/apple-utils": "2.1.13", "@expo/code-signing-certificates": "0.0.5", "@expo/config": "10.0.6", "@expo/config-plugins": "9.0.12", 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__/app-info.test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-info.test.ts index f9f90a2110..b3dc54cc95 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-version.test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/app-version.test.ts index b996749216..0e91c6e9f9 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 }); @@ -368,6 +374,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), }; @@ -396,6 +403,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 }; @@ -414,6 +422,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( @@ -437,8 +446,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/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..7103819df4 --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/previews.ts @@ -0,0 +1,287 @@ +import { + AppPreview, + AppPreviewSet, + AppStoreVersionLocalization, + PreviewType, +} from '@expo/apple-utils'; +import assert from 'assert'; +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) { + 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 { + assert(context.previewSets, `Preview sets not initialized, can't download previews`); + + 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 { + assert(context.previewSets, `Preview sets not initialized, can't upload previews`); + + 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/assets/previews/{locale}/{previewType}/ + const previewTypeDir = previewType.toLowerCase().replace(/_/g, '-'); + const previewsDir = path.join(projectDir, 'store', 'assets', 'previews', locale, previewTypeDir); + await fs.ensureDir(previewsDir); + + // Use original filename or generate one + const fileName = preview.attributes.fileName || 'preview.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..043584ae6c --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts @@ -0,0 +1,307 @@ +import { + AppScreenshot, + AppScreenshotSet, + AppStoreVersionLocalization, + ScreenshotDisplayType, +} from '@expo/apple-utils'; +import assert from 'assert'; +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 { + assert(context.screenshotSets, `Screenshot sets not initialized, can't download screenshots`); + + 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 { + assert(context.screenshotSets, `Screenshot sets not initialized, can't upload screenshots`); + + 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/assets/screenshots/{locale}/{displayType}/ + const displayTypeDir = displayType.toLowerCase().replace(/_/g, '-'); + const screenshotsDir = path.join( + projectDir, + 'store', + 'assets', + 'screenshots', + locale, + displayTypeDir + ); + await fs.ensureDir(screenshotsDir); + + // Use original filename or generate one based on index + const fileName = + screenshot.attributes.fileName || `${String(index + 1).padStart(2, '0')}-screenshot.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 43ef6729d9..af0a11300b 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.12": - version "2.1.12" - resolved "https://registry.yarnpkg.com/@expo/apple-utils/-/apple-utils-2.1.12.tgz#9c40a6294820e59d8b1a677318dccb1ccefc5b00" - integrity sha512-ugpL2URxNFxIRw943AIpX3dcvL5rhNCumpL8XTosYqZPyQQ7JRLVNd1m8FNyPg3pmFrz8M4tSucY2pt2ATuKOA== +"@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/build-tools@1.0.260": version "1.0.260" From dcac7f6af2634857396d2b44f49e0ec57635aa9d Mon Sep 17 00:00:00 2001 From: evanbacon Date: Sat, 10 Jan 2026 14:40:37 -0800 Subject: [PATCH 2/6] Update apple-utils and improve asset file handling Bump @expo/apple-utils to 2.1.14 and add a version check for preview support. Normalize preview and screenshot asset directory structure and filenames for consistency and future compatibility. --- packages/eas-cli/package.json | 2 +- .../src/metadata/apple/tasks/previews.ts | 17 +++++++++++++---- .../src/metadata/apple/tasks/screenshots.ts | 12 ++++++------ yarn.lock | 8 ++++---- 4 files changed, 24 insertions(+), 15 deletions(-) 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/src/metadata/apple/tasks/previews.ts b/packages/eas-cli/src/metadata/apple/tasks/previews.ts index 7103819df4..d05378d045 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/previews.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/previews.ts @@ -53,6 +53,14 @@ export class PreviewsTask extends AppleTask { // 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(); @@ -259,13 +267,14 @@ async function downloadPreviewAsync( return null; } - // Create directory structure: store/assets/previews/{locale}/{previewType}/ + // Create directory structure: store/apple/preview/{locale}/{previewType}/ const previewTypeDir = previewType.toLowerCase().replace(/_/g, '-'); - const previewsDir = path.join(projectDir, 'store', 'assets', 'previews', locale, previewTypeDir); + const previewsDir = path.join(projectDir, 'store', 'apple', 'preview', locale, previewTypeDir); await fs.ensureDir(previewsDir); - // Use original filename or generate one - const fileName = preview.attributes.fileName || 'preview.mp4'; + // Use normalized filename: 01.mp4, 01.mov, etc. + const ext = (path.extname(preview.attributes.fileName || '.mp4') || '.mp4').toLowerCase(); + const fileName = `01${ext}`; const outputPath = path.join(previewsDir, fileName); const relativePath = path.relative(projectDir, outputPath); diff --git a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts index 043584ae6c..2a7c2631b0 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts @@ -271,21 +271,21 @@ async function downloadScreenshotAsync( return null; } - // Create directory structure: store/assets/screenshots/{locale}/{displayType}/ + // Create directory structure: store/apple/screenshot/{locale}/{displayType}/ const displayTypeDir = displayType.toLowerCase().replace(/_/g, '-'); const screenshotsDir = path.join( projectDir, 'store', - 'assets', - 'screenshots', + 'apple', + 'screenshot', locale, displayTypeDir ); await fs.ensureDir(screenshotsDir); - // Use original filename or generate one based on index - const fileName = - screenshot.attributes.fileName || `${String(index + 1).padStart(2, '0')}-screenshot.png`; + // Use normalized index-based filename: 01.png, 02.png, etc. + const ext = (path.extname(screenshot.attributes.fileName || '.png') || '.png').toLowerCase(); + const fileName = `${String(index + 1).padStart(2, '0')}${ext}`; const outputPath = path.join(screenshotsDir, fileName); const relativePath = path.relative(projectDir, outputPath); diff --git a/yarn.lock b/yarn.lock index af0a11300b..663d34b22a 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.260": version "1.0.260" From 7de81fbe1cf311259fd750cabe180894433014dc Mon Sep 17 00:00:00 2001 From: evanbacon Date: Sat, 10 Jan 2026 14:44:06 -0800 Subject: [PATCH 3/6] Use original type names for preview and screenshot dirs Updated directory structure for storing Apple previews and screenshots to use the original previewType and displayType values instead of lowercased, hyphenated versions. This change ensures consistency with the original type naming. --- packages/eas-cli/src/metadata/apple/tasks/previews.ts | 3 +-- packages/eas-cli/src/metadata/apple/tasks/screenshots.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/eas-cli/src/metadata/apple/tasks/previews.ts b/packages/eas-cli/src/metadata/apple/tasks/previews.ts index d05378d045..9c91a22293 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/previews.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/previews.ts @@ -268,8 +268,7 @@ async function downloadPreviewAsync( } // Create directory structure: store/apple/preview/{locale}/{previewType}/ - const previewTypeDir = previewType.toLowerCase().replace(/_/g, '-'); - const previewsDir = path.join(projectDir, 'store', 'apple', 'preview', locale, previewTypeDir); + const previewsDir = path.join(projectDir, 'store', 'apple', 'preview', locale, previewType); await fs.ensureDir(previewsDir); // Use normalized filename: 01.mp4, 01.mov, etc. diff --git a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts index 2a7c2631b0..4999e96b4b 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts @@ -272,14 +272,13 @@ async function downloadScreenshotAsync( } // Create directory structure: store/apple/screenshot/{locale}/{displayType}/ - const displayTypeDir = displayType.toLowerCase().replace(/_/g, '-'); const screenshotsDir = path.join( projectDir, 'store', 'apple', 'screenshot', locale, - displayTypeDir + displayType ); await fs.ensureDir(screenshotsDir); From 37630e9a15d047372478bc0b1a4499f34405b475 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Sat, 10 Jan 2026 14:49:38 -0800 Subject: [PATCH 4/6] Improve Apple metadata tasks error handling and schema Replaces assert-based error handling with conditional checks and log messages in Apple metadata tasks (age-rating, app-review-detail, previews, screenshots) to prevent crashes when context is missing. Updates the metadata schema to add support for 'screenshots' and 'previews' fields, including detailed structure for video previews. --- packages/eas-cli/schema/metadata-0.json | 36 +++++++++++++++++++ .../src/metadata/apple/tasks/age-rating.ts | 11 ++++-- .../metadata/apple/tasks/app-review-detail.ts | 11 ++++-- .../src/metadata/apple/tasks/previews.ts | 10 ++++-- .../src/metadata/apple/tasks/screenshots.ts | 10 ++++-- 5 files changed, 66 insertions(+), 12 deletions(-) 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/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/previews.ts b/packages/eas-cli/src/metadata/apple/tasks/previews.ts index 9c91a22293..47962da8aa 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/previews.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/previews.ts @@ -4,7 +4,6 @@ import { AppStoreVersionLocalization, PreviewType, } from '@expo/apple-utils'; -import assert from 'assert'; import chalk from 'chalk'; import fs from 'fs-extra'; import path from 'path'; @@ -73,7 +72,9 @@ export class PreviewsTask extends AppleTask { } public async downloadAsync({ config, context }: TaskDownloadOptions): Promise { - assert(context.previewSets, `Preview sets not initialized, can't download previews`); + if (!context.previewSets || !context.versionLocales) { + return; + } for (const locale of context.versionLocales) { const localeCode = locale.attributes.locale; @@ -121,7 +122,10 @@ export class PreviewsTask extends AppleTask { } public async uploadAsync({ config, context }: TaskUploadOptions): Promise { - assert(context.previewSets, `Preview sets not initialized, can't upload previews`); + if (!context.previewSets || !context.versionLocales) { + Log.log(chalk`{dim - Skipped video previews, no version available}`); + return; + } const locales = config.getLocales(); if (locales.length <= 0) { diff --git a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts index 4999e96b4b..12c454e605 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts @@ -4,7 +4,6 @@ import { AppStoreVersionLocalization, ScreenshotDisplayType, } from '@expo/apple-utils'; -import assert from 'assert'; import chalk from 'chalk'; import fs from 'fs-extra'; import path from 'path'; @@ -52,7 +51,9 @@ export class ScreenshotsTask extends AppleTask { } public async downloadAsync({ config, context }: TaskDownloadOptions): Promise { - assert(context.screenshotSets, `Screenshot sets not initialized, can't download screenshots`); + if (!context.screenshotSets || !context.versionLocales) { + return; + } for (const locale of context.versionLocales) { const localeCode = locale.attributes.locale; @@ -98,7 +99,10 @@ export class ScreenshotsTask extends AppleTask { } public async uploadAsync({ config, context }: TaskUploadOptions): Promise { - assert(context.screenshotSets, `Screenshot sets not initialized, can't upload screenshots`); + if (!context.screenshotSets || !context.versionLocales) { + Log.log(chalk`{dim - Skipped screenshots, no version available}`); + return; + } const locales = config.getLocales(); if (locales.length <= 0) { From 9d8517173e36b885b77a317d41f9b22b345a83ed Mon Sep 17 00:00:00 2001 From: evanbacon Date: Sat, 10 Jan 2026 14:57:33 -0800 Subject: [PATCH 5/6] Use original filenames for Apple previews and screenshots Updated the download logic to use the original filenames from Apple metadata for previews and screenshots instead of normalized index-based names. This change improves file matching during sync operations. --- packages/eas-cli/src/metadata/apple/tasks/previews.ts | 5 ++--- packages/eas-cli/src/metadata/apple/tasks/screenshots.ts | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/eas-cli/src/metadata/apple/tasks/previews.ts b/packages/eas-cli/src/metadata/apple/tasks/previews.ts index 47962da8aa..a3dfb97aab 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/previews.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/previews.ts @@ -275,9 +275,8 @@ async function downloadPreviewAsync( const previewsDir = path.join(projectDir, 'store', 'apple', 'preview', locale, previewType); await fs.ensureDir(previewsDir); - // Use normalized filename: 01.mp4, 01.mov, etc. - const ext = (path.extname(preview.attributes.fileName || '.mp4') || '.mp4').toLowerCase(); - const fileName = `01${ext}`; + // 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); diff --git a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts index 12c454e605..ec24ac53d1 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/screenshots.ts @@ -286,9 +286,8 @@ async function downloadScreenshotAsync( ); await fs.ensureDir(screenshotsDir); - // Use normalized index-based filename: 01.png, 02.png, etc. - const ext = (path.extname(screenshot.attributes.fileName || '.png') || '.png').toLowerCase(); - const fileName = `${String(index + 1).padStart(2, '0')}${ext}`; + // 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); From 01821a4ec808dc8361074a9543be642c0b3507d4 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Wed, 14 Jan 2026 12:46:00 -0800 Subject: [PATCH 6/6] Update tests to skip on missing context instead of abort Changed age rating and app review detail task tests to expect skipping (not throwing) when required context is missing, reflecting updated behavior. Also updated CHANGELOG to note new screenshots and previews support for metadata commands. --- CHANGELOG.md | 1 + .../apple/tasks/__tests__/age-rating.test.ts | 14 +++++----- .../tasks/__tests__/app-review-detail.test.ts | 28 +++++++++---------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4885cb5358..da40e1c7c4 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 `--runtime-version` and `--platform` filters to `eas update:list`. ([#3261](https://github.com/expo/eas-cli/pull/3261) by [@HarelSultan](https://github.com/HarelSultan)) ### 🐛 Bug fixes 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-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 () => {