From 6a80baeeea61a12aa600fd430400b66d13f58337 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 31 Jul 2026 12:02:46 +0200 Subject: [PATCH 1/3] fix(ci): rotate iPad showcase captures without Simulator UI scripting The landscape iPad captures from #5065 rotate the simulator by AppleScript-clicking Simulator's menus, which CI cannot do: the runner denies osascript Accessibility access (error -1719), and simctl has no orientation command. Rotate from inside the app instead: - t3-native-controls gains getShowcaseOrientation (launch-arg reader), applyShowcaseOrientation (UIWindowScene.requestGeometryUpdate), and getInterfaceOrientation so the coordinator can verify the rotation actually took effect and retry attempts made before the scene was foreground-active. - iPadOS ignores programmatic orientation for multitasking-capable apps, so the capture build sets requireFullScreen (env-gated via T3_SHOWCASE_CAPTURE_BUILD; production builds are unchanged). - iPadOS 26 windowing would put a requires-full-screen app in a fixed portrait compatibility window, so the harness switches the simulator to Full Screen Apps mode (SBChamoisWindowingEnabled=false + SpringBoard restart) before landscape captures. - The framebuffer is now natively landscape, so the sips post-rotation is gone along with the osascript helper. Verified locally end to end from a clean simulator: the iPad capture renders upright landscape at 2752x2064 and passes validation. Co-Authored-By: Claude Fable 5 --- apps/mobile/app.config.ts | 18 +++ .../ios/T3NativeControlsModule.swift | 42 +++++++ .../showcase/ShowcaseCaptureCoordinator.tsx | 16 +++ .../features/showcase/nativeShowcaseScene.ts | 34 ++++++ scripts/mobile-showcase.ts | 106 ++++++++---------- 5 files changed, 158 insertions(+), 58 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 0147bb71a86..9a51373cfd0 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -181,6 +181,9 @@ const config: ExpoConfig = { ios: { icon: variant.assets.iosIcon, supportsTablet: true, + // Multitasking-capable iPad apps cannot rotate programmatically, so the + // showcase capture build requires full screen (see infoPlist below). + requireFullScreen: process.env.T3_SHOWCASE_CAPTURE_BUILD === "1", bundleIdentifier: iosBundleIdentifier, // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, @@ -197,6 +200,21 @@ const config: ExpoConfig = { NSLocalNetworkUsageDescription: "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", ITSAppUsesNonExemptEncryption: false, + // The App Store screenshot harness rotates the iPad interface from + // inside the app (CI denies osascript the Accessibility access that + // Simulator menu scripting needs), and iPadOS ignores programmatic + // orientation requests for multitasking-capable apps — so the capture + // build opts out of multitasking and declares landscape support. + ...(process.env.T3_SHOWCASE_CAPTURE_BUILD === "1" + ? { + "UISupportedInterfaceOrientations~ipad": [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationPortraitUpsideDown", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ], + } + : {}), }, }, android: { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 7781b164a2c..2c588bbc68f 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,5 +1,6 @@ import ExpoModulesCore import Security +import UIKit public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { @@ -32,6 +33,47 @@ public final class T3NativeControlsModule: Module { return arguments[flagIndex + 1] } + Function("getShowcaseOrientation") { () -> String? in + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcaseOrientation"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + // Rotates the interface without Simulator menu UI scripting, which CI + // runners cannot perform (osascript is denied Accessibility access there). + AsyncFunction("applyShowcaseOrientation") { (orientation: String) in + guard #available(iOS 16.0, *) else { return } + let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait + for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes { + windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in + NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)") + } + for window in windowScene.windows { + window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } + } + }.runOnQueue(.main) + + // The geometry request above can fail transiently (for example before the + // scene is foreground-active), so callers poll this until it settles. + AsyncFunction("getInterfaceOrientation") { () -> String in + guard #available(iOS 16.0, *), + let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first + else { + return "unknown" + } + return windowScene.effectiveGeometry.interfaceOrientation.isLandscape + ? "landscape" + : "portrait" + }.runOnQueue(.main) + Function("prepareShowcaseCapture") { for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { SecItemDelete([kSecClass as String: itemClass] as CFDictionary) diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 9a4272824ba..7b46022f138 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -9,6 +9,8 @@ import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; import { + applyNativeShowcaseOrientation, + getNativeShowcaseOrientation, getNativeShowcasePairingUrls, getNativeShowcaseScene, markNativeShowcaseReady, @@ -60,6 +62,20 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) return () => clearInterval(interval); }, [pairingUrls.length]); + useEffect(() => { + if (!SHOWCASE_ENABLED) return; + const orientation = getNativeShowcaseOrientation(); + if (orientation === null) return; + + let cancelled = false; + void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { + isCancelled: () => cancelled, + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (!SHOWCASE_ENABLED) return; diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 1f2e263ebf1..07ca60cf533 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -3,9 +3,14 @@ import { requireOptionalNativeModule } from "expo"; export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; +export type ShowcaseOrientation = "portrait" | "landscape"; + interface NativeShowcaseControls { readonly getShowcasePairingUrl?: () => string | null; readonly getShowcaseScene?: () => string | null; + readonly getShowcaseOrientation?: () => string | null; + readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise; + readonly getInterfaceOrientation?: () => Promise; readonly prepareShowcaseCapture?: () => void; readonly markShowcaseReady?: (scene: ShowcaseScene) => void; } @@ -59,6 +64,35 @@ export function prepareNativeShowcaseCapture(): void { } } +export function getNativeShowcaseOrientation(): ShowcaseOrientation | null { + try { + const orientation = nativeShowcaseControls()?.getShowcaseOrientation?.()?.trim(); + return orientation === "portrait" || orientation === "landscape" ? orientation : null; + } catch { + return null; + } +} + +export async function applyNativeShowcaseOrientation( + orientation: ShowcaseOrientation, +): Promise { + const controls = nativeShowcaseControls(); + if (!controls?.applyShowcaseOrientation || !controls.getInterfaceOrientation) { + // A development build that predates this helper keeps its default + // orientation; report success so callers do not retry forever. + return true; + } + try { + await controls.applyShowcaseOrientation(orientation); + // The geometry request settles asynchronously; confirm it took effect so + // callers can retry attempts made before the scene was foreground-active. + await new Promise((resolve) => setTimeout(resolve, 500)); + return (await controls.getInterfaceOrientation()) === orientation; + } catch { + return false; + } +} + export function markNativeShowcaseReady(scene: ShowcaseScene): void { try { nativeShowcaseControls()?.markShowcaseReady?.(scene); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index ebc22f338be..01ffef3631c 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -60,6 +60,9 @@ const MOBILE_BUILD_ENV = { ANDROID_HOME: ANDROID_SDK_ROOT, APP_VARIANT: "production", EXPO_NO_GIT_STATUS: "1", + // Lets the capture build require full screen on iPad so the app can rotate + // itself to landscape (see app.config.ts). + T3_SHOWCASE_CAPTURE_BUILD: "1", JAVA_HOME: NodeProcess.env.JAVA_HOME ?? (NodeProcess.platform === "darwin" @@ -87,11 +90,9 @@ export interface ShowcaseCapture { } interface IosCaptureCleanup { - readonly name: string; readonly udid: string; readonly startedByRunner: boolean; readonly createdByRunner: boolean; - readonly restorePortrait: boolean; } interface AndroidCaptureCleanup { @@ -794,47 +795,42 @@ 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, +// iPadOS 26 windowing ("Chamois") runs UIRequiresFullScreen apps in a fixed +// portrait compatibility window, which defeats the in-app landscape rotation +// the capture build relies on. Switch the device to Full Screen Apps mode +// (Settings > Multitasking & Gestures) and restart SpringBoard to apply it. +async function ensureIosFullScreenAppsMode(udid: string): Promise { + const current = await commandOutput("xcrun", [ + "simctl", + "spawn", + udid, + "defaults", + "read", + "com.apple.springboard", + "SBChamoisWindowingEnabled", + ]).catch(() => ""); + if (current.trim() === "0") return; + await runCommand("xcrun", [ + "simctl", + "spawn", + udid, + "defaults", + "write", + "com.apple.springboard", + "SBChamoisWindowingEnabled", + "-bool", + "false", + ]); + await runCommand("xcrun", [ + "simctl", + "spawn", + udid, + "launchctl", + "kickstart", + "-k", + "user/foreground/com.apple.SpringBoard", ]); + await delay(5_000); } async function iosAppContainer(udid: string): Promise { @@ -873,13 +869,7 @@ async function captureIos( ): Promise { const { simulator, createdByRunner } = await ensureIosSimulator(capture.device); const startedByRunner = simulator.state !== "Booted"; - registerCleanup({ - name: simulator.name, - udid: simulator.udid, - startedByRunner, - createdByRunner, - restorePortrait: capture.device.orientation === "landscape", - }); + registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner }); if (!startedByRunner) { // Clear transient SpringBoard state (permission prompts, stale URL-open // confirmations, keyboards) without erasing the developer's simulator. @@ -887,6 +877,9 @@ async function captureIos( } await runCommand("xcrun", ["simctl", "boot", simulator.udid]); await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]); + if (capture.device.orientation === "landscape") { + await ensureIosFullScreenAppsMode(simulator.udid); + } await normalizeIosSimulator(capture.appearance, simulator.udid); if (appPath) { await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch( @@ -937,10 +930,11 @@ async function captureIos( JSON.stringify(pairingUrls), "--showcaseScene", firstScene, + // The app rotates itself; Simulator menu UI scripting needs macOS + // Accessibility permission that CI runners do not grant to osascript. + "--showcaseOrientation", + capture.device.orientation ?? "portrait", ]); - if (capture.device.orientation === "landscape") { - await setIosSimulatorOrientation("landscape", simulator); - } }; await NodeFSP.rm(readyPath, { force: true }); await NodeFSP.writeFile(scenePath, firstScene); @@ -970,10 +964,9 @@ async function captureIos( showcaseCaptureDirectory(outputDirectory, capture), `${scene}.png`, ); + // With windowing disabled and the app rotated in place, the framebuffer + // is already landscape-oriented; no post-rotation is needed. 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); } } @@ -1389,9 +1382,6 @@ 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); } From 87ff8194916be2c630a4746d266e27936de1f304 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 31 Jul 2026 12:24:48 +0200 Subject: [PATCH 2/3] fix(scripts): make Full Screen Apps mode stick on freshly created simulators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first CI run captured a floating portrait-screen window: on a brand new device SBChamoisWindowingEnabled alone is not honored. The Settings toggle writes three SpringBoard keys (Chamois windowing, Medusa multitasking, flexible-windowing stage creation), so write all three, reboot the simulator instead of kickstarting SpringBoard, and verify the mode applied. Also gate showcase scene readiness on the orientation having settled, and report orientation from screen bounds so a floating landscape window on a portrait screen counts as portrait — an early or failed rotation now times out loudly instead of shipping wrong-size screenshots. Verified locally against a freshly created simulator (the CI-equivalent path): upright landscape 2752x2064, validation passes. Co-Authored-By: Claude Fable 5 --- .../ios/T3NativeControlsModule.swift | 11 +++-- .../showcase/ShowcaseCaptureCoordinator.tsx | 19 ++++++-- scripts/mobile-showcase.ts | 46 +++++++++++++------ 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 2c588bbc68f..23cf4720d8c 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -61,17 +61,20 @@ public final class T3NativeControlsModule: Module { // The geometry request above can fail transiently (for example before the // scene is foreground-active), so callers poll this until it settles. + // Screen bounds — not the scene's interface orientation — decide the + // answer because they match the captured framebuffer: with iPadOS + // windowing active, a floating landscape window still reports a portrait + // screen, and screenshots would come out portrait. AsyncFunction("getInterfaceOrientation") { () -> String in - guard #available(iOS 16.0, *), + guard let windowScene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first else { return "unknown" } - return windowScene.effectiveGeometry.interfaceOrientation.isLandscape - ? "landscape" - : "portrait" + let bounds = windowScene.screen.coordinateSpace.bounds + return bounds.width > bounds.height ? "landscape" : "portrait" }.runOnQueue(.main) Function("prepareShowcaseCapture") { diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 7b46022f138..ffeca9671b7 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -49,6 +49,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const [pendingTasksReady, setPendingTasksReady] = useState(false); const [requestedScene, setRequestedScene] = useState(null); const [readyScene, setReadyScene] = useState(null); + const [orientationSettled, setOrientationSettled] = useState(false); useEffect(() => { if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; @@ -63,18 +64,23 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) }, [pairingUrls.length]); useEffect(() => { - if (!SHOWCASE_ENABLED) return; + if (!SHOWCASE_ENABLED || orientationSettled) return; const orientation = getNativeShowcaseOrientation(); - if (orientation === null) return; + if (orientation === null) { + setOrientationSettled(true); + return; + } let cancelled = false; void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { isCancelled: () => cancelled, + }).then((applied) => { + if (!cancelled && applied) setOrientationSettled(true); }); return () => { cancelled = true; }; - }, []); + }, [orientationSettled]); useEffect(() => { if (!SHOWCASE_ENABLED) return; @@ -198,7 +204,10 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) scene === null || requestedScene === null || scene !== requestedScene || - !hasFixture + !hasFixture || + // Never report a scene ready while the capture orientation is still + // being applied — a screenshot taken early has the wrong dimensions. + !orientationSettled ) { setReadyScene(null); return; @@ -226,7 +235,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) if (renderFrame !== null) cancelAnimationFrame(renderFrame); if (readyFrame !== null) cancelAnimationFrame(readyFrame); }; - }, [hasFixture, requestedScene, scene]); + }, [hasFixture, orientationSettled, requestedScene, scene]); if (!SHOWCASE_ENABLED || readyScene === null) return null; diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 01ffef3631c..8bca51b2179 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -810,27 +810,43 @@ async function ensureIosFullScreenAppsMode(udid: string): Promise { "SBChamoisWindowingEnabled", ]).catch(() => ""); if (current.trim() === "0") return; - await runCommand("xcrun", [ + // The Settings toggle writes all three keys; SBChamoisWindowingEnabled + // alone is not honored on a freshly created device. + for (const key of [ + "SBChamoisWindowingEnabled", + "SBMedusaMultitaskingEnabled", + "SBFlexibleWindowingPreviouslyEnabledAutomaticStageCreation", + ]) { + await runCommand("xcrun", [ + "simctl", + "spawn", + udid, + "defaults", + "write", + "com.apple.springboard", + key, + "-bool", + "false", + ]); + } + // A SpringBoard restart is not enough on a freshly created simulator (the + // first CI run captured with windowing still active), so reboot the device + // and verify the mode actually stuck. + await runCommand("xcrun", ["simctl", "shutdown", udid]); + await runCommand("xcrun", ["simctl", "boot", udid]); + await runCommand("xcrun", ["simctl", "bootstatus", udid, "-b"]); + const applied = await commandOutput("xcrun", [ "simctl", "spawn", udid, "defaults", - "write", + "read", "com.apple.springboard", "SBChamoisWindowingEnabled", - "-bool", - "false", - ]); - await runCommand("xcrun", [ - "simctl", - "spawn", - udid, - "launchctl", - "kickstart", - "-k", - "user/foreground/com.apple.SpringBoard", - ]); - await delay(5_000); + ]).catch(() => ""); + if (applied.trim() !== "0") { + throw new Error(`Simulator ${udid} did not switch to Full Screen Apps mode.`); + } } async function iosAppContainer(udid: string): Promise { From 0647dbc12be4e83b95337eb0bccbeaa982967d39 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 31 Jul 2026 12:40:02 +0200 Subject: [PATCH 3/3] fix(scripts): post-rotate landscape captures when the display stays portrait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A headless CI simulator keeps its display portrait while the rotated app renders sideways inside it (with Simulator.app attached, the display itself rotates and no correction is needed). Detect which case we are in from the captured PNG's dimensions and rotate 270 degrees only when the buffer is portrait — verified against the actual CI capture. Co-Authored-By: Claude Fable 5 --- scripts/mobile-showcase.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 8bca51b2179..d9991f42361 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -980,9 +980,16 @@ async function captureIos( showcaseCaptureDirectory(outputDirectory, capture), `${scene}.png`, ); - // With windowing disabled and the app rotated in place, the framebuffer - // is already landscape-oriented; no post-rotation is needed. await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); + if (capture.device.orientation === "landscape") { + // A headless simulator keeps its display portrait while the rotated app + // renders sideways inside it; with Simulator.app attached the display + // itself rotates. Only post-rotate the former. + const { width, height } = readPngDimensions(await NodeFSP.readFile(destination)); + if (height > width) { + await runCommand("sips", ["--rotate", "270", destination]); + } + } await finalizeCapture(destination, capture.device); } }