| 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 | 181 | ios: { |
| 182 | 182 | icon: variant.assets.iosIcon, |
| 183 | 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 | 187 | bundleIdentifier: iosBundleIdentifier, |
| 185 | 188 | // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` |
| 186 | 189 | // does not fall back to a personal team (which cannot sign app groups, |
| ... | ... | @@ -197,6 +200,21 @@ const config: ExpoConfig = { |
| 197 | 200 | NSLocalNetworkUsageDescription: |
| 198 | 201 | "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", |
| 199 | 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 | 220 | android: { |
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+45| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import ExpoModulesCore |
| 2 | 2 | import Security |
| 3 | import UIKit | |
| 3 | 4 | |
| 4 | 5 | public final class T3NativeControlsModule: Module { |
| 5 | 6 | public func definition() -> ModuleDefinition { |
| ... | ... | @@ -32,6 +33,50 @@ public final class T3NativeControlsModule: Module { |
| 32 | 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 | 80 | Function("prepareShowcaseCapture") { |
| 36 | 81 | for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { |
| 37 | 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 | 9 | import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; |
| 10 | 10 | import { useWorkspaceState } from "../../state/workspace"; |
| 11 | 11 | import { |
| 12 | applyNativeShowcaseOrientation, | |
| 13 | getNativeShowcaseOrientation, | |
| 12 | 14 | getNativeShowcasePairingUrls, |
| 13 | 15 | getNativeShowcaseScene, |
| 14 | 16 | markNativeShowcaseReady, |
| ... | ... | @@ -47,6 +49,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 47 | 49 | const [pendingTasksReady, setPendingTasksReady] = useState(false); |
| 48 | 50 | const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null); |
| 49 | 51 | const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null); |
| 52 | const [orientationSettled, setOrientationSettled] = useState(false); | |
| 50 | 53 | |
| 51 | 54 | useEffect(() => { |
| 52 | 55 | if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; |
| ... | ... | @@ -60,6 +63,25 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 60 | 63 | return () => clearInterval(interval); |
| 61 | 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 | 85 | useEffect(() => { |
| 64 | 86 | if (!SHOWCASE_ENABLED) return; |
| 65 | 87 | |
| ... | ... | @@ -182,7 +204,10 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 182 | 204 | scene === null || |
| 183 | 205 | requestedScene === null || |
| 184 | 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 | 212 | setReadyScene(null); |
| 188 | 213 | return; |
| ... | ... | @@ -210,7 +235,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 210 | 235 | if (renderFrame !== null) cancelAnimationFrame(renderFrame); |
| 211 | 236 | if (readyFrame !== null) cancelAnimationFrame(readyFrame); |
| 212 | 237 | }; |
| 213 | }, [hasFixture, requestedScene, scene]); | |
| 238 | }, [hasFixture, orientationSettled, requestedScene, scene]); | |
| 214 | 239 | |
| 215 | 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 | 3 | export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; |
| 4 | 4 | export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; |
| 5 | 5 | |
| 6 | export type ShowcaseOrientation = "portrait" | "landscape"; | |
| 7 | ||
| 6 | 8 | interface NativeShowcaseControls { |
| 7 | 9 | readonly getShowcasePairingUrl?: () => string | null; |
| 8 | 10 | readonly getShowcaseScene?: () => string | null; |
| 11 | readonly getShowcaseOrientation?: () => string | null; | |
| 12 | readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>; | |
| 13 | readonly getInterfaceOrientation?: () => Promise<string>; | |
| 9 | 14 | readonly prepareShowcaseCapture?: () => void; |
| 10 | 15 | readonly markShowcaseReady?: (scene: ShowcaseScene) => void; |
| 11 | 16 | } |
| ... | ... | @@ -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 | 96 | export function markNativeShowcaseReady(scene: ShowcaseScene): void { |
| 63 | 97 | try { |
| 64 | 98 | nativeShowcaseControls()?.markShowcaseReady?.(scene); |
scripts/mobile-showcase.ts+70-57| ... | ... | @@ -60,6 +60,9 @@ const MOBILE_BUILD_ENV = { |
| 60 | 60 | ANDROID_HOME: ANDROID_SDK_ROOT, |
| 61 | 61 | APP_VARIANT: "production", |
| 62 | 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 | 66 | JAVA_HOME: |
| 64 | 67 | NodeProcess.env.JAVA_HOME ?? |
| 65 | 68 | (NodeProcess.platform === "darwin" |
| ... | ... | @@ -87,11 +90,9 @@ export interface ShowcaseCapture { |
| 87 | 90 | } |
| 88 | 91 | |
| 89 | 92 | interface IosCaptureCleanup { |
| 90 | readonly name: string; | |
| 91 | 93 | readonly udid: string; |
| 92 | 94 | readonly startedByRunner: boolean; |
| 93 | 95 | readonly createdByRunner: boolean; |
| 94 | readonly restorePortrait: boolean; | |
| 95 | 96 | } |
| 96 | 97 | |
| 97 | 98 | interface AndroidCaptureCleanup { |
| ... | ... | @@ -794,47 +795,58 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin |
| 794 | 795 | ]); |
| 795 | 796 | } |
| 796 | 797 | |
| 797 | async 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. | |
| 802 | async 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 | } | |
| 838 | 850 | } |
| 839 | 851 | |
| 840 | 852 | async function iosAppContainer(udid: string): Promise<string> { |
| ... | ... | @@ -873,13 +885,7 @@ async function captureIos( |
| 873 | 885 | ): Promise<void> { |
| 874 | 886 | const { simulator, createdByRunner } = await ensureIosSimulator(capture.device); |
| 875 | 887 | 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 }); | |
| 883 | 889 | if (!startedByRunner) { |
| 884 | 890 | // Clear transient SpringBoard state (permission prompts, stale URL-open |
| 885 | 891 | // confirmations, keyboards) without erasing the developer's simulator. |
| ... | ... | @@ -887,6 +893,9 @@ async function captureIos( |
| 887 | 893 | } |
| 888 | 894 | await runCommand("xcrun", ["simctl", "boot", simulator.udid]); |
| 889 | 895 | await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]); |
| 896 | if (capture.device.orientation === "landscape") { | |
| 897 | await ensureIosFullScreenAppsMode(simulator.udid); | |
| 898 | } | |
| 890 | 899 | await normalizeIosSimulator(capture.appearance, simulator.udid); |
| 891 | 900 | if (appPath) { |
| 892 | 901 | await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch( |
| ... | ... | @@ -937,10 +946,11 @@ async function captureIos( |
| 937 | 946 | JSON.stringify(pairingUrls), |
| 938 | 947 | "--showcaseScene", |
| 939 | 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 | 955 | await NodeFSP.rm(readyPath, { force: true }); |
| 946 | 956 | await NodeFSP.writeFile(scenePath, firstScene); |
| ... | ... | @@ -972,7 +982,13 @@ async function captureIos( |
| 972 | 982 | ); |
| 973 | 983 | await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); |
| 974 | 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 | 993 | await finalizeCapture(destination, capture.device); |
| 978 | 994 | } |
| ... | ... | @@ -1389,9 +1405,6 @@ async function main(): Promise<void> { |
| 1389 | 1405 | } |
| 1390 | 1406 | } |
| 1391 | 1407 | for (const cleanup of iosCleanups) { |
| 1392 | if (cleanup.restorePortrait) { | |
| 1393 | await setIosSimulatorOrientation("portrait", cleanup).catch(() => undefined); | |
| 1394 | } | |
| 1395 | 1408 | if (cleanup.startedByRunner || cleanup.createdByRunner) { |
| 1396 | 1409 | await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined); |
| 1397 | 1410 | } |