Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ExpoModulesCore
import Security
import UIKit

public final class T3NativeControlsModule: Module {
public func definition() -> ModuleDefinition {
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 27 additions & 2 deletions apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -47,6 +49,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
const [pendingTasksReady, setPendingTasksReady] = useState(false);
const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);
const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);
const [orientationSettled, setOrientationSettled] = useState(false);

useEffect(() => {
if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;
Expand All @@ -60,6 +63,25 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
return () => clearInterval(interval);
}, [pairingUrls.length]);

useEffect(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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]);

Comment thread
cursor[bot] marked this conversation as resolved.
useEffect(() => {
if (!SHOWCASE_ENABLED) return;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
34 changes: 34 additions & 0 deletions apps/mobile/src/features/showcase/nativeShowcaseScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
readonly getInterfaceOrientation?: () => Promise<string>;
readonly prepareShowcaseCapture?: () => void;
readonly markShowcaseReady?: (scene: ShowcaseScene) => void;
}
Expand Down Expand Up @@ -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<boolean> {
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);
Expand Down
127 changes: 70 additions & 57 deletions scripts/mobile-showcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -794,47 +795,58 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin
]);
}

async function setIosSimulatorOrientation(
orientation: NonNullable<ShowcaseIosDevice["orientation"]>,
simulator: Pick<SimctlDevice, "name" | "udid">,
): Promise<void> {
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<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/mobile-showcase.ts:802

ensureIosFullScreenAppsMode permanently writes SBChamoisWindowingEnabled=false to the simulator and never restores the previous value. Running a landscape capture against a pre-existing developer simulator leaves that simulator stuck in Full Screen Apps mode after the harness exits, silently changing its multitasking configuration. The function reads the current value (and early-returns when already 0) but neither records it for cleanup nor restores it in the finally block. Consider capturing the prior value and writing it back during iOS cleanup, or only toggling the setting on simulators the runner created.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/mobile-showcase.ts around line 802:

`ensureIosFullScreenAppsMode` permanently writes `SBChamoisWindowingEnabled=false` to the simulator and never restores the previous value. Running a landscape capture against a pre-existing developer simulator leaves that simulator stuck in Full Screen Apps mode after the harness exits, silently changing its multitasking configuration. The function reads the current value (and early-returns when already `0`) but neither records it for cleanup nor restores it in the `finally` block. Consider capturing the prior value and writing it back during iOS cleanup, or only toggling the setting on simulators the runner created.

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",
]);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early exit skips multitasking keys

Medium Severity

The ensureIosFullScreenAppsMode function exits early if SBChamoisWindowingEnabled is 0. This is problematic because fully disabling iPadOS windowing now requires setting three SpringBoard keys. If the other two keys remain enabled, simulators can retain windowing behavior, causing landscape rotation and orientation checks to fail, which leads to showcase capture timeouts or validation failures.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 87ff819. Configure here.

// 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<string> {
Expand Down Expand Up @@ -873,20 +885,17 @@ async function captureIos(
): Promise<void> {
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.
await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]);
}
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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -1389,9 +1405,6 @@ async function main(): Promise<void> {
}
}
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);
}
Expand Down
Loading