diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index e0e7a365a66..f271e54098a 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -61,7 +61,7 @@ The default matrix is: | ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- | | `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | | `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | -| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2064×2752 | App Store Connect iPad 13-inch | +| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape | | `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | | `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | | `google-play/tablet-10/dark/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | @@ -86,7 +86,8 @@ A light-only run writes the same tree under `light/`; `--appearance both` writes folders. Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD -names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport. +names, light/dark appearance, iOS orientation, scenes, output directory, capture delay, Android ABI, +or viewport. ## Capture in GitHub Actions diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 3237933bec8..7f112396838 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -25,6 +25,8 @@ export interface ShowcaseIosDevice { readonly simulatorDeviceType?: string; /** Appearance used when the CLI does not pass --appearance. */ readonly appearance: ShowcaseAppearance; + /** Orientation applied by the capture harness. Defaults to portrait. */ + readonly orientation?: "portrait" | "landscape"; readonly scenes: ReadonlyArray; readonly storeAsset: ShowcaseStoreAssetSpec; } @@ -122,12 +124,13 @@ const config: ShowcaseConfig = { simulator: "iPad Pro 13-inch (M5)", simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", appearance: "dark", + orientation: "landscape", scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { store: "apple", directory: "apple/ipad-13", - width: 2064, - height: 2752, + width: 2752, + height: 2064, minimumUploadCount: 1, maximumUploadCount: 10, }, diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index a0deadbef65..16fb3e230bb 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -216,17 +216,18 @@ it("configures every default device with an exact upload-ready store target", () assert.deepStrictEqual( showcaseConfig.devices.map((device) => [ device.id, + device.platform === "ios" ? (device.orientation ?? "portrait") : null, device.storeAsset.directory, device.storeAsset.width, device.storeAsset.height, ]), [ - ["iphone-6.9", "apple/iphone-6.9", 1320, 2868], - ["iphone-6.5", "apple/iphone-6.5", 1284, 2778], - ["ipad-13", "apple/ipad-13", 2064, 2752], - ["pixel", "google-play/phone", 1080, 1920], - ["android-tablet-7", "google-play/tablet-7", 1080, 1920], - ["android-tablet-10", "google-play/tablet-10", 1440, 2560], + ["iphone-6.9", "portrait", "apple/iphone-6.9", 1320, 2868], + ["iphone-6.5", "portrait", "apple/iphone-6.5", 1284, 2778], + ["ipad-13", "landscape", "apple/ipad-13", 2752, 2064], + ["pixel", null, "google-play/phone", 1080, 1920], + ["android-tablet-7", null, "google-play/tablet-7", 1080, 1920], + ["android-tablet-10", null, "google-play/tablet-10", 1440, 2560], ], ); }); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 35d6ffd5202..caf5c4f8ec7 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -87,9 +87,11 @@ export interface ShowcaseCapture { } interface IosCaptureCleanup { + readonly name: string; readonly udid: string; readonly startedByRunner: boolean; readonly createdByRunner: boolean; + readonly restorePortrait: boolean; } interface AndroidCaptureCleanup { @@ -775,6 +777,49 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin ]); } +async function setIosSimulatorOrientation( + orientation: NonNullable, + simulator: Pick, +): Promise { + await runCommand("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", simulator.udid]); + const menuItem = orientation === "landscape" ? "Landscape Right" : "Portrait"; + await runCommand("osascript", [ + "-e", + "on run argv", + "-e", + "set simulatorName to item 1 of argv", + "-e", + 'tell application "Simulator" to activate', + "-e", + 'tell application "System Events" to tell process "Simulator"', + "-e", + "set simulatorWindows to {}", + "-e", + "repeat 40 times", + "-e", + 'set simulatorWindows to menu items of menu "Window" of menu bar item "Window" of menu bar 1 whose name starts with simulatorName', + "-e", + "if (count of simulatorWindows) is greater than 0 then exit repeat", + "-e", + "delay 0.25", + "-e", + "end repeat", + "-e", + 'if (count of simulatorWindows) is not 1 then error "Expected exactly one Simulator window for " & simulatorName', + "-e", + "click item 1 of simulatorWindows", + "-e", + `click menu item "${menuItem}" of menu "Orientation" of menu item "Orientation" of menu "Device" of menu bar item "Device" of menu bar 1`, + "-e", + "end tell", + "-e", + "delay 1", + "-e", + "end run", + simulator.name, + ]); +} + async function iosAppContainer(udid: string): Promise { return ( await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) @@ -811,7 +856,13 @@ async function captureIos( ): Promise { const { simulator, createdByRunner } = await ensureIosSimulator(capture.device); const startedByRunner = simulator.state !== "Booted"; - registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner }); + registerCleanup({ + name: simulator.name, + udid: simulator.udid, + startedByRunner, + createdByRunner, + restorePortrait: capture.device.orientation === "landscape", + }); if (!startedByRunner) { // Clear transient SpringBoard state (permission prompts, stale URL-open // confirmations, keyboards) without erasing the developer's simulator. @@ -870,6 +921,9 @@ async function captureIos( "--showcaseScene", firstScene, ]); + if (capture.device.orientation === "landscape") { + await setIosSimulatorOrientation("landscape", simulator); + } }; await NodeFSP.rm(readyPath, { force: true }); await NodeFSP.writeFile(scenePath, firstScene); @@ -900,6 +954,9 @@ async function captureIos( `${scene}.png`, ); await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); + if (capture.device.orientation === "landscape") { + await runCommand("sips", ["--rotate", "90", destination]); + } await finalizeCapture(destination, capture.device); } } @@ -1315,6 +1372,9 @@ async function main(): Promise { } } for (const cleanup of iosCleanups) { + if (cleanup.restorePortrait) { + await setIosSimulatorOrientation("portrait", cleanup).catch(() => undefined); + } if (cleanup.startedByRunner || cleanup.createdByRunner) { await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined); }