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 = {...@@ -181,6 +181,9 @@ const config: ExpoConfig = {
181 ios: {181 ios: {
182 icon: variant.assets.iosIcon,182 icon: variant.assets.iosIcon,
183 supportsTablet: true,183 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",
184 bundleIdentifier: iosBundleIdentifier,187 bundleIdentifier: iosBundleIdentifier,
185 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`188 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
186 // does not fall back to a personal team (which cannot sign app groups,189 // does not fall back to a personal team (which cannot sign app groups,
...@@ -197,6 +200,21 @@ const config: ExpoConfig = {...@@ -197,6 +200,21 @@ const config: ExpoConfig = {
197 NSLocalNetworkUsageDescription:200 NSLocalNetworkUsageDescription:
198 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",201 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
199 ITSAppUsesNonExemptEncryption: false,202 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 : {}),
200 },218 },
201 },219 },
202 android: {220 android: {
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+45
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import ExpoModulesCore1import ExpoModulesCore
2import Security2import Security
3import UIKit
34
4public final class T3NativeControlsModule: Module {5public final class T3NativeControlsModule: Module {
5 public func definition() -> ModuleDefinition {6 public func definition() -> ModuleDefinition {
...@@ -32,6 +33,50 @@ public final class T3NativeControlsModule: Module {...@@ -32,6 +33,50 @@ public final class T3NativeControlsModule: Module {
32 return arguments[flagIndex + 1]33 return arguments[flagIndex + 1]
33 }34 }
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
35 Function("prepareShowcaseCapture") {80 Function("prepareShowcaseCapture") {
36 for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] {81 for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] {
37 SecItemDelete([kSecClass as String: itemClass] as CFDictionary)82 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";...@@ -9,6 +9,8 @@ import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
9import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";9import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";
10import { useWorkspaceState } from "../../state/workspace";10import { useWorkspaceState } from "../../state/workspace";
11import {11import {
12 applyNativeShowcaseOrientation,
13 getNativeShowcaseOrientation,
12 getNativeShowcasePairingUrls,14 getNativeShowcasePairingUrls,
13 getNativeShowcaseScene,15 getNativeShowcaseScene,
14 markNativeShowcaseReady,16 markNativeShowcaseReady,
...@@ -47,6 +49,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })...@@ -47,6 +49,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
47 const [pendingTasksReady, setPendingTasksReady] = useState(false);49 const [pendingTasksReady, setPendingTasksReady] = useState(false);
48 const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);50 const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);
49 const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);51 const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);
52 const [orientationSettled, setOrientationSettled] = useState(false);
5053
51 useEffect(() => {54 useEffect(() => {
52 if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;55 if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;
...@@ -60,6 +63,25 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })...@@ -60,6 +63,25 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
60 return () => clearInterval(interval);63 return () => clearInterval(interval);
61 }, [pairingUrls.length]);64 }, [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
63 useEffect(() => {85 useEffect(() => {
64 if (!SHOWCASE_ENABLED) return;86 if (!SHOWCASE_ENABLED) return;
6587
...@@ -182,7 +204,10 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })...@@ -182,7 +204,10 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
182 scene === null ||204 scene === null ||
183 requestedScene === null ||205 requestedScene === null ||
184 scene !== requestedScene ||206 scene !== requestedScene ||
185 !hasFixture207 !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
186 ) {211 ) {
187 setReadyScene(null);212 setReadyScene(null);
188 return;213 return;
...@@ -210,7 +235,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })...@@ -210,7 +235,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
210 if (renderFrame !== null) cancelAnimationFrame(renderFrame);235 if (renderFrame !== null) cancelAnimationFrame(renderFrame);
211 if (readyFrame !== null) cancelAnimationFrame(readyFrame);236 if (readyFrame !== null) cancelAnimationFrame(readyFrame);
212 };237 };
213 }, [hasFixture, requestedScene, scene]);238 }, [hasFixture, orientationSettled, requestedScene, scene]);
214239
215 if (!SHOWCASE_ENABLED || readyScene === null) return null;240 if (!SHOWCASE_ENABLED || readyScene === null) return null;
216241
apps/mobile/src/features/showcase/nativeShowcaseScene.ts+34
...@@ -3,9 +3,14 @@ import { requireOptionalNativeModule } from "expo";...@@ -3,9 +3,14 @@ import { requireOptionalNativeModule } from "expo";
3export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;3export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;
4export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];4export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
55
6export type ShowcaseOrientation = "portrait" | "landscape";
7
6interface NativeShowcaseControls {8interface NativeShowcaseControls {
7 readonly getShowcasePairingUrl?: () => string | null;9 readonly getShowcasePairingUrl?: () => string | null;
8 readonly getShowcaseScene?: () => string | null;10 readonly getShowcaseScene?: () => string | null;
11 readonly getShowcaseOrientation?: () => string | null;
12 readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>;
13 readonly getInterfaceOrientation?: () => Promise<string>;
9 readonly prepareShowcaseCapture?: () => void;14 readonly prepareShowcaseCapture?: () => void;
10 readonly markShowcaseReady?: (scene: ShowcaseScene) => void;15 readonly markShowcaseReady?: (scene: ShowcaseScene) => void;
11}16}
...@@ -59,6 +64,35 @@ export function prepareNativeShowcaseCapture(): void {...@@ -59,6 +64,35 @@ export function prepareNativeShowcaseCapture(): void {
59 }64 }
60}65}
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
62export function markNativeShowcaseReady(scene: ShowcaseScene): void {96export function markNativeShowcaseReady(scene: ShowcaseScene): void {
63 try {97 try {
64 nativeShowcaseControls()?.markShowcaseReady?.(scene);98 nativeShowcaseControls()?.markShowcaseReady?.(scene);
scripts/mobile-showcase.ts+70-57
...@@ -60,6 +60,9 @@ const MOBILE_BUILD_ENV = {...@@ -60,6 +60,9 @@ const MOBILE_BUILD_ENV = {
60 ANDROID_HOME: ANDROID_SDK_ROOT,60 ANDROID_HOME: ANDROID_SDK_ROOT,
61 APP_VARIANT: "production",61 APP_VARIANT: "production",
62 EXPO_NO_GIT_STATUS: "1",62 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",
63 JAVA_HOME:66 JAVA_HOME:
64 NodeProcess.env.JAVA_HOME ??67 NodeProcess.env.JAVA_HOME ??
65 (NodeProcess.platform === "darwin"68 (NodeProcess.platform === "darwin"
...@@ -87,11 +90,9 @@ export interface ShowcaseCapture {...@@ -87,11 +90,9 @@ export interface ShowcaseCapture {
87}90}
8891
89interface IosCaptureCleanup {92interface IosCaptureCleanup {
90 readonly name: string;
91 readonly udid: string;93 readonly udid: string;
92 readonly startedByRunner: boolean;94 readonly startedByRunner: boolean;
93 readonly createdByRunner: boolean;95 readonly createdByRunner: boolean;
94 readonly restorePortrait: boolean;
95}96}
9697
97interface AndroidCaptureCleanup {98interface AndroidCaptureCleanup {
...@@ -794,47 +795,58 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin...@@ -794,47 +795,58 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin
794 ]);795 ]);
795}796}
796797
797async function setIosSimulatorOrientation(798// iPadOS 26 windowing ("Chamois") runs UIRequiresFullScreen apps in a fixed
798 orientation: NonNullable<ShowcaseIosDevice["orientation"]>,799// portrait compatibility window, which defeats the in-app landscape rotation
799 simulator: Pick<SimctlDevice, "name" | "udid">,800// the capture build relies on. Switch the device to Full Screen Apps mode
800): Promise<void> {801// (Settings > Multitasking & Gestures) and restart SpringBoard to apply it.
801 await runCommand("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", simulator.udid]);802async function ensureIosFullScreenAppsMode(udid: string): Promise<void> {
802 const menuItem = orientation === "landscape" ? "Landscape Right" : "Portrait";803 const current = await commandOutput("xcrun", [
803 await runCommand("osascript", [804 "simctl",
804 "-e",805 "spawn",
805 "on run argv",806 udid,
806 "-e",807 "defaults",
807 "set simulatorName to item 1 of argv",808 "read",
808 "-e",809 "com.apple.springboard",
809 'tell application "Simulator" to activate',810 "SBChamoisWindowingEnabled",
810 "-e",811 ]).catch(() => "");
811 'tell application "System Events" to tell process "Simulator"',812 if (current.trim() === "0") return;
812 "-e",813 // The Settings toggle writes all three keys; SBChamoisWindowingEnabled
813 "set simulatorWindows to {}",814 // alone is not honored on a freshly created device.
814 "-e",815 for (const key of [
815 "repeat 40 times",816 "SBChamoisWindowingEnabled",
816 "-e",817 "SBMedusaMultitaskingEnabled",
817 'set simulatorWindows to menu items of menu "Window" of menu bar item "Window" of menu bar 1 whose name starts with simulatorName',818 "SBFlexibleWindowingPreviouslyEnabledAutomaticStageCreation",
818 "-e",819 ]) {
819 "if (count of simulatorWindows) is greater than 0 then exit repeat",820 await runCommand("xcrun", [
820 "-e",821 "simctl",
821 "delay 0.25",822 "spawn",
822 "-e",823 udid,
823 "end repeat",824 "defaults",
824 "-e",825 "write",
825 'if (count of simulatorWindows) is not 1 then error "Expected exactly one Simulator window for " & simulatorName',826 "com.apple.springboard",
826 "-e",827 key,
827 "click item 1 of simulatorWindows",828 "-bool",
828 "-e",829 "false",
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 ]);
830 "-e",831 }
831 "end tell",832 // A SpringBoard restart is not enough on a freshly created simulator (the
832 "-e",833 // first CI run captured with windowing still active), so reboot the device
833 "delay 1",834 // and verify the mode actually stuck.
834 "-e",835 await runCommand("xcrun", ["simctl", "shutdown", udid]);
835 "end run",836 await runCommand("xcrun", ["simctl", "boot", udid]);
836 simulator.name,837 await runCommand("xcrun", ["simctl", "bootstatus", udid, "-b"]);
837 ]);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 }
838}850}
839851
840async function iosAppContainer(udid: string): Promise<string> {852async function iosAppContainer(udid: string): Promise<string> {
...@@ -873,13 +885,7 @@ async function captureIos(...@@ -873,13 +885,7 @@ async function captureIos(
873): Promise<void> {885): Promise<void> {
874 const { simulator, createdByRunner } = await ensureIosSimulator(capture.device);886 const { simulator, createdByRunner } = await ensureIosSimulator(capture.device);
875 const startedByRunner = simulator.state !== "Booted";887 const startedByRunner = simulator.state !== "Booted";
876 registerCleanup({888 registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner });
877 name: simulator.name,
878 udid: simulator.udid,
879 startedByRunner,
880 createdByRunner,
881 restorePortrait: capture.device.orientation === "landscape",
882 });
883 if (!startedByRunner) {889 if (!startedByRunner) {
884 // Clear transient SpringBoard state (permission prompts, stale URL-open890 // Clear transient SpringBoard state (permission prompts, stale URL-open
885 // confirmations, keyboards) without erasing the developer's simulator.891 // confirmations, keyboards) without erasing the developer's simulator.
...@@ -887,6 +893,9 @@ async function captureIos(...@@ -887,6 +893,9 @@ async function captureIos(
887 }893 }
888 await runCommand("xcrun", ["simctl", "boot", simulator.udid]);894 await runCommand("xcrun", ["simctl", "boot", simulator.udid]);
889 await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]);895 await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]);
896 if (capture.device.orientation === "landscape") {
897 await ensureIosFullScreenAppsMode(simulator.udid);
898 }
890 await normalizeIosSimulator(capture.appearance, simulator.udid);899 await normalizeIosSimulator(capture.appearance, simulator.udid);
891 if (appPath) {900 if (appPath) {
892 await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch(901 await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch(
...@@ -937,10 +946,11 @@ async function captureIos(...@@ -937,10 +946,11 @@ async function captureIos(
937 JSON.stringify(pairingUrls),946 JSON.stringify(pairingUrls),
938 "--showcaseScene",947 "--showcaseScene",
939 firstScene,948 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",
940 ]);953 ]);
941 if (capture.device.orientation === "landscape") {
942 await setIosSimulatorOrientation("landscape", simulator);
943 }
944 };954 };
945 await NodeFSP.rm(readyPath, { force: true });955 await NodeFSP.rm(readyPath, { force: true });
946 await NodeFSP.writeFile(scenePath, firstScene);956 await NodeFSP.writeFile(scenePath, firstScene);
...@@ -972,7 +982,13 @@ async function captureIos(...@@ -972,7 +982,13 @@ async function captureIos(
972 );982 );
973 await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]);983 await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]);
974 if (capture.device.orientation === "landscape") {984 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 }
976 }992 }
977 await finalizeCapture(destination, capture.device);993 await finalizeCapture(destination, capture.device);
978 }994 }
...@@ -1389,9 +1405,6 @@ async function main(): Promise<void> {...@@ -1389,9 +1405,6 @@ async function main(): Promise<void> {
1389 }1405 }
1390 }1406 }
1391 for (const cleanup of iosCleanups) {1407 for (const cleanup of iosCleanups) {
1392 if (cleanup.restorePortrait) {
1393 await setIosSimulatorOrientation("portrait", cleanup).catch(() => undefined);
1394 }
1395 if (cleanup.startedByRunner || cleanup.createdByRunner) {1408 if (cleanup.startedByRunner || cleanup.createdByRunner) {
1396 await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined);1409 await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined);
1397 }1410 }