authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-07-31 01:12:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 10:12:23+02:00
logfccec9f097ab6b89714161ccab2efc7e19d59c00
tree73f88a2548d9790f0a2c4a674c25ee29921c85fd
parent50a3d6b872406bb15af72d439740dd63e89f820d
signature Signed by PGP key B5690EEEBB952194

fix(ci): capture iPad App Store screenshots in landscape (#5065)


4 files changed, 76 insertions(+), 11 deletions(-)

docs/operations/mobile-app-store-screenshots.md+3-2
...@@ -61,7 +61,7 @@ The default matrix is:...@@ -61,7 +61,7 @@ The default matrix is:
61| ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- |61| ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- |
62| `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch |62| `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch |
63| `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch |63| `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch |
64| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2064×2752 | App Store Connect iPad 13-inch |64| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape |
65| `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 |65| `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 |
66| `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 |66| `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 |
67| `google-play/tablet-10/dark/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 |67| `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...@@ -86,7 +86,8 @@ A light-only run writes the same tree under `light/`; `--appearance both` writes
86folders.86folders.
8787
88Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD88Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD
89names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport.89names, light/dark appearance, iOS orientation, scenes, output directory, capture delay, Android ABI,
90or viewport.
9091
91## Capture in GitHub Actions92## Capture in GitHub Actions
9293
scripts/mobile-showcase.config.ts+5-2
...@@ -25,6 +25,8 @@ export interface ShowcaseIosDevice {...@@ -25,6 +25,8 @@ export interface ShowcaseIosDevice {
25 readonly simulatorDeviceType?: string;25 readonly simulatorDeviceType?: string;
26 /** Appearance used when the CLI does not pass --appearance. */26 /** Appearance used when the CLI does not pass --appearance. */
27 readonly appearance: ShowcaseAppearance;27 readonly appearance: ShowcaseAppearance;
28 /** Orientation applied by the capture harness. Defaults to portrait. */
29 readonly orientation?: "portrait" | "landscape";
28 readonly scenes: ReadonlyArray<ShowcaseScene>;30 readonly scenes: ReadonlyArray<ShowcaseScene>;
29 readonly storeAsset: ShowcaseStoreAssetSpec;31 readonly storeAsset: ShowcaseStoreAssetSpec;
30}32}
...@@ -122,12 +124,13 @@ const config: ShowcaseConfig = {...@@ -122,12 +124,13 @@ const config: ShowcaseConfig = {
122 simulator: "iPad Pro 13-inch (M5)",124 simulator: "iPad Pro 13-inch (M5)",
123 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB",125 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB",
124 appearance: "dark",126 appearance: "dark",
127 orientation: "landscape",
125 scenes: ["thread", "terminal", "review", "threads", "environments"],128 scenes: ["thread", "terminal", "review", "threads", "environments"],
126 storeAsset: {129 storeAsset: {
127 store: "apple",130 store: "apple",
128 directory: "apple/ipad-13",131 directory: "apple/ipad-13",
129 width: 2064,132 width: 2752,
130 height: 2752,133 height: 2064,
131 minimumUploadCount: 1,134 minimumUploadCount: 1,
132 maximumUploadCount: 10,135 maximumUploadCount: 10,
133 },136 },
scripts/mobile-showcase.test.ts+7-6
...@@ -216,17 +216,18 @@ it("configures every default device with an exact upload-ready store target", ()...@@ -216,17 +216,18 @@ it("configures every default device with an exact upload-ready store target", ()
216 assert.deepStrictEqual(216 assert.deepStrictEqual(
217 showcaseConfig.devices.map((device) => [217 showcaseConfig.devices.map((device) => [
218 device.id,218 device.id,
219 device.platform === "ios" ? (device.orientation ?? "portrait") : null,
219 device.storeAsset.directory,220 device.storeAsset.directory,
220 device.storeAsset.width,221 device.storeAsset.width,
221 device.storeAsset.height,222 device.storeAsset.height,
222 ]),223 ]),
223 [224 [
224 ["iphone-6.9", "apple/iphone-6.9", 1320, 2868],225 ["iphone-6.9", "portrait", "apple/iphone-6.9", 1320, 2868],
225 ["iphone-6.5", "apple/iphone-6.5", 1284, 2778],226 ["iphone-6.5", "portrait", "apple/iphone-6.5", 1284, 2778],
226 ["ipad-13", "apple/ipad-13", 2064, 2752],227 ["ipad-13", "landscape", "apple/ipad-13", 2752, 2064],
227 ["pixel", "google-play/phone", 1080, 1920],228 ["pixel", null, "google-play/phone", 1080, 1920],
228 ["android-tablet-7", "google-play/tablet-7", 1080, 1920],229 ["android-tablet-7", null, "google-play/tablet-7", 1080, 1920],
229 ["android-tablet-10", "google-play/tablet-10", 1440, 2560],230 ["android-tablet-10", null, "google-play/tablet-10", 1440, 2560],
230 ],231 ],
231 );232 );
232});233});
scripts/mobile-showcase.ts+61-1
...@@ -87,9 +87,11 @@ export interface ShowcaseCapture {...@@ -87,9 +87,11 @@ export interface ShowcaseCapture {
87}87}
8888
89interface IosCaptureCleanup {89interface IosCaptureCleanup {
90 readonly name: string;
90 readonly udid: string;91 readonly udid: string;
91 readonly startedByRunner: boolean;92 readonly startedByRunner: boolean;
92 readonly createdByRunner: boolean;93 readonly createdByRunner: boolean;
94 readonly restorePortrait: boolean;
93}95}
9496
95interface AndroidCaptureCleanup {97interface AndroidCaptureCleanup {
...@@ -792,6 +794,49 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin...@@ -792,6 +794,49 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin
792 ]);794 ]);
793}795}
794796
797async function setIosSimulatorOrientation(
798 orientation: NonNullable<ShowcaseIosDevice["orientation"]>,
799 simulator: Pick<SimctlDevice, "name" | "udid">,
800): Promise<void> {
801 await runCommand("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", simulator.udid]);
802 const menuItem = orientation === "landscape" ? "Landscape Right" : "Portrait";
803 await runCommand("osascript", [
804 "-e",
805 "on run argv",
806 "-e",
807 "set simulatorName to item 1 of argv",
808 "-e",
809 'tell application "Simulator" to activate',
810 "-e",
811 'tell application "System Events" to tell process "Simulator"',
812 "-e",
813 "set simulatorWindows to {}",
814 "-e",
815 "repeat 40 times",
816 "-e",
817 'set simulatorWindows to menu items of menu "Window" of menu bar item "Window" of menu bar 1 whose name starts with simulatorName',
818 "-e",
819 "if (count of simulatorWindows) is greater than 0 then exit repeat",
820 "-e",
821 "delay 0.25",
822 "-e",
823 "end repeat",
824 "-e",
825 'if (count of simulatorWindows) is not 1 then error "Expected exactly one Simulator window for " & simulatorName',
826 "-e",
827 "click item 1 of simulatorWindows",
828 "-e",
829 `click menu item "${menuItem}" of menu "Orientation" of menu item "Orientation" of menu "Device" of menu bar item "Device" of menu bar 1`,
830 "-e",
831 "end tell",
832 "-e",
833 "delay 1",
834 "-e",
835 "end run",
836 simulator.name,
837 ]);
838}
839
795async function iosAppContainer(udid: string): Promise<string> {840async function iosAppContainer(udid: string): Promise<string> {
796 return (841 return (
797 await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"])842 await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"])
...@@ -828,7 +873,13 @@ async function captureIos(...@@ -828,7 +873,13 @@ async function captureIos(
828): Promise<void> {873): Promise<void> {
829 const { simulator, createdByRunner } = await ensureIosSimulator(capture.device);874 const { simulator, createdByRunner } = await ensureIosSimulator(capture.device);
830 const startedByRunner = simulator.state !== "Booted";875 const startedByRunner = simulator.state !== "Booted";
831 registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner });876 registerCleanup({
877 name: simulator.name,
878 udid: simulator.udid,
879 startedByRunner,
880 createdByRunner,
881 restorePortrait: capture.device.orientation === "landscape",
882 });
832 if (!startedByRunner) {883 if (!startedByRunner) {
833 // Clear transient SpringBoard state (permission prompts, stale URL-open884 // Clear transient SpringBoard state (permission prompts, stale URL-open
834 // confirmations, keyboards) without erasing the developer's simulator.885 // confirmations, keyboards) without erasing the developer's simulator.
...@@ -887,6 +938,9 @@ async function captureIos(...@@ -887,6 +938,9 @@ async function captureIos(
887 "--showcaseScene",938 "--showcaseScene",
888 firstScene,939 firstScene,
889 ]);940 ]);
941 if (capture.device.orientation === "landscape") {
942 await setIosSimulatorOrientation("landscape", simulator);
943 }
890 };944 };
891 await NodeFSP.rm(readyPath, { force: true });945 await NodeFSP.rm(readyPath, { force: true });
892 await NodeFSP.writeFile(scenePath, firstScene);946 await NodeFSP.writeFile(scenePath, firstScene);
...@@ -917,6 +971,9 @@ async function captureIos(...@@ -917,6 +971,9 @@ async function captureIos(
917 `${scene}.png`,971 `${scene}.png`,
918 );972 );
919 await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]);973 await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]);
974 if (capture.device.orientation === "landscape") {
975 await runCommand("sips", ["--rotate", "90", destination]);
976 }
920 await finalizeCapture(destination, capture.device);977 await finalizeCapture(destination, capture.device);
921 }978 }
922}979}
...@@ -1332,6 +1389,9 @@ async function main(): Promise<void> {...@@ -1332,6 +1389,9 @@ async function main(): Promise<void> {
1332 }1389 }
1333 }1390 }
1334 for (const cleanup of iosCleanups) {1391 for (const cleanup of iosCleanups) {
1392 if (cleanup.restorePortrait) {
1393 await setIosSimulatorOrientation("portrait", cleanup).catch(() => undefined);
1394 }
1335 if (cleanup.startedByRunner || cleanup.createdByRunner) {1395 if (cleanup.startedByRunner || cleanup.createdByRunner) {
1336 await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined);1396 await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined);
1337 }1397 }