| author | |
| committer | |
| log | ab8182cb4e5338caf93ee90a7bd57351a0c77283 |
| tree | 59b91478a6d28988a4173013f939c2d254bfd47a |
| parent | ef4ec2ad4b9b08d1aca31d68aea28f0a846ef295 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import ExpoModulesCore | 1 | import ExpoModulesCore |
| 2 | import Security | 2 | import Security |
| 3 | import UIKit | ||
| 3 | 4 | ||
| 4 | public final class T3NativeControlsModule: Module { | 5 | public 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 | } |
| 34 | 35 | ||
| 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"; |
| 9 | import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; | 9 | import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; |
| 10 | import { useWorkspaceState } from "../../state/workspace"; | 10 | import { useWorkspaceState } from "../../state/workspace"; |
| 11 | import { | 11 | import { |
| 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); | ||
| 50 | 53 | ||
| 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]); |
| 62 | 65 | ||
| 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; |
| 65 | 87 | ||
| ... | @@ -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 | !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 | ||
| 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]); |
| 214 | 239 | ||
| 215 | if (!SHOWCASE_ENABLED || readyScene === null) return null; | 240 | if (!SHOWCASE_ENABLED || readyScene === null) return null; |
| 216 | 241 |
apps/mobile/src/features/showcase/nativeShowcaseScene.ts+34| ... | @@ -3,9 +3,14 @@ import { requireOptionalNativeModule } from "expo"; | ... | @@ -3,9 +3,14 @@ import { requireOptionalNativeModule } from "expo"; |
| 3 | export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; | 3 | export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; |
| 4 | export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; | 4 | export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; |
| 5 | 5 | ||
| 6 | export type ShowcaseOrientation = "portrait" | "landscape"; | ||
| 7 | |||
| 6 | interface NativeShowcaseControls { | 8 | interface 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 | } |
| 61 | 66 | ||
| 67 | export 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 | |||
| 76 | export 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 | |||
| 62 | export function markNativeShowcaseReady(scene: ShowcaseScene): void { | 96 | export 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 | } |
| 88 | 91 | ||
| 89 | interface IosCaptureCleanup { | 92 | interface 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 | } |
| 96 | 97 | ||
| 97 | interface AndroidCaptureCleanup { | 98 | interface 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 | } |
| 796 | 797 | ||
| 797 | async 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]); | 802 | async 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 | } |
| 839 | 851 | ||
| 840 | async function iosAppContainer(udid: string): Promise<string> { | 852 | async 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-open | 890 | // 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 | } |