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..23cf4720d8c 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,50 @@ 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. + // 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 + let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first + else { + return "unknown" + } + let bounds = windowScene.screen.coordinateSpace.bounds + return bounds.width > bounds.height ? "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..ffeca9671b7 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, @@ -47,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; @@ -60,6 +63,25 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) return () => clearInterval(interval); }, [pairingUrls.length]); + useEffect(() => { + if (!SHOWCASE_ENABLED || orientationSettled) return; + const orientation = getNativeShowcaseOrientation(); + 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; @@ -182,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; @@ -210,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/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..d9991f42361 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,58 @@ 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; + // 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", + "read", + "com.apple.springboard", + "SBChamoisWindowingEnabled", + ]).catch(() => ""); + if (applied.trim() !== "0") { + throw new Error(`Simulator ${udid} did not switch to Full Screen Apps mode.`); + } } async function iosAppContainer(udid: string): Promise { @@ -873,13 +885,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 +893,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 +946,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); @@ -972,7 +982,13 @@ async function captureIos( ); await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); if (capture.device.orientation === "landscape") { - await runCommand("sips", ["--rotate", "90", destination]); + // 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); } @@ -1389,9 +1405,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); }