authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-31 13:11:56+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 13:11:56+02:00
logab8182cb4e5338caf93ee90a7bd57351a0c77283
tree59b91478a6d28988a4173013f939c2d254bfd47a
parentef4ec2ad4b9b08d1aca31d68aea28f0a846ef295
signature Signed by PGP key B5690EEEBB952194

fix(ci): rotate iPad showcase captures without Simulator UI scripting (#5094)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 194 insertions(+), 59 deletions(-)

apps/mobile/app.config.ts+18
......@@ -181,6 +181,9 @@ const config: ExpoConfig = {
181181 ios: {
182182 icon: variant.assets.iosIcon,
183183 supportsTablet: true,
184 // Multitasking-capable iPad apps cannot rotate programmatically, so the
185 // showcase capture build requires full screen (see infoPlist below).
186 requireFullScreen: process.env.T3_SHOWCASE_CAPTURE_BUILD === "1",
184187 bundleIdentifier: iosBundleIdentifier,
185188 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
186189 // does not fall back to a personal team (which cannot sign app groups,
......@@ -197,6 +200,21 @@ const config: ExpoConfig = {
197200 NSLocalNetworkUsageDescription:
198201 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
199202 ITSAppUsesNonExemptEncryption: false,
203 // The App Store screenshot harness rotates the iPad interface from
204 // inside the app (CI denies osascript the Accessibility access that
205 // Simulator menu scripting needs), and iPadOS ignores programmatic
206 // orientation requests for multitasking-capable apps — so the capture
207 // build opts out of multitasking and declares landscape support.
208 ...(process.env.T3_SHOWCASE_CAPTURE_BUILD === "1"
209 ? {
210 "UISupportedInterfaceOrientations~ipad": [
211 "UIInterfaceOrientationPortrait",
212 "UIInterfaceOrientationPortraitUpsideDown",
213 "UIInterfaceOrientationLandscapeLeft",
214 "UIInterfaceOrientationLandscapeRight",
215 ],
216 }
217 : {}),
200218 },
201219 },
202220 android: {
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+45
......@@ -1,5 +1,6 @@
11import ExpoModulesCore
22import Security
3import UIKit
34
45public final class T3NativeControlsModule: Module {
56 public func definition() -> ModuleDefinition {
......@@ -32,6 +33,50 @@ public final class T3NativeControlsModule: Module {
3233 return arguments[flagIndex + 1]
3334 }
3435
36 Function("getShowcaseOrientation") { () -> String? in
37 let arguments = ProcessInfo.processInfo.arguments
38 guard
39 let flagIndex = arguments.firstIndex(of: "--showcaseOrientation"),
40 arguments.indices.contains(flagIndex + 1)
41 else {
42 return nil as String?
43 }
44 return arguments[flagIndex + 1]
45 }
46
47 // Rotates the interface without Simulator menu UI scripting, which CI
48 // runners cannot perform (osascript is denied Accessibility access there).
49 AsyncFunction("applyShowcaseOrientation") { (orientation: String) in
50 guard #available(iOS 16.0, *) else { return }
51 let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait
52 for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes {
53 windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
54 NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)")
55 }
56 for window in windowScene.windows {
57 window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
58 }
59 }
60 }.runOnQueue(.main)
61
62 // The geometry request above can fail transiently (for example before the
63 // scene is foreground-active), so callers poll this until it settles.
64 // Screen bounds — not the scene's interface orientation — decide the
65 // answer because they match the captured framebuffer: with iPadOS
66 // windowing active, a floating landscape window still reports a portrait
67 // screen, and screenshots would come out portrait.
68 AsyncFunction("getInterfaceOrientation") { () -> String in
69 guard
70 let windowScene = UIApplication.shared.connectedScenes
71 .compactMap({ $0 as? UIWindowScene })
72 .first
73 else {
74 return "unknown"
75 }
76 let bounds = windowScene.screen.coordinateSpace.bounds
77 return bounds.width > bounds.height ? "landscape" : "portrait"
78 }.runOnQueue(.main)
79
3580 Function("prepareShowcaseCapture") {
3681 for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] {
3782 SecItemDelete([kSecClass as String: itemClass] as CFDictionary)
apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx+27-2
......@@ -9,6 +9,8 @@ import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
99import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";
1010import { useWorkspaceState } from "../../state/workspace";
1111import {
12 applyNativeShowcaseOrientation,
13 getNativeShowcaseOrientation,
1214 getNativeShowcasePairingUrls,
1315 getNativeShowcaseScene,
1416 markNativeShowcaseReady,
......@@ -47,6 +49,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
4749 const [pendingTasksReady, setPendingTasksReady] = useState(false);
4850 const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);
4951 const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);
52 const [orientationSettled, setOrientationSettled] = useState(false);
5053
5154 useEffect(() => {
5255 if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;
......@@ -60,6 +63,25 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
6063 return () => clearInterval(interval);
6164 }, [pairingUrls.length]);
6265
66 useEffect(() => {
67 if (!SHOWCASE_ENABLED || orientationSettled) return;
68 const orientation = getNativeShowcaseOrientation();
69 if (orientation === null) {
70 setOrientationSettled(true);
71 return;
72 }
73
74 let cancelled = false;
75 void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), {
76 isCancelled: () => cancelled,
77 }).then((applied) => {
78 if (!cancelled && applied) setOrientationSettled(true);
79 });
80 return () => {
81 cancelled = true;
82 };
83 }, [orientationSettled]);
84
6385 useEffect(() => {
6486 if (!SHOWCASE_ENABLED) return;
6587
......@@ -182,7 +204,10 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
182204 scene === null ||
183205 requestedScene === null ||
184206 scene !== requestedScene ||
185 !hasFixture
207 !hasFixture ||
208 // Never report a scene ready while the capture orientation is still
209 // being applied — a screenshot taken early has the wrong dimensions.
210 !orientationSettled
186211 ) {
187212 setReadyScene(null);
188213 return;
......@@ -210,7 +235,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
210235 if (renderFrame !== null) cancelAnimationFrame(renderFrame);
211236 if (readyFrame !== null) cancelAnimationFrame(readyFrame);
212237 };
213 }, [hasFixture, requestedScene, scene]);
238 }, [hasFixture, orientationSettled, requestedScene, scene]);
214239
215240 if (!SHOWCASE_ENABLED || readyScene === null) return null;
216241
apps/mobile/src/features/showcase/nativeShowcaseScene.ts+34
......@@ -3,9 +3,14 @@ import { requireOptionalNativeModule } from "expo";
33export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;
44export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
55
6export type ShowcaseOrientation = "portrait" | "landscape";
7
68interface NativeShowcaseControls {
79 readonly getShowcasePairingUrl?: () => string | null;
810 readonly getShowcaseScene?: () => string | null;
11 readonly getShowcaseOrientation?: () => string | null;
12 readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>;
13 readonly getInterfaceOrientation?: () => Promise<string>;
914 readonly prepareShowcaseCapture?: () => void;
1015 readonly markShowcaseReady?: (scene: ShowcaseScene) => void;
1116}
......@@ -59,6 +64,35 @@ export function prepareNativeShowcaseCapture(): void {
5964 }
6065}
6166
67export function getNativeShowcaseOrientation(): ShowcaseOrientation | null {
68 try {
69 const orientation = nativeShowcaseControls()?.getShowcaseOrientation?.()?.trim();
70 return orientation === "portrait" || orientation === "landscape" ? orientation : null;
71 } catch {
72 return null;
73 }
74}
75
76export async function applyNativeShowcaseOrientation(
77 orientation: ShowcaseOrientation,
78): Promise<boolean> {
79 const controls = nativeShowcaseControls();
80 if (!controls?.applyShowcaseOrientation || !controls.getInterfaceOrientation) {
81 // A development build that predates this helper keeps its default
82 // orientation; report success so callers do not retry forever.
83 return true;
84 }
85 try {
86 await controls.applyShowcaseOrientation(orientation);
87 // The geometry request settles asynchronously; confirm it took effect so
88 // callers can retry attempts made before the scene was foreground-active.
89 await new Promise((resolve) => setTimeout(resolve, 500));
90 return (await controls.getInterfaceOrientation()) === orientation;
91 } catch {
92 return false;
93 }
94}
95
6296export function markNativeShowcaseReady(scene: ShowcaseScene): void {
6397 try {
6498 nativeShowcaseControls()?.markShowcaseReady?.(scene);
scripts/mobile-showcase.ts+70-57
......@@ -60,6 +60,9 @@ const MOBILE_BUILD_ENV = {
6060 ANDROID_HOME: ANDROID_SDK_ROOT,
6161 APP_VARIANT: "production",
6262 EXPO_NO_GIT_STATUS: "1",
63 // Lets the capture build require full screen on iPad so the app can rotate
64 // itself to landscape (see app.config.ts).
65 T3_SHOWCASE_CAPTURE_BUILD: "1",
6366 JAVA_HOME:
6467 NodeProcess.env.JAVA_HOME ??
6568 (NodeProcess.platform === "darwin"
......@@ -87,11 +90,9 @@ export interface ShowcaseCapture {
8790}
8891
8992interface IosCaptureCleanup {
90 readonly name: string;
9193 readonly udid: string;
9294 readonly startedByRunner: boolean;
9395 readonly createdByRunner: boolean;
94 readonly restorePortrait: boolean;
9596}
9697
9798interface AndroidCaptureCleanup {
......@@ -794,47 +795,58 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin
794795 ]);
795796}
796797
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 ]);
798// iPadOS 26 windowing ("Chamois") runs UIRequiresFullScreen apps in a fixed
799// portrait compatibility window, which defeats the in-app landscape rotation
800// the capture build relies on. Switch the device to Full Screen Apps mode
801// (Settings > Multitasking & Gestures) and restart SpringBoard to apply it.
802async function ensureIosFullScreenAppsMode(udid: string): Promise<void> {
803 const current = await commandOutput("xcrun", [
804 "simctl",
805 "spawn",
806 udid,
807 "defaults",
808 "read",
809 "com.apple.springboard",
810 "SBChamoisWindowingEnabled",
811 ]).catch(() => "");
812 if (current.trim() === "0") return;
813 // The Settings toggle writes all three keys; SBChamoisWindowingEnabled
814 // alone is not honored on a freshly created device.
815 for (const key of [
816 "SBChamoisWindowingEnabled",
817 "SBMedusaMultitaskingEnabled",
818 "SBFlexibleWindowingPreviouslyEnabledAutomaticStageCreation",
819 ]) {
820 await runCommand("xcrun", [
821 "simctl",
822 "spawn",
823 udid,
824 "defaults",
825 "write",
826 "com.apple.springboard",
827 key,
828 "-bool",
829 "false",
830 ]);
831 }
832 // A SpringBoard restart is not enough on a freshly created simulator (the
833 // first CI run captured with windowing still active), so reboot the device
834 // and verify the mode actually stuck.
835 await runCommand("xcrun", ["simctl", "shutdown", udid]);
836 await runCommand("xcrun", ["simctl", "boot", udid]);
837 await runCommand("xcrun", ["simctl", "bootstatus", udid, "-b"]);
838 const applied = await commandOutput("xcrun", [
839 "simctl",
840 "spawn",
841 udid,
842 "defaults",
843 "read",
844 "com.apple.springboard",
845 "SBChamoisWindowingEnabled",
846 ]).catch(() => "");
847 if (applied.trim() !== "0") {
848 throw new Error(`Simulator ${udid} did not switch to Full Screen Apps mode.`);
849 }
838850}
839851
840852async function iosAppContainer(udid: string): Promise<string> {
......@@ -873,13 +885,7 @@ async function captureIos(
873885): Promise<void> {
874886 const { simulator, createdByRunner } = await ensureIosSimulator(capture.device);
875887 const startedByRunner = simulator.state !== "Booted";
876 registerCleanup({
877 name: simulator.name,
878 udid: simulator.udid,
879 startedByRunner,
880 createdByRunner,
881 restorePortrait: capture.device.orientation === "landscape",
882 });
888 registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner });
883889 if (!startedByRunner) {
884890 // Clear transient SpringBoard state (permission prompts, stale URL-open
885891 // confirmations, keyboards) without erasing the developer's simulator.
......@@ -887,6 +893,9 @@ async function captureIos(
887893 }
888894 await runCommand("xcrun", ["simctl", "boot", simulator.udid]);
889895 await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]);
896 if (capture.device.orientation === "landscape") {
897 await ensureIosFullScreenAppsMode(simulator.udid);
898 }
890899 await normalizeIosSimulator(capture.appearance, simulator.udid);
891900 if (appPath) {
892901 await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch(
......@@ -937,10 +946,11 @@ async function captureIos(
937946 JSON.stringify(pairingUrls),
938947 "--showcaseScene",
939948 firstScene,
949 // The app rotates itself; Simulator menu UI scripting needs macOS
950 // Accessibility permission that CI runners do not grant to osascript.
951 "--showcaseOrientation",
952 capture.device.orientation ?? "portrait",
940953 ]);
941 if (capture.device.orientation === "landscape") {
942 await setIosSimulatorOrientation("landscape", simulator);
943 }
944954 };
945955 await NodeFSP.rm(readyPath, { force: true });
946956 await NodeFSP.writeFile(scenePath, firstScene);
......@@ -972,7 +982,13 @@ async function captureIos(
972982 );
973983 await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]);
974984 if (capture.device.orientation === "landscape") {
975 await runCommand("sips", ["--rotate", "90", destination]);
985 // A headless simulator keeps its display portrait while the rotated app
986 // renders sideways inside it; with Simulator.app attached the display
987 // itself rotates. Only post-rotate the former.
988 const { width, height } = readPngDimensions(await NodeFSP.readFile(destination));
989 if (height > width) {
990 await runCommand("sips", ["--rotate", "270", destination]);
991 }
976992 }
977993 await finalizeCapture(destination, capture.device);
978994 }
......@@ -1389,9 +1405,6 @@ async function main(): Promise<void> {
13891405 }
13901406 }
13911407 for (const cleanup of iosCleanups) {
1392 if (cleanup.restorePortrait) {
1393 await setIosSimulatorOrientation("portrait", cleanup).catch(() => undefined);
1394 }
13951408 if (cleanup.startedByRunner || cleanup.createdByRunner) {
13961409 await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined);
13971410 }