From 2d0722206c837aa1aec97b89dc202c18ba634b4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Jul 2026 20:49:22 +0200 Subject: [PATCH 1/9] Add mobile screenshot harness Co-authored-by: codex --- .gitignore | 3 + apps/mobile/index.ts | 5 + .../ios/T3NativeControlsModule.swift | 15 + .../modules/t3terminal/T3TerminalModule.kt | 4 + .../expo/modules/t3terminal/T3TerminalView.kt | 18 + .../t3-terminal/ios/T3TerminalModule.swift | 4 + .../t3-terminal/ios/T3TerminalView.swift | 13 +- apps/mobile/package.json | 2 + apps/mobile/src/App.tsx | 60 +- apps/mobile/src/Stack.tsx | 30 +- apps/mobile/src/features/home/HomeScreen.tsx | 11 +- .../features/showcase/ShowcaseRouteScreen.tsx | 388 ++++++++ .../features/showcase/nativeShowcaseScene.ts | 34 + .../features/showcase/showcaseData.test.ts | 39 + .../src/features/showcase/showcaseData.ts | 306 +++++++ .../terminal/NativeTerminalSurface.tsx | 2 + .../features/terminal/nativeTerminalModule.ts | 1 + .../mobile-app-store-screenshots.md | 96 ++ package.json | 1 + scripts/mobile-showcase.config.ts | 77 ++ scripts/mobile-showcase.test.ts | 82 ++ scripts/mobile-showcase.ts | 837 ++++++++++++++++++ 22 files changed, 1991 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/src/features/showcase/ShowcaseRouteScreen.tsx create mode 100644 apps/mobile/src/features/showcase/nativeShowcaseScene.ts create mode 100644 apps/mobile/src/features/showcase/showcaseData.test.ts create mode 100644 apps/mobile/src/features/showcase/showcaseData.ts create mode 100644 docs/operations/mobile-app-store-screenshots.md create mode 100644 scripts/mobile-showcase.config.ts create mode 100644 scripts/mobile-showcase.test.ts create mode 100644 scripts/mobile-showcase.ts diff --git a/.gitignore b/.gitignore index ef6067824f2..8e1669c8115 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ squashfs-root/ .gstack/ dist-electron/ .electron-runtime/ +.showcase/ +apps/mobile/.showcase/ +artifacts/app-store/screenshots/ node_modules/ .alchemy/ *.log diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 979dc75d5f1..9b887f58a28 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,6 @@ import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; +import { LogBox } from "react-native"; import { featureFlags } from "react-native-screens"; import App from "./src/App"; @@ -8,4 +9,8 @@ import App from "./src/App"; // native stack is rendered inside a non-fitToContents formSheet. featureFlags.experiment.synchronousScreenUpdatesEnabled = true; +if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { + LogBox.ignoreAllLogs(); +} + registerRootComponent(App); diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 33e1dc9086c..48b593c317b 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -4,6 +4,21 @@ public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Name("T3NativeControls") + Function("getShowcaseScene") { + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcaseScene"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + Function("markShowcaseReady") { (scene: String) in + UserDefaults.standard.set(scene, forKey: "T3ShowcaseReadyScene") + } + View(T3HeaderButtonView.self) { Prop("label") { (view: T3HeaderButtonView, label: String) in view.setLabel(label) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index 44840eb570e..1631c7fe68a 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -30,6 +30,10 @@ class T3TerminalModule : Module() { view.focusRequest = focusRequest } + Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean -> + view.autoFocus = autoFocus + } + Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> view.appearanceScheme = appearanceScheme } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index fcc6092dbee..88de793a8f7 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -76,6 +76,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } + var autoFocus: Boolean = true + set(value) { + field = value + if (value) { + requestKeyboardFocus() + } else { + inputView.clearFocus() + hideKeyboard() + } + } + var backgroundColorHex: String = "#24292E" set(value) { field = value @@ -368,6 +379,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex inputMethodManager?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) } + private fun hideKeyboard() { + val inputMethodManager = context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as? InputMethodManager + inputMethodManager?.hideSoftInputFromWindow(windowToken, 0) + } + private fun applyTheme() { setBackgroundColor(backgroundColorValue) container.setBackgroundColor(backgroundColorValue) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 39e1874d5d7..d5eadf189ae 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -27,6 +27,10 @@ public class T3TerminalModule: Module { view.focusRequest = focusRequest } + Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in + view.autoFocus = autoFocus + } + Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in view.appearanceScheme = appearanceScheme } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 14cdb4b7802..ebfee6a5952 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -233,6 +233,17 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + var autoFocus = true { + didSet { + guard oldValue != autoFocus else { return } + if autoFocus { + requestKeyboardFocus() + } else { + inputField.resignFirstResponder() + } + } + } + var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { didSet { guard oldValue != appearanceScheme else { return } @@ -344,7 +355,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { public override func didMoveToWindow() { super.didMoveToWindow() - guard window != nil else { return } + guard window != nil, autoFocus else { return } DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 1f87560c8ca..44ecbfe1f13 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,6 +11,8 @@ "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", "start:prod": "APP_VARIANT=production expo start", + "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear", + "screenshots": "node ../../scripts/mobile-showcase.ts", "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index c76d3010081..300bacba31f 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -20,6 +20,8 @@ import { useThemeColor } from "./lib/useThemeColor"; import "../global.css"; +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], // The Expo dev client launches the app via @@ -39,39 +41,41 @@ export default function App() { SplashScreen.hide(); }, []); - return ( - - - - - - - - {/* The navigation theme drives the NATIVE header appearance: native-stack + const content = ( + + + + + + {/* The navigation theme drives the NATIVE header appearance: native-stack forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without this, React Navigation defaults to its light theme and every native header (glass buttons, title, materials) is forced light even when the system is in dark mode. */} - {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} - - - - - {/* Anchored-menu overlays render here — in-window, so the + {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} + + + + + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} - - - - - - + + + + + + ); + + return ( + + {SHOWCASE_ENABLED ? content : {content}} ); } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index c2abb50c559..b072dc3219d 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -47,6 +47,8 @@ import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnv import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; +import { ShowcaseRouteScreen } from "./features/showcase/ShowcaseRouteScreen"; +import { getNativeShowcaseScene } from "./features/showcase/nativeShowcaseScene"; import { SettingsLegalDocumentCloseHeaderButton, SettingsLegalDocumentExternalHeaderButton, @@ -56,6 +58,7 @@ import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; // Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header // background stay STATIC config while still adapting to appearance changes. @@ -286,13 +289,18 @@ function RootStackLayout(props: { const path = getPathFromState(props.state, navigationPathConfig); const pathname = path.startsWith("/") ? path : `/${path}`; const workspacePathname = workspacePathFromState(props.state); + const isShowcaseRoute = pathname.startsWith("/showcase/"); return ( - - {props.children} - + {isShowcaseRoute ? ( + props.children + ) : ( + + {props.children} + + )} ); @@ -338,7 +346,7 @@ function NotFoundScreen() { } export const RootStack = createNativeStackNavigator({ - initialRouteName: "Home", + initialRouteName: SHOWCASE_ENABLED ? "Showcase" : "Home", layout: RootStackLayout, screenOptions: { headerShown: false, @@ -354,6 +362,20 @@ export const RootStack = createNativeStackNavigator({ title: "Threads", }, }), + ...(SHOWCASE_ENABLED + ? { + Showcase: createNativeStackScreen({ + screen: ShowcaseRouteScreen, + linking: "showcase/:scene", + initialParams: { scene: getNativeShowcaseScene() }, + options: { + ...GLASS_HEADER_OPTIONS, + contentStyle: { backgroundColor: "transparent" }, + headerBackVisible: false, + }, + }), + } + : {}), Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: THREAD_LINKING_PREFIX, diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0807304631d..99d0307ac1b 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -61,6 +61,8 @@ interface HomeScreenProps { readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; + /** Showcase/test surfaces can opt out so persisted collapse state cannot change the frame. */ + readonly persistGroupDisplayState?: boolean; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; @@ -167,10 +169,11 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); + const persistGroupDisplayState = props.persistGroupDisplayState ?? true; const effectiveGroupDisplayStates = useMemo(() => { const next = new Map(groupDisplayStates); - if (!AsyncResult.isSuccess(preferencesResult)) { + if (!persistGroupDisplayState || !AsyncResult.isSuccess(preferencesResult)) { return next; } for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { @@ -181,7 +184,7 @@ export function HomeScreen(props: HomeScreenProps) { }); } return next; - }, [groupDisplayStates, preferencesResult]); + }, [groupDisplayStates, persistGroupDisplayState, preferencesResult]); const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; @@ -191,7 +194,7 @@ export function HomeScreen(props: HomeScreenProps) { next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); effectiveGroupDisplayStatesRef.current = next; setGroupDisplayStates(next); - if (action === "toggle-collapsed") { + if (action === "toggle-collapsed" && persistGroupDisplayState) { const collapsedProjectGroups: string[] = []; for (const [groupKey, state] of next) { if (state.collapsed) { @@ -201,7 +204,7 @@ export function HomeScreen(props: HomeScreenProps) { savePreferences({ collapsedProjectGroups }); } }, - [savePreferences], + [persistGroupDisplayState, savePreferences], ); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { diff --git a/apps/mobile/src/features/showcase/ShowcaseRouteScreen.tsx b/apps/mobile/src/features/showcase/ShowcaseRouteScreen.tsx new file mode 100644 index 00000000000..bc5bf7c2de1 --- /dev/null +++ b/apps/mobile/src/features/showcase/ShowcaseRouteScreen.tsx @@ -0,0 +1,388 @@ +import { useEffect, useMemo } from "react"; +import { + Keyboard, + Platform, + ScrollView, + StyleSheet, + useColorScheme, + useWindowDimensions, + View, +} from "react-native"; +import { SymbolView } from "expo-symbols"; +import type { StaticScreenProps } from "@react-navigation/native"; + +import { AppText as Text } from "../../components/AppText"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import type { WorkspaceState } from "../../state/workspaceModel"; +import { HomeHeader } from "../home/HomeHeader"; +import { HomeScreen } from "../home/HomeScreen"; +import { ThreadDetailScreen } from "../threads/ThreadDetailScreen"; +import { ThreadListGroupHeader, ThreadListRow } from "../threads/thread-list-items"; +import { TerminalSurface } from "../terminal/NativeTerminalSurface"; +import { getPierreTerminalTheme } from "../terminal/terminalTheme"; +import { + NATIVE_REVIEW_DIFF_CONTENT_WIDTH, + buildNativeReviewDiffData, + createNativeReviewDiffStyle, + createNativeReviewDiffTheme, +} from "../review/nativeReviewDiffAdapter"; +import { buildReviewParsedDiff } from "../review/reviewModel"; +import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + SHOWCASE_DIFF, + SHOWCASE_ENVIRONMENT_ID, + SHOWCASE_SCENES, + SHOWCASE_TERMINAL_BUFFER, + createShowcaseFixture, + type ShowcaseFixture, + type ShowcaseScene, +} from "./showcaseData"; +import { markNativeShowcaseReady } from "./nativeShowcaseScene"; + +const NOOP = () => undefined; +const NOOP_ASYNC = async () => undefined; +const ANDROID_SHOWCASE_TERMINAL_BUFFER = [ + "\u001b[38;5;75m", + "\u001b[38;5;212m", + "\u001b[32m", + "\u001b[0m", +].reduce((buffer, sequence) => buffer.replaceAll(sequence, ""), SHOWCASE_TERMINAL_BUFFER); +const CONNECTED_WORKSPACE: WorkspaceState = { + isLoadingConnections: false, + hasConnections: true, + hasLoadedShellSnapshot: true, + hasPendingShellSnapshot: false, + hasReadyEnvironment: true, + hasConnectingEnvironment: false, + connectingEnvironments: [], + connectionState: "connected", + connectionError: null, + shellSnapshotError: null, + latestCachedSnapshotReceivedAt: null, + networkStatus: "online", +}; + +function resolveScene(value: string | string[] | undefined): ShowcaseScene { + const candidate = Array.isArray(value) ? value[0] : value; + return SHOWCASE_SCENES.find((scene) => scene === candidate) ?? "thread"; +} + +function ShowcaseSidebar(props: { readonly fixture: ShowcaseFixture }) { + return ( + + + + + Threads + + {props.fixture.environmentLabel} + + + + + + + + + Search projects and threads + + + + + + {props.fixture.threads.map((thread, index) => ( + + ))} + + + + ); +} + +function ShowcaseThread(props: { readonly fixture: ShowcaseFixture; readonly split: boolean }) { + const thread = props.fixture.selectedThread; + return ( + + null} + onStopThread={NOOP} + onSubmitUserInput={NOOP_ASYNC} + onUpdateThreadInteractionMode={NOOP} + onUpdateThreadModelSelection={NOOP} + onUpdateThreadRuntimeMode={NOOP} + /> + + ); +} + +function ShowcaseTerminal() { + const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; + const theme = getPierreTerminalTheme(appearanceScheme); + return ( + + + + + feat/command-palette + + zsh · 104 × 32 + + + + ); +} + +function ShowcaseReview() { + const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; + const { codeSurface } = useAppearanceCodeSurface(); + const NativeReviewDiffView = resolveNativeReviewDiffView(); + const parsedDiff = useMemo(() => buildReviewParsedDiff(SHOWCASE_DIFF, "showcase"), []); + const data = useMemo(() => buildNativeReviewDiffData(parsedDiff), [parsedDiff]); + const theme = useMemo(() => createNativeReviewDiffTheme(appearanceScheme), [appearanceScheme]); + const style = useMemo(() => createNativeReviewDiffStyle(codeSurface), [codeSurface]); + + return ( + + + + Ready to review + 2 files changed + + + +19 + −4 + + + {NativeReviewDiffView ? ( + + + + ) : ( + + {SHOWCASE_DIFF} + + )} + + ); +} + +function ShowcaseThreads(props: { readonly fixture: ShowcaseFixture }) { + return ( + <> + + + + ); +} + +type ShowcaseRouteProps = StaticScreenProps<{ readonly scene?: string }>; + +export function ShowcaseRouteScreen(props: ShowcaseRouteProps) { + const scene = resolveScene(props.route.params?.scene); + const { width } = useWindowDimensions(); + const split = width >= 760; + + useEffect(() => { + if (scene === "terminal") Keyboard.dismiss(); + let readyFrame: number | null = null; + const renderFrame = requestAnimationFrame(() => { + readyFrame = requestAnimationFrame(() => markNativeShowcaseReady(scene)); + }); + return () => { + cancelAnimationFrame(renderFrame); + if (readyFrame !== null) cancelAnimationFrame(readyFrame); + }; + }, [scene]); + const fixture = useMemo(() => createShowcaseFixture(), []); + const sheetColor = useThemeColor("--color-sheet"); + const usesSolidHeader = split || scene === "terminal" || scene === "review"; + const title = + scene === "threads" + ? "Threads" + : scene === "terminal" + ? "Terminal" + : scene === "review" + ? "Review changes" + : fixture.selectedThread.title; + + return ( + + + {split ? ( + + + {scene === "terminal" ? ( + + + + ) : scene === "review" ? ( + + + + ) : ( + + )} + + ) : scene === "threads" ? ( + + ) : scene === "terminal" ? ( + + ) : scene === "review" ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts new file mode 100644 index 00000000000..27c333fdfcd --- /dev/null +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -0,0 +1,34 @@ +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; + +import { SHOWCASE_SCENES, type ShowcaseScene } from "./showcaseData"; + +interface NativeShowcaseControls { + readonly getShowcaseScene?: () => string | null; + readonly markShowcaseReady?: (scene: ShowcaseScene) => void; +} + +function nativeShowcaseControls(): NativeShowcaseControls | null { + return requireOptionalNativeModule("T3NativeControls"); +} + +export function getNativeShowcaseScene(): ShowcaseScene { + if (Platform.OS !== "ios") return "thread"; + + try { + const value = nativeShowcaseControls()?.getShowcaseScene?.(); + return SHOWCASE_SCENES.find((scene) => scene === value) ?? "thread"; + } catch { + return "thread"; + } +} + +export function markNativeShowcaseReady(scene: ShowcaseScene): void { + if (Platform.OS !== "ios") return; + + try { + nativeShowcaseControls()?.markShowcaseReady?.(scene); + } catch { + // The readiness marker is capture-runner metadata, never app functionality. + } +} diff --git a/apps/mobile/src/features/showcase/showcaseData.test.ts b/apps/mobile/src/features/showcase/showcaseData.test.ts new file mode 100644 index 00000000000..a49abb40840 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseData.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildReviewParsedDiff } from "../review/reviewModel"; +import { + SHOWCASE_DIFF, + SHOWCASE_NOW, + SHOWCASE_SCENES, + SHOWCASE_TERMINAL_BUFFER, + createShowcaseFixture, +} from "./showcaseData"; + +describe("showcase fixture", () => { + it("stays deterministic for a supplied clock", () => { + const now = Date.parse("2026-07-15T09:41:00.000Z"); + expect(createShowcaseFixture(now)).toEqual(createShowcaseFixture(now)); + }); + + it("uses a fixed default clock across device captures", () => { + expect(createShowcaseFixture()).toEqual(createShowcaseFixture(SHOWCASE_NOW)); + }); + + it("contains distinct scenes and enough polished content for captures", () => { + const fixture = createShowcaseFixture(Date.parse("2026-07-15T09:41:00.000Z")); + expect(new Set(SHOWCASE_SCENES).size).toBe(SHOWCASE_SCENES.length); + expect(fixture.threads).toHaveLength(4); + expect(fixture.feed.filter((entry) => entry.type === "message")).toHaveLength(2); + expect(SHOWCASE_TERMINAL_BUFFER).toContain("All checks passed"); + }); + + it("uses a parseable multi-file review diff", () => { + const parsed = buildReviewParsedDiff(SHOWCASE_DIFF, "showcase-test"); + expect(parsed.kind).toBe("files"); + if (parsed.kind === "files") { + expect(parsed.fileCount).toBe(2); + expect(parsed.additions).toBeGreaterThan(10); + expect(parsed.deletions).toBeGreaterThan(0); + } + }); +}); diff --git a/apps/mobile/src/features/showcase/showcaseData.ts b/apps/mobile/src/features/showcase/showcaseData.ts new file mode 100644 index 00000000000..e40621fcd3b --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseData.ts @@ -0,0 +1,306 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; + +import type { ThreadFeedEntry } from "../../lib/threadActivity"; + +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review"] as const; +export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; + +export const SHOWCASE_ENVIRONMENT_ID = EnvironmentId.make("showcase-studio"); +export const SHOWCASE_PROJECT_ID = ProjectId.make("lumen-notes"); +export const SHOWCASE_THREAD_ID = ThreadId.make("polish-command-palette"); +export const SHOWCASE_NOW = Date.parse("2026-07-15T11:31:00.000Z"); + +const MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", +} as const; + +function minutesBefore(now: number, minutes: number): string { + return new Date(now - minutes * 60_000).toISOString(); +} + +function makeThread( + now: number, + input: { + readonly id: string; + readonly title: string; + readonly branch: string; + readonly minutesAgo: number; + readonly state?: "working" | "approval" | "plan"; + }, +): OrchestrationThreadShell { + const threadId = ThreadId.make(input.id); + const turnId = TurnId.make(`${input.id}-turn`); + const updatedAt = minutesBefore(now, input.minutesAgo); + const isWorking = input.state === "working"; + + return { + id: threadId, + projectId: SHOWCASE_PROJECT_ID, + title: input.title, + modelSelection: MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: input.state === "plan" ? "plan" : "default", + branch: input.branch, + worktreePath: `/Users/alex/Code/lumen-notes/.worktrees/${input.branch}`, + latestTurn: { + turnId, + state: isWorking ? "running" : "completed", + requestedAt: minutesBefore(now, input.minutesAgo + 2), + startedAt: minutesBefore(now, input.minutesAgo + 2), + completedAt: isWorking ? null : updatedAt, + assistantMessageId: isWorking ? null : MessageId.make(`${input.id}-answer`), + }, + createdAt: minutesBefore(now, input.minutesAgo + 120), + updatedAt, + archivedAt: null, + session: { + threadId, + status: isWorking ? "running" : "ready", + providerName: "Codex", + providerInstanceId: MODEL_SELECTION.instanceId, + runtimeMode: "full-access", + activeTurnId: isWorking ? turnId : null, + lastError: null, + updatedAt, + }, + latestUserMessageAt: minutesBefore(now, input.minutesAgo + 1), + hasPendingApprovals: input.state === "approval", + hasPendingUserInput: false, + hasActionableProposedPlan: input.state === "plan", + }; +} + +export interface ShowcaseFixture { + readonly environmentLabel: string; + readonly project: EnvironmentProject; + readonly projects: ReadonlyArray; + readonly selectedThread: EnvironmentThreadShell; + readonly threads: ReadonlyArray; + readonly feed: ReadonlyArray; +} + +export function createShowcaseFixture(now = SHOWCASE_NOW): ShowcaseFixture { + const project: EnvironmentProject = { + environmentId: SHOWCASE_ENVIRONMENT_ID, + id: SHOWCASE_PROJECT_ID, + title: "Lumen Notes", + workspaceRoot: "/Users/alex/Code/lumen-notes", + repositoryIdentity: { + canonicalKey: "github.com/lumen-labs/lumen-notes", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/lumen-labs/lumen-notes.git", + }, + provider: "github", + owner: "lumen-labs", + name: "lumen-notes", + }, + defaultModelSelection: MODEL_SELECTION, + scripts: [ + { + id: "dev", + name: "Dev", + command: "pnpm dev", + icon: "play", + runOnWorktreeCreate: false, + }, + { + id: "test", + name: "Tests", + command: "pnpm test", + icon: "test", + runOnWorktreeCreate: false, + }, + ], + createdAt: minutesBefore(now, 60 * 24 * 30), + updatedAt: minutesBefore(now, 2), + }; + + const selectedThread = { + environmentId: SHOWCASE_ENVIRONMENT_ID, + ...makeThread(now, { + id: String(SHOWCASE_THREAD_ID), + title: "Polish the command palette", + branch: "feat/command-palette", + minutesAgo: 2, + }), + }; + const threads: ReadonlyArray = [ + selectedThread, + { + environmentId: SHOWCASE_ENVIRONMENT_ID, + ...makeThread(now, { + id: "offline-first-sync", + title: "Make sync feel instant", + branch: "feat/offline-sync", + minutesAgo: 14, + state: "working", + }), + }, + { + environmentId: SHOWCASE_ENVIRONMENT_ID, + ...makeThread(now, { + id: "share-sheet", + title: "Add a beautiful share sheet", + branch: "feat/share-sheet", + minutesAgo: 47, + state: "approval", + }), + }, + { + environmentId: SHOWCASE_ENVIRONMENT_ID, + ...makeThread(now, { + id: "editor-motion", + title: "Smooth editor transitions", + branch: "perf/editor-motion", + minutesAgo: 126, + state: "plan", + }), + }, + ]; + + const turnId = TurnId.make("palette-turn"); + const feed: ReadonlyArray = [ + { + type: "message", + id: "palette-request", + createdAt: minutesBefore(now, 8), + message: { + id: MessageId.make("palette-request"), + role: "user", + text: "Make the command palette feel fast, calm, and unmistakably native. Add fuzzy search and keyboard shortcuts.", + turnId, + streaming: false, + createdAt: minutesBefore(now, 8), + updatedAt: minutesBefore(now, 8), + }, + }, + { + type: "activity-group", + id: "palette-work", + createdAt: minutesBefore(now, 6), + turnId, + activities: [ + { + id: "inspect-components", + createdAt: minutesBefore(now, 7), + turnId, + summary: "Explored the navigation and command registry", + detail: "Found shared command metadata and keyboard routing", + fullDetail: null, + copyText: "Explored the navigation and command registry", + icon: "eye", + toolLike: true, + status: "success", + }, + { + id: "edit-palette", + createdAt: minutesBefore(now, 6), + turnId, + summary: "Built the new palette experience", + detail: "6 files changed · fuzzy ranking · native shortcuts", + fullDetail: null, + copyText: "Built the new palette experience", + icon: "edit", + toolLike: true, + status: "success", + }, + ], + }, + { + type: "message", + id: "palette-answer", + createdAt: minutesBefore(now, 2), + message: { + id: MessageId.make("palette-answer"), + role: "assistant", + text: "The command palette is ready. Search now ranks exact and recent matches first, every action shows its shortcut, and the transition stays smooth even with hundreds of commands.\n\nI also added focused keyboard-navigation tests and verified the full mobile check suite.", + turnId, + streaming: false, + createdAt: minutesBefore(now, 2), + updatedAt: minutesBefore(now, 2), + }, + }, + ]; + + return { + environmentLabel: "Alex’s MacBook Pro", + project, + projects: [project], + selectedThread, + threads, + feed, + }; +} + +export const SHOWCASE_TERMINAL_BUFFER = [ + "\u001b[38;5;75m~/Code/lumen-notes\u001b[0m \u001b[38;5;212mfeat/command-palette\u001b[0m", + "$ pnpm check", + "", + " ✓ lint 1.3s", + " ✓ typecheck 2.1s", + " ✓ unit tests 84 passed", + " ✓ native checks 0 issues", + "", + "\u001b[32mAll checks passed\u001b[0m · ready to ship ✦", + "", + "\u001b[38;5;75m~/Code/lumen-notes\u001b[0m \u001b[38;5;212mfeat/command-palette\u001b[0m $ ", +].join("\r\n"); + +export const SHOWCASE_DIFF = `diff --git a/apps/mobile/src/CommandPalette.tsx b/apps/mobile/src/CommandPalette.tsx +index c45a8f1..9e421ad 100644 +--- a/apps/mobile/src/CommandPalette.tsx ++++ b/apps/mobile/src/CommandPalette.tsx +@@ -18,9 +18,16 @@ export function CommandPalette({ commands }: Props) { +- const visibleCommands = commands.filter((command) => +- command.label.toLowerCase().includes(query.toLowerCase()), +- ); ++ const visibleCommands = rankCommands(commands, { ++ query, ++ recentCommandIds, ++ limit: 12, ++ }); + + return ( +- ++ ++ + + +diff --git a/apps/mobile/src/rankCommands.ts b/apps/mobile/src/rankCommands.ts +new file mode 100644 +index 0000000..71a6d09 +--- /dev/null ++++ b/apps/mobile/src/rankCommands.ts +@@ -0,0 +1,11 @@ ++export function rankCommands(commands: Command[], input: RankInput) { ++ const query = input.query.trim().toLocaleLowerCase(); ++ return commands ++ .map((command) => ({ ++ command, ++ score: fuzzyScore(command.label, query), ++ })) ++ .filter((match) => match.score > 0) ++ .sort((left, right) => right.score - left.score) ++ .slice(0, input.limit) ++ .map((match) => match.command); ++} +`; diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 68d214193bd..35ea35589c8 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -37,6 +37,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly buffer: string; readonly fontSize?: number; readonly isRunning: boolean; + readonly autoFocus?: boolean; readonly keyboardFocusRequest?: number; readonly theme?: TerminalTheme; readonly onInput: (data: string) => void; @@ -214,6 +215,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf ; +} + +export interface ShowcaseAndroidDevice { + readonly id: string; + readonly platform: "android"; + /** Exact name from `emulator -list-avds`. */ + readonly avd: string; + readonly appearance: "light" | "dark"; + /** Native ABI used by the AVD, from its config.ini `abi.type`. */ + readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a"; + readonly scenes: ReadonlyArray; + /** Optional capture viewport. Omit to use the AVD's native size and density. */ + readonly viewport?: { + readonly width: number; + readonly height: number; + readonly density?: number; + }; +} + +export type ShowcaseDevice = ShowcaseIosDevice | ShowcaseAndroidDevice; + +export interface ShowcaseConfig { + readonly outputDirectory: string; + readonly metroPort: number; + readonly settleDelayMs: number; + readonly devices: ReadonlyArray; +} + +/** + * The defaults cover the current large iPhone, 13-inch iPad, and a flagship + * Pixel AVD. Edit this matrix (or pass --device / --scene) without changing + * the runner. Simulator and AVD names are intentionally explicit so captures + * never silently move to a different screen class after an SDK update. + */ +const config: ShowcaseConfig = { + outputDirectory: "artifacts/app-store/screenshots", + // Dedicated port so the harness cannot attach to a normal mobile dev server + // (or a second worktree) and capture the wrong bundle. + metroPort: 8199, + settleDelayMs: 2_500, + devices: [ + { + id: "iphone-6.9", + platform: "ios", + simulator: "iPhone 17 Pro Max", + appearance: "dark", + scenes: ["thread", "terminal", "review", "threads"], + }, + { + id: "ipad-13", + platform: "ios", + simulator: "iPad Pro 13-inch (M5)", + appearance: "dark", + scenes: ["thread", "terminal", "review"], + }, + { + id: "pixel", + platform: "android", + avd: "Pixel_10_Pro", + abi: "arm64-v8a", + appearance: "dark", + scenes: ["thread", "terminal", "review", "threads"], + }, + ], +}; + +export default config; diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts new file mode 100644 index 00000000000..5c60daf54a1 --- /dev/null +++ b/scripts/mobile-showcase.test.ts @@ -0,0 +1,82 @@ +import { assert, it } from "@effect/vitest"; + +import type { ShowcaseConfig } from "./mobile-showcase.config.ts"; +import { + parseShowcaseCliArgs, + planShowcaseCaptures, + readPngDimensions, + selectLanIpv4Address, +} from "./mobile-showcase.ts"; + +const config: ShowcaseConfig = { + outputDirectory: "artifacts", + metroPort: 8199, + settleDelayMs: 1, + devices: [ + { + id: "phone", + platform: "ios", + simulator: "iPhone Test", + appearance: "dark", + scenes: ["thread", "review"], + }, + { + id: "pixel", + platform: "android", + avd: "Pixel_Test", + appearance: "light", + scenes: ["thread", "terminal"], + }, + ], +}; + +it("parses repeatable capture filters", () => { + const options = parseShowcaseCliArgs([ + "--platform", + "ios", + "--device", + "phone", + "--scene", + "review", + "--skip-build", + ]); + assert.deepStrictEqual([...options.platforms], ["ios"]); + assert.deepStrictEqual([...options.deviceIds], ["phone"]); + assert.deepStrictEqual([...options.scenes], ["review"]); + assert.equal(options.skipBuild, true); +}); + +it("plans only scenes supported by each selected device", () => { + const options = parseShowcaseCliArgs(["--platform", "all", "--scene", "terminal"]); + const captures = planShowcaseCaptures(config, options); + assert.deepStrictEqual( + captures.map((capture) => ({ id: capture.device.id, scenes: capture.scenes })), + [{ id: "pixel", scenes: ["terminal"] }], + ); +}); + +it("rejects unknown devices instead of silently capturing another target", () => { + const options = parseShowcaseCliArgs(["--device", "missing"]); + assert.throws(() => planShowcaseCaptures(config, options), /Unknown device 'missing'/u); +}); + +it("reads captured PNG dimensions from the IHDR header", () => { + const bytes = new Uint8Array(24); + bytes.set([137, 80, 78, 71, 13, 10, 26, 10]); + const view = new DataView(bytes.buffer); + view.setUint32(16, 1320); + view.setUint32(20, 2868); + assert.deepStrictEqual(readPngDimensions(bytes), { width: 1320, height: 2868 }); +}); + +it("selects a reachable LAN IPv4 address", () => { + assert.equal( + selectLanIpv4Address([ + { address: "127.0.0.1", family: "IPv4", internal: true }, + { address: "fe80::1", family: "IPv6", internal: false }, + { address: "169.254.2.4", family: "IPv4", internal: false }, + { address: "192.168.1.80", family: "IPv4", internal: false }, + ]), + "192.168.1.80", + ); +}); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts new file mode 100644 index 00000000000..a0d372e880c --- /dev/null +++ b/scripts/mobile-showcase.ts @@ -0,0 +1,837 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Host-side simulator and emulator automation uses Node subprocess and timing APIs directly. + +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; +import * as NodeURL from "node:url"; + +import showcaseConfig, { + type ShowcaseAndroidDevice, + type ShowcaseConfig, + type ShowcaseDevice, + type ShowcaseIosDevice, + SHOWCASE_SCENES, + type ShowcaseScene, +} from "./mobile-showcase.config.ts"; + +const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); +const ANDROID_PACKAGE = "com.t3tools.t3code.dev"; +const APP_SCHEME = "t3code-dev"; +const IOS_READY_KEY = "T3ShowcaseReadyScene"; +const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64"; +const IOS_APP_PATH = NodePath.join( + MOBILE_ROOT, + ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3CodeDev.app", +); +const ANDROID_APK_PATH = NodePath.join( + MOBILE_ROOT, + "android/app/build/outputs/apk/debug/app-debug.apk", +); +const MOBILE_BUILD_ENV = { + ...NodeProcess.env, + APP_VARIANT: "development", + EXPO_NO_GIT_STATUS: "1", +}; + +interface CliOptions { + readonly platforms: ReadonlySet; + readonly deviceIds: ReadonlySet; + readonly scenes: ReadonlySet; + readonly skipBuild: boolean; + readonly skipMetro: boolean; + readonly keepRunning: boolean; + readonly list: boolean; +} + +export interface ShowcaseCapture { + readonly device: ShowcaseDevice; + readonly scenes: ReadonlyArray; +} + +interface NetworkAddress { + readonly address: string; + readonly family: string; + readonly internal: boolean; +} + +export function selectLanIpv4Address(addresses: ReadonlyArray): string | null { + return ( + addresses.find( + ({ address, family, internal }) => + family === "IPv4" && !internal && !address.startsWith("169.254."), + )?.address ?? null + ); +} + +function lanIpv4Address(): string { + const address = selectLanIpv4Address( + Object.values(NodeOS.networkInterfaces()).flatMap((addresses) => addresses ?? []), + ); + if (!address) { + throw new Error("No LAN IPv4 address is available for the iOS Simulator to reach Metro."); + } + return address; +} + +export function readPngDimensions(bytes: Uint8Array): { + readonly width: number; + readonly height: number; +} { + const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]; + if (bytes.byteLength < 24 || !pngSignature.every((value, index) => bytes[index] === value)) { + throw new Error("Captured file is not a valid PNG."); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { width: view.getUint32(16), height: view.getUint32(20) }; +} + +async function reportCapture(destination: string): Promise { + const dimensions = readPngDimensions(await NodeFSP.readFile(destination)); + NodeProcess.stdout.write( + `Captured ${NodePath.relative(REPO_ROOT, destination)} (${dimensions.width}×${dimensions.height})\n`, + ); +} + +function argumentValue(args: ReadonlyArray, index: number, flag: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value.`); + } + return value; +} + +export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { + const platforms = new Set(); + const deviceIds = new Set(); + const scenes = new Set(); + let skipBuild = false; + let skipMetro = false; + let keepRunning = false; + let list = false; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--platform") { + const value = argumentValue(args, index, argument); + if (value !== "ios" && value !== "android" && value !== "all") { + throw new Error(`Unsupported platform '${value}'. Use ios, android, or all.`); + } + if (value === "all") { + platforms.add("ios"); + platforms.add("android"); + } else { + platforms.add(value); + } + index += 1; + } else if (argument === "--device") { + deviceIds.add(argumentValue(args, index, argument)); + index += 1; + } else if (argument === "--scene") { + const value = argumentValue(args, index, argument); + if (!SHOWCASE_SCENES.includes(value as ShowcaseScene)) { + throw new Error(`Unsupported scene '${value}'. Use ${SHOWCASE_SCENES.join(", ")}.`); + } + scenes.add(value as ShowcaseScene); + index += 1; + } else if (argument === "--skip-build") { + skipBuild = true; + } else if (argument === "--skip-metro") { + skipMetro = true; + } else if (argument === "--keep-running") { + keepRunning = true; + } else if (argument === "--list") { + list = true; + } else if (argument === "--help" || argument === "-h") { + list = true; + } else { + throw new Error(`Unknown option '${argument}'.`); + } + } + + return { + platforms, + deviceIds, + scenes, + skipBuild, + skipMetro, + keepRunning, + list, + }; +} + +export function planShowcaseCaptures( + config: ShowcaseConfig, + options: Pick, +): ReadonlyArray { + const captures = config.devices + .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform)) + .filter((device) => options.deviceIds.size === 0 || options.deviceIds.has(device.id)) + .map((device) => ({ + device, + scenes: + options.scenes.size === 0 + ? device.scenes + : device.scenes.filter((scene) => options.scenes.has(scene)), + })) + .filter((capture) => capture.scenes.length > 0); + + const knownDeviceIds = new Set(config.devices.map((device) => device.id)); + for (const id of options.deviceIds) { + if (!knownDeviceIds.has(id)) { + throw new Error(`Unknown device '${id}'. Run with --list to see configured devices.`); + } + } + if (captures.length === 0) { + throw new Error("No captures match the selected platform, device, and scene filters."); + } + return captures; +} + +function printUsage(config: ShowcaseConfig): void { + NodeProcess.stdout.write(`App screenshot showcase + +Usage: + pnpm --filter @t3tools/mobile screenshots [options] + +Options: + --platform ios|android|all Capture one platform (repeatable) + --device Capture one configured device (repeatable) + --scene Capture one scene (repeatable) + --skip-build Reuse the existing simulator app / debug APK + --skip-metro Reuse an already running showcase Metro server + --keep-running Leave devices and Metro running after capture + --list Print this help and the configured matrix + +Scenes: ${SHOWCASE_SCENES.join(", ")} + +Configured devices: +${config.devices + .map((device) => { + const target = device.platform === "ios" ? device.simulator : device.avd; + return ` ${device.id.padEnd(14)} ${device.platform.padEnd(8)} ${target} [${device.scenes.join(", ")}]`; + }) + .join("\n")} +`); +} + +function spawnProcess( + command: string, + args: ReadonlyArray, + options: NodeChildProcess.SpawnOptions = {}, +): NodeChildProcess.ChildProcess { + return NodeChildProcess.spawn(command, args, { + cwd: REPO_ROOT, + env: NodeProcess.env, + stdio: "inherit", + ...options, + }); +} + +async function runCommand( + command: string, + args: ReadonlyArray, + options: NodeChildProcess.SpawnOptions = {}, +): Promise { + await new Promise((resolve, reject) => { + const child = spawnProcess(command, args, options); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `${command} ${args.join(" ")} failed ${signal ? `with signal ${signal}` : `with code ${String(code)}`}.`, + ), + ); + } + }); + }); +} + +async function commandOutput(command: string, args: ReadonlyArray): Promise { + return await new Promise((resolve, reject) => { + NodeChildProcess.execFile( + command, + [...args], + { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }, + ); + }); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function waitForPort(port: number, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const open = await new Promise((resolve) => { + const socket = NodeNet.createConnection({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); + if (open) return; + await delay(500); + } + throw new Error(`Metro did not begin listening on port ${port} within ${timeoutMs}ms.`); +} + +function startMetro(config: ShowcaseConfig): NodeChildProcess.ChildProcess { + return spawnProcess( + "pnpm", + ["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort), "--clear"], + { + cwd: MOBILE_ROOT, + env: { + ...MOBILE_BUILD_ENV, + EXPO_PUBLIC_SHOWCASE: "1", + }, + }, + ); +} + +async function warmMetroBundle( + platform: ShowcaseDevice["platform"], + host: string, + config: ShowcaseConfig, +): Promise { + const url = `http://${host}:${config.metroPort}/apps/mobile/index.bundle?platform=${platform}&dev=true&minify=false`; + await runCommand("curl", ["--fail", "--silent", "--show-error", "--output", "/dev/null", url]); +} + +async function buildIos(): Promise { + const derivedData = NodePath.join(MOBILE_ROOT, ".showcase/ios-derived-data"); + await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "ios"], { + cwd: MOBILE_ROOT, + env: MOBILE_BUILD_ENV, + }); + await runCommand( + "xcodebuild", + [ + "-workspace", + NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"), + "-scheme", + "T3CodeDev", + "-configuration", + "Debug", + "-sdk", + "iphonesimulator", + "-derivedDataPath", + derivedData, + "-quiet", + `ARCHS=${IOS_SIMULATOR_ARCH}`, + "ONLY_ACTIVE_ARCH=YES", + "CODE_SIGNING_ALLOWED=NO", + "build", + ], + { cwd: MOBILE_ROOT }, + ); + return IOS_APP_PATH; +} + +async function buildAndroid(abis: ReadonlyArray): Promise { + await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "android"], { + cwd: MOBILE_ROOT, + env: MOBILE_BUILD_ENV, + }); + await runCommand( + "./gradlew", + [ + "app:assembleDebug", + ...(abis.length > 0 ? [`-PreactNativeArchitectures=${abis.join(",")}`] : []), + ], + { + cwd: NodePath.join(MOBILE_ROOT, "android"), + }, + ); + return ANDROID_APK_PATH; +} + +async function existingArtifact(path: string): Promise { + return await NodeFSP.access(path).then( + () => path, + () => null, + ); +} + +interface SimctlDevice { + readonly name: string; + readonly udid: string; + readonly state: "Booted" | "Shutdown" | string; + readonly isAvailable: boolean; +} + +async function findIosSimulator(name: string): Promise { + const parsed = JSON.parse( + await commandOutput("xcrun", ["simctl", "list", "devices", "available", "-j"]), + ) as { + readonly devices: Readonly>>; + }; + const candidates = Object.entries(parsed.devices) + .filter(([runtime]) => runtime.includes("iOS")) + .flatMap(([, devices]) => devices) + .filter((device) => device.isAvailable && device.name === name); + const simulator = candidates.at(-1); + if (!simulator) { + throw new Error( + `iOS simulator '${name}' is not installed. Run xcrun simctl list devices available.`, + ); + } + return simulator; +} + +async function normalizeIosSimulator(device: ShowcaseIosDevice, udid: string): Promise { + await runCommand("xcrun", ["simctl", "ui", udid, "appearance", device.appearance]); + await runCommand("xcrun", [ + "simctl", + "status_bar", + udid, + "override", + "--time", + "9:41", + "--batteryState", + "charged", + "--batteryLevel", + "100", + "--wifiBars", + "3", + "--cellularBars", + "4", + ]); +} + +async function iosPreferencesPath(udid: string): Promise { + const appContainer = ( + await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) + ).trim(); + return NodePath.join(appContainer, "Library/Preferences", `${ANDROID_PACKAGE}.plist`); +} + +async function waitForIosShowcaseScene( + udid: string, + scene: ShowcaseScene, + timeoutMs = 90_000, +): Promise { + const preferencesPath = await iosPreferencesPath(udid); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const readyScene = await commandOutput("plutil", [ + "-extract", + IOS_READY_KEY, + "raw", + "-o", + "-", + preferencesPath, + ]).catch(() => ""); + if (readyScene.trim() === scene) return; + await delay(500); + } + throw new Error(`iOS showcase scene '${scene}' did not render within ${timeoutMs}ms.`); +} + +async function captureIos( + capture: ShowcaseCapture & { readonly device: ShowcaseIosDevice }, + appPath: string | null, + outputDirectory: string, + config: ShowcaseConfig, + metroHost: string, +): Promise { + const simulator = await findIosSimulator(capture.device.simulator); + const startedByRunner = simulator.state !== "Booted"; + if (startedByRunner) { + await runCommand("xcrun", ["simctl", "boot", simulator.udid]); + } + await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]); + await normalizeIosSimulator(capture.device, simulator.udid); + if (appPath) { + await runCommand("xcrun", ["simctl", "install", simulator.udid, appPath]); + } + + for (const [key, value] of [ + ["EXDevMenuIsOnboardingFinished", "true"], + ["EXDevMenuShowFloatingActionButton", "false"], + ["EXDevMenuShowsAtLaunch", "false"], + ] as const) { + await runCommand("xcrun", [ + "simctl", + "spawn", + simulator.udid, + "defaults", + "write", + ANDROID_PACKAGE, + key, + "-bool", + value, + ]); + } + + const metroUrl = `http://${metroHost}:${config.metroPort}?disableOnboarding=1`; + + for (const scene of capture.scenes) { + await runCommand("xcrun", ["simctl", "terminate", simulator.udid, ANDROID_PACKAGE]).catch( + () => undefined, + ); + const preferencesPath = await iosPreferencesPath(simulator.udid); + await runCommand("plutil", ["-remove", IOS_READY_KEY, preferencesPath]).catch(() => undefined); + await runCommand("xcrun", [ + "simctl", + "launch", + simulator.udid, + ANDROID_PACKAGE, + "--initialUrl", + metroUrl, + "--showcaseScene", + scene, + ]); + await waitForIosShowcaseScene(simulator.udid, scene); + await delay(config.settleDelayMs); + const destination = NodePath.join(outputDirectory, `${capture.device.id}-${scene}.png`); + await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); + await reportCapture(destination); + } + return startedByRunner; +} + +function androidSdkTool(relativePath: string): string { + const sdkRoot = + NodeProcess.env.ANDROID_HOME ?? + NodePath.join(NodeProcess.env.HOME ?? "", "Library/Android/sdk"); + return NodePath.join(sdkRoot, relativePath); +} + +async function adbOutput(serial: string, args: ReadonlyArray): Promise { + return await commandOutput(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]); +} + +async function runAdb(serial: string, args: ReadonlyArray): Promise { + await runCommand(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]); +} + +async function runningAndroidAvds(): Promise> { + const adb = androidSdkTool("platform-tools/adb"); + const devices = (await commandOutput(adb, ["devices"])) + .split("\n") + .map((line) => line.trim().split(/\s+/u)) + .filter((parts) => parts[0]?.startsWith("emulator-") && parts[1] === "device") + .map((parts) => parts[0] as string); + const result = new Map(); + for (const serial of devices) { + const avdName = (await adbOutput(serial, ["emu", "avd", "name"])).split("\n")[0]?.trim(); + if (avdName) result.set(avdName, serial); + } + return result; +} + +async function waitForAndroidSerial(avd: string, timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const serial = (await runningAndroidAvds()).get(avd); + if (serial) { + await runAdb(serial, ["wait-for-device"]); + const bootCompleted = ( + await adbOutput(serial, ["shell", "getprop", "sys.boot_completed"]) + ).trim(); + if (bootCompleted === "1") return serial; + } + await delay(1_000); + } + throw new Error(`Android AVD '${avd}' did not finish booting within ${timeoutMs}ms.`); +} + +async function normalizeAndroidEmulator( + device: ShowcaseAndroidDevice, + serial: string, +): Promise { + await runAdb(serial, ["shell", "settings", "put", "global", "window_animation_scale", "0"]); + await runAdb(serial, ["shell", "settings", "put", "global", "transition_animation_scale", "0"]); + await runAdb(serial, ["shell", "settings", "put", "global", "animator_duration_scale", "0"]); + await runAdb(serial, [ + "shell", + "cmd", + "uimode", + "night", + device.appearance === "dark" ? "yes" : "no", + ]); + await runAdb(serial, ["shell", "settings", "put", "system", "time_12_24", "12"]); + await runAdb(serial, ["emu", "power", "capacity", "100"]); + await runAdb(serial, ["shell", "settings", "put", "global", "sysui_demo_allowed", "1"]); + await runAdb(serial, [ + "shell", + "am", + "broadcast", + "-a", + "com.android.systemui.demo", + "-e", + "command", + "enter", + ]); + await runAdb(serial, [ + "shell", + "am", + "broadcast", + "-a", + "com.android.systemui.demo", + "-e", + "command", + "clock", + "-e", + "hhmm", + "0941", + ]); + await runAdb(serial, [ + "shell", + "am", + "broadcast", + "-a", + "com.android.systemui.demo", + "-e", + "command", + "battery", + "-e", + "level", + "100", + "-e", + "plugged", + "false", + ]); + if (device.viewport) { + await runAdb(serial, [ + "shell", + "wm", + "size", + `${device.viewport.width}x${device.viewport.height}`, + ]); + if (device.viewport.density) { + await runAdb(serial, ["shell", "wm", "density", String(device.viewport.density)]); + } + } +} + +async function waitForAndroidShowcaseScene( + serial: string, + scene: ShowcaseScene, + timeoutMs = 90_000, +): Promise { + const marker = `showcase-ready-${scene}`; + const hierarchyPath = "/sdcard/t3-showcase-window.xml"; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await adbOutput(serial, [ + "shell", + "am", + "start", + "-W", + "-a", + "android.intent.action.VIEW", + "-d", + `${APP_SCHEME}://showcase/${scene}`, + ANDROID_PACKAGE, + ]).catch(() => ""); + await delay(750); + await adbOutput(serial, ["shell", "rm", "-f", hierarchyPath]).catch(() => ""); + const hierarchy = await adbOutput(serial, ["shell", "uiautomator", "dump", hierarchyPath]) + .then(() => adbOutput(serial, ["shell", "cat", hierarchyPath])) + .catch(() => ""); + if (hierarchy.includes(marker)) return; + await delay(500); + } + throw new Error(`Android showcase scene '${scene}' did not render within ${timeoutMs}ms.`); +} + +async function captureAndroid( + capture: ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, + apkPath: string | null, + outputDirectory: string, + config: ShowcaseConfig, +): Promise<{ readonly startedByRunner: boolean; readonly serial: string }> { + const running = await runningAndroidAvds(); + const existingSerial = running.get(capture.device.avd); + const startedByRunner = !existingSerial; + if (startedByRunner) { + const installedAvds = (await commandOutput(androidSdkTool("emulator/emulator"), ["-list-avds"])) + .split("\n") + .map((value) => value.trim()); + if (!installedAvds.includes(capture.device.avd)) { + throw new Error( + `Android AVD '${capture.device.avd}' is not installed. Run emulator -list-avds.`, + ); + } + spawnProcess( + androidSdkTool("emulator/emulator"), + ["-avd", capture.device.avd, "-no-snapshot-load", "-no-boot-anim"], + { stdio: "ignore", detached: true }, + ).unref(); + } + const serial = existingSerial ?? (await waitForAndroidSerial(capture.device.avd)); + await normalizeAndroidEmulator(capture.device, serial); + if (apkPath) { + await runAdb(serial, ["install", "-r", apkPath]); + } + await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]); + await runAdb(serial, ["shell", "am", "force-stop", ANDROID_PACKAGE]); + const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`); + await runAdb(serial, [ + "shell", + "am", + "start", + "-W", + "-a", + "android.intent.action.VIEW", + "-d", + `${APP_SCHEME}://expo-development-client/?url=${metroUrl}`, + ANDROID_PACKAGE, + ]); + for (const scene of capture.scenes) { + await waitForAndroidShowcaseScene(serial, scene); + await delay(Math.max(config.settleDelayMs, 5_000)); + const destination = NodePath.join(outputDirectory, `${capture.device.id}-${scene}.png`); + const png = await new Promise((resolve, reject) => { + NodeChildProcess.execFile( + androidSdkTool("platform-tools/adb"), + ["-s", serial, "exec-out", "screencap", "-p"], + { cwd: REPO_ROOT, encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, + (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }, + ); + }); + await NodeFSP.writeFile(destination, png); + await reportCapture(destination); + } + return { startedByRunner, serial }; +} + +async function cleanupAndroidViewport( + device: ShowcaseAndroidDevice, + serial: string, +): Promise { + await runAdb(serial, [ + "shell", + "am", + "broadcast", + "-a", + "com.android.systemui.demo", + "-e", + "command", + "exit", + ]); + if (!device.viewport) return; + await runAdb(serial, ["shell", "wm", "size", "reset"]); + if (device.viewport.density) { + await runAdb(serial, ["shell", "wm", "density", "reset"]); + } +} + +async function main(): Promise { + const options = parseShowcaseCliArgs(NodeProcess.argv.slice(2)); + if (options.list) { + printUsage(showcaseConfig); + return; + } + const captures = planShowcaseCaptures(showcaseConfig, options); + const hasIos = captures.some((capture) => capture.device.platform === "ios"); + const hasAndroid = captures.some((capture) => capture.device.platform === "android"); + const metroHost = hasIos ? lanIpv4Address() : "127.0.0.1"; + const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory); + await NodeFSP.mkdir(outputDirectory, { recursive: true }); + + let metro: NodeChildProcess.ChildProcess | null = null; + const startedIosUdids: string[] = []; + const androidCleanups: Array<{ + readonly device: ShowcaseAndroidDevice; + readonly serial: string; + readonly startedByRunner: boolean; + }> = []; + + try { + if (!options.skipMetro) { + metro = startMetro(showcaseConfig); + await waitForPort(showcaseConfig.metroPort); + await Promise.all([ + hasIos ? warmMetroBundle("ios", metroHost, showcaseConfig) : Promise.resolve(), + hasAndroid ? warmMetroBundle("android", "127.0.0.1", showcaseConfig) : Promise.resolve(), + ]); + } + + const iosAppPath = hasIos + ? options.skipBuild + ? await existingArtifact(IOS_APP_PATH) + : await buildIos() + : null; + const androidAbis = captures.flatMap((capture) => + capture.device.platform === "android" && capture.device.abi ? [capture.device.abi] : [], + ); + const androidApkPath = hasAndroid + ? options.skipBuild + ? await existingArtifact(ANDROID_APK_PATH) + : await buildAndroid([...new Set(androidAbis)]) + : null; + + for (const capture of captures) { + if (capture.device.platform === "ios") { + const simulator = await findIosSimulator(capture.device.simulator); + const started = await captureIos( + capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice }, + iosAppPath, + outputDirectory, + showcaseConfig, + metroHost, + ); + if (started) startedIosUdids.push(simulator.udid); + } else { + const result = await captureAndroid( + capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, + androidApkPath, + outputDirectory, + showcaseConfig, + ); + androidCleanups.push({ device: capture.device, ...result }); + } + } + + NodeProcess.stdout.write( + `\nDone. Screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/\n`, + ); + if (options.keepRunning) { + metro?.unref(); + } + } finally { + if (!options.keepRunning) { + for (const cleanup of androidCleanups) { + await cleanupAndroidViewport(cleanup.device, cleanup.serial).catch(() => undefined); + if (cleanup.startedByRunner) { + await runAdb(cleanup.serial, ["emu", "kill"]).catch(() => undefined); + } + } + for (const udid of startedIosUdids) { + await runCommand("xcrun", ["simctl", "shutdown", udid]).catch(() => undefined); + } + metro?.kill("SIGTERM"); + } + } +} + +if (import.meta.main) { + void main().catch((error: unknown) => { + NodeProcess.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + NodeProcess.exit(1); + }); +} From 668ab02a5a05dca223c31915811146217ca063ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 10:17:44 +0200 Subject: [PATCH 2/9] Use real app routes in screenshot harness Co-authored-by: codex --- .../T3NativeControlsModule.kt | 26 ++ .../ios/T3NativeControlsModule.swift | 28 +- apps/mobile/src/App.tsx | 63 +-- apps/mobile/src/Stack.tsx | 32 +- apps/mobile/src/features/home/HomeScreen.tsx | 12 +- .../src/features/review/ReviewSheet.tsx | 14 + .../showcase/ShowcaseCaptureCoordinator.tsx | 141 +++++++ .../features/showcase/ShowcaseRouteScreen.tsx | 388 ----------------- .../features/showcase/nativeShowcaseScene.ts | 32 +- .../features/showcase/showcaseData.test.ts | 39 -- .../src/features/showcase/showcaseData.ts | 306 -------------- .../terminal/ThreadTerminalRouteScreen.tsx | 2 + .../mobile-app-store-screenshots.md | 121 +++--- scripts/mobile-showcase-environment.ts | 391 ++++++++++++++++++ scripts/mobile-showcase.config.ts | 6 +- scripts/mobile-showcase.test.ts | 25 ++ scripts/mobile-showcase.ts | 304 +++++++++++--- 17 files changed, 1006 insertions(+), 924 deletions(-) create mode 100644 apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx delete mode 100644 apps/mobile/src/features/showcase/ShowcaseRouteScreen.tsx delete mode 100644 apps/mobile/src/features/showcase/showcaseData.test.ts delete mode 100644 apps/mobile/src/features/showcase/showcaseData.ts create mode 100644 scripts/mobile-showcase-environment.ts diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index 4d5f02aaa9c..b15a1cd4428 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -7,6 +7,32 @@ class T3NativeControlsModule : Module() { override fun definition() = ModuleDefinition { Name("T3NativeControls") + Function("getShowcasePairingUrl") { + appContext.currentActivity?.intent?.getStringExtra("showcasePairingUrl") + } + + Function("getShowcaseScene") { + val storedScene = appContext.reactContext + ?.filesDir + ?.resolve("t3-showcase-scene") + ?.takeIf { it.isFile } + ?.readText() + ?.trim() + ?.takeIf { it.isNotEmpty() } + storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene") + } + + Function("prepareShowcaseCapture") { + // Android app data is cleared by the host runner before launch. + } + + Function("markShowcaseReady") { scene: String -> + appContext.reactContext + ?.filesDir + ?.resolve("t3-showcase-ready") + ?.writeText(scene) + } + View(T3HeaderButtonView::class) { Prop("label") { view: T3HeaderButtonView, label: String -> view.setLabel(label) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 48b593c317b..7781b164a2c 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,10 +1,27 @@ import ExpoModulesCore +import Security public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Name("T3NativeControls") - Function("getShowcaseScene") { + Function("getShowcasePairingUrl") { + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcasePairingUrl"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + Function("getShowcaseScene") { () -> String? in + let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene" + if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty { + return storedScene + } let arguments = ProcessInfo.processInfo.arguments guard let flagIndex = arguments.firstIndex(of: "--showcaseScene"), @@ -15,8 +32,15 @@ public final class T3NativeControlsModule: Module { return arguments[flagIndex + 1] } + Function("prepareShowcaseCapture") { + for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { + SecItemDelete([kSecClass as String: itemClass] as CFDictionary) + } + } + Function("markShowcaseReady") { (scene: String) in - UserDefaults.standard.set(scene, forKey: "T3ShowcaseReadyScene") + let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" + try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) } View(T3HeaderButtonView.self) { diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 300bacba31f..ec5382f8339 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -11,6 +11,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati import { RegistryContext } from "@effect/atom-react"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; +import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; @@ -20,7 +21,9 @@ import { useThemeColor } from "./lib/useThemeColor"; import "../global.css"; -const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; +if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { + prepareNativeShowcaseCapture(); +} const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], @@ -41,41 +44,39 @@ export default function App() { SplashScreen.hide(); }, []); - const content = ( - - - - - - {/* The navigation theme drives the NATIVE header appearance: native-stack + return ( + + + + + + + + {/* The navigation theme drives the NATIVE header appearance: native-stack forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without this, React Navigation defaults to its light theme and every native header (glass buttons, title, materials) is forced light even when the system is in dark mode. */} - {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} - - - - - {/* Anchored-menu overlays render here — in-window, so the + {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} + + + + + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} - - - - - - ); - - return ( - - {SHOWCASE_ENABLED ? content : {content}} + + + + + + ); } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b072dc3219d..87b152c566b 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -47,8 +47,7 @@ import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnv import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; -import { ShowcaseRouteScreen } from "./features/showcase/ShowcaseRouteScreen"; -import { getNativeShowcaseScene } from "./features/showcase/nativeShowcaseScene"; +import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, SettingsLegalDocumentExternalHeaderButton, @@ -58,7 +57,6 @@ import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); -const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; // Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header // background stay STATIC config while still adapting to appearance changes. @@ -289,18 +287,14 @@ function RootStackLayout(props: { const path = getPathFromState(props.state, navigationPathConfig); const pathname = path.startsWith("/") ? path : `/${path}`; const workspacePathname = workspacePathFromState(props.state); - const isShowcaseRoute = pathname.startsWith("/showcase/"); return ( + - {isShowcaseRoute ? ( - props.children - ) : ( - - {props.children} - - )} + + {props.children} + ); @@ -346,7 +340,7 @@ function NotFoundScreen() { } export const RootStack = createNativeStackNavigator({ - initialRouteName: SHOWCASE_ENABLED ? "Showcase" : "Home", + initialRouteName: "Home", layout: RootStackLayout, screenOptions: { headerShown: false, @@ -362,20 +356,6 @@ export const RootStack = createNativeStackNavigator({ title: "Threads", }, }), - ...(SHOWCASE_ENABLED - ? { - Showcase: createNativeStackScreen({ - screen: ShowcaseRouteScreen, - linking: "showcase/:scene", - initialParams: { scene: getNativeShowcaseScene() }, - options: { - ...GLASS_HEADER_OPTIONS, - contentStyle: { backgroundColor: "transparent" }, - headerBackVisible: false, - }, - }), - } - : {}), Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: THREAD_LINKING_PREFIX, diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 99d0307ac1b..456afc438d9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -61,8 +61,6 @@ interface HomeScreenProps { readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; - /** Showcase/test surfaces can opt out so persisted collapse state cannot change the frame. */ - readonly persistGroupDisplayState?: boolean; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; @@ -169,11 +167,9 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); - const persistGroupDisplayState = props.persistGroupDisplayState ?? true; - const effectiveGroupDisplayStates = useMemo(() => { const next = new Map(groupDisplayStates); - if (!persistGroupDisplayState || !AsyncResult.isSuccess(preferencesResult)) { + if (!AsyncResult.isSuccess(preferencesResult)) { return next; } for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { @@ -184,7 +180,7 @@ export function HomeScreen(props: HomeScreenProps) { }); } return next; - }, [groupDisplayStates, persistGroupDisplayState, preferencesResult]); + }, [groupDisplayStates, preferencesResult]); const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; @@ -194,7 +190,7 @@ export function HomeScreen(props: HomeScreenProps) { next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); effectiveGroupDisplayStatesRef.current = next; setGroupDisplayStates(next); - if (action === "toggle-collapsed" && persistGroupDisplayState) { + if (action === "toggle-collapsed") { const collapsedProjectGroups: string[] = []; for (const [groupKey, state] of next) { if (state.collapsed) { @@ -204,7 +200,7 @@ export function HomeScreen(props: HomeScreenProps) { savePreferences({ collapsedProjectGroups }); } }, - [persistGroupDisplayState, savePreferences], + [savePreferences], ); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index cafc0326d2f..d6902e04ce6 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -71,8 +71,10 @@ import { resolveReviewAvailability } from "./reviewAvailability"; import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; import { buildReviewSectionMenu } from "./review-section-menu"; import type { ReviewSectionItem } from "./reviewModel"; +import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; const REVIEW_HEADER_SPACING = 0; +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( @@ -392,6 +394,11 @@ export function ReviewSheet(props: ReviewSheetProps) { selectedSection, draftMessage, }); + const showcaseReviewReady = SHOWCASE_ENABLED && parsedDiff.kind === "files"; + useEffect(() => { + if (!showcaseReviewReady) return; + markNativeShowcaseReady("review"); + }, [showcaseReviewReady]); const NativeReviewDiffView = resolveNativeReviewDiffView()!; const nativeReviewDiffViewRef = useRef(null); // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item). @@ -612,6 +619,13 @@ export function ReviewSheet(props: ReviewSheetProps) { return ( <> + {showcaseReviewReady ? ( + + ) : null} (null); + const [pairingUrl, setPairingUrl] = useState(null); + const [requestedScene, setRequestedScene] = useState(null); + const [readyScene, setReadyScene] = useState(null); + + useEffect(() => { + if (!SHOWCASE_ENABLED || pairingUrl !== null) return; + + const readPairingUrl = () => { + const value = getNativeShowcasePairingUrl(); + if (value) setPairingUrl(value); + }; + readPairingUrl(); + const interval = setInterval(readPairingUrl, 250); + return () => clearInterval(interval); + }, [pairingUrl]); + + useEffect(() => { + if (!SHOWCASE_ENABLED) return; + + const readRequestedScene = () => { + const value = getNativeShowcaseScene(); + if (value) setRequestedScene(value); + }; + readRequestedScene(); + const interval = setInterval(readRequestedScene, 250); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (!SHOWCASE_ENABLED || pairingUrl === null) return; + if (attemptedPairingRef.current === pairingUrl) return; + attemptedPairingRef.current = pairingUrl; + void connectPairingUrl(pairingUrl); + }, [connectPairingUrl, pairingUrl]); + + const scene = sceneFromPathname(props.pathname); + const hasFixture = + workspaceState.hasReadyEnvironment && + projects.length > 0 && + threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + + useEffect(() => { + if (!SHOWCASE_ENABLED || requestedScene === null || !hasFixture || !showcaseThread) return; + if (scene === requestedScene) return; + + const params = { + environmentId: String(showcaseThread.environmentId), + threadId: SHOWCASE_THREAD_ID, + }; + if (requestedScene === "threads") { + navigation.dispatch(StackActions.replace("Home")); + } else if (requestedScene === "thread") { + navigation.dispatch(StackActions.replace("Thread", params)); + } else if (requestedScene === "terminal") { + navigation.dispatch( + StackActions.replace("ThreadTerminal", { ...params, terminalId: "term-1" }), + ); + } else { + navigation.dispatch(StackActions.replace("ThreadReview", params)); + } + }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); + + useEffect(() => { + if ( + !SHOWCASE_ENABLED || + scene === null || + requestedScene === null || + scene !== requestedScene || + !hasFixture + ) { + setReadyScene(null); + return; + } + // Review owns its readiness marker because route activation happens before + // the VCS request is parsed and the native diff surface is mounted. + if (scene === "review") { + setReadyScene(null); + return; + } + if (scene === "terminal") Keyboard.dismiss(); + + let readyFrame: number | null = null; + const settleTimer = setTimeout(() => { + const renderFrame = requestAnimationFrame(() => { + readyFrame = requestAnimationFrame(() => { + markNativeShowcaseReady(scene); + setReadyScene(scene); + }); + }); + readyFrame = renderFrame; + }, 500); + return () => { + clearTimeout(settleTimer); + if (readyFrame !== null) cancelAnimationFrame(readyFrame); + }; + }, [hasFixture, requestedScene, scene]); + + if (!SHOWCASE_ENABLED || readyScene === null) return null; + + return ( + + ); +} diff --git a/apps/mobile/src/features/showcase/ShowcaseRouteScreen.tsx b/apps/mobile/src/features/showcase/ShowcaseRouteScreen.tsx deleted file mode 100644 index bc5bf7c2de1..00000000000 --- a/apps/mobile/src/features/showcase/ShowcaseRouteScreen.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { - Keyboard, - Platform, - ScrollView, - StyleSheet, - useColorScheme, - useWindowDimensions, - View, -} from "react-native"; -import { SymbolView } from "expo-symbols"; -import type { StaticScreenProps } from "@react-navigation/native"; - -import { AppText as Text } from "../../components/AppText"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import type { WorkspaceState } from "../../state/workspaceModel"; -import { HomeHeader } from "../home/HomeHeader"; -import { HomeScreen } from "../home/HomeScreen"; -import { ThreadDetailScreen } from "../threads/ThreadDetailScreen"; -import { ThreadListGroupHeader, ThreadListRow } from "../threads/thread-list-items"; -import { TerminalSurface } from "../terminal/NativeTerminalSurface"; -import { getPierreTerminalTheme } from "../terminal/terminalTheme"; -import { - NATIVE_REVIEW_DIFF_CONTENT_WIDTH, - buildNativeReviewDiffData, - createNativeReviewDiffStyle, - createNativeReviewDiffTheme, -} from "../review/nativeReviewDiffAdapter"; -import { buildReviewParsedDiff } from "../review/reviewModel"; -import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; -import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; -import { useThemeColor } from "../../lib/useThemeColor"; -import { - SHOWCASE_DIFF, - SHOWCASE_ENVIRONMENT_ID, - SHOWCASE_SCENES, - SHOWCASE_TERMINAL_BUFFER, - createShowcaseFixture, - type ShowcaseFixture, - type ShowcaseScene, -} from "./showcaseData"; -import { markNativeShowcaseReady } from "./nativeShowcaseScene"; - -const NOOP = () => undefined; -const NOOP_ASYNC = async () => undefined; -const ANDROID_SHOWCASE_TERMINAL_BUFFER = [ - "\u001b[38;5;75m", - "\u001b[38;5;212m", - "\u001b[32m", - "\u001b[0m", -].reduce((buffer, sequence) => buffer.replaceAll(sequence, ""), SHOWCASE_TERMINAL_BUFFER); -const CONNECTED_WORKSPACE: WorkspaceState = { - isLoadingConnections: false, - hasConnections: true, - hasLoadedShellSnapshot: true, - hasPendingShellSnapshot: false, - hasReadyEnvironment: true, - hasConnectingEnvironment: false, - connectingEnvironments: [], - connectionState: "connected", - connectionError: null, - shellSnapshotError: null, - latestCachedSnapshotReceivedAt: null, - networkStatus: "online", -}; - -function resolveScene(value: string | string[] | undefined): ShowcaseScene { - const candidate = Array.isArray(value) ? value[0] : value; - return SHOWCASE_SCENES.find((scene) => scene === candidate) ?? "thread"; -} - -function ShowcaseSidebar(props: { readonly fixture: ShowcaseFixture }) { - return ( - - - - - Threads - - {props.fixture.environmentLabel} - - - - - - - - - Search projects and threads - - - - - - {props.fixture.threads.map((thread, index) => ( - - ))} - - - - ); -} - -function ShowcaseThread(props: { readonly fixture: ShowcaseFixture; readonly split: boolean }) { - const thread = props.fixture.selectedThread; - return ( - - null} - onStopThread={NOOP} - onSubmitUserInput={NOOP_ASYNC} - onUpdateThreadInteractionMode={NOOP} - onUpdateThreadModelSelection={NOOP} - onUpdateThreadRuntimeMode={NOOP} - /> - - ); -} - -function ShowcaseTerminal() { - const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; - const theme = getPierreTerminalTheme(appearanceScheme); - return ( - - - - - feat/command-palette - - zsh · 104 × 32 - - - - ); -} - -function ShowcaseReview() { - const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; - const { codeSurface } = useAppearanceCodeSurface(); - const NativeReviewDiffView = resolveNativeReviewDiffView(); - const parsedDiff = useMemo(() => buildReviewParsedDiff(SHOWCASE_DIFF, "showcase"), []); - const data = useMemo(() => buildNativeReviewDiffData(parsedDiff), [parsedDiff]); - const theme = useMemo(() => createNativeReviewDiffTheme(appearanceScheme), [appearanceScheme]); - const style = useMemo(() => createNativeReviewDiffStyle(codeSurface), [codeSurface]); - - return ( - - - - Ready to review - 2 files changed - - - +19 - −4 - - - {NativeReviewDiffView ? ( - - - - ) : ( - - {SHOWCASE_DIFF} - - )} - - ); -} - -function ShowcaseThreads(props: { readonly fixture: ShowcaseFixture }) { - return ( - <> - - - - ); -} - -type ShowcaseRouteProps = StaticScreenProps<{ readonly scene?: string }>; - -export function ShowcaseRouteScreen(props: ShowcaseRouteProps) { - const scene = resolveScene(props.route.params?.scene); - const { width } = useWindowDimensions(); - const split = width >= 760; - - useEffect(() => { - if (scene === "terminal") Keyboard.dismiss(); - let readyFrame: number | null = null; - const renderFrame = requestAnimationFrame(() => { - readyFrame = requestAnimationFrame(() => markNativeShowcaseReady(scene)); - }); - return () => { - cancelAnimationFrame(renderFrame); - if (readyFrame !== null) cancelAnimationFrame(readyFrame); - }; - }, [scene]); - const fixture = useMemo(() => createShowcaseFixture(), []); - const sheetColor = useThemeColor("--color-sheet"); - const usesSolidHeader = split || scene === "terminal" || scene === "review"; - const title = - scene === "threads" - ? "Threads" - : scene === "terminal" - ? "Terminal" - : scene === "review" - ? "Review changes" - : fixture.selectedThread.title; - - return ( - - - {split ? ( - - - {scene === "terminal" ? ( - - - - ) : scene === "review" ? ( - - - - ) : ( - - )} - - ) : scene === "threads" ? ( - - ) : scene === "terminal" ? ( - - ) : scene === "review" ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 27c333fdfcd..c703411e8e5 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -1,10 +1,12 @@ import { requireOptionalNativeModule } from "expo"; -import { Platform } from "react-native"; -import { SHOWCASE_SCENES, type ShowcaseScene } from "./showcaseData"; +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review"] as const; +export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; interface NativeShowcaseControls { + readonly getShowcasePairingUrl?: () => string | null; readonly getShowcaseScene?: () => string | null; + readonly prepareShowcaseCapture?: () => void; readonly markShowcaseReady?: (scene: ShowcaseScene) => void; } @@ -12,20 +14,32 @@ function nativeShowcaseControls(): NativeShowcaseControls | null { return requireOptionalNativeModule("T3NativeControls"); } -export function getNativeShowcaseScene(): ShowcaseScene { - if (Platform.OS !== "ios") return "thread"; +export function getNativeShowcasePairingUrl(): string | null { + try { + return nativeShowcaseControls()?.getShowcasePairingUrl?.()?.trim() || null; + } catch { + return null; + } +} +export function getNativeShowcaseScene(): ShowcaseScene | null { try { - const value = nativeShowcaseControls()?.getShowcaseScene?.(); - return SHOWCASE_SCENES.find((scene) => scene === value) ?? "thread"; + const scene = nativeShowcaseControls()?.getShowcaseScene?.()?.trim(); + return SHOWCASE_SCENES.find((candidate) => candidate === scene) ?? null; } catch { - return "thread"; + return null; } } -export function markNativeShowcaseReady(scene: ShowcaseScene): void { - if (Platform.OS !== "ios") return; +export function prepareNativeShowcaseCapture(): void { + try { + nativeShowcaseControls()?.prepareShowcaseCapture?.(); + } catch { + // The harness still works when a development build predates this helper. + } +} +export function markNativeShowcaseReady(scene: ShowcaseScene): void { try { nativeShowcaseControls()?.markShowcaseReady?.(scene); } catch { diff --git a/apps/mobile/src/features/showcase/showcaseData.test.ts b/apps/mobile/src/features/showcase/showcaseData.test.ts deleted file mode 100644 index a49abb40840..00000000000 --- a/apps/mobile/src/features/showcase/showcaseData.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { buildReviewParsedDiff } from "../review/reviewModel"; -import { - SHOWCASE_DIFF, - SHOWCASE_NOW, - SHOWCASE_SCENES, - SHOWCASE_TERMINAL_BUFFER, - createShowcaseFixture, -} from "./showcaseData"; - -describe("showcase fixture", () => { - it("stays deterministic for a supplied clock", () => { - const now = Date.parse("2026-07-15T09:41:00.000Z"); - expect(createShowcaseFixture(now)).toEqual(createShowcaseFixture(now)); - }); - - it("uses a fixed default clock across device captures", () => { - expect(createShowcaseFixture()).toEqual(createShowcaseFixture(SHOWCASE_NOW)); - }); - - it("contains distinct scenes and enough polished content for captures", () => { - const fixture = createShowcaseFixture(Date.parse("2026-07-15T09:41:00.000Z")); - expect(new Set(SHOWCASE_SCENES).size).toBe(SHOWCASE_SCENES.length); - expect(fixture.threads).toHaveLength(4); - expect(fixture.feed.filter((entry) => entry.type === "message")).toHaveLength(2); - expect(SHOWCASE_TERMINAL_BUFFER).toContain("All checks passed"); - }); - - it("uses a parseable multi-file review diff", () => { - const parsed = buildReviewParsedDiff(SHOWCASE_DIFF, "showcase-test"); - expect(parsed.kind).toBe("files"); - if (parsed.kind === "files") { - expect(parsed.fileCount).toBe(2); - expect(parsed.additions).toBeGreaterThan(10); - expect(parsed.deletions).toBeGreaterThan(0); - } - }); -}); diff --git a/apps/mobile/src/features/showcase/showcaseData.ts b/apps/mobile/src/features/showcase/showcaseData.ts deleted file mode 100644 index e40621fcd3b..00000000000 --- a/apps/mobile/src/features/showcase/showcaseData.ts +++ /dev/null @@ -1,306 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { - EnvironmentId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationThreadShell, -} from "@t3tools/contracts"; - -import type { ThreadFeedEntry } from "../../lib/threadActivity"; - -export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review"] as const; -export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; - -export const SHOWCASE_ENVIRONMENT_ID = EnvironmentId.make("showcase-studio"); -export const SHOWCASE_PROJECT_ID = ProjectId.make("lumen-notes"); -export const SHOWCASE_THREAD_ID = ThreadId.make("polish-command-palette"); -export const SHOWCASE_NOW = Date.parse("2026-07-15T11:31:00.000Z"); - -const MODEL_SELECTION = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", -} as const; - -function minutesBefore(now: number, minutes: number): string { - return new Date(now - minutes * 60_000).toISOString(); -} - -function makeThread( - now: number, - input: { - readonly id: string; - readonly title: string; - readonly branch: string; - readonly minutesAgo: number; - readonly state?: "working" | "approval" | "plan"; - }, -): OrchestrationThreadShell { - const threadId = ThreadId.make(input.id); - const turnId = TurnId.make(`${input.id}-turn`); - const updatedAt = minutesBefore(now, input.minutesAgo); - const isWorking = input.state === "working"; - - return { - id: threadId, - projectId: SHOWCASE_PROJECT_ID, - title: input.title, - modelSelection: MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: input.state === "plan" ? "plan" : "default", - branch: input.branch, - worktreePath: `/Users/alex/Code/lumen-notes/.worktrees/${input.branch}`, - latestTurn: { - turnId, - state: isWorking ? "running" : "completed", - requestedAt: minutesBefore(now, input.minutesAgo + 2), - startedAt: minutesBefore(now, input.minutesAgo + 2), - completedAt: isWorking ? null : updatedAt, - assistantMessageId: isWorking ? null : MessageId.make(`${input.id}-answer`), - }, - createdAt: minutesBefore(now, input.minutesAgo + 120), - updatedAt, - archivedAt: null, - session: { - threadId, - status: isWorking ? "running" : "ready", - providerName: "Codex", - providerInstanceId: MODEL_SELECTION.instanceId, - runtimeMode: "full-access", - activeTurnId: isWorking ? turnId : null, - lastError: null, - updatedAt, - }, - latestUserMessageAt: minutesBefore(now, input.minutesAgo + 1), - hasPendingApprovals: input.state === "approval", - hasPendingUserInput: false, - hasActionableProposedPlan: input.state === "plan", - }; -} - -export interface ShowcaseFixture { - readonly environmentLabel: string; - readonly project: EnvironmentProject; - readonly projects: ReadonlyArray; - readonly selectedThread: EnvironmentThreadShell; - readonly threads: ReadonlyArray; - readonly feed: ReadonlyArray; -} - -export function createShowcaseFixture(now = SHOWCASE_NOW): ShowcaseFixture { - const project: EnvironmentProject = { - environmentId: SHOWCASE_ENVIRONMENT_ID, - id: SHOWCASE_PROJECT_ID, - title: "Lumen Notes", - workspaceRoot: "/Users/alex/Code/lumen-notes", - repositoryIdentity: { - canonicalKey: "github.com/lumen-labs/lumen-notes", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/lumen-labs/lumen-notes.git", - }, - provider: "github", - owner: "lumen-labs", - name: "lumen-notes", - }, - defaultModelSelection: MODEL_SELECTION, - scripts: [ - { - id: "dev", - name: "Dev", - command: "pnpm dev", - icon: "play", - runOnWorktreeCreate: false, - }, - { - id: "test", - name: "Tests", - command: "pnpm test", - icon: "test", - runOnWorktreeCreate: false, - }, - ], - createdAt: minutesBefore(now, 60 * 24 * 30), - updatedAt: minutesBefore(now, 2), - }; - - const selectedThread = { - environmentId: SHOWCASE_ENVIRONMENT_ID, - ...makeThread(now, { - id: String(SHOWCASE_THREAD_ID), - title: "Polish the command palette", - branch: "feat/command-palette", - minutesAgo: 2, - }), - }; - const threads: ReadonlyArray = [ - selectedThread, - { - environmentId: SHOWCASE_ENVIRONMENT_ID, - ...makeThread(now, { - id: "offline-first-sync", - title: "Make sync feel instant", - branch: "feat/offline-sync", - minutesAgo: 14, - state: "working", - }), - }, - { - environmentId: SHOWCASE_ENVIRONMENT_ID, - ...makeThread(now, { - id: "share-sheet", - title: "Add a beautiful share sheet", - branch: "feat/share-sheet", - minutesAgo: 47, - state: "approval", - }), - }, - { - environmentId: SHOWCASE_ENVIRONMENT_ID, - ...makeThread(now, { - id: "editor-motion", - title: "Smooth editor transitions", - branch: "perf/editor-motion", - minutesAgo: 126, - state: "plan", - }), - }, - ]; - - const turnId = TurnId.make("palette-turn"); - const feed: ReadonlyArray = [ - { - type: "message", - id: "palette-request", - createdAt: minutesBefore(now, 8), - message: { - id: MessageId.make("palette-request"), - role: "user", - text: "Make the command palette feel fast, calm, and unmistakably native. Add fuzzy search and keyboard shortcuts.", - turnId, - streaming: false, - createdAt: minutesBefore(now, 8), - updatedAt: minutesBefore(now, 8), - }, - }, - { - type: "activity-group", - id: "palette-work", - createdAt: minutesBefore(now, 6), - turnId, - activities: [ - { - id: "inspect-components", - createdAt: minutesBefore(now, 7), - turnId, - summary: "Explored the navigation and command registry", - detail: "Found shared command metadata and keyboard routing", - fullDetail: null, - copyText: "Explored the navigation and command registry", - icon: "eye", - toolLike: true, - status: "success", - }, - { - id: "edit-palette", - createdAt: minutesBefore(now, 6), - turnId, - summary: "Built the new palette experience", - detail: "6 files changed · fuzzy ranking · native shortcuts", - fullDetail: null, - copyText: "Built the new palette experience", - icon: "edit", - toolLike: true, - status: "success", - }, - ], - }, - { - type: "message", - id: "palette-answer", - createdAt: minutesBefore(now, 2), - message: { - id: MessageId.make("palette-answer"), - role: "assistant", - text: "The command palette is ready. Search now ranks exact and recent matches first, every action shows its shortcut, and the transition stays smooth even with hundreds of commands.\n\nI also added focused keyboard-navigation tests and verified the full mobile check suite.", - turnId, - streaming: false, - createdAt: minutesBefore(now, 2), - updatedAt: minutesBefore(now, 2), - }, - }, - ]; - - return { - environmentLabel: "Alex’s MacBook Pro", - project, - projects: [project], - selectedThread, - threads, - feed, - }; -} - -export const SHOWCASE_TERMINAL_BUFFER = [ - "\u001b[38;5;75m~/Code/lumen-notes\u001b[0m \u001b[38;5;212mfeat/command-palette\u001b[0m", - "$ pnpm check", - "", - " ✓ lint 1.3s", - " ✓ typecheck 2.1s", - " ✓ unit tests 84 passed", - " ✓ native checks 0 issues", - "", - "\u001b[32mAll checks passed\u001b[0m · ready to ship ✦", - "", - "\u001b[38;5;75m~/Code/lumen-notes\u001b[0m \u001b[38;5;212mfeat/command-palette\u001b[0m $ ", -].join("\r\n"); - -export const SHOWCASE_DIFF = `diff --git a/apps/mobile/src/CommandPalette.tsx b/apps/mobile/src/CommandPalette.tsx -index c45a8f1..9e421ad 100644 ---- a/apps/mobile/src/CommandPalette.tsx -+++ b/apps/mobile/src/CommandPalette.tsx -@@ -18,9 +18,16 @@ export function CommandPalette({ commands }: Props) { -- const visibleCommands = commands.filter((command) => -- command.label.toLowerCase().includes(query.toLowerCase()), -- ); -+ const visibleCommands = rankCommands(commands, { -+ query, -+ recentCommandIds, -+ limit: 12, -+ }); - - return ( -- -+ -+ - - -diff --git a/apps/mobile/src/rankCommands.ts b/apps/mobile/src/rankCommands.ts -new file mode 100644 -index 0000000..71a6d09 ---- /dev/null -+++ b/apps/mobile/src/rankCommands.ts -@@ -0,0 +1,11 @@ -+export function rankCommands(commands: Command[], input: RankInput) { -+ const query = input.query.trim().toLocaleLowerCase(); -+ return commands -+ .map((command) => ({ -+ command, -+ score: fuzzyScore(command.label, query), -+ })) -+ .filter((match) => match.score > 0) -+ .sort((left, right) => right.score - left.score) -+ .slice(0, input.limit) -+ .map((match) => match.command); -+} -`; diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index f927ca001fe..cb281bf4aed 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -70,6 +70,7 @@ import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiSt const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const TERMINAL_ACCESSORY_HEIGHT = 52; +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; type PendingModifier = "ctrl" | "meta"; type HostPlatform = "mac" | "linux" | "windows" | "unknown"; @@ -1218,6 +1219,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) <> + command.label.toLowerCase().includes(query.toLowerCase()), + ); + + return ( + + + + ); +} +`; + +const UPDATED_COMMAND_PALETTE = `import { Modal } from "react-native"; +import { rankCommands } from "./rankCommands"; + +export function CommandPalette({ commands, open, query, recentCommandIds }: Props) { + const visibleCommands = rankCommands(commands, { + query, + recentCommandIds, + limit: 12, + }); + + return ( + + + + + ); +} +`; + +const RANK_COMMANDS = `export function rankCommands(commands: Command[], input: RankInput) { + const query = input.query.trim().toLocaleLowerCase(); + return commands + .map((command) => ({ + command, + score: fuzzyScore(command.label, query), + })) + .filter((match) => match.score > 0) + .sort((left, right) => right.score - left.score) + .slice(0, input.limit) + .map((match) => match.command); +} +`; + +function minutesBefore(now: number, minutes: number): string { + return new Date(now - minutes * 60_000).toISOString(); +} + +async function runGit(workspaceRoot: string, args: ReadonlyArray): Promise { + await execFile("git", [...args], { + cwd: workspaceRoot, + env: { + ...process.env, + GIT_AUTHOR_NAME: "Alex Rivera", + GIT_AUTHOR_EMAIL: "alex@lumen.test", + GIT_COMMITTER_NAME: "Alex Rivera", + GIT_COMMITTER_EMAIL: "alex@lumen.test", + }, + }); +} + +async function seedWorkspace(workspaceRoot: string): Promise { + await NodeFSP.mkdir(NodePath.join(workspaceRoot, "apps/mobile/src"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "package.json"), + JSON.stringify( + { name: "lumen-notes", private: true, scripts: { check: "pnpm test" } }, + null, + 2, + ), + ); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "apps/mobile/src/CommandPalette.tsx"), + BASE_COMMAND_PALETTE, + ); + await runGit(workspaceRoot, ["init", "-b", "main"]); + await runGit(workspaceRoot, [ + "remote", + "add", + "origin", + "https://github.com/lumen-labs/lumen-notes.git", + ]); + await runGit(workspaceRoot, ["add", "."]); + await runGit(workspaceRoot, ["commit", "-m", "Initial command palette"]); + await runGit(workspaceRoot, ["checkout", "-b", "feat/command-palette"]); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "apps/mobile/src/CommandPalette.tsx"), + UPDATED_COMMAND_PALETTE, + ); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "apps/mobile/src/rankCommands.ts"), + RANK_COMMANDS, + ); +} + +function insertThread( + database: NodeSqlite.DatabaseSync, + now: number, + input: { + readonly id: string; + readonly title: string; + readonly branch: string; + readonly minutesAgo: number; + readonly state?: "working" | "approval" | "plan"; + readonly workspaceRoot: string; + }, +): void { + const turnId = `${input.id}-turn`; + const updatedAt = minutesBefore(now, input.minutesAgo); + const isWorking = input.state === "working"; + database + .prepare( + `INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + branch, worktree_path, latest_turn_id, latest_user_message_at, pending_approval_count, + pending_user_input_count, has_actionable_proposed_plan, created_at, updated_at, + archived_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL)`, + ) + .run( + input.id, + SHOWCASE_PROJECT_ID, + input.title, + MODEL_SELECTION, + "full-access", + input.state === "plan" ? "plan" : "default", + input.branch, + input.workspaceRoot, + turnId, + minutesBefore(now, input.minutesAgo + 1), + input.state === "approval" ? 1 : 0, + input.state === "plan" ? 1 : 0, + minutesBefore(now, input.minutesAgo + 120), + updatedAt, + ); + database + .prepare( + `INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, assistant_message_id, state, requested_at, + started_at, completed_at, checkpoint_turn_count, checkpoint_ref, checkpoint_status, + checkpoint_files_json, source_proposed_plan_thread_id, source_proposed_plan_id + ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, NULL, NULL, NULL, '[]', NULL, NULL)`, + ) + .run( + input.id, + turnId, + isWorking ? null : `${input.id}-answer`, + isWorking ? "running" : "completed", + minutesBefore(now, input.minutesAgo + 2), + minutesBefore(now, input.minutesAgo + 2), + isWorking ? null : updatedAt, + ); + database + .prepare( + `INSERT INTO projection_thread_sessions ( + thread_id, status, provider_name, provider_instance_id, provider_session_id, + provider_thread_id, runtime_mode, active_turn_id, last_error, updated_at + ) VALUES (?, ?, 'Codex', 'codex', NULL, NULL, 'full-access', ?, NULL, ?)`, + ) + .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); +} + +function seedDatabase(dbPath: string, workspaceRoot: string, now: number): void { + const database = new NodeSqlite.DatabaseSync(dbPath); + try { + database.exec("BEGIN IMMEDIATE"); + for (const table of [ + "projection_pending_approvals", + "projection_thread_proposed_plans", + "projection_thread_activities", + "projection_thread_messages", + "projection_thread_sessions", + "projection_turns", + "projection_threads", + "projection_projects", + "projection_state", + ]) { + database.exec(`DELETE FROM ${table}`); + } + database + .prepare( + `INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, scripts_json, + created_at, updated_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`, + ) + .run( + SHOWCASE_PROJECT_ID, + "Lumen Notes", + workspaceRoot, + MODEL_SELECTION, + PROJECT_SCRIPTS, + minutesBefore(now, 60 * 24 * 30), + minutesBefore(now, 2), + ); + + for (const thread of [ + { + id: SHOWCASE_THREAD_ID, + title: "Polish the command palette", + branch: "feat/command-palette", + minutesAgo: 2, + }, + { + id: "offline-first-sync", + title: "Make sync feel instant", + branch: "feat/offline-sync", + minutesAgo: 14, + state: "working" as const, + }, + { + id: "share-sheet", + title: "Add a beautiful share sheet", + branch: "feat/share-sheet", + minutesAgo: 47, + state: "approval" as const, + }, + { + id: "editor-motion", + title: "Smooth editor transitions", + branch: "perf/editor-motion", + minutesAgo: 126, + state: "plan" as const, + }, + ]) { + insertThread(database, now, { ...thread, workspaceRoot }); + } + + const turnId = `${SHOWCASE_THREAD_ID}-turn`; + const insertMessage = database.prepare( + `INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, attachments_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 0, NULL, ?, ?)`, + ); + insertMessage.run( + "palette-request", + SHOWCASE_THREAD_ID, + turnId, + "user", + "Make the command palette feel fast, calm, and unmistakably native. Add fuzzy search and keyboard shortcuts.", + minutesBefore(now, 8), + minutesBefore(now, 8), + ); + insertMessage.run( + `${SHOWCASE_THREAD_ID}-answer`, + SHOWCASE_THREAD_ID, + turnId, + "assistant", + "The command palette is ready. Search now ranks exact and recent matches first, every action shows its shortcut, and the transition stays smooth even with hundreds of commands.\n\nI also added focused keyboard-navigation tests and verified the full mobile check suite.", + minutesBefore(now, 2), + minutesBefore(now, 2), + ); + + const insertActivity = database.prepare( + `INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES (?, ?, ?, 'tool', 'tool.completed', ?, ?, ?, ?)`, + ); + insertActivity.run( + "inspect-components", + SHOWCASE_THREAD_ID, + turnId, + "Explored the navigation and command registry", + JSON.stringify({ + itemType: "command_execution", + title: "Explored the navigation and command registry", + detail: "Found shared command metadata and keyboard routing", + status: "completed", + }), + 1, + minutesBefore(now, 7), + ); + insertActivity.run( + "edit-palette", + SHOWCASE_THREAD_ID, + turnId, + "Built the new palette experience", + JSON.stringify({ + itemType: "file_change", + title: "Built the new palette experience", + detail: "6 files changed · fuzzy ranking · native shortcuts", + status: "completed", + }), + 2, + minutesBefore(now, 6), + ); + + for (const [index, projector] of PROJECTOR_NAMES.entries()) { + database + .prepare( + "INSERT INTO projection_state (projector, last_applied_sequence, updated_at) VALUES (?, ?, ?)", + ) + .run(projector, index + 1, minutesBefore(now, 1)); + } + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } finally { + database.close(); + } +} + +export async function seedShowcaseEnvironment(input: { + readonly baseDir: string; + readonly now?: number; +}): Promise<{ readonly dbPath: string; readonly workspaceRoot: string }> { + const now = input.now ?? Date.now(); + const workspaceRoot = NodePath.join(input.baseDir, "workspace", "lumen-notes"); + const dbPath = NodePath.join(input.baseDir, "userdata", "state.sqlite"); + await seedWorkspace(workspaceRoot); + seedDatabase(dbPath, workspaceRoot, now); + + const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals"); + const safeThreadId = Buffer.from(SHOWCASE_THREAD_ID).toString("base64url"); + await NodeFSP.mkdir(terminalDirectory, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(terminalDirectory, `terminal_${safeThreadId}.log`), + SHOWCASE_TERMINAL_BUFFER, + ); + return { dbPath, workspaceRoot }; +} diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index eaa1ae5277c..6770a7d033e 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -1,5 +1,7 @@ -export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review"] as const; -export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; +import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts"; + +export { SHOWCASE_SCENES }; +export type { ShowcaseScene }; export interface ShowcaseIosDevice { readonly id: string; diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 5c60daf54a1..d3a13845053 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -3,9 +3,11 @@ import { assert, it } from "@effect/vitest"; import type { ShowcaseConfig } from "./mobile-showcase.config.ts"; import { parseShowcaseCliArgs, + parsePairingCredentialOutput, planShowcaseCaptures, readPngDimensions, selectLanIpv4Address, + showcaseSceneUrl, } from "./mobile-showcase.ts"; const config: ShowcaseConfig = { @@ -80,3 +82,26 @@ it("selects a reachable LAN IPv4 address", () => { "192.168.1.80", ); }); + +it("maps capture scenes to the real application routes", () => { + assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code-dev://"); + assert.equal( + showcaseSceneUrl("thread", "environment-1"), + "t3code-dev://threads/environment-1/polish-command-palette", + ); + assert.equal( + showcaseSceneUrl("terminal", "environment-1"), + "t3code-dev://threads/environment-1/polish-command-palette/terminal?terminalId=term-1", + ); + assert.equal( + showcaseSceneUrl("review", "environment-1"), + "t3code-dev://threads/environment-1/polish-command-palette/review", + ); +}); + +it("reads multiline JSON from the pairing CLI", () => { + assert.equal( + parsePairingCredentialOutput('server log\n{\n "credential": "PAIR-ME"\n}\n'), + "PAIR-ME", + ); +}); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index a0d372e880c..53c3a985a1c 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -17,12 +17,18 @@ import showcaseConfig, { SHOWCASE_SCENES, type ShowcaseScene, } from "./mobile-showcase.config.ts"; +import { + SHOWCASE_TERMINAL_ID, + SHOWCASE_THREAD_ID, + seedShowcaseEnvironment, +} from "./mobile-showcase-environment.ts"; const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); const ANDROID_PACKAGE = "com.t3tools.t3code.dev"; const APP_SCHEME = "t3code-dev"; -const IOS_READY_KEY = "T3ShowcaseReadyScene"; +const IOS_READY_FILENAME = "T3ShowcaseReadyScene"; +const SERVER_HOST = "0.0.0.0"; const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64"; const IOS_APP_PATH = NodePath.join( MOBILE_ROOT, @@ -254,15 +260,19 @@ async function runCommand( }); } -async function commandOutput(command: string, args: ReadonlyArray): Promise { +async function commandOutput( + command: string, + args: ReadonlyArray, + options: NodeChildProcess.ExecFileOptions = {}, +): Promise { return await new Promise((resolve, reject) => { NodeChildProcess.execFile( command, [...args], - { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, ...options }, (error, stdout) => { if (error) reject(error); - else resolve(stdout); + else resolve(String(stdout)); }, ); }); @@ -272,7 +282,7 @@ function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } -async function waitForPort(port: number, timeoutMs = 60_000): Promise { +async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const open = await new Promise((resolve) => { @@ -290,13 +300,115 @@ async function waitForPort(port: number, timeoutMs = 60_000): Promise { if (open) return; await delay(500); } - throw new Error(`Metro did not begin listening on port ${port} within ${timeoutMs}ms.`); + throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`); +} + +async function reserveAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = NodeNet.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Could not reserve a local port for the showcase environment.")); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +async function createShowcaseShell(baseDir: string): Promise { + const shellPath = NodePath.join(baseDir, "showcase-shell"); + await NodeFSP.writeFile( + shellPath, + `#!/bin/sh +if [ "$1" = "-ilc" ] || [ "$1" = "-lic" ]; then + exec /bin/sh -c "$2" +fi +exec /bin/cat +`, + { mode: 0o755 }, + ); + return shellPath; +} + +function startShowcaseServer( + baseDir: string, + workspaceRoot: string, + port: number, + shellPath: string, +): NodeChildProcess.ChildProcess { + return spawnProcess( + "node", + [ + "apps/server/src/bin.ts", + "serve", + "--host", + SERVER_HOST, + "--port", + String(port), + "--base-dir", + baseDir, + "--no-browser", + "--log-level", + "error", + workspaceRoot, + ], + { + env: { + ...NodeProcess.env, + SHELL: shellPath, + }, + }, + ); +} + +export function parsePairingCredentialOutput(output: string): string { + const jsonStart = output.indexOf("{"); + const jsonEnd = output.lastIndexOf("}"); + if (jsonStart === -1 || jsonEnd < jsonStart) { + throw new Error("Pairing credential command did not return JSON."); + } + const parsed = JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as { + readonly credential?: unknown; + }; + if (typeof parsed.credential !== "string" || parsed.credential.length === 0) { + throw new Error("Pairing credential command returned no credential."); + } + return parsed.credential; +} + +async function issuePairingCredential(baseDir: string): Promise { + const output = await commandOutput( + "node", + ["apps/server/src/bin.ts", "auth", "pairing", "create", "--base-dir", baseDir, "--json"], + { env: { ...NodeProcess.env, NO_COLOR: "1" } }, + ); + return parsePairingCredentialOutput(output); +} + +function buildShowcasePairingUrl(host: string, port: number, credential: string): string { + const url = new URL(`http://${host}:${port}/`); + url.hash = new URLSearchParams([["token", credential]]).toString(); + return url.toString(); +} + +export function showcaseSceneUrl(scene: ShowcaseScene, environmentId: string): string { + if (scene === "threads") return `${APP_SCHEME}://`; + const threadPath = `threads/${encodeURIComponent(environmentId)}/${SHOWCASE_THREAD_ID}`; + if (scene === "thread") return `${APP_SCHEME}://${threadPath}`; + if (scene === "terminal") { + return `${APP_SCHEME}://${threadPath}/terminal?terminalId=${SHOWCASE_TERMINAL_ID}`; + } + return `${APP_SCHEME}://${threadPath}/review`; } function startMetro(config: ShowcaseConfig): NodeChildProcess.ChildProcess { return spawnProcess( "pnpm", - ["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort), "--clear"], + ["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort)], { cwd: MOBILE_ROOT, env: { @@ -338,7 +450,6 @@ async function buildIos(): Promise { "-quiet", `ARCHS=${IOS_SIMULATOR_ARCH}`, "ONLY_ACTIVE_ARCH=YES", - "CODE_SIGNING_ALLOWED=NO", "build", ], { cwd: MOBILE_ROOT }, @@ -417,11 +528,10 @@ async function normalizeIosSimulator(device: ShowcaseIosDevice, udid: string): P ]); } -async function iosPreferencesPath(udid: string): Promise { - const appContainer = ( +async function iosAppContainer(udid: string): Promise { + return ( await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) ).trim(); - return NodePath.join(appContainer, "Library/Preferences", `${ANDROID_PACKAGE}.plist`); } async function waitForIosShowcaseScene( @@ -429,17 +539,14 @@ async function waitForIosShowcaseScene( scene: ShowcaseScene, timeoutMs = 90_000, ): Promise { - const preferencesPath = await iosPreferencesPath(udid); + const readyPath = NodePath.join( + await iosAppContainer(udid), + "Library/Caches", + IOS_READY_FILENAME, + ); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const readyScene = await commandOutput("plutil", [ - "-extract", - IOS_READY_KEY, - "raw", - "-o", - "-", - preferencesPath, - ]).catch(() => ""); + const readyScene = await NodeFSP.readFile(readyPath, "utf8").catch(() => ""); if (readyScene.trim() === scene) return; await delay(500); } @@ -452,15 +559,22 @@ async function captureIos( outputDirectory: string, config: ShowcaseConfig, metroHost: string, + pairingUrl: string, ): Promise { const simulator = await findIosSimulator(capture.device.simulator); const startedByRunner = simulator.state !== "Booted"; - if (startedByRunner) { - await runCommand("xcrun", ["simctl", "boot", simulator.udid]); + 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"]); await normalizeIosSimulator(capture.device, simulator.udid); if (appPath) { + await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch( + () => undefined, + ); await runCommand("xcrun", ["simctl", "install", simulator.udid, appPath]); } @@ -483,25 +597,55 @@ async function captureIos( } const metroUrl = `http://${metroHost}:${config.metroPort}?disableOnboarding=1`; - - for (const scene of capture.scenes) { - await runCommand("xcrun", ["simctl", "terminate", simulator.udid, ANDROID_PACKAGE]).catch( - () => undefined, - ); - const preferencesPath = await iosPreferencesPath(simulator.udid); - await runCommand("plutil", ["-remove", IOS_READY_KEY, preferencesPath]).catch(() => undefined); + const scenePath = NodePath.join( + await iosAppContainer(simulator.udid), + "Library/Caches/T3ShowcaseScene", + ); + const readyPath = NodePath.join( + await iosAppContainer(simulator.udid), + "Library/Caches", + IOS_READY_FILENAME, + ); + const firstScene = capture.scenes[0] ?? "threads"; + const launchShowcaseApp = async (terminateRunningProcess: boolean) => { await runCommand("xcrun", [ "simctl", "launch", + ...(terminateRunningProcess ? ["--terminate-running-process"] : []), simulator.udid, ANDROID_PACKAGE, "--initialUrl", metroUrl, + "--showcasePairingUrl", + pairingUrl, "--showcaseScene", - scene, + firstScene, ]); - await waitForIosShowcaseScene(simulator.udid, scene); - await delay(config.settleDelayMs); + }; + await NodeFSP.rm(readyPath, { force: true }); + await NodeFSP.writeFile(scenePath, firstScene); + await launchShowcaseApp(false); + for (const [sceneIndex, scene] of capture.scenes.entries()) { + if (sceneIndex > 0) await NodeFSP.rm(readyPath, { force: true }); + await NodeFSP.writeFile(scenePath, scene); + if (sceneIndex === 0) { + for (let attempt = 0; attempt < 2; attempt += 1) { + const isLastAttempt = attempt === 1; + try { + // A freshly installed Expo development build can spend well over 30s + // applying an already-bundled update after it reaches 100%. Killing it + // at that point sends the next capture back to the dev launcher. + await waitForIosShowcaseScene(simulator.udid, scene, 120_000); + break; + } catch (error) { + if (isLastAttempt) throw error; + await launchShowcaseApp(true); + } + } + } else { + await waitForIosShowcaseScene(simulator.udid, scene); + } + await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs); const destination = NodePath.join(outputDirectory, `${capture.device.id}-${scene}.png`); await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); await reportCapture(destination); @@ -629,37 +773,51 @@ async function waitForAndroidShowcaseScene( scene: ShowcaseScene, timeoutMs = 90_000, ): Promise { - const marker = `showcase-ready-${scene}`; - const hierarchyPath = "/sdcard/t3-showcase-window.xml"; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - await adbOutput(serial, [ + const readyScene = await adbOutput(serial, [ "shell", - "am", - "start", - "-W", - "-a", - "android.intent.action.VIEW", - "-d", - `${APP_SCHEME}://showcase/${scene}`, + "run-as", ANDROID_PACKAGE, + "cat", + "files/t3-showcase-ready", ]).catch(() => ""); - await delay(750); - await adbOutput(serial, ["shell", "rm", "-f", hierarchyPath]).catch(() => ""); - const hierarchy = await adbOutput(serial, ["shell", "uiautomator", "dump", hierarchyPath]) - .then(() => adbOutput(serial, ["shell", "cat", hierarchyPath])) - .catch(() => ""); - if (hierarchy.includes(marker)) return; + if (readyScene.trim() === scene) return; await delay(500); } throw new Error(`Android showcase scene '${scene}' did not render within ${timeoutMs}ms.`); } +async function writeAndroidShowcaseScene(serial: string, scene: ShowcaseScene): Promise { + await runAdb(serial, [ + "shell", + `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p files && rm -f files/t3-showcase-ready && printf %s ${scene} > files/t3-showcase-scene'`, + ]); +} + +async function prepareAndroidShowcaseApp(serial: string): Promise { + const preferences = ` + + + + + + + +`; + const encodedPreferences = Buffer.from(preferences).toString("base64"); + await runAdb(serial, [ + "shell", + `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p shared_prefs && printf %s ${encodedPreferences} | base64 -d > shared_prefs/expo.modules.devmenu.sharedpreferences.xml'`, + ]); +} + async function captureAndroid( capture: ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, apkPath: string | null, outputDirectory: string, config: ShowcaseConfig, + pairingUrl: string, ): Promise<{ readonly startedByRunner: boolean; readonly serial: string }> { const running = await runningAndroidAvds(); const existingSerial = running.get(capture.device.avd); @@ -684,9 +842,12 @@ async function captureAndroid( if (apkPath) { await runAdb(serial, ["install", "-r", apkPath]); } + await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]); + await prepareAndroidShowcaseApp(serial); await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]); - await runAdb(serial, ["shell", "am", "force-stop", ANDROID_PACKAGE]); const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`); + const firstScene = capture.scenes[0] ?? "threads"; + await writeAndroidShowcaseScene(serial, firstScene); await runAdb(serial, [ "shell", "am", @@ -696,11 +857,18 @@ async function captureAndroid( "android.intent.action.VIEW", "-d", `${APP_SCHEME}://expo-development-client/?url=${metroUrl}`, + "--es", + "showcasePairingUrl", + pairingUrl, + "--es", + "showcaseScene", + firstScene, ANDROID_PACKAGE, ]); for (const scene of capture.scenes) { + await writeAndroidShowcaseScene(serial, scene); await waitForAndroidShowcaseScene(serial, scene); - await delay(Math.max(config.settleDelayMs, 5_000)); + await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000)); const destination = NodePath.join(outputDirectory, `${capture.device.id}-${scene}.png`); const png = await new Promise((resolve, reject) => { NodeChildProcess.execFile( @@ -753,6 +921,14 @@ async function main(): Promise { const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory); await NodeFSP.mkdir(outputDirectory, { recursive: true }); + const showcaseBaseDir = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"), + ); + const serverPort = await reserveAvailablePort(); + const showcaseShell = await createShowcaseShell(showcaseBaseDir); + const showcaseWorkspaceRoot = NodePath.join(showcaseBaseDir, "workspace", "lumen-notes"); + await NodeFSP.mkdir(showcaseWorkspaceRoot, { recursive: true }); + let showcaseServer: NodeChildProcess.ChildProcess | null = null; let metro: NodeChildProcess.ChildProcess | null = null; const startedIosUdids: string[] = []; const androidCleanups: Array<{ @@ -762,9 +938,24 @@ async function main(): Promise { }> = []; try { + showcaseServer = startShowcaseServer( + showcaseBaseDir, + showcaseWorkspaceRoot, + serverPort, + showcaseShell, + ); + await waitForPort(serverPort, "Showcase server"); + await seedShowcaseEnvironment({ baseDir: showcaseBaseDir }); + const environmentId = ( + await NodeFSP.readFile(NodePath.join(showcaseBaseDir, "userdata", "environment-id"), "utf8") + ).trim(); + if (!environmentId) { + throw new Error("Showcase server did not persist an environment id."); + } + if (!options.skipMetro) { metro = startMetro(showcaseConfig); - await waitForPort(showcaseConfig.metroPort); + await waitForPort(showcaseConfig.metroPort, "Metro"); await Promise.all([ hasIos ? warmMetroBundle("ios", metroHost, showcaseConfig) : Promise.resolve(), hasAndroid ? warmMetroBundle("android", "127.0.0.1", showcaseConfig) : Promise.resolve(), @@ -786,6 +977,9 @@ async function main(): Promise { : null; for (const capture of captures) { + const credential = await issuePairingCredential(showcaseBaseDir); + const pairingHost = capture.device.platform === "ios" ? "127.0.0.1" : "10.0.2.2"; + const pairingUrl = buildShowcasePairingUrl(pairingHost, serverPort, credential); if (capture.device.platform === "ios") { const simulator = await findIosSimulator(capture.device.simulator); const started = await captureIos( @@ -794,6 +988,7 @@ async function main(): Promise { outputDirectory, showcaseConfig, metroHost, + pairingUrl, ); if (started) startedIosUdids.push(simulator.udid); } else { @@ -802,6 +997,7 @@ async function main(): Promise { androidApkPath, outputDirectory, showcaseConfig, + pairingUrl, ); androidCleanups.push({ device: capture.device, ...result }); } @@ -812,6 +1008,10 @@ async function main(): Promise { ); if (options.keepRunning) { metro?.unref(); + showcaseServer?.unref(); + NodeProcess.stdout.write( + `Showcase environment kept at ${showcaseBaseDir} (server port ${serverPort}).\n`, + ); } } finally { if (!options.keepRunning) { @@ -825,6 +1025,8 @@ async function main(): Promise { await runCommand("xcrun", ["simctl", "shutdown", udid]).catch(() => undefined); } metro?.kill("SIGTERM"); + showcaseServer?.kill("SIGTERM"); + await NodeFSP.rm(showcaseBaseDir, { recursive: true, force: true }); } } } From e9e0b12020f7f99700d5ce0cf2a96f05d4837832 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 10:59:16 +0200 Subject: [PATCH 3/9] Expand mobile showcase environments Co-authored-by: codex --- .../connection/CloudEnvironmentRows.tsx | 7 +- .../diffs/nativeReviewDiffSurface.test.ts | 7 + .../features/diffs/nativeReviewDiffSurface.ts | 6 +- .../SettingsEnvironmentsRouteScreen.tsx | 29 +- .../showcase/ShowcaseCaptureCoordinator.tsx | 56 +- .../features/showcase/nativeShowcaseScene.ts | 28 +- .../showcase/showcaseEnvironmentRows.ts | 43 ++ .../mobile-app-store-screenshots.md | 42 +- scripts/mobile-showcase-environment.ts | 489 ++++++++++++------ scripts/mobile-showcase.config.ts | 19 +- scripts/mobile-showcase.test.ts | 41 +- scripts/mobile-showcase.ts | 113 ++-- 12 files changed, 642 insertions(+), 238 deletions(-) create mode 100644 apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 400c4e5e3d3..608d43b2acb 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -33,6 +33,8 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect export function CloudEnvironmentRows(props: { readonly connectedCloudEnvironments: ReadonlyArray; readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; + readonly showcaseAvailableEnvironments?: ReadonlyArray; + readonly showcaseSignedIn?: boolean; /** * Hide the "T3 Connect" section title + refresh button for hosts that * provide their own chrome (the onboarding sheet's native header and @@ -43,7 +45,8 @@ export function CloudEnvironmentRows(props: { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = controller.availableRelayEnvironments; + const availableCloudEnvironments = + props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments; const [expandedErrorId, setExpandedErrorId] = useState(null); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; @@ -64,7 +67,7 @@ export function CloudEnvironmentRows(props: { const showHeader = props.showHeader ?? true; - if (!isSignedIn) return null; + if (!(props.showcaseSignedIn ?? isSignedIn)) return null; return ( diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 0409655583b..c12f3f0f94c 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -96,5 +96,12 @@ describe("isPendingNativeViewRegistration", () => { new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), ), ).toBe(false); + expect( + isPendingNativeViewRegistration( + new Error( + "Unable to find the class expo.modules.t3reviewdiff.T3ReviewDiffView view with tag 1150", + ), + ), + ).toBe(true); }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 551e577b294..04ca652575e 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -170,9 +170,11 @@ let nativeReviewDiffViewResolutionFailed = false; type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; export function isPendingNativeViewRegistration(error: unknown): boolean { + if (!(error instanceof Error)) return false; return ( - error instanceof Error && - error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) + error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) || + (error.message.includes("Unable to find the class") && + error.message.includes("T3ReviewDiffView view with tag")) ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index ccf049e0c21..77333d22513 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -15,6 +15,13 @@ import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { + SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, + SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS, +} from "../showcase/showcaseEnvironmentRows"; +import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; + +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; export function SettingsEnvironmentsRouteScreen() { const { @@ -25,15 +32,25 @@ export function SettingsEnvironmentsRouteScreen() { } = useRemoteConnections(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ + const environmentSections = splitEnvironmentSections({ connectedEnvironments, cloudEnvironments: null, }); + const { localEnvironments } = environmentSections; + const connectedCloudEnvironments = SHOWCASE_ENABLED + ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS + : environmentSections.connectedCloudEnvironments; const hasLocalEnvironments = localEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); const accentColor = useThemeColor("--color-icon-muted"); const headerIconColor = useThemeColor("--color-icon"); + useEffect(() => { + if (!SHOWCASE_ENABLED) return; + const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500); + return () => clearTimeout(timer); + }, []); + const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); }, []); @@ -114,10 +131,16 @@ export function SettingsEnvironmentsRouteScreen() { )} - {hasCloudPublicConfig() ? ( + {hasCloudPublicConfig() || SHOWCASE_ENABLED ? ( ) : null} diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index efd4863e2d9..2b847faf3b8 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -6,17 +6,20 @@ import { useConnectionController } from "../connection/useConnectionController"; import { useProjects, useThreadShells } from "../../state/entities"; import { useWorkspaceState } from "../../state/workspace"; import { - getNativeShowcasePairingUrl, + getNativeShowcasePairingUrls, getNativeShowcaseScene, markNativeShowcaseReady, type ShowcaseScene, } from "./nativeShowcaseScene"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; -const SHOWCASE_THREAD_ID = "polish-command-palette"; +const SHOWCASE_THREAD_ID = "terminal-heartbeat"; function sceneFromPathname(pathname: string): ShowcaseScene | null { const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; + if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { + return "environments"; + } if (routePath.endsWith("/terminal")) return "terminal"; if (routePath.endsWith("/review")) return "review"; if (routePath.startsWith("/threads/")) return "thread"; @@ -27,25 +30,25 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null { export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { const navigation = useNavigation(); const { connectPairingUrl } = useConnectionController(); - const { state: workspaceState } = useWorkspaceState(); + const workspace = useWorkspaceState(); const projects = useProjects(); const threads = useThreadShells(); - const attemptedPairingRef = useRef(null); - const [pairingUrl, setPairingUrl] = useState(null); + const attemptedPairingRef = useRef(new Set()); + const [pairingUrls, setPairingUrls] = useState>([]); const [requestedScene, setRequestedScene] = useState(null); const [readyScene, setReadyScene] = useState(null); useEffect(() => { - if (!SHOWCASE_ENABLED || pairingUrl !== null) return; + if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; - const readPairingUrl = () => { - const value = getNativeShowcasePairingUrl(); - if (value) setPairingUrl(value); + const readPairingUrls = () => { + const values = getNativeShowcasePairingUrls(); + if (values.length > 0) setPairingUrls(values); }; - readPairingUrl(); - const interval = setInterval(readPairingUrl, 250); + readPairingUrls(); + const interval = setInterval(readPairingUrls, 250); return () => clearInterval(interval); - }, [pairingUrl]); + }, [pairingUrls.length]); useEffect(() => { if (!SHOWCASE_ENABLED) return; @@ -60,16 +63,25 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) }, []); useEffect(() => { - if (!SHOWCASE_ENABLED || pairingUrl === null) return; - if (attemptedPairingRef.current === pairingUrl) return; - attemptedPairingRef.current = pairingUrl; - void connectPairingUrl(pairingUrl); - }, [connectPairingUrl, pairingUrl]); + if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return; + let cancelled = false; + void (async () => { + for (const pairingUrl of pairingUrls) { + if (cancelled || attemptedPairingRef.current.has(pairingUrl)) continue; + attemptedPairingRef.current.add(pairingUrl); + await connectPairingUrl(pairingUrl); + } + })(); + return () => { + cancelled = true; + }; + }, [connectPairingUrl, pairingUrls]); const scene = sceneFromPathname(props.pathname); const hasFixture = - workspaceState.hasReadyEnvironment && - projects.length > 0 && + workspace.state.hasReadyEnvironment && + workspace.environments.length >= 3 && + projects.length >= 3 && threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID); const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); @@ -83,13 +95,17 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) }; if (requestedScene === "threads") { navigation.dispatch(StackActions.replace("Home")); + } else if (requestedScene === "environments") { + navigation.dispatch( + StackActions.replace("SettingsSheet", { screen: "SettingsEnvironments" }), + ); } else if (requestedScene === "thread") { navigation.dispatch(StackActions.replace("Thread", params)); } else if (requestedScene === "terminal") { navigation.dispatch( StackActions.replace("ThreadTerminal", { ...params, terminalId: "term-1" }), ); - } else { + } else if (requestedScene === "review") { navigation.dispatch(StackActions.replace("ThreadReview", params)); } }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index c703411e8e5..1f2e263ebf1 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -1,6 +1,6 @@ import { requireOptionalNativeModule } from "expo"; -export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review"] as const; +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; interface NativeShowcaseControls { @@ -14,11 +14,31 @@ function nativeShowcaseControls(): NativeShowcaseControls | null { return requireOptionalNativeModule("T3NativeControls"); } -export function getNativeShowcasePairingUrl(): string | null { +export function getNativeShowcasePairingUrls(): ReadonlyArray { try { - return nativeShowcaseControls()?.getShowcasePairingUrl?.()?.trim() || null; + let raw = nativeShowcaseControls()?.getShowcasePairingUrl?.()?.trim(); + if (!raw) return []; + if (raw.startsWith("json-uri:")) { + try { + raw = decodeURIComponent(raw.slice("json-uri:".length)); + } catch { + return []; + } + } + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.trim().length > 0, + ); + } + } catch { + // Older runners pass a single URL rather than a JSON array. + } + return [raw]; } catch { - return null; + return []; } } diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts new file mode 100644 index 00000000000..7ab1e60f103 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts @@ -0,0 +1,43 @@ +import { EnvironmentId } from "@t3tools/contracts"; + +import type { RelayEnvironmentView } from "../connection/useConnectionController"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; + +const pocketPiId = EnvironmentId.make("showcase-pocket-pi"); +const pocketPiEndpoint = { + httpBaseUrl: "https://pocket-pi.t3.sh", + wsBaseUrl: "wss://pocket-pi.t3.sh", + providerKind: "t3_relay" as const, +}; + +export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray = [ + { + environmentId: EnvironmentId.make("showcase-aurora-gpu"), + environmentLabel: "Aurora GPU Pod", + displayUrl: "https://aurora-gpu.t3.sh", + isRelayManaged: true, + connectionState: "connected", + connectionError: null, + connectionErrorTraceId: null, + }, +]; + +export const SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS: ReadonlyArray = [ + { + environment: { + environmentId: pocketPiId, + label: "Pocket Pi", + endpoint: pocketPiEndpoint, + linkedAt: "2026-07-16T08:00:00.000Z", + }, + availability: "online", + status: { + environmentId: pocketPiId, + endpoint: pocketPiEndpoint, + status: "online", + checkedAt: "2026-07-16T08:41:00.000Z", + }, + error: null, + traceId: null, + }, +]; diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index 57be8ba2c88..a36ed2eef8c 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -1,14 +1,15 @@ # Mobile app-store screenshot harness -The screenshot harness runs the real mobile application against a disposable local T3 environment. -It creates an ephemeral T3 base directory, a real Git project with deterministic changes, seeded -orchestration projections, and persisted terminal history. The app pairs with that server through -its normal connection flow and React Navigation opens the production Home, Thread, -ThreadTerminal, and ThreadReview routes. +The screenshot harness runs the real mobile application against three disposable local T3 +environments. It creates an isolated base directory and server for each environment, real Git +projects with deterministic content, seeded orchestration projections, and persisted terminal +history. The app pairs with every server through its normal connection flow and React Navigation +opens the production Home, Thread, ThreadTerminal, ThreadReview, and SettingsEnvironments routes. -No screenshot-specific screen recreates application UI. EXPO_PUBLIC_SHOWCASE=1 only enables the -non-rendering pairing/readiness coordinator and disables terminal autofocus so captures do not -contain the software keyboard. +No screenshot-specific screen recreates application UI. `EXPO_PUBLIC_SHOWCASE=1` only enables the +non-rendering pairing/readiness coordinator, disables terminal autofocus so captures do not contain +the software keyboard, and supplies deterministic T3 Connect discovery rows to the real +Environments screen. The local environment cards always come from real paired servers. ## Capture the default matrix @@ -18,19 +19,21 @@ From the repository root: The command: -1. Creates a temporary T3 base directory and starts a local server on an available port. -2. Creates a Lumen Notes Git repository with a feature branch and uncommitted review diff. -3. Seeds the server's migrated SQLite database with projects, threads, messages, activities, and +1. Creates three temporary T3 base directories and starts a local server for each on an available + port. +2. Creates Codex, React, and Linux Git repositories with recognizable favicons, feature branches, + and a deterministic Codex review diff. +3. Seeds each server's migrated SQLite database with playful threads, messages, activities, and terminal history. 4. Starts an isolated Metro server, builds the selected native apps, and boots each device. -5. Pairs each clean app installation with the temporary environment. +5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin. 6. Navigates to the real application route for every requested scene. 7. Normalizes appearance and status bars and writes exact-size PNGs to artifacts/app-store/screenshots/. -The server, Metro, temporary base directory, and devices started by the runner are cleaned up after -capture. Pass --keep-running to retain them for inspection; the runner prints the base-directory -path and server port. +The servers, Metro, temporary root directory, and devices started by the runner are cleaned up after +capture. Pass `--keep-running` to retain them for inspection; the runner prints the base-directory +paths and server ports. Captures wait for the real environment snapshot to hydrate and for the requested route to become active. Both platforms record readiness in the simulator/emulator app container. A final settle @@ -47,6 +50,10 @@ The default matrix is: - iphone-6.9: iPhone 17 Pro Max - ipad-13: iPad Pro 13-inch (M5) - pixel: Pixel 10 Pro Android AVD +- android-tablet: Pixel Android AVD rendered at an 800dp tablet viewport + +Each device captures the thread, terminal, review, thread list, and environments scenes, for 20 +PNG files in the complete matrix. Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport. @@ -81,8 +88,9 @@ List the matrix and flags: [mobile-showcase.ts](../../scripts/mobile-showcase.ts) Fixture timestamps are generated relative to capture startup so every route shows stable relative -labels while the server still receives valid current data. The same ephemeral environment serves -iPhone, iPad, and Android; responsive differences come entirely from the production app layout. +labels while the server still receives valid current data. The same deterministic three-environment +ensemble serves iPhone, iPad, Android phone, and Android tablet captures; responsive differences +come entirely from the production app layout. ## Local prerequisites diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index 6597d298713..e27c23ae560 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -7,11 +7,11 @@ import * as NodeUtil from "node:util"; const execFile = NodeUtil.promisify(NodeChildProcess.execFile); -export const SHOWCASE_PROJECT_ID = "lumen-notes"; -export const SHOWCASE_THREAD_ID = "polish-command-palette"; +export const SHOWCASE_PROJECT_ID = "codex"; +export const SHOWCASE_THREAD_ID = "terminal-heartbeat"; export const SHOWCASE_TERMINAL_ID = "term-1"; -export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review"] as const; +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; const PROJECTOR_NAMES = [ @@ -45,71 +45,182 @@ const PROJECT_SCRIPTS = JSON.stringify([ ]); export const SHOWCASE_TERMINAL_BUFFER = [ - "\u001b[38;5;75m~/Code/lumen-notes\u001b[0m \u001b[38;5;212mfeat/command-palette\u001b[0m", - "$ pnpm check", + "\u001b[38;5;75m~/Code/codex\u001b[0m \u001b[38;5;212mfeat/terminal-heartbeat\u001b[0m", + "$ cargo nextest run --workspace", "", - " ✓ lint 1.3s", - " ✓ typecheck 2.1s", - " ✓ unit tests 84 passed", - " ✓ native checks 0 issues", + " \u001b[38;5;117mcodex-core\u001b[0m 418 passed", + " \u001b[38;5;213mcodex-tui\u001b[0m 267 passed", + " \u001b[38;5;221mprotocol\u001b[0m 162 passed", "", - "\u001b[32mAll checks passed\u001b[0m · ready to ship ✦", + "\u001b[32m✨ 847 tests passed\u001b[0m · terminal pulse is steady", "", - "\u001b[38;5;75m~/Code/lumen-notes\u001b[0m \u001b[38;5;212mfeat/command-palette\u001b[0m $ ", + "\u001b[38;5;75m~/Code/codex\u001b[0m \u001b[38;5;212mfeat/terminal-heartbeat\u001b[0m $ ", ].join("\r\n"); -const BASE_COMMAND_PALETTE = `import { Modal } from "react-native"; +const BASE_STATUS_INDICATOR = `use ratatui::text::Line; -export function CommandPalette({ commands, open, query }: Props) { - const visibleCommands = commands.filter((command) => - command.label.toLowerCase().includes(query.toLowerCase()), - ); - - return ( - - - - ); +pub(crate) fn status_line(label: &str) -> Line<'static> { + Line::from(format!(" {label}")) } `; -const UPDATED_COMMAND_PALETTE = `import { Modal } from "react-native"; -import { rankCommands } from "./rankCommands"; +const UPDATED_STATUS_INDICATOR = `use ratatui::{style::Stylize, text::{Line, Span}}; -export function CommandPalette({ commands, open, query, recentCommandIds }: Props) { - const visibleCommands = rankCommands(commands, { - query, - recentCommandIds, - limit: 12, - }); +const PULSE: [&str; 4] = ["✦", "✧", "·", "✧"]; - return ( - - - - - ); +pub(crate) fn status_line(label: &str, frame: usize) -> Line<'static> { + Line::from(vec![ + Span::raw(" "), + Span::raw(PULSE[frame % PULSE.len()]).cyan(), + Span::raw(format!(" {label}")).white(), + ]) } `; -const RANK_COMMANDS = `export function rankCommands(commands: Command[], input: RankInput) { - const query = input.query.trim().toLocaleLowerCase(); - return commands - .map((command) => ({ - command, - score: fuzzyScore(command.label, query), - })) - .filter((match) => match.score > 0) - .sort((left, right) => right.score - left.score) - .slice(0, input.limit) - .map((match) => match.command); +const TOOL_CALL_CARD = `use ratatui::{style::Stylize, text::Line}; + +pub(crate) fn completed_tool(title: &str, detail: &str) -> Vec> { + vec![ + Line::from(format!(" ✓ {title}")).green().bold(), + Line::from(format!(" {detail}")).dark_gray(), + ] } `; +const PROJECT_FAVICONS = { + codex: ` + + + + +`, + react: ` + + + +`, + linux: ` + + + + + + +`, +} as const; + +export const SHOWCASE_PROJECTS = [ + { + id: "codex", + title: "Codex", + directory: "codex", + repositoryUrl: "https://github.com/openai/codex.git", + favicon: PROJECT_FAVICONS.codex, + }, + { + id: "react", + title: "React", + directory: "react", + repositoryUrl: "https://github.com/facebook/react.git", + favicon: PROJECT_FAVICONS.react, + }, + { + id: "linux", + title: "Linux", + directory: "linux", + repositoryUrl: "https://github.com/torvalds/linux.git", + favicon: PROJECT_FAVICONS.linux, + }, +] as const; + +export const SHOWCASE_ENVIRONMENTS = [ + { + id: "moonbase-terminal", + label: "Moonbase Terminal", + projectIds: ["codex"], + }, + { + id: "suspense-station", + label: "Suspense Station", + projectIds: ["react"], + }, + { + id: "kernel-cabin", + label: "Kernel Cabin", + projectIds: ["linux"], + }, +] as const; + +export const SHOWCASE_THREADS = [ + { + id: SHOWCASE_THREAD_ID, + projectId: "codex", + title: "Give the terminal a heartbeat ✦", + branch: "feat/terminal-heartbeat", + minutesAgo: 3, + request: + "Give the Codex terminal a little pulse. Stream tool calls as crisp cards, make success feel electric, and keep everything fast enough to disappear.", + response: + "The terminal has a heartbeat now — expressive, but never noisy. ✦\n\n- Tool calls arrive as compact live cards\n- Successful runs resolve with a subtle electric pulse\n- Reconnects preserve the exact animation frame\n- Reduced-motion mode stays completely calm\n\nI also ran the full Rust workspace: **847 tests passed**.", + }, + { + id: "green-build-celebration", + projectId: "codex", + title: "Teach agents to celebrate green builds", + branch: "feat/green-builds", + minutesAgo: 21, + state: "approval" as const, + request: "Make successful builds feel rewarding without turning the CLI into a slot machine.", + response: + "Added a restrained success moment: one shimmer, one crisp summary, then straight back to work. The final color treatment is ready for approval.", + }, + { + id: "buttery-suspense", + projectId: "react", + title: "Make Suspense transitions buttery", + branch: "perf/buttery-suspense", + minutesAgo: 12, + state: "working" as const, + request: + "Trace the last few dropped frames in nested Suspense transitions and make them disappear.", + response: null, + }, + { + id: "hydration-haikus", + projectId: "react", + title: "Turn hydration warnings into haikus", + branch: "dev/hydration-haikus", + minutesAgo: 44, + request: + "Keep hydration errors precise, but make the development copy unexpectedly delightful.", + response: + "The diagnostics still lead with the exact mismatch and component stack. A tiny optional haiku now closes the expanded explanation.", + }, + { + id: "beautiful-boot", + projectId: "linux", + title: "Make boot logs oddly beautiful", + branch: "feat/beautiful-boot", + minutesAgo: 34, + state: "plan" as const, + request: + "Design a clearer boot timeline that remains useful over serial and never hides kernel detail.", + response: + "The plan groups milestones without changing the underlying log stream, preserves plain-text output, and adds zero work to the hot path.", + }, + { + id: "scheduler-breathe", + projectId: "linux", + title: "Let the scheduler breathe", + branch: "perf/scheduler-breathe", + minutesAgo: 76, + request: + "Find a calmer balancing strategy for bursty mixed workloads without hurting tail latency.", + response: + "The new heuristic reduces needless migrations during short bursts while preserving the existing latency guardrails.", + }, +] as const; + function minutesBefore(now: number, minutes: number): string { return new Date(now - minutes * 60_000).toISOString(); } @@ -127,45 +238,69 @@ async function runGit(workspaceRoot: string, args: ReadonlyArray): Promi }); } -async function seedWorkspace(workspaceRoot: string): Promise { - await NodeFSP.mkdir(NodePath.join(workspaceRoot, "apps/mobile/src"), { recursive: true }); +async function initializeRepository(input: { + readonly workspaceRoot: string; + readonly repositoryUrl: string; + readonly commitMessage: string; +}): Promise { + await runGit(input.workspaceRoot, ["init", "-b", "main"]); + await runGit(input.workspaceRoot, ["remote", "add", "origin", input.repositoryUrl]); + await runGit(input.workspaceRoot, ["add", "."]); + await runGit(input.workspaceRoot, ["commit", "-m", input.commitMessage]); +} + +async function seedCodexWorkspace(workspaceRoot: string): Promise { + await NodeFSP.mkdir(NodePath.join(workspaceRoot, "codex-rs/tui/src"), { recursive: true }); await NodeFSP.writeFile( - NodePath.join(workspaceRoot, "package.json"), - JSON.stringify( - { name: "lumen-notes", private: true, scripts: { check: "pnpm test" } }, - null, - 2, - ), + NodePath.join(workspaceRoot, "Cargo.toml"), + `[workspace]\nmembers = ["codex-rs/tui"]\nresolver = "2"\n`, ); + await NodeFSP.writeFile(NodePath.join(workspaceRoot, "favicon.svg"), PROJECT_FAVICONS.codex); await NodeFSP.writeFile( - NodePath.join(workspaceRoot, "apps/mobile/src/CommandPalette.tsx"), - BASE_COMMAND_PALETTE, + NodePath.join(workspaceRoot, "codex-rs/tui/src/status_indicator.rs"), + BASE_STATUS_INDICATOR, ); - await runGit(workspaceRoot, ["init", "-b", "main"]); - await runGit(workspaceRoot, [ - "remote", - "add", - "origin", - "https://github.com/lumen-labs/lumen-notes.git", - ]); - await runGit(workspaceRoot, ["add", "."]); - await runGit(workspaceRoot, ["commit", "-m", "Initial command palette"]); - await runGit(workspaceRoot, ["checkout", "-b", "feat/command-palette"]); + await initializeRepository({ + workspaceRoot, + repositoryUrl: "https://github.com/openai/codex.git", + commitMessage: "Render terminal status", + }); + await runGit(workspaceRoot, ["checkout", "-b", "feat/terminal-heartbeat"]); await NodeFSP.writeFile( - NodePath.join(workspaceRoot, "apps/mobile/src/CommandPalette.tsx"), - UPDATED_COMMAND_PALETTE, + NodePath.join(workspaceRoot, "codex-rs/tui/src/status_indicator.rs"), + UPDATED_STATUS_INDICATOR, ); await NodeFSP.writeFile( - NodePath.join(workspaceRoot, "apps/mobile/src/rankCommands.ts"), - RANK_COMMANDS, + NodePath.join(workspaceRoot, "codex-rs/tui/src/tool_call_card.rs"), + TOOL_CALL_CARD, ); } +async function seedCompanionWorkspace(input: { + readonly workspaceRoot: string; + readonly title: string; + readonly repositoryUrl: string; + readonly favicon: string; +}): Promise { + await NodeFSP.mkdir(input.workspaceRoot, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(input.workspaceRoot, "favicon.svg"), input.favicon); + await NodeFSP.writeFile( + NodePath.join(input.workspaceRoot, "README.md"), + `# ${input.title}\n\nSeeded by the T3 Code mobile screenshot harness.\n`, + ); + await initializeRepository({ + workspaceRoot: input.workspaceRoot, + repositoryUrl: input.repositoryUrl, + commitMessage: `Seed ${input.title} workspace`, + }); +} + function insertThread( database: NodeSqlite.DatabaseSync, now: number, input: { readonly id: string; + readonly projectId: string; readonly title: string; readonly branch: string; readonly minutesAgo: number; @@ -187,7 +322,7 @@ function insertThread( ) .run( input.id, - SHOWCASE_PROJECT_ID, + input.projectId, input.title, MODEL_SELECTION, "full-access", @@ -228,7 +363,13 @@ function insertThread( .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); } -function seedDatabase(dbPath: string, workspaceRoot: string, now: number): void { +function seedDatabase( + dbPath: string, + workspaceRoots: ReadonlyMap, + projects: ReadonlyArray<(typeof SHOWCASE_PROJECTS)[number]>, + threads: ReadonlyArray<(typeof SHOWCASE_THREADS)[number]>, + now: number, +): void { const database = new NodeSqlite.DatabaseSync(dbPath); try { database.exec("BEGIN IMMEDIATE"); @@ -245,114 +386,121 @@ function seedDatabase(dbPath: string, workspaceRoot: string, now: number): void ]) { database.exec(`DELETE FROM ${table}`); } - database - .prepare( - `INSERT INTO projection_projects ( + const insertProject = database.prepare( + `INSERT INTO projection_projects ( project_id, title, workspace_root, default_model_selection_json, scripts_json, created_at, updated_at, deleted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`, - ) - .run( - SHOWCASE_PROJECT_ID, - "Lumen Notes", + ); + for (const [index, project] of projects.entries()) { + const workspaceRoot = workspaceRoots.get(project.id); + if (!workspaceRoot) throw new Error(`Missing workspace root for ${project.id}.`); + const latestThreadMinutes = Math.min( + ...threads + .filter((thread) => thread.projectId === project.id) + .map((thread) => thread.minutesAgo), + ); + insertProject.run( + project.id, + project.title, workspaceRoot, MODEL_SELECTION, PROJECT_SCRIPTS, - minutesBefore(now, 60 * 24 * 30), - minutesBefore(now, 2), + minutesBefore(now, 60 * 24 * (90 - index * 12)), + minutesBefore(now, latestThreadMinutes), ); + } - for (const thread of [ - { - id: SHOWCASE_THREAD_ID, - title: "Polish the command palette", - branch: "feat/command-palette", - minutesAgo: 2, - }, - { - id: "offline-first-sync", - title: "Make sync feel instant", - branch: "feat/offline-sync", - minutesAgo: 14, - state: "working" as const, - }, - { - id: "share-sheet", - title: "Add a beautiful share sheet", - branch: "feat/share-sheet", - minutesAgo: 47, - state: "approval" as const, - }, - { - id: "editor-motion", - title: "Smooth editor transitions", - branch: "perf/editor-motion", - minutesAgo: 126, - state: "plan" as const, - }, - ]) { - insertThread(database, now, { ...thread, workspaceRoot }); + for (const thread of threads) { + const workspaceRoot = workspaceRoots.get(thread.projectId); + if (!workspaceRoot) throw new Error(`Missing workspace root for ${thread.projectId}.`); + insertThread(database, now, { + ...thread, + ...("state" in thread ? { state: thread.state } : {}), + workspaceRoot, + }); } - const turnId = `${SHOWCASE_THREAD_ID}-turn`; const insertMessage = database.prepare( `INSERT INTO projection_thread_messages ( message_id, thread_id, turn_id, role, text, is_streaming, attachments_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, 0, NULL, ?, ?)`, ); - insertMessage.run( - "palette-request", - SHOWCASE_THREAD_ID, - turnId, - "user", - "Make the command palette feel fast, calm, and unmistakably native. Add fuzzy search and keyboard shortcuts.", - minutesBefore(now, 8), - minutesBefore(now, 8), - ); - insertMessage.run( - `${SHOWCASE_THREAD_ID}-answer`, - SHOWCASE_THREAD_ID, - turnId, - "assistant", - "The command palette is ready. Search now ranks exact and recent matches first, every action shows its shortcut, and the transition stays smooth even with hundreds of commands.\n\nI also added focused keyboard-navigation tests and verified the full mobile check suite.", - minutesBefore(now, 2), - minutesBefore(now, 2), - ); + for (const thread of threads) { + const turnId = `${thread.id}-turn`; + const requestTime = minutesBefore(now, thread.minutesAgo + 5); + insertMessage.run( + `${thread.id}-request`, + thread.id, + turnId, + "user", + thread.request, + requestTime, + requestTime, + ); + if (thread.response !== null) { + const responseTime = minutesBefore(now, thread.minutesAgo); + insertMessage.run( + `${thread.id}-answer`, + thread.id, + turnId, + "assistant", + thread.response, + responseTime, + responseTime, + ); + } + } + const turnId = `${SHOWCASE_THREAD_ID}-turn`; const insertActivity = database.prepare( `INSERT INTO projection_thread_activities ( activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at ) VALUES (?, ?, ?, 'tool', 'tool.completed', ?, ?, ?, ?)`, ); insertActivity.run( - "inspect-components", + "trace-render-loop", SHOWCASE_THREAD_ID, turnId, - "Explored the navigation and command registry", + "Traced the terminal rendering loop", JSON.stringify({ itemType: "command_execution", - title: "Explored the navigation and command registry", - detail: "Found shared command metadata and keyboard routing", + title: "Traced the terminal rendering loop", + detail: "Found a zero-allocation path for the pulse frames", status: "completed", }), 1, - minutesBefore(now, 7), + minutesBefore(now, 8), ); insertActivity.run( - "edit-palette", + "paint-tool-cards", SHOWCASE_THREAD_ID, turnId, - "Built the new palette experience", + "Painted live tool-call cards", JSON.stringify({ itemType: "file_change", - title: "Built the new palette experience", - detail: "6 files changed · fuzzy ranking · native shortcuts", + title: "Painted live tool-call cards", + detail: "2 files changed · cyan pulse · calm reconnects", status: "completed", }), 2, minutesBefore(now, 6), ); + insertActivity.run( + "run-rust-suite", + SHOWCASE_THREAD_ID, + turnId, + "Ran the Rust workspace", + JSON.stringify({ + itemType: "command_execution", + title: "Ran the Rust workspace", + detail: "847 tests passed · 0 flaky retries", + status: "completed", + }), + 3, + minutesBefore(now, 4), + ); for (const [index, projector] of PROJECTOR_NAMES.entries()) { database @@ -372,20 +520,55 @@ function seedDatabase(dbPath: string, workspaceRoot: string, now: number): void export async function seedShowcaseEnvironment(input: { readonly baseDir: string; + readonly projectIds?: ReadonlyArray; readonly now?: number; }): Promise<{ readonly dbPath: string; readonly workspaceRoot: string }> { const now = input.now ?? Date.now(); - const workspaceRoot = NodePath.join(input.baseDir, "workspace", "lumen-notes"); + const selectedProjectIds = new Set( + input.projectIds ?? SHOWCASE_PROJECTS.map((project) => project.id), + ); + const projects = SHOWCASE_PROJECTS.filter((project) => selectedProjectIds.has(project.id)); + if (projects.length === 0) throw new Error("At least one showcase project must be selected."); + const threads = SHOWCASE_THREADS.filter((thread) => selectedProjectIds.has(thread.projectId)); + const workspaceBase = NodePath.join(input.baseDir, "workspace"); + const workspaceRoots = new Map( + projects.map( + (project) => [project.id, NodePath.join(workspaceBase, project.directory)] as const, + ), + ); + const primaryProject = + projects.find((project) => project.id === SHOWCASE_PROJECT_ID) ?? projects[0]; + if (!primaryProject) throw new Error("The primary showcase workspace is not configured."); + const workspaceRoot = workspaceRoots.get(primaryProject.id); + if (!workspaceRoot) throw new Error("The primary showcase workspace is not configured."); const dbPath = NodePath.join(input.baseDir, "userdata", "state.sqlite"); - await seedWorkspace(workspaceRoot); - seedDatabase(dbPath, workspaceRoot, now); + if (primaryProject.id === SHOWCASE_PROJECT_ID) { + await seedCodexWorkspace(workspaceRoot); + } + await Promise.all( + projects + .filter((project) => project.id !== SHOWCASE_PROJECT_ID) + .map(async (project) => { + const projectWorkspaceRoot = workspaceRoots.get(project.id); + if (!projectWorkspaceRoot) throw new Error(`Missing workspace root for ${project.id}.`); + await seedCompanionWorkspace({ + workspaceRoot: projectWorkspaceRoot, + title: project.title, + repositoryUrl: project.repositoryUrl, + favicon: project.favicon, + }); + }), + ); + seedDatabase(dbPath, workspaceRoots, projects, threads, now); const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals"); - const safeThreadId = Buffer.from(SHOWCASE_THREAD_ID).toString("base64url"); - await NodeFSP.mkdir(terminalDirectory, { recursive: true }); - await NodeFSP.writeFile( - NodePath.join(terminalDirectory, `terminal_${safeThreadId}.log`), - SHOWCASE_TERMINAL_BUFFER, - ); + if (selectedProjectIds.has(SHOWCASE_PROJECT_ID)) { + const safeThreadId = Buffer.from(SHOWCASE_THREAD_ID).toString("base64url"); + await NodeFSP.mkdir(terminalDirectory, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(terminalDirectory, `terminal_${safeThreadId}.log`), + SHOWCASE_TERMINAL_BUFFER, + ); + } return { dbPath, workspaceRoot }; } diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 6770a7d033e..834965ef65c 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -56,14 +56,14 @@ const config: ShowcaseConfig = { platform: "ios", simulator: "iPhone 17 Pro Max", appearance: "dark", - scenes: ["thread", "terminal", "review", "threads"], + scenes: ["thread", "terminal", "review", "threads", "environments"], }, { id: "ipad-13", platform: "ios", simulator: "iPad Pro 13-inch (M5)", appearance: "dark", - scenes: ["thread", "terminal", "review"], + scenes: ["thread", "terminal", "review", "threads", "environments"], }, { id: "pixel", @@ -71,7 +71,20 @@ const config: ShowcaseConfig = { avd: "Pixel_10_Pro", abi: "arm64-v8a", appearance: "dark", - scenes: ["thread", "terminal", "review", "threads"], + scenes: ["thread", "terminal", "review", "threads", "environments"], + }, + { + id: "android-tablet", + platform: "android", + avd: "Pixel_10_Pro", + abi: "arm64-v8a", + appearance: "dark", + viewport: { + width: 1600, + height: 2560, + density: 320, + }, + scenes: ["thread", "terminal", "review", "threads", "environments"], }, ], }; diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index d3a13845053..6c41ecb6b84 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -2,6 +2,12 @@ import { assert, it } from "@effect/vitest"; import type { ShowcaseConfig } from "./mobile-showcase.config.ts"; import { + SHOWCASE_ENVIRONMENTS, + SHOWCASE_PROJECTS, + SHOWCASE_THREADS, +} from "./mobile-showcase-environment.ts"; +import { + encodeAndroidPairingUrls, parseShowcaseCliArgs, parsePairingCredentialOutput, planShowcaseCaptures, @@ -85,17 +91,38 @@ it("selects a reachable LAN IPv4 address", () => { it("maps capture scenes to the real application routes", () => { assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code-dev://"); + assert.equal( + showcaseSceneUrl("environments", "environment-1"), + "t3code-dev://settings/environments", + ); assert.equal( showcaseSceneUrl("thread", "environment-1"), - "t3code-dev://threads/environment-1/polish-command-palette", + "t3code-dev://threads/environment-1/terminal-heartbeat", ); assert.equal( showcaseSceneUrl("terminal", "environment-1"), - "t3code-dev://threads/environment-1/polish-command-palette/terminal?terminalId=term-1", + "t3code-dev://threads/environment-1/terminal-heartbeat/terminal?terminalId=term-1", ); assert.equal( showcaseSceneUrl("review", "environment-1"), - "t3code-dev://threads/environment-1/polish-command-palette/review", + "t3code-dev://threads/environment-1/terminal-heartbeat/review", + ); +}); + +it("seeds a playful multi-environment project spectrum", () => { + assert.deepStrictEqual( + SHOWCASE_PROJECTS.map((project) => project.title), + ["Codex", "React", "Linux"], + ); + assert.deepStrictEqual( + SHOWCASE_ENVIRONMENTS.map((environment) => environment.label), + ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"], + ); + assert.equal(SHOWCASE_THREADS.length, 6); + assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3); + assert.equal( + SHOWCASE_PROJECTS.every((project) => project.favicon.includes(" { "PAIR-ME", ); }); + +it("encodes Android pairing URLs without shell-sensitive JSON quotes", () => { + const urls = ["http://10.0.2.2:65164/#token=ONE", "http://10.0.2.2:65198/#token=TWO"]; + const encoded = encodeAndroidPairingUrls(urls); + assert.equal(encoded.startsWith("json-uri:"), true); + assert.deepStrictEqual(JSON.parse(decodeURIComponent(encoded.slice("json-uri:".length))), urls); + assert.equal(encoded.includes('"'), false); +}); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 53c3a985a1c..86070b54457 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -18,6 +18,8 @@ import showcaseConfig, { type ShowcaseScene, } from "./mobile-showcase.config.ts"; import { + SHOWCASE_ENVIRONMENTS, + SHOWCASE_PROJECTS, SHOWCASE_TERMINAL_ID, SHOWCASE_THREAD_ID, seedShowcaseEnvironment, @@ -334,11 +336,30 @@ exec /bin/cat return shellPath; } +async function createShowcaseLabelProbe(baseDir: string, label: string): Promise { + const binDirectory = NodePath.join(baseDir, "showcase-bin"); + const probePath = NodePath.join(binDirectory, "scutil"); + await NodeFSP.mkdir(binDirectory, { recursive: true }); + await NodeFSP.writeFile( + probePath, + `#!/bin/sh +if [ "$1" = "--get" ] && [ "$2" = "ComputerName" ]; then + printf '%s\\n' ${JSON.stringify(label)} + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + return binDirectory; +} + function startShowcaseServer( baseDir: string, workspaceRoot: string, port: number, shellPath: string, + labelProbeDirectory: string, ): NodeChildProcess.ChildProcess { return spawnProcess( "node", @@ -359,6 +380,7 @@ function startShowcaseServer( { env: { ...NodeProcess.env, + PATH: `${labelProbeDirectory}:${NodeProcess.env.PATH ?? ""}`, SHELL: shellPath, }, }, @@ -397,6 +419,7 @@ function buildShowcasePairingUrl(host: string, port: number, credential: string) export function showcaseSceneUrl(scene: ShowcaseScene, environmentId: string): string { if (scene === "threads") return `${APP_SCHEME}://`; + if (scene === "environments") return `${APP_SCHEME}://settings/environments`; const threadPath = `threads/${encodeURIComponent(environmentId)}/${SHOWCASE_THREAD_ID}`; if (scene === "thread") return `${APP_SCHEME}://${threadPath}`; if (scene === "terminal") { @@ -405,6 +428,10 @@ export function showcaseSceneUrl(scene: ShowcaseScene, environmentId: string): s return `${APP_SCHEME}://${threadPath}/review`; } +export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray): string { + return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`; +} + function startMetro(config: ShowcaseConfig): NodeChildProcess.ChildProcess { return spawnProcess( "pnpm", @@ -559,7 +586,7 @@ async function captureIos( outputDirectory: string, config: ShowcaseConfig, metroHost: string, - pairingUrl: string, + pairingUrls: ReadonlyArray, ): Promise { const simulator = await findIosSimulator(capture.device.simulator); const startedByRunner = simulator.state !== "Booted"; @@ -617,7 +644,7 @@ async function captureIos( "--initialUrl", metroUrl, "--showcasePairingUrl", - pairingUrl, + JSON.stringify(pairingUrls), "--showcaseScene", firstScene, ]); @@ -817,7 +844,7 @@ async function captureAndroid( apkPath: string | null, outputDirectory: string, config: ShowcaseConfig, - pairingUrl: string, + pairingUrls: ReadonlyArray, ): Promise<{ readonly startedByRunner: boolean; readonly serial: string }> { const running = await runningAndroidAvds(); const existingSerial = running.get(capture.device.avd); @@ -859,7 +886,7 @@ async function captureAndroid( `${APP_SCHEME}://expo-development-client/?url=${metroUrl}`, "--es", "showcasePairingUrl", - pairingUrl, + encodeAndroidPairingUrls(pairingUrls), "--es", "showcaseScene", firstScene, @@ -921,14 +948,16 @@ async function main(): Promise { const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory); await NodeFSP.mkdir(outputDirectory, { recursive: true }); - const showcaseBaseDir = await NodeFSP.mkdtemp( + const showcaseRootDir = await NodeFSP.mkdtemp( NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"), ); - const serverPort = await reserveAvailablePort(); - const showcaseShell = await createShowcaseShell(showcaseBaseDir); - const showcaseWorkspaceRoot = NodePath.join(showcaseBaseDir, "workspace", "lumen-notes"); - await NodeFSP.mkdir(showcaseWorkspaceRoot, { recursive: true }); - let showcaseServer: NodeChildProcess.ChildProcess | null = null; + const showcaseServers: NodeChildProcess.ChildProcess[] = []; + const showcaseEnvironments: Array<{ + readonly baseDir: string; + readonly environmentId: string; + readonly label: string; + readonly port: number; + }> = []; let metro: NodeChildProcess.ChildProcess | null = null; const startedIosUdids: string[] = []; const androidCleanups: Array<{ @@ -938,19 +967,34 @@ async function main(): Promise { }> = []; try { - showcaseServer = startShowcaseServer( - showcaseBaseDir, - showcaseWorkspaceRoot, - serverPort, - showcaseShell, - ); - await waitForPort(serverPort, "Showcase server"); - await seedShowcaseEnvironment({ baseDir: showcaseBaseDir }); - const environmentId = ( - await NodeFSP.readFile(NodePath.join(showcaseBaseDir, "userdata", "environment-id"), "utf8") - ).trim(); - if (!environmentId) { - throw new Error("Showcase server did not persist an environment id."); + for (const environment of SHOWCASE_ENVIRONMENTS) { + const projectId = environment.projectIds[0]; + const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId); + if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`); + + const baseDir = NodePath.join(showcaseRootDir, "environments", environment.id); + const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory); + const port = await reserveAvailablePort(); + await NodeFSP.mkdir(workspaceRoot, { recursive: true }); + const shellPath = await createShowcaseShell(baseDir); + const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label); + const server = startShowcaseServer( + baseDir, + workspaceRoot, + port, + shellPath, + labelProbeDirectory, + ); + showcaseServers.push(server); + await waitForPort(port, `${environment.label} server`); + await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); + const environmentId = ( + await NodeFSP.readFile(NodePath.join(baseDir, "userdata", "environment-id"), "utf8") + ).trim(); + if (!environmentId) { + throw new Error(`${environment.label} did not persist an environment id.`); + } + showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port }); } if (!options.skipMetro) { @@ -977,9 +1021,13 @@ async function main(): Promise { : null; for (const capture of captures) { - const credential = await issuePairingCredential(showcaseBaseDir); const pairingHost = capture.device.platform === "ios" ? "127.0.0.1" : "10.0.2.2"; - const pairingUrl = buildShowcasePairingUrl(pairingHost, serverPort, credential); + const pairingUrls = await Promise.all( + showcaseEnvironments.map(async (environment) => { + const credential = await issuePairingCredential(environment.baseDir); + return buildShowcasePairingUrl(pairingHost, environment.port, credential); + }), + ); if (capture.device.platform === "ios") { const simulator = await findIosSimulator(capture.device.simulator); const started = await captureIos( @@ -988,7 +1036,7 @@ async function main(): Promise { outputDirectory, showcaseConfig, metroHost, - pairingUrl, + pairingUrls, ); if (started) startedIosUdids.push(simulator.udid); } else { @@ -997,7 +1045,7 @@ async function main(): Promise { androidApkPath, outputDirectory, showcaseConfig, - pairingUrl, + pairingUrls, ); androidCleanups.push({ device: capture.device, ...result }); } @@ -1008,9 +1056,12 @@ async function main(): Promise { ); if (options.keepRunning) { metro?.unref(); - showcaseServer?.unref(); + for (const server of showcaseServers) server.unref(); + const serverSummary = showcaseEnvironments + .map((environment) => `${environment.label}:${environment.port}`) + .join(", "); NodeProcess.stdout.write( - `Showcase environment kept at ${showcaseBaseDir} (server port ${serverPort}).\n`, + `Showcase environments kept at ${showcaseRootDir} (${serverSummary}).\n`, ); } } finally { @@ -1025,8 +1076,8 @@ async function main(): Promise { await runCommand("xcrun", ["simctl", "shutdown", udid]).catch(() => undefined); } metro?.kill("SIGTERM"); - showcaseServer?.kill("SIGTERM"); - await NodeFSP.rm(showcaseBaseDir, { recursive: true, force: true }); + for (const server of showcaseServers) server.kill("SIGTERM"); + await NodeFSP.rm(showcaseRootDir, { recursive: true, force: true }); } } } From ce95acd3d17f78e4bd036643ce13fa803f034ed2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:55:33 +0200 Subject: [PATCH 4/9] Feature T3 Code in showcase captures Co-authored-by: codex --- .../showcase/ShowcaseCaptureCoordinator.tsx | 49 ++++-- .../mobile-app-store-screenshots.md | 4 +- scripts/mobile-showcase-environment.ts | 149 +++++++++--------- scripts/mobile-showcase.test.ts | 8 +- scripts/mobile-showcase.ts | 27 +++- 5 files changed, 136 insertions(+), 101 deletions(-) diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 2b847faf3b8..0d50a397bb5 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { Keyboard, View } from "react-native"; -import { StackActions, useNavigation } from "@react-navigation/native"; +import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { useConnectionController } from "../connection/useConnectionController"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -13,7 +13,7 @@ import { } from "./nativeShowcaseScene"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; -const SHOWCASE_THREAD_ID = "terminal-heartbeat"; +const SHOWCASE_THREAD_ID = "remote-command-center"; function sceneFromPathname(pathname: string): ShowcaseScene | null { const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; @@ -94,20 +94,39 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) threadId: SHOWCASE_THREAD_ID, }; if (requestedScene === "threads") { - navigation.dispatch(StackActions.replace("Home")); - } else if (requestedScene === "environments") { - navigation.dispatch( - StackActions.replace("SettingsSheet", { screen: "SettingsEnvironments" }), - ); - } else if (requestedScene === "thread") { - navigation.dispatch(StackActions.replace("Thread", params)); - } else if (requestedScene === "terminal") { - navigation.dispatch( - StackActions.replace("ThreadTerminal", { ...params, terminalId: "term-1" }), - ); - } else if (requestedScene === "review") { - navigation.dispatch(StackActions.replace("ThreadReview", params)); + navigation.dispatch(StackActions.popToTop()); + return; + } + const routes: Array<{ + name: string; + params?: Record; + state?: { index: number; routes: Array<{ name: string }> }; + }> = [{ name: "Home" }]; + if (requestedScene === "environments") { + routes.push({ + name: "SettingsSheet", + state: { + index: 1, + routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + }, + }); + } else { + routes.push({ name: "Thread", params }); + if (requestedScene === "terminal") { + routes.push({ + name: "ThreadTerminal", + params: { ...params, terminalId: "term-1" }, + }); + } else if (requestedScene === "review") { + routes.push({ name: "ThreadReview", params }); + } } + navigation.dispatch( + CommonActions.reset({ + index: routes.length - 1, + routes, + }), + ); }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); useEffect(() => { diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index a36ed2eef8c..254e053cf0f 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -21,8 +21,8 @@ The command: 1. Creates three temporary T3 base directories and starts a local server for each on an available port. -2. Creates Codex, React, and Linux Git repositories with recognizable favicons, feature branches, - and a deterministic Codex review diff. +2. Creates T3 Code, React, and Linux Git repositories with recognizable favicons, feature branches, + and a deterministic T3 Code review diff. 3. Seeds each server's migrated SQLite database with playful threads, messages, activities, and terminal history. 4. Starts an isolated Metro server, builds the selected native apps, and boots each device. diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index e27c23ae560..c42239e427f 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -7,8 +7,8 @@ import * as NodeUtil from "node:util"; const execFile = NodeUtil.promisify(NodeChildProcess.execFile); -export const SHOWCASE_PROJECT_ID = "codex"; -export const SHOWCASE_THREAD_ID = "terminal-heartbeat"; +export const SHOWCASE_PROJECT_ID = "t3code"; +export const SHOWCASE_THREAD_ID = "remote-command-center"; export const SHOWCASE_TERMINAL_ID = "term-1"; export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; @@ -45,54 +45,47 @@ const PROJECT_SCRIPTS = JSON.stringify([ ]); export const SHOWCASE_TERMINAL_BUFFER = [ - "\u001b[38;5;75m~/Code/codex\u001b[0m \u001b[38;5;212mfeat/terminal-heartbeat\u001b[0m", - "$ cargo nextest run --workspace", + "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m", + "$ vp test run --changed", "", - " \u001b[38;5;117mcodex-core\u001b[0m 418 passed", - " \u001b[38;5;213mcodex-tui\u001b[0m 267 passed", - " \u001b[38;5;221mprotocol\u001b[0m 162 passed", + " \u001b[38;5;117mt3code-mobile\u001b[0m 184 passed", + " \u001b[38;5;213mclient-runtime\u001b[0m 263 passed", + " \u001b[38;5;221mserver\u001b[0m 165 passed", "", - "\u001b[32m✨ 847 tests passed\u001b[0m · terminal pulse is steady", + "\u001b[32m✨ 612 tests passed\u001b[0m · 3 environments online", "", - "\u001b[38;5;75m~/Code/codex\u001b[0m \u001b[38;5;212mfeat/terminal-heartbeat\u001b[0m $ ", + "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m $ ", ].join("\r\n"); -const BASE_STATUS_INDICATOR = `use ratatui::text::Line; - -pub(crate) fn status_line(label: &str) -> Line<'static> { - Line::from(format!(" {label}")) +const BASE_ENVIRONMENT_PRESENCE = `export function environmentLabel(count: number): string { + return \`${"${count}"} environments\`; } `; -const UPDATED_STATUS_INDICATOR = `use ratatui::{style::Stylize, text::{Line, Span}}; - -const PULSE: [&str; 4] = ["✦", "✧", "·", "✧"]; +const UPDATED_ENVIRONMENT_PRESENCE = `const PULSE = ["✦", "✧", "·", "✧"] as const; -pub(crate) fn status_line(label: &str, frame: usize) -> Line<'static> { - Line::from(vec![ - Span::raw(" "), - Span::raw(PULSE[frame % PULSE.len()]).cyan(), - Span::raw(format!(" {label}")).white(), - ]) +export function environmentLabel(connected: number, total: number, frame: number): string { + const pulse = PULSE[frame % PULSE.length]; + return \`${"${pulse} ${connected}/${total}"} ready\`; } `; -const TOOL_CALL_CARD = `use ratatui::{style::Stylize, text::Line}; +const REMOTE_HANDOFF_CARD = `import { View, Text } from "react-native"; -pub(crate) fn completed_tool(title: &str, detail: &str) -> Vec> { - vec![ - Line::from(format!(" ✓ {title}")).green().bold(), - Line::from(format!(" {detail}")).dark_gray(), - ] +export function RemoteHandoffCard(props: { machine: string; latencyMs: number }) { + return ( + + Ready on {props.machine} + Handoff in {props.latencyMs}ms + + ); } `; const PROJECT_FAVICONS = { - codex: ` - - - - + t3code: ` + + `, react: ` @@ -111,11 +104,11 @@ const PROJECT_FAVICONS = { export const SHOWCASE_PROJECTS = [ { - id: "codex", - title: "Codex", - directory: "codex", - repositoryUrl: "https://github.com/openai/codex.git", - favicon: PROJECT_FAVICONS.codex, + id: "t3code", + title: "T3 Code", + directory: "t3code", + repositoryUrl: "https://github.com/pingdotgg/t3code.git", + favicon: PROJECT_FAVICONS.t3code, }, { id: "react", @@ -137,7 +130,7 @@ export const SHOWCASE_ENVIRONMENTS = [ { id: "moonbase-terminal", label: "Moonbase Terminal", - projectIds: ["codex"], + projectIds: ["t3code"], }, { id: "suspense-station", @@ -154,25 +147,25 @@ export const SHOWCASE_ENVIRONMENTS = [ export const SHOWCASE_THREADS = [ { id: SHOWCASE_THREAD_ID, - projectId: "codex", - title: "Give the terminal a heartbeat ✦", - branch: "feat/terminal-heartbeat", + projectId: "t3code", + title: "Make remote coding feel local ✦", + branch: "feat/remote-command-center", minutesAgo: 3, request: - "Give the Codex terminal a little pulse. Stream tool calls as crisp cards, make success feel electric, and keep everything fast enough to disappear.", + "Give T3 Code a remote-first command center. Make three machines feel one tap away, keep agent work in sync, and make every handoff feel instant.", response: - "The terminal has a heartbeat now — expressive, but never noisy. ✦\n\n- Tool calls arrive as compact live cards\n- Successful runs resolve with a subtle electric pulse\n- Reconnects preserve the exact animation frame\n- Reduced-motion mode stays completely calm\n\nI also ran the full Rust workspace: **847 tests passed**.", + "T3 Code now treats every machine like it is right here in the room. ✦\n\n- Moonbase, Suspense Station, and Kernel Cabin stay live together\n- Terminal state follows you without losing a single line\n- Agent work remains perfectly in sync across devices\n- Handoffs land before your train of thought can wander\n\nI also ran the changed workspace: **612 tests passed**.", }, { - id: "green-build-celebration", - projectId: "codex", - title: "Teach agents to celebrate green builds", - branch: "feat/green-builds", + id: "pocket-command-center", + projectId: "t3code", + title: "Put the command center in your pocket", + branch: "feat/pocket-command-center", minutesAgo: 21, state: "approval" as const, - request: "Make successful builds feel rewarding without turning the CLI into a slot machine.", + request: "Make switching between desktop, phone, and tablet feel like one continuous session.", response: - "Added a restrained success moment: one shimmer, one crisp summary, then straight back to work. The final color treatment is ready for approval.", + "The handoff flow preserves the selected thread, terminal buffer, and working diff. The final motion treatment is ready for approval.", }, { id: "buttery-suspense", @@ -249,30 +242,32 @@ async function initializeRepository(input: { await runGit(input.workspaceRoot, ["commit", "-m", input.commitMessage]); } -async function seedCodexWorkspace(workspaceRoot: string): Promise { - await NodeFSP.mkdir(NodePath.join(workspaceRoot, "codex-rs/tui/src"), { recursive: true }); +async function seedT3CodeWorkspace(workspaceRoot: string): Promise { + await NodeFSP.mkdir(NodePath.join(workspaceRoot, "apps/mobile/src/features/home"), { + recursive: true, + }); await NodeFSP.writeFile( - NodePath.join(workspaceRoot, "Cargo.toml"), - `[workspace]\nmembers = ["codex-rs/tui"]\nresolver = "2"\n`, + NodePath.join(workspaceRoot, "package.json"), + `${JSON.stringify({ name: "t3code", private: true, scripts: { test: "vp test" } }, null, 2)}\n`, ); - await NodeFSP.writeFile(NodePath.join(workspaceRoot, "favicon.svg"), PROJECT_FAVICONS.codex); + await NodeFSP.writeFile(NodePath.join(workspaceRoot, "favicon.svg"), PROJECT_FAVICONS.t3code); await NodeFSP.writeFile( - NodePath.join(workspaceRoot, "codex-rs/tui/src/status_indicator.rs"), - BASE_STATUS_INDICATOR, + NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"), + BASE_ENVIRONMENT_PRESENCE, ); await initializeRepository({ workspaceRoot, - repositoryUrl: "https://github.com/openai/codex.git", - commitMessage: "Render terminal status", + repositoryUrl: "https://github.com/pingdotgg/t3code.git", + commitMessage: "Show connected environments", }); - await runGit(workspaceRoot, ["checkout", "-b", "feat/terminal-heartbeat"]); + await runGit(workspaceRoot, ["checkout", "-b", "feat/remote-command-center"]); await NodeFSP.writeFile( - NodePath.join(workspaceRoot, "codex-rs/tui/src/status_indicator.rs"), - UPDATED_STATUS_INDICATOR, + NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"), + UPDATED_ENVIRONMENT_PRESENCE, ); await NodeFSP.writeFile( - NodePath.join(workspaceRoot, "codex-rs/tui/src/tool_call_card.rs"), - TOOL_CALL_CARD, + NodePath.join(workspaceRoot, "apps/mobile/src/features/home/RemoteHandoffCard.tsx"), + REMOTE_HANDOFF_CARD, ); } @@ -460,42 +455,42 @@ function seedDatabase( ) VALUES (?, ?, ?, 'tool', 'tool.completed', ?, ?, ?, ?)`, ); insertActivity.run( - "trace-render-loop", + "trace-remote-handoff", SHOWCASE_THREAD_ID, turnId, - "Traced the terminal rendering loop", + "Traced the remote handoff path", JSON.stringify({ itemType: "command_execution", - title: "Traced the terminal rendering loop", - detail: "Found a zero-allocation path for the pulse frames", + title: "Traced the remote handoff path", + detail: "Three environments, one continuous workspace", status: "completed", }), 1, minutesBefore(now, 8), ); insertActivity.run( - "paint-tool-cards", + "sync-command-center", SHOWCASE_THREAD_ID, turnId, - "Painted live tool-call cards", + "Synced the command center", JSON.stringify({ itemType: "file_change", - title: "Painted live tool-call cards", - detail: "2 files changed · cyan pulse · calm reconnects", + title: "Synced the command center", + detail: "2 files changed · instant handoffs · calm reconnects", status: "completed", }), 2, minutesBefore(now, 6), ); insertActivity.run( - "run-rust-suite", + "run-changed-suite", SHOWCASE_THREAD_ID, turnId, - "Ran the Rust workspace", + "Ran the changed workspace", JSON.stringify({ itemType: "command_execution", - title: "Ran the Rust workspace", - detail: "847 tests passed · 0 flaky retries", + title: "Ran the changed workspace", + detail: "612 tests passed · 3 environments online", status: "completed", }), 3, @@ -543,7 +538,7 @@ export async function seedShowcaseEnvironment(input: { if (!workspaceRoot) throw new Error("The primary showcase workspace is not configured."); const dbPath = NodePath.join(input.baseDir, "userdata", "state.sqlite"); if (primaryProject.id === SHOWCASE_PROJECT_ID) { - await seedCodexWorkspace(workspaceRoot); + await seedT3CodeWorkspace(workspaceRoot); } await Promise.all( projects diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 6c41ecb6b84..e45d45424f0 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -97,22 +97,22 @@ it("maps capture scenes to the real application routes", () => { ); assert.equal( showcaseSceneUrl("thread", "environment-1"), - "t3code-dev://threads/environment-1/terminal-heartbeat", + "t3code-dev://threads/environment-1/remote-command-center", ); assert.equal( showcaseSceneUrl("terminal", "environment-1"), - "t3code-dev://threads/environment-1/terminal-heartbeat/terminal?terminalId=term-1", + "t3code-dev://threads/environment-1/remote-command-center/terminal?terminalId=term-1", ); assert.equal( showcaseSceneUrl("review", "environment-1"), - "t3code-dev://threads/environment-1/terminal-heartbeat/review", + "t3code-dev://threads/environment-1/remote-command-center/review", ); }); it("seeds a playful multi-environment project spectrum", () => { assert.deepStrictEqual( SHOWCASE_PROJECTS.map((project) => project.title), - ["Codex", "React", "Linux"], + ["T3 Code", "React", "Linux"], ); assert.deepStrictEqual( SHOWCASE_ENVIRONMENTS.map((environment) => environment.label), diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 86070b54457..c250a851fc8 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -284,6 +284,20 @@ function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +async function stopProcess(child: NodeChildProcess.ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + + const exited = new Promise((resolve) => { + child.once("exit", () => resolve()); + }); + child.kill("SIGTERM"); + await Promise.race([exited, delay(5_000)]); + if (child.exitCode !== null || child.signalCode !== null) return; + + child.kill("SIGKILL"); + await Promise.race([exited, delay(1_000)]); +} + async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -1075,9 +1089,16 @@ async function main(): Promise { for (const udid of startedIosUdids) { await runCommand("xcrun", ["simctl", "shutdown", udid]).catch(() => undefined); } - metro?.kill("SIGTERM"); - for (const server of showcaseServers) server.kill("SIGTERM"); - await NodeFSP.rm(showcaseRootDir, { recursive: true, force: true }); + await Promise.all([ + ...(metro ? [stopProcess(metro)] : []), + ...showcaseServers.map((server) => stopProcess(server)), + ]); + await NodeFSP.rm(showcaseRootDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); } } } From 4841b8a2d9cc2ed6df99535729f841ee59e12580 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 12:34:27 +0200 Subject: [PATCH 5/9] Add dispatched mobile screenshot workflow Co-authored-by: codex --- .../workflows/mobile-showcase-screenshots.yml | 119 ++++++++++++++++++ .../mobile-app-store-screenshots.md | 15 +++ scripts/mobile-showcase.config.ts | 23 +++- scripts/mobile-showcase.test.ts | 8 +- scripts/mobile-showcase.ts | 16 ++- 5 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/mobile-showcase-screenshots.yml diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml new file mode 100644 index 00000000000..0e768031010 --- /dev/null +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -0,0 +1,119 @@ +name: Mobile Showcase Screenshots + +on: + workflow_dispatch: + inputs: + platform: + description: Device platforms to capture + required: true + default: all + type: choice + options: + - all + - ios + - android + +permissions: + contents: read + +env: + NODE_OPTIONS: --max-old-space-size=8192 + +jobs: + ios: + name: iPhone and iPad + if: inputs.platform == 'all' || inputs.platform == 'ios' + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Capture iOS showcase + run: pnpm screenshots:mobile --platform ios + + - name: Upload iOS screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: app-store-screenshots-ios + path: artifacts/app-store/screenshots/*.png + if-no-files-found: warn + retention-days: 14 + + android: + name: Pixel phone and tablet + if: inputs.platform == 'all' || inputs.platform == 'android' + runs-on: blacksmith-16vcpu-ubuntu-2404 + timeout-minutes: 45 + env: + T3_SHOWCASE_ANDROID_ABI: x86_64 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle cache + uses: gradle/actions/setup-gradle@v5 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Capture Android showcase + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 36 + target: google_apis + arch: x86_64 + profile: pixel_7_pro + avd-name: Pixel_10_Pro + cores: 8 + ram-size: 4096M + disable-animations: false + script: pnpm screenshots:mobile --platform android + + - name: Upload Android screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: app-store-screenshots-android + path: artifacts/app-store/screenshots/*.png + if-no-files-found: warn + retention-days: 14 diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index 254e053cf0f..c140d3a22b5 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -58,6 +58,21 @@ PNG files in the complete matrix. Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport. +## Capture in GitHub Actions + +Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab and choose `all`, `ios`, or +`android`. The default `all` dispatch runs iOS and Android concurrently: iPhone and iPad capture on a +12-vCPU Blacksmith macOS runner, while Pixel phone and tablet capture on a 16-vCPU Blacksmith Linux +runner with a KVM-accelerated x86_64 emulator. + +Every job uploads its PNGs even when a later capture fails, which makes partial runs useful for +diagnosis. Download `app-store-screenshots-ios` and `app-store-screenshots-android` from the workflow +run's Artifacts section. Artifacts are retained for 14 days. + +The workflow uses the same checked-in device and scene matrix as local capture. Android remains +ARM64 by default for local Apple Silicon development; CI sets `T3_SHOWCASE_ANDROID_ABI=x86_64` so the +debug APK matches its accelerated emulator. + ## Fast iteration Capture one scene or device: diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 834965ef65c..cf14cb7a98a 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -38,6 +38,20 @@ export interface ShowcaseConfig { readonly devices: ReadonlyArray; } +const ANDROID_ABIS = ["arm64-v8a", "x86_64", "x86", "armeabi-v7a"] as const; + +export function resolveShowcaseAndroidAbi( + value: string | undefined, +): NonNullable { + if (!value) return "arm64-v8a"; + if (ANDROID_ABIS.some((abi) => abi === value)) { + return value as NonNullable; + } + throw new Error( + `Unsupported T3_SHOWCASE_ANDROID_ABI '${value}'. Use ${ANDROID_ABIS.join(", ")}.`, + ); +} + /** * The defaults cover the current large iPhone, 13-inch iPad, and a flagship * Pixel AVD. Edit this matrix (or pass --device / --scene) without changing @@ -69,8 +83,15 @@ const config: ShowcaseConfig = { id: "pixel", platform: "android", avd: "Pixel_10_Pro", - abi: "arm64-v8a", + // Apple Silicon uses ARM64 locally; CI overrides this with x86_64 so its + // Blacksmith Linux runner can use KVM acceleration. + abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), appearance: "dark", + viewport: { + width: 1280, + height: 2856, + density: 480, + }, scenes: ["thread", "terminal", "review", "threads", "environments"], }, { diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index e45d45424f0..4152e175ec9 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -1,6 +1,6 @@ import { assert, it } from "@effect/vitest"; -import type { ShowcaseConfig } from "./mobile-showcase.config.ts"; +import { resolveShowcaseAndroidAbi, type ShowcaseConfig } from "./mobile-showcase.config.ts"; import { SHOWCASE_ENVIRONMENTS, SHOWCASE_PROJECTS, @@ -54,6 +54,12 @@ it("parses repeatable capture filters", () => { assert.equal(options.skipBuild, true); }); +it("selects an explicit CI Android ABI without changing the local default", () => { + assert.equal(resolveShowcaseAndroidAbi(undefined), "arm64-v8a"); + assert.equal(resolveShowcaseAndroidAbi("x86_64"), "x86_64"); + assert.throws(() => resolveShowcaseAndroidAbi("mips"), /Unsupported T3_SHOWCASE_ANDROID_ABI/u); +}); + it("plans only scenes supported by each selected device", () => { const options = parseShowcaseCliArgs(["--platform", "all", "--scene", "terminal"]); const captures = planShowcaseCaptures(config, options); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index c250a851fc8..af1e9977751 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -352,18 +352,22 @@ exec /bin/cat async function createShowcaseLabelProbe(baseDir: string, label: string): Promise { const binDirectory = NodePath.join(baseDir, "showcase-bin"); - const probePath = NodePath.join(binDirectory, "scutil"); await NodeFSP.mkdir(binDirectory, { recursive: true }); - await NodeFSP.writeFile( - probePath, - `#!/bin/sh + const probeScript = `#!/bin/sh if [ "$1" = "--get" ] && [ "$2" = "ComputerName" ]; then printf '%s\\n' ${JSON.stringify(label)} exit 0 fi +if [ "$1" = "--pretty" ]; then + printf '%s\\n' ${JSON.stringify(label)} + exit 0 +fi exit 1 -`, - { mode: 0o755 }, +`; + await Promise.all( + ["scutil", "hostnamectl"].map((executable) => + NodeFSP.writeFile(NodePath.join(binDirectory, executable), probeScript, { mode: 0o755 }), + ), ); return binDirectory; } From 19c78e8dce0f26e890e0daa71e1f5a3fa6395317 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 16:16:28 +0200 Subject: [PATCH 6/9] Show pending offline tasks in showcase captures Co-authored-by: codex --- .../showcase/ShowcaseCaptureCoordinator.tsx | 27 ++++++- .../showcase/showcasePendingTasks.test.ts | 72 +++++++++++++++++++ .../features/showcase/showcasePendingTasks.ts | 66 +++++++++++++++++ .../mobile-app-store-screenshots.md | 7 +- 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/features/showcase/showcasePendingTasks.test.ts create mode 100644 apps/mobile/src/features/showcase/showcasePendingTasks.ts diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 0d50a397bb5..84e3294fe12 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -4,6 +4,8 @@ import { CommonActions, StackActions, useNavigation } from "@react-navigation/na import { useConnectionController } from "../connection/useConnectionController"; import { useProjects, useThreadShells } from "../../state/entities"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; import { getNativeShowcasePairingUrls, @@ -11,6 +13,10 @@ import { markNativeShowcaseReady, type ShowcaseScene, } from "./nativeShowcaseScene"; +import { + buildShowcasePendingTasks, + SHOWCASE_PENDING_TASK_DEFINITIONS, +} from "./showcasePendingTasks"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; @@ -34,7 +40,9 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const projects = useProjects(); const threads = useThreadShells(); const attemptedPairingRef = useRef(new Set()); + const attemptedPendingTaskSeedRef = useRef(false); const [pairingUrls, setPairingUrls] = useState>([]); + const [pendingTasksReady, setPendingTasksReady] = useState(false); const [requestedScene, setRequestedScene] = useState(null); const [readyScene, setReadyScene] = useState(null); @@ -78,13 +86,30 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) }, [connectPairingUrl, pairingUrls]); const scene = sceneFromPathname(props.pathname); - const hasFixture = + const hasServerFixture = workspace.state.hasReadyEnvironment && workspace.environments.length >= 3 && projects.length >= 3 && threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + const hasFixture = hasServerFixture && pendingTasksReady; const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + useEffect(() => { + if (!SHOWCASE_ENABLED || !hasServerFixture || attemptedPendingTaskSeedRef.current) return; + + const pendingTasks = buildShowcasePendingTasks(projects, Date.now()); + if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; + + attemptedPendingTaskSeedRef.current = true; + for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId); + void Promise.all(pendingTasks.map((task) => enqueueThreadOutboxMessage(task))) + .then(() => setPendingTasksReady(true)) + .catch((error: unknown) => { + attemptedPendingTaskSeedRef.current = false; + console.warn("[showcase] failed to seed pending offline tasks", error); + }); + }, [hasServerFixture, projects]); + useEffect(() => { if (!SHOWCASE_ENABLED || requestedScene === null || !hasFixture || !showcaseThread) return; if (scene === requestedScene) return; diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts new file mode 100644 index 00000000000..8ab043d7cd8 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts @@ -0,0 +1,72 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import { + buildShowcasePendingTasks, + SHOWCASE_PENDING_TASK_DEFINITIONS, +} from "./showcasePendingTasks"; + +const projects: ReadonlyArray = [ + { + environmentId: EnvironmentId.make("moonbase-terminal"), + id: ProjectId.make("t3code"), + title: "T3 Code", + workspaceRoot: "/workspace/t3code", + repositoryIdentity: null, + defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + scripts: [], + createdAt: "2026-07-16T08:00:00.000Z", + updatedAt: "2026-07-16T08:00:00.000Z", + }, + { + environmentId: EnvironmentId.make("suspense-station"), + id: ProjectId.make("react"), + title: "React", + workspaceRoot: "/workspace/react", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-16T08:00:00.000Z", + updatedAt: "2026-07-16T08:00:00.000Z", + }, +]; + +it("builds sendable-looking pending tasks against real showcase projects", () => { + const tasks = buildShowcasePendingTasks(projects, Date.parse("2026-07-16T09:00:00.000Z")); + + assert.equal(tasks.length, SHOWCASE_PENDING_TASK_DEFINITIONS.length); + assert.deepStrictEqual( + tasks.map((task) => ({ + environmentId: String(task.environmentId), + projectId: task.creation ? String(task.creation.projectId) : undefined, + title: task.creation?.projectTitle, + branch: task.creation?.branch, + createdAt: task.createdAt, + })), + [ + { + environmentId: "moonbase-terminal", + projectId: "t3code", + title: "T3 Code", + branch: "feat/offline-launchpad", + createdAt: "2026-07-16T08:52:00.000Z", + }, + { + environmentId: "suspense-station", + projectId: "react", + title: "React", + branch: "perf/tunnel-handoff", + createdAt: "2026-07-16T08:33:00.000Z", + }, + ], + ); + assert.equal( + tasks.every((task) => task.modelSelection !== undefined), + true, + ); +}); + +it("waits until every referenced project has hydrated", () => { + assert.equal(buildShowcasePendingTasks(projects.slice(0, 1), Date.now()).length, 1); +}); diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.ts new file mode 100644 index 00000000000..1b0b5ed22f4 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.ts @@ -0,0 +1,66 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; + +import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; + +export const SHOWCASE_PENDING_TASK_DEFINITIONS = [ + { + projectId: "t3code", + id: "offline-launch-checklist", + text: "Ship the offline launch checklist before touchdown ✈️", + branch: "feat/offline-launchpad", + minutesAgo: 8, + }, + { + projectId: "react", + id: "train-tunnel-suspense", + text: "Polish the Suspense handoff for the train tunnel 🚇", + branch: "perf/tunnel-handoff", + minutesAgo: 27, + }, +] as const; + +const FALLBACK_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", +} as const; + +export function buildShowcasePendingTasks( + projects: ReadonlyArray, + now: number, +): ReadonlyArray { + return SHOWCASE_PENDING_TASK_DEFINITIONS.flatMap((definition) => { + const project = projects.find((candidate) => String(candidate.id) === definition.projectId); + if (!project) return []; + + return [ + { + environmentId: project.environmentId, + threadId: ThreadId.make(`showcase-pending-${definition.id}`), + messageId: MessageId.make(`showcase-pending-message-${definition.id}`), + commandId: CommandId.make(`showcase-pending-command-${definition.id}`), + text: definition.text, + attachments: [], + modelSelection: project.defaultModelSelection ?? FALLBACK_MODEL_SELECTION, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + creation: { + projectId: project.id, + projectTitle: project.title, + projectCwd: project.workspaceRoot, + workspaceMode: "local" as const, + branch: definition.branch, + worktreePath: project.workspaceRoot, + }, + createdAt: new Date(now - definition.minutesAgo * 60_000).toISOString(), + }, + ]; + }); +} diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index c140d3a22b5..9e24302cfa5 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -24,7 +24,7 @@ The command: 2. Creates T3 Code, React, and Linux Git repositories with recognizable favicons, feature branches, and a deterministic T3 Code review diff. 3. Seeds each server's migrated SQLite database with playful threads, messages, activities, and - terminal history. + terminal history, then adds two persisted mobile-outbox tasks waiting to send. 4. Starts an isolated Metro server, builds the selected native apps, and boots each device. 5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin. 6. Navigates to the real application route for every requested scene. @@ -107,6 +107,11 @@ labels while the server still receives valid current data. The same deterministi ensemble serves iPhone, iPad, Android phone, and Android tablet captures; responsive differences come entirely from the production app layout. +The Pending rows use the production offline outbox and point at the real T3 Code and React fixture +projects. Showcase coordination holds those two entries in the outbox for capture, just like a task +currently open for editing, so reconnecting the seeded environments cannot deliver and remove them +before the screenshot is taken. + ## Local prerequisites - iOS: Xcode command-line tools, the configured simulator runtimes, and installed CocoaPods. From 6a296485e943acb7ba04eb580e9c03075f1d7706 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 16:30:21 +0200 Subject: [PATCH 7/9] Present remote endpoints in showcase environments Co-authored-by: codex --- .../SettingsEnvironmentsRouteScreen.tsx | 5 +- .../showcase/showcaseEnvironmentRows.test.ts | 48 +++++++++++++++++++ .../showcase/showcaseEnvironmentRows.ts | 17 +++++++ .../mobile-app-store-screenshots.md | 4 ++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 77333d22513..cb87b2b4bf3 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -16,6 +16,7 @@ import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { + applyShowcaseLocalEnvironmentDisplayUrls, SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS, } from "../showcase/showcaseEnvironmentRows"; @@ -36,7 +37,9 @@ export function SettingsEnvironmentsRouteScreen() { connectedEnvironments, cloudEnvironments: null, }); - const { localEnvironments } = environmentSections; + const localEnvironments = SHOWCASE_ENABLED + ? applyShowcaseLocalEnvironmentDisplayUrls(environmentSections.localEnvironments) + : environmentSections.localEnvironments; const connectedCloudEnvironments = SHOWCASE_ENABLED ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS : environmentSections.connectedCloudEnvironments; diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts new file mode 100644 index 00000000000..cbbcb848968 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts @@ -0,0 +1,48 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { applyShowcaseLocalEnvironmentDisplayUrls } from "./showcaseEnvironmentRows"; + +function environment( + environmentId: string, + environmentLabel: string, + displayUrl = "http://127.0.0.1:3773/", +): ConnectedEnvironmentSummary { + return { + environmentId: EnvironmentId.make(environmentId), + environmentLabel, + displayUrl, + isRelayManaged: false, + connectionState: "connected", + connectionError: null, + connectionErrorTraceId: null, + }; +} + +it("presents showcase transports as remote endpoints", () => { + const environments = applyShowcaseLocalEnvironmentDisplayUrls([ + environment("runtime-id-1", "Moonbase Terminal"), + environment("runtime-id-2", "Suspense Station"), + environment("runtime-id-3", "Kernel Cabin"), + ]); + + assert.deepStrictEqual( + environments.map(({ displayUrl }) => displayUrl), + [ + "https://moonbase.tail9f3a.ts.net/", + "https://suspense-vps.hel1.t3.sh/", + "http://100.82.16.5:3773/", + ], + ); +}); + +it("leaves environments outside the showcase fixture unchanged", () => { + const original = environment( + "runtime-id-4", + "My Workstation", + "https://workstation.example.test/", + ); + + assert.deepStrictEqual(applyShowcaseLocalEnvironmentDisplayUrls([original]), [original]); +}); diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts index 7ab1e60f103..8cdbb3b9c20 100644 --- a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts @@ -3,6 +3,23 @@ import { EnvironmentId } from "@t3tools/contracts"; import type { RelayEnvironmentView } from "../connection/useConnectionController"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +const SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS: Readonly> = { + "Moonbase Terminal": "https://moonbase.tail9f3a.ts.net/", + "Suspense Station": "https://suspense-vps.hel1.t3.sh/", + "Kernel Cabin": "http://100.82.16.5:3773/", +}; + +export function applyShowcaseLocalEnvironmentDisplayUrls( + environments: ReadonlyArray, +): ReadonlyArray { + return environments.map((environment) => ({ + ...environment, + displayUrl: + SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS[environment.environmentLabel] ?? + environment.displayUrl, + })); +} + const pocketPiId = EnvironmentId.make("showcase-pocket-pi"); const pocketPiEndpoint = { httpBaseUrl: "https://pocket-pi.t3.sh", diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index 9e24302cfa5..9ef694647fb 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -112,6 +112,10 @@ projects. Showcase coordination holds those two entries in the outbox for captur currently open for editing, so reconnecting the seeded environments cannot deliver and remove them before the screenshot is taken. +The Environments capture presents the three local fixture transports as a Tailscale HTTPS hostname, +a Helsinki VPS hostname, and a Tailnet IPv4 address. This display-only substitution keeps the cards +remote-first while the harness retains reliable loopback connections to its ephemeral servers. + ## Local prerequisites - iOS: Xcode command-line tools, the configured simulator runtimes, and installed CocoaPods. From 584c932af42c8864234b6bc05272c5a680348bbc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 17:32:16 +0200 Subject: [PATCH 8/9] fix(mobile): generate upload-ready store screenshots Co-authored-by: codex --- .../workflows/mobile-showcase-screenshots.yml | 22 +- .../mobile-app-store-screenshots.md | 57 ++-- pnpm-lock.yaml | 23 +- pnpm-workspace.yaml | 3 + scripts/mobile-showcase.config.ts | 111 +++++++- scripts/mobile-showcase.test.ts | 102 ++++++- scripts/mobile-showcase.ts | 253 +++++++++++++++--- scripts/package.json | 2 + 8 files changed, 502 insertions(+), 71 deletions(-) diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 0e768031010..f47d343d939 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -21,10 +21,10 @@ env: jobs: ios: - name: iPhone and iPad + name: iPhone 6.9, iPhone 6.5, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' runs-on: blacksmith-12vcpu-macos-26 - timeout-minutes: 45 + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v6 @@ -46,20 +46,23 @@ jobs: - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios + - name: Validate App Store Connect assets + run: pnpm screenshots:mobile --platform ios --validate-only + - name: Upload iOS screenshots if: always() uses: actions/upload-artifact@v7 with: - name: app-store-screenshots-ios - path: artifacts/app-store/screenshots/*.png + name: app-store-connect-screenshots + path: artifacts/app-store/screenshots/apple/ if-no-files-found: warn retention-days: 14 android: - name: Pixel phone and tablet + name: Android phone, 7-inch tablet, and 10-inch tablet if: inputs.platform == 'all' || inputs.platform == 'android' runs-on: blacksmith-16vcpu-ubuntu-2404 - timeout-minutes: 45 + timeout-minutes: 60 env: T3_SHOWCASE_ANDROID_ABI: x86_64 steps: @@ -109,11 +112,14 @@ jobs: disable-animations: false script: pnpm screenshots:mobile --platform android + - name: Validate Google Play assets + run: pnpm screenshots:mobile --platform android --validate-only + - name: Upload Android screenshots if: always() uses: actions/upload-artifact@v7 with: - name: app-store-screenshots-android - path: artifacts/app-store/screenshots/*.png + name: google-play-screenshots + path: artifacts/app-store/screenshots/google-play/ if-no-files-found: warn retention-days: 14 diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index 9ef694647fb..144a8ee686c 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -28,8 +28,10 @@ The command: 4. Starts an isolated Metro server, builds the selected native apps, and boots each device. 5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin. 6. Navigates to the real application route for every requested scene. -7. Normalizes appearance and status bars and writes exact-size PNGs to - artifacts/app-store/screenshots/. +7. Normalizes appearance and status bars, converts captures to 24-bit RGB PNGs without alpha, and + validates dimensions, aspect ratio, file size, and screenshot count before succeeding. +8. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded + directly to App Store Connect or Google Play Console. The servers, Metro, temporary root directory, and devices started by the runner are cleaned up after capture. Pass `--keep-running` to retain them for inspection; the runner prints the base-directory @@ -47,13 +49,30 @@ worktree cannot accidentally provide the bundle being photographed. The default matrix is: -- iphone-6.9: iPhone 17 Pro Max -- ipad-13: iPad Pro 13-inch (M5) -- pixel: Pixel 10 Pro Android AVD -- android-tablet: Pixel Android AVD rendered at an 800dp tablet viewport - -Each device captures the thread, terminal, review, thread list, and environments scenes, for 20 -PNG files in the complete matrix. +| Output folder | Capture target | Upload dimensions | Store slot | +| ------------------------ | ------------------------- | ----------------- | ----------------------------------------- | +| `apple/iphone-6.9/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | +| `apple/iphone-6.5/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | +| `apple/ipad-13/` | iPad Pro 13-inch (M5) | 2064×2752 | App Store Connect iPad 13-inch | +| `google-play/phone/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | +| `google-play/tablet-7/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | +| `google-play/tablet-10/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | + +Each target captures thread, terminal, review, thread list, and environments, producing 30 PNG +files in a complete matrix. Five screenshots satisfy the configured Apple limit of 1–10, Google +phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8. + +The generated tree is deliberately aligned with the store upload fields: + + artifacts/app-store/screenshots/ + ├── apple/ + │ ├── iphone-6.9/{thread,terminal,review,threads,environments}.png + │ ├── iphone-6.5/{thread,terminal,review,threads,environments}.png + │ └── ipad-13/{thread,terminal,review,threads,environments}.png + └── google-play/ + ├── phone/{thread,terminal,review,threads,environments}.png + ├── tablet-7/{thread,terminal,review,threads,environments}.png + └── tablet-10/{thread,terminal,review,threads,environments}.png Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport. @@ -62,12 +81,13 @@ names, light/dark appearance, scenes, output directory, capture delay, Android A Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab and choose `all`, `ios`, or `android`. The default `all` dispatch runs iOS and Android concurrently: iPhone and iPad capture on a -12-vCPU Blacksmith macOS runner, while Pixel phone and tablet capture on a 16-vCPU Blacksmith Linux -runner with a KVM-accelerated x86_64 emulator. +12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a +16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator. Every job uploads its PNGs even when a later capture fails, which makes partial runs useful for -diagnosis. Download `app-store-screenshots-ios` and `app-store-screenshots-android` from the workflow -run's Artifacts section. Artifacts are retained for 14 days. +diagnosis. Download `app-store-connect-screenshots` and `google-play-screenshots` from the workflow +run's Artifacts section. Each job runs validation again immediately before upload. Artifacts are +retained for 14 days. The workflow uses the same checked-in device and scene matrix as local capture. Android remains ARM64 by default for local Apple Silicon development; CI sets `T3_SHOWCASE_ANDROID_ABI=x86_64` so the @@ -93,6 +113,11 @@ List the matrix and flags: pnpm screenshots:mobile --list +Validate existing files without starting Metro, servers, simulators, or emulators: + + pnpm screenshots:mobile --validate-only + pnpm screenshots:mobile --platform ios --validate-only + ## Customize the seeded environment - Project repository, thread projections, conversation, terminal transcript, and Git changes: @@ -121,5 +146,7 @@ remote-first while the harness retains reliable loopback connections to its ephe - iOS: Xcode command-line tools, the configured simulator runtimes, and installed CocoaPods. - Android: ANDROID_HOME (or the default macOS SDK path), adb, emulator, and the configured AVD. -For store submission, keep generated PNGs unscaled. Configure device classes and Android viewport -dimensions that match the exact upload slots. +The harness is the source of truth for upload dimensions; do not resize its output. If store rules +change, update the target's `storeAsset` specification. Capture fails when a PNG is the wrong size, +has alpha, is not 8-bit RGB, exceeds the configured file-size limit, violates Google Play's 9:16 +shape/bounds, or leaves a full output set below its store minimum. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c50f4a98de..e0271c378ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ overrides: vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-FV3+2NW/9MFbunJaPsMxvO0LAzyfccoPtPiKoIXAwUU= +packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f @@ -193,7 +193,7 @@ importers: version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: 3.7.2 - version: 3.7.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.7.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -902,6 +902,9 @@ importers: effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + pngjs: + specifier: 7.0.0 + version: 7.0.0 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -909,6 +912,9 @@ importers: '@effect/vitest': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@types/pngjs': + specifier: 6.0.5 + version: 6.0.5 vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -4898,6 +4904,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -11472,11 +11481,12 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@3.7.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.7.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': dependencies: '@clerk/clerk-js': 6.25.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/shared': 4.25.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@expo/config-plugins': 56.0.9(typescript@6.0.3) base-64: 1.0.0 expo: 56.0.12(8895228379997a2a064f9644cda56ed0) react: 19.2.3 @@ -11490,6 +11500,9 @@ snapshots: expo-secure-store: 56.0.4(expo@56.0.12) expo-web-browser: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - supports-color + - typescript '@clerk/react@6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -14925,6 +14938,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 24.12.4 + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 096e21fcfa6..22757702fc8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -92,6 +92,9 @@ overrides: yaml: "catalog:" packageExtensions: + "@clerk/expo@*": + dependencies: + "@expo/config-plugins": 56.0.9 "@effect/vitest@*": dependencies: vite-plus: "catalog:" diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index cf14cb7a98a..154d005f37b 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -3,13 +3,27 @@ import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environme export { SHOWCASE_SCENES }; export type { ShowcaseScene }; +export interface ShowcaseStoreAssetSpec { + readonly store: "apple" | "google-play"; + /** Upload-ready directory relative to ShowcaseConfig.outputDirectory. */ + readonly directory: string; + readonly width: number; + readonly height: number; + readonly minimumUploadCount: number; + readonly maximumUploadCount: number; + readonly maximumFileSizeBytes?: number; +} + export interface ShowcaseIosDevice { readonly id: string; readonly platform: "ios"; /** Exact name from `xcrun simctl list devices available`. */ readonly simulator: string; + /** Device type used to create a disposable simulator when the named one is absent. */ + readonly simulatorDeviceType?: string; readonly appearance: "light" | "dark"; readonly scenes: ReadonlyArray; + readonly storeAsset: ShowcaseStoreAssetSpec; } export interface ShowcaseAndroidDevice { @@ -27,6 +41,7 @@ export interface ShowcaseAndroidDevice { readonly height: number; readonly density?: number; }; + readonly storeAsset: ShowcaseStoreAssetSpec; } export type ShowcaseDevice = ShowcaseIosDevice | ShowcaseAndroidDevice; @@ -53,10 +68,10 @@ export function resolveShowcaseAndroidAbi( } /** - * The defaults cover the current large iPhone, 13-inch iPad, and a flagship - * Pixel AVD. Edit this matrix (or pass --device / --scene) without changing - * the runner. Simulator and AVD names are intentionally explicit so captures - * never silently move to a different screen class after an SDK update. + * The defaults cover every App Store Connect and Google Play upload slot used + * by the mobile app. Edit this matrix (or pass --device / --scene) without + * changing the runner. Every target declares and validates its exact upload + * dimensions so SDK or emulator changes cannot silently produce invalid files. */ const config: ShowcaseConfig = { outputDirectory: "artifacts/app-store/screenshots", @@ -69,15 +84,49 @@ const config: ShowcaseConfig = { id: "iphone-6.9", platform: "ios", simulator: "iPhone 17 Pro Max", + simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max", + appearance: "dark", + scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "apple", + directory: "apple/iphone-6.9", + width: 1320, + height: 2868, + minimumUploadCount: 1, + maximumUploadCount: 10, + }, + }, + { + id: "iphone-6.5", + platform: "ios", + simulator: "T3 Showcase iPhone 14 Plus", + simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus", appearance: "dark", scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "apple", + directory: "apple/iphone-6.5", + width: 1284, + height: 2778, + minimumUploadCount: 1, + maximumUploadCount: 10, + }, }, { id: "ipad-13", platform: "ios", simulator: "iPad Pro 13-inch (M5)", + simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", appearance: "dark", scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "apple", + directory: "apple/ipad-13", + width: 2064, + height: 2752, + minimumUploadCount: 1, + maximumUploadCount: 10, + }, }, { id: "pixel", @@ -88,24 +137,64 @@ const config: ShowcaseConfig = { abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), appearance: "dark", viewport: { - width: 1280, - height: 2856, - density: 480, + width: 1080, + height: 1920, + density: 420, + }, + scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "google-play", + directory: "google-play/phone", + width: 1080, + height: 1920, + minimumUploadCount: 2, + maximumUploadCount: 8, + maximumFileSizeBytes: 8 * 1024 * 1024, + }, + }, + { + id: "android-tablet-7", + platform: "android", + avd: "Pixel_10_Pro", + abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), + appearance: "dark", + viewport: { + width: 1080, + height: 1920, + density: 288, }, scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "google-play", + directory: "google-play/tablet-7", + width: 1080, + height: 1920, + minimumUploadCount: 4, + maximumUploadCount: 8, + maximumFileSizeBytes: 8 * 1024 * 1024, + }, }, { - id: "android-tablet", + id: "android-tablet-10", platform: "android", avd: "Pixel_10_Pro", - abi: "arm64-v8a", + abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), appearance: "dark", viewport: { - width: 1600, + width: 1440, height: 2560, - density: 320, + density: 288, }, scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "google-play", + directory: "google-play/tablet-10", + width: 1440, + height: 2560, + minimumUploadCount: 4, + maximumUploadCount: 8, + maximumFileSizeBytes: 8 * 1024 * 1024, + }, }, ], }; diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 4152e175ec9..02d0ea3f6f3 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -1,6 +1,11 @@ import { assert, it } from "@effect/vitest"; +import { PNG } from "pngjs"; -import { resolveShowcaseAndroidAbi, type ShowcaseConfig } from "./mobile-showcase.config.ts"; +import showcaseConfig, { + resolveShowcaseAndroidAbi, + type ShowcaseConfig, + type ShowcaseStoreAssetSpec, +} from "./mobile-showcase.config.ts"; import { SHOWCASE_ENVIRONMENTS, SHOWCASE_PROJECTS, @@ -8,14 +13,37 @@ import { } from "./mobile-showcase-environment.ts"; import { encodeAndroidPairingUrls, + normalizeStorePng, parseShowcaseCliArgs, parsePairingCredentialOutput, planShowcaseCaptures, readPngDimensions, + readPngMetadata, selectLanIpv4Address, showcaseSceneUrl, + validateStoreAsset, + validateStoreAssetCount, } from "./mobile-showcase.ts"; +const appleSpec: ShowcaseStoreAssetSpec = { + store: "apple", + directory: "apple/iphone-test", + width: 1284, + height: 2778, + minimumUploadCount: 1, + maximumUploadCount: 10, +}; + +const googleSpec: ShowcaseStoreAssetSpec = { + store: "google-play", + directory: "google-play/phone", + width: 1080, + height: 1920, + minimumUploadCount: 2, + maximumUploadCount: 8, + maximumFileSizeBytes: 8 * 1024 * 1024, +}; + const config: ShowcaseConfig = { outputDirectory: "artifacts", metroPort: 8199, @@ -27,6 +55,7 @@ const config: ShowcaseConfig = { simulator: "iPhone Test", appearance: "dark", scenes: ["thread", "review"], + storeAsset: appleSpec, }, { id: "pixel", @@ -34,6 +63,7 @@ const config: ShowcaseConfig = { avd: "Pixel_Test", appearance: "light", scenes: ["thread", "terminal"], + storeAsset: googleSpec, }, ], }; @@ -54,6 +84,10 @@ it("parses repeatable capture filters", () => { assert.equal(options.skipBuild, true); }); +it("parses validation-only mode", () => { + assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true); +}); + it("selects an explicit CI Android ABI without changing the local default", () => { assert.equal(resolveShowcaseAndroidAbi(undefined), "arm64-v8a"); assert.equal(resolveShowcaseAndroidAbi("x86_64"), "x86_64"); @@ -75,12 +109,76 @@ it("rejects unknown devices instead of silently capturing another target", () => }); it("reads captured PNG dimensions from the IHDR header", () => { - const bytes = new Uint8Array(24); + const bytes = new Uint8Array(26); bytes.set([137, 80, 78, 71, 13, 10, 26, 10]); const view = new DataView(bytes.buffer); view.setUint32(16, 1320); view.setUint32(20, 2868); + view.setUint8(24, 8); + view.setUint8(25, 2); assert.deepStrictEqual(readPngDimensions(bytes), { width: 1320, height: 2868 }); + assert.deepStrictEqual(readPngMetadata(bytes), { + width: 1320, + height: 2868, + bitDepth: 8, + colorType: 2, + hasAlpha: false, + }); +}); + +function rgbaPng(width: number, height: number): Buffer { + const png = new PNG({ width, height }); + png.data.fill(255); + return PNG.sync.write(png); +} + +it("converts simulator RGBA captures to upload-safe 24-bit RGB PNGs", () => { + const normalized = normalizeStorePng(rgbaPng(2, 3)); + assert.deepStrictEqual(readPngMetadata(normalized), { + width: 2, + height: 3, + bitDepth: 8, + colorType: 2, + hasAlpha: false, + }); +}); + +it("validates exact Apple and Google Play upload assets", () => { + const apple = normalizeStorePng(rgbaPng(appleSpec.width, appleSpec.height)); + const google = normalizeStorePng(rgbaPng(googleSpec.width, googleSpec.height)); + assert.equal(validateStoreAsset(appleSpec, apple).width, 1284); + assert.equal(validateStoreAsset(googleSpec, google).height, 1920); +}); + +it("rejects wrong dimensions and alpha-bearing PNGs", () => { + const wrongSize = normalizeStorePng(rgbaPng(1242, 2688)); + assert.throws(() => validateStoreAsset(appleSpec, wrongSize), /requires 1284×2778/u); + assert.throws(() => validateStoreAsset(appleSpec, rgbaPng(1284, 2778)), /without alpha/u); +}); + +it("enforces store screenshot count limits", () => { + assert.doesNotThrow(() => validateStoreAssetCount(googleSpec, 5, true)); + assert.throws(() => validateStoreAssetCount(googleSpec, 1, true), /requires at least 2/u); + assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u); +}); + +it("configures every default device with an exact upload-ready store target", () => { + assert.deepStrictEqual( + showcaseConfig.devices.map((device) => [ + device.id, + device.storeAsset.directory, + device.storeAsset.width, + device.storeAsset.height, + ]), + [ + ["iphone-6.9", "apple/iphone-6.9", 1320, 2868], + ["iphone-6.5", "apple/iphone-6.5", 1284, 2778], + ["ipad-13", "apple/ipad-13", 2064, 2752], + ["pixel", "google-play/phone", 1080, 1920], + ["android-tablet-7", "google-play/tablet-7", 1080, 1920], + ["android-tablet-10", "google-play/tablet-10", 1440, 2560], + ], + ); }); it("selects a reachable LAN IPv4 address", () => { diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index af1e9977751..1f8ac5fa4c2 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -9,11 +9,14 @@ import * as NodePath from "node:path"; import * as NodeProcess from "node:process"; import * as NodeURL from "node:url"; +import { PNG } from "pngjs"; + import showcaseConfig, { type ShowcaseAndroidDevice, type ShowcaseConfig, type ShowcaseDevice, type ShowcaseIosDevice, + type ShowcaseStoreAssetSpec, SHOWCASE_SCENES, type ShowcaseScene, } from "./mobile-showcase.config.ts"; @@ -40,10 +43,18 @@ const ANDROID_APK_PATH = NodePath.join( MOBILE_ROOT, "android/app/build/outputs/apk/debug/app-debug.apk", ); +const DEFAULT_ANDROID_HOME = NodePath.join(NodeProcess.env.HOME ?? "", "Library/Android/sdk"); const MOBILE_BUILD_ENV = { ...NodeProcess.env, + ANDROID_HOME: NodeProcess.env.ANDROID_HOME ?? DEFAULT_ANDROID_HOME, APP_VARIANT: "development", EXPO_NO_GIT_STATUS: "1", + JAVA_HOME: + NodeProcess.env.JAVA_HOME ?? + (NodeProcess.platform === "darwin" + ? "/Applications/Android Studio.app/Contents/jbr/Contents/Home" + : undefined), + NODE_ENV: "development", }; interface CliOptions { @@ -53,6 +64,7 @@ interface CliOptions { readonly skipBuild: boolean; readonly skipMetro: boolean; readonly keepRunning: boolean; + readonly validateOnly: boolean; readonly list: boolean; } @@ -86,22 +98,137 @@ function lanIpv4Address(): string { return address; } -export function readPngDimensions(bytes: Uint8Array): { +export interface PngMetadata { readonly width: number; readonly height: number; -} { + readonly bitDepth: number; + readonly colorType: number; + readonly hasAlpha: boolean; +} + +export function readPngMetadata(bytes: Uint8Array): PngMetadata { const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]; - if (bytes.byteLength < 24 || !pngSignature.every((value, index) => bytes[index] === value)) { + if (bytes.byteLength < 26 || !pngSignature.every((value, index) => bytes[index] === value)) { throw new Error("Captured file is not a valid PNG."); } const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return { width: view.getUint32(16), height: view.getUint32(20) }; + const colorType = view.getUint8(25); + return { + width: view.getUint32(16), + height: view.getUint32(20), + bitDepth: view.getUint8(24), + colorType, + hasAlpha: colorType === 4 || colorType === 6, + }; } -async function reportCapture(destination: string): Promise { - const dimensions = readPngDimensions(await NodeFSP.readFile(destination)); +export function readPngDimensions(bytes: Uint8Array): { + readonly width: number; + readonly height: number; +} { + const { width, height } = readPngMetadata(bytes); + return { width, height }; +} + +export function normalizeStorePng(bytes: Uint8Array): Buffer { + const png = PNG.sync.read(Buffer.from(bytes)); + return PNG.sync.write(png, { + bitDepth: 8, + colorType: 2, + inputColorType: 6, + inputHasAlpha: true, + }); +} + +export function validateStoreAsset( + spec: ShowcaseStoreAssetSpec, + bytes: Uint8Array, + label = "Screenshot", +): PngMetadata { + const metadata = readPngMetadata(bytes); + if (metadata.width !== spec.width || metadata.height !== spec.height) { + throw new Error( + `${label} is ${metadata.width}×${metadata.height}; ${spec.store} requires ${spec.width}×${spec.height}.`, + ); + } + if (metadata.bitDepth !== 8 || metadata.colorType !== 2 || metadata.hasAlpha) { + throw new Error( + `${label} must be an 8-bit, 24-bit RGB PNG without alpha (found bit depth ${metadata.bitDepth}, color type ${metadata.colorType}).`, + ); + } + if (spec.maximumFileSizeBytes && bytes.byteLength > spec.maximumFileSizeBytes) { + throw new Error( + `${label} is ${bytes.byteLength} bytes; ${spec.store} allows at most ${spec.maximumFileSizeBytes} bytes.`, + ); + } + if (spec.store === "google-play") { + const shortestSide = Math.min(metadata.width, metadata.height); + const longestSide = Math.max(metadata.width, metadata.height); + if (shortestSide < 320 || longestSide > 3_840 || longestSide > shortestSide * 2) { + throw new Error( + `${label} does not meet Google Play's 320–3,840 px bounds and 2:1 maximum aspect ratio.`, + ); + } + if (metadata.width * 16 !== metadata.height * 9) { + throw new Error(`${label} must use Google Play's recommended portrait 9:16 aspect ratio.`); + } + } + return metadata; +} + +export function validateStoreAssetCount( + spec: ShowcaseStoreAssetSpec, + count: number, + requireMinimum: boolean, +): void { + if (count > spec.maximumUploadCount) { + throw new Error( + `${spec.directory} contains ${count} screenshots; ${spec.store} allows at most ${spec.maximumUploadCount}.`, + ); + } + if (requireMinimum && count < spec.minimumUploadCount) { + throw new Error( + `${spec.directory} contains ${count} screenshots; ${spec.store} requires at least ${spec.minimumUploadCount}.`, + ); + } +} + +function storeAssetDirectory(outputDirectory: string, device: ShowcaseDevice): string { + return NodePath.join(outputDirectory, device.storeAsset.directory); +} + +async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise { + const normalized = normalizeStorePng(await NodeFSP.readFile(destination)); + await NodeFSP.writeFile(destination, normalized); + const metadata = validateStoreAsset( + device.storeAsset, + normalized, + NodePath.basename(destination), + ); + NodeProcess.stdout.write( + `Captured ${NodePath.relative(REPO_ROOT, destination)} (${metadata.width}×${metadata.height}, 24-bit RGB, validated for ${device.storeAsset.store})\n`, + ); +} + +async function validateCaptureSet( + capture: ShowcaseCapture, + outputDirectory: string, + requireMinimum: boolean, +): Promise { + const directory = storeAssetDirectory(outputDirectory, capture.device); + const files = (await NodeFSP.readdir(directory)).filter((file) => file.endsWith(".png")).sort(); + const expectedFiles = capture.scenes.map((scene) => `${scene}.png`).sort(); + const missingFiles = expectedFiles.filter((file) => !files.includes(file)); + if (missingFiles.length > 0) { + throw new Error(`${capture.device.id} is missing ${missingFiles.join(", ")} in ${directory}.`); + } + validateStoreAssetCount(capture.device.storeAsset, files.length, requireMinimum); + for (const file of files) { + const bytes = await NodeFSP.readFile(NodePath.join(directory, file)); + validateStoreAsset(capture.device.storeAsset, bytes, `${capture.device.id}/${file}`); + } NodeProcess.stdout.write( - `Captured ${NodePath.relative(REPO_ROOT, destination)} (${dimensions.width}×${dimensions.height})\n`, + `Validated ${files.length} upload-ready ${capture.device.storeAsset.store} screenshots in ${NodePath.relative(REPO_ROOT, directory)}/\n`, ); } @@ -120,6 +247,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { let skipBuild = false; let skipMetro = false; let keepRunning = false; + let validateOnly = false; let list = false; for (let index = 0; index < args.length; index += 1) { @@ -152,6 +280,8 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { skipMetro = true; } else if (argument === "--keep-running") { keepRunning = true; + } else if (argument === "--validate-only") { + validateOnly = true; } else if (argument === "--list") { list = true; } else if (argument === "--help" || argument === "-h") { @@ -168,6 +298,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { skipBuild, skipMetro, keepRunning, + validateOnly, list, }; } @@ -213,6 +344,7 @@ Options: --skip-build Reuse the existing simulator app / debug APK --skip-metro Reuse an already running showcase Metro server --keep-running Leave devices and Metro running after capture + --validate-only Validate existing upload assets without capturing --list Print this help and the configured matrix Scenes: ${SHOWCASE_SCENES.join(", ")} @@ -221,7 +353,7 @@ Configured devices: ${config.devices .map((device) => { const target = device.platform === "ios" ? device.simulator : device.avd; - return ` ${device.id.padEnd(14)} ${device.platform.padEnd(8)} ${target} [${device.scenes.join(", ")}]`; + return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory} (${device.storeAsset.width}×${device.storeAsset.height}) [${device.scenes.join(", ")}]`; }) .join("\n")} `); @@ -497,7 +629,7 @@ async function buildIos(): Promise { "ONLY_ACTIVE_ARCH=YES", "build", ], - { cwd: MOBILE_ROOT }, + { cwd: MOBILE_ROOT, env: MOBILE_BUILD_ENV }, ); return IOS_APP_PATH; } @@ -515,6 +647,7 @@ async function buildAndroid(abis: ReadonlyArray): Promise { ], { cwd: NodePath.join(MOBILE_ROOT, "android"), + env: MOBILE_BUILD_ENV, }, ); return ANDROID_APK_PATH; @@ -534,7 +667,7 @@ interface SimctlDevice { readonly isAvailable: boolean; } -async function findIosSimulator(name: string): Promise { +async function findIosSimulator(name: string): Promise { const parsed = JSON.parse( await commandOutput("xcrun", ["simctl", "list", "devices", "available", "-j"]), ) as { @@ -544,13 +677,33 @@ async function findIosSimulator(name: string): Promise { .filter(([runtime]) => runtime.includes("iOS")) .flatMap(([, devices]) => devices) .filter((device) => device.isAvailable && device.name === name); - const simulator = candidates.at(-1); - if (!simulator) { + return candidates.at(-1) ?? null; +} + +async function ensureIosSimulator(device: ShowcaseIosDevice): Promise<{ + readonly simulator: SimctlDevice; + readonly createdByRunner: boolean; +}> { + const existing = await findIosSimulator(device.simulator); + if (existing) return { simulator: existing, createdByRunner: false }; + if (!device.simulatorDeviceType) { throw new Error( - `iOS simulator '${name}' is not installed. Run xcrun simctl list devices available.`, + `iOS simulator '${device.simulator}' is not installed and has no simulatorDeviceType configured.`, ); } - return simulator; + const udid = ( + await commandOutput("xcrun", ["simctl", "create", device.simulator, device.simulatorDeviceType]) + ).trim(); + if (!udid) throw new Error(`Could not create iOS simulator '${device.simulator}'.`); + return { + simulator: { + name: device.simulator, + udid, + state: "Shutdown", + isAvailable: true, + }, + createdByRunner: true, + }; } async function normalizeIosSimulator(device: ShowcaseIosDevice, udid: string): Promise { @@ -605,8 +758,12 @@ async function captureIos( config: ShowcaseConfig, metroHost: string, pairingUrls: ReadonlyArray, -): Promise { - const simulator = await findIosSimulator(capture.device.simulator); +): Promise<{ + readonly udid: string; + readonly startedByRunner: boolean; + readonly createdByRunner: boolean; +}> { + const { simulator, createdByRunner } = await ensureIosSimulator(capture.device); const startedByRunner = simulator.state !== "Booted"; if (!startedByRunner) { // Clear transient SpringBoard state (permission prompts, stale URL-open @@ -691,17 +848,18 @@ async function captureIos( await waitForIosShowcaseScene(simulator.udid, scene); } await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs); - const destination = NodePath.join(outputDirectory, `${capture.device.id}-${scene}.png`); + const destination = NodePath.join( + storeAssetDirectory(outputDirectory, capture.device), + `${scene}.png`, + ); await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); - await reportCapture(destination); + await finalizeCapture(destination, capture.device); } - return startedByRunner; + return { udid: simulator.udid, startedByRunner, createdByRunner }; } function androidSdkTool(relativePath: string): string { - const sdkRoot = - NodeProcess.env.ANDROID_HOME ?? - NodePath.join(NodeProcess.env.HOME ?? "", "Library/Android/sdk"); + const sdkRoot = NodeProcess.env.ANDROID_HOME ?? DEFAULT_ANDROID_HOME; return NodePath.join(sdkRoot, relativePath); } @@ -914,7 +1072,10 @@ async function captureAndroid( await writeAndroidShowcaseScene(serial, scene); await waitForAndroidShowcaseScene(serial, scene); await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000)); - const destination = NodePath.join(outputDirectory, `${capture.device.id}-${scene}.png`); + const destination = NodePath.join( + storeAssetDirectory(outputDirectory, capture.device), + `${scene}.png`, + ); const png = await new Promise((resolve, reject) => { NodeChildProcess.execFile( androidSdkTool("platform-tools/adb"), @@ -927,7 +1088,7 @@ async function captureAndroid( ); }); await NodeFSP.writeFile(destination, png); - await reportCapture(destination); + await finalizeCapture(destination, capture.device); } return { startedByRunner, serial }; } @@ -960,11 +1121,26 @@ async function main(): Promise { return; } const captures = planShowcaseCaptures(showcaseConfig, options); + const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory); + if (options.validateOnly) { + for (const capture of captures) { + await validateCaptureSet( + capture, + outputDirectory, + capture.scenes.length === capture.device.scenes.length, + ); + } + return; + } const hasIos = captures.some((capture) => capture.device.platform === "ios"); const hasAndroid = captures.some((capture) => capture.device.platform === "android"); const metroHost = hasIos ? lanIpv4Address() : "127.0.0.1"; - const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory); await NodeFSP.mkdir(outputDirectory, { recursive: true }); + for (const capture of captures) { + const directory = storeAssetDirectory(outputDirectory, capture.device); + await NodeFSP.rm(directory, { recursive: true, force: true }); + await NodeFSP.mkdir(directory, { recursive: true }); + } const showcaseRootDir = await NodeFSP.mkdtemp( NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"), @@ -977,7 +1153,11 @@ async function main(): Promise { readonly port: number; }> = []; let metro: NodeChildProcess.ChildProcess | null = null; - const startedIosUdids: string[] = []; + const iosCleanups: Array<{ + readonly udid: string; + readonly startedByRunner: boolean; + readonly createdByRunner: boolean; + }> = []; const androidCleanups: Array<{ readonly device: ShowcaseAndroidDevice; readonly serial: string; @@ -1047,8 +1227,7 @@ async function main(): Promise { }), ); if (capture.device.platform === "ios") { - const simulator = await findIosSimulator(capture.device.simulator); - const started = await captureIos( + const cleanup = await captureIos( capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice }, iosAppPath, outputDirectory, @@ -1056,7 +1235,7 @@ async function main(): Promise { metroHost, pairingUrls, ); - if (started) startedIosUdids.push(simulator.udid); + iosCleanups.push(cleanup); } else { const result = await captureAndroid( capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, @@ -1067,10 +1246,15 @@ async function main(): Promise { ); androidCleanups.push({ device: capture.device, ...result }); } + await validateCaptureSet( + capture, + outputDirectory, + capture.scenes.length === capture.device.scenes.length, + ); } NodeProcess.stdout.write( - `\nDone. Screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/\n`, + `\nDone. Upload-ready screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/apple/ and ${NodePath.relative(REPO_ROOT, outputDirectory)}/google-play/.\n`, ); if (options.keepRunning) { metro?.unref(); @@ -1090,8 +1274,13 @@ async function main(): Promise { await runAdb(cleanup.serial, ["emu", "kill"]).catch(() => undefined); } } - for (const udid of startedIosUdids) { - await runCommand("xcrun", ["simctl", "shutdown", udid]).catch(() => undefined); + for (const cleanup of iosCleanups) { + if (cleanup.startedByRunner || cleanup.createdByRunner) { + await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined); + } + if (cleanup.createdByRunner) { + await runCommand("xcrun", ["simctl", "delete", cleanup.udid]).catch(() => undefined); + } } await Promise.all([ ...(metro ? [stopProcess(metro)] : []), diff --git a/scripts/package.json b/scripts/package.json index 510bb230b8c..629163d688e 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -11,10 +11,12 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "effect": "catalog:", + "pngjs": "7.0.0", "yaml": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", + "@types/pngjs": "6.0.5", "vite-plus": "catalog:" } } From 687930d7bf20ab2ebe4262762f69c761f6a72536 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 18:12:29 +0200 Subject: [PATCH 9/9] fix(mobile): harden showcase capture recovery Co-authored-by: codex --- .../diffs/nativeReviewDiffSurface.test.ts | 10 +++ .../features/diffs/nativeReviewDiffSurface.ts | 4 + .../src/features/review/ReviewSheet.tsx | 35 +++++--- .../SettingsEnvironmentsRouteScreen.tsx | 29 +++++- .../showcase/ShowcaseCaptureCoordinator.tsx | 57 ++++++++---- .../showcase/showcaseEnvironmentRows.test.ts | 24 ++++- .../showcase/showcaseEnvironmentRows.ts | 10 +++ .../features/showcase/showcaseRetry.test.ts | 44 ++++++++++ .../src/features/showcase/showcaseRetry.ts | 46 ++++++++++ .../mobile/src/state/thread-outbox-manager.ts | 5 +- apps/mobile/src/state/thread-outbox.test.ts | 25 ++++++ scripts/mobile-showcase.test.ts | 19 ++++ scripts/mobile-showcase.ts | 88 +++++++++++-------- 13 files changed, 328 insertions(+), 68 deletions(-) create mode 100644 apps/mobile/src/features/showcase/showcaseRetry.test.ts create mode 100644 apps/mobile/src/features/showcase/showcaseRetry.ts diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index c12f3f0f94c..438b50a27ee 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -105,3 +105,13 @@ describe("isPendingNativeViewRegistration", () => { ).toBe(true); }); }); + +describe("isNativeReviewDiffDrawEvent", () => { + it("accepts only native events emitted after diff rows draw", async () => { + const { isNativeReviewDiffDrawEvent } = await import("./nativeReviewDiffSurface"); + + expect(isNativeReviewDiffDrawEvent({ message: "draw-metrics" })).toBe(true); + expect(isNativeReviewDiffDrawEvent({ message: "visible-range" })).toBe(true); + expect(isNativeReviewDiffDrawEvent({ message: "rows-decoded" })).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 04ca652575e..fda73410668 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -149,6 +149,10 @@ export interface NativeReviewDiffViewHandle { readonly scrollToTop: (animated?: boolean) => Promise; } +export function isNativeReviewDiffDrawEvent(payload: Readonly>): boolean { + return payload.message === "draw-metrics" || payload.message === "visible-range"; +} + interface NativeReviewDiffViewRef { readonly setRowsJson: (rowsJson: string) => Promise; readonly setTokensJson: (tokensJson: string) => Promise; diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index d6902e04ce6..1ebb3aaf7b1 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -56,6 +56,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { ThreadGitMenu } from "../threads/ThreadGitControls"; import { useReviewCacheForThread } from "./reviewState"; import { + isNativeReviewDiffDrawEvent, type NativeReviewDiffViewHandle, resolveNativeReviewDiffView, } from "../diffs/nativeReviewDiffSurface"; @@ -394,13 +395,9 @@ export function ReviewSheet(props: ReviewSheetProps) { selectedSection, draftMessage, }); - const showcaseReviewReady = SHOWCASE_ENABLED && parsedDiff.kind === "files"; - useEffect(() => { - if (!showcaseReviewReady) return; - markNativeShowcaseReady("review"); - }, [showcaseReviewReady]); const NativeReviewDiffView = resolveNativeReviewDiffView()!; const nativeReviewDiffViewRef = useRef(null); + const showcasedReviewDrawRef = useRef(null); // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item). const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = useCallback(async () => { @@ -442,6 +439,25 @@ export function ReviewSheet(props: ReviewSheetProps) { selectedRowIds: commentSelection.selectedRowIds, canHighlight: parsedDiff.kind === "files", }); + const showcaseReviewKey = + SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection + ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}` + : null; + const handleNativeDebug = useCallback( + (event: NativeSyntheticEvent>) => { + nativeBridge.onDebug(event); + if ( + showcaseReviewKey === null || + showcasedReviewDrawRef.current === showcaseReviewKey || + !isNativeReviewDiffDrawEvent(event.nativeEvent) + ) { + return; + } + showcasedReviewDrawRef.current = showcaseReviewKey; + markNativeShowcaseReady("review"); + }, + [nativeBridge.onDebug, showcaseReviewKey], + ); const handleSelectFile = useCallback( (fileId: string | null) => { @@ -619,13 +635,6 @@ export function ReviewSheet(props: ReviewSheetProps) { return ( <> - {showcaseReviewReady ? ( - - ) : null} { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); }, []); + const handleUpdateEnvironment = useCallback( + ( + environmentId: EnvironmentId, + updates: { readonly label: string; readonly displayUrl: string }, + ) => { + if (!SHOWCASE_ENABLED) return onUpdateEnvironment(environmentId, updates); + const actualEnvironment = environmentSections.localEnvironments.find( + (environment) => environment.environmentId === environmentId, + ); + const presentedEnvironment = localEnvironments.find( + (environment) => environment.environmentId === environmentId, + ); + return onUpdateEnvironment(environmentId, { + ...updates, + displayUrl: + actualEnvironment && presentedEnvironment + ? resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: actualEnvironment.displayUrl, + presentedDisplayUrl: presentedEnvironment.displayUrl, + submittedDisplayUrl: updates.displayUrl, + }) + : updates.displayUrl, + }); + }, + [environmentSections.localEnvironments, localEnvironments, onUpdateEnvironment], + ); return ( @@ -112,7 +139,7 @@ export function SettingsEnvironmentsRouteScreen() { onToggle={() => handleToggle(environment.environmentId)} onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} - onUpdate={onUpdateEnvironment} + onUpdate={handleUpdateEnvironment} /> ))} diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 84e3294fe12..9a4272824ba 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Keyboard, View } from "react-native"; import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useConnectionController } from "../connection/useConnectionController"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -17,6 +18,7 @@ import { buildShowcasePendingTasks, SHOWCASE_PENDING_TASK_DEFINITIONS, } from "./showcasePendingTasks"; +import { retryShowcaseOperation } from "./showcaseRetry"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; @@ -40,7 +42,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const projects = useProjects(); const threads = useThreadShells(); const attemptedPairingRef = useRef(new Set()); - const attemptedPendingTaskSeedRef = useRef(false); + const seededPendingTaskIdsRef = useRef(new Set()); const [pairingUrls, setPairingUrls] = useState>([]); const [pendingTasksReady, setPendingTasksReady] = useState(false); const [requestedScene, setRequestedScene] = useState(null); @@ -74,11 +76,16 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return; let cancelled = false; void (async () => { - for (const pairingUrl of pairingUrls) { - if (cancelled || attemptedPairingRef.current.has(pairingUrl)) continue; - attemptedPairingRef.current.add(pairingUrl); - await connectPairingUrl(pairingUrl); - } + await Promise.all( + pairingUrls.map(async (pairingUrl) => { + if (cancelled || attemptedPairingRef.current.has(pairingUrl)) return; + const paired = await retryShowcaseOperation( + async () => AsyncResult.isSuccess(await connectPairingUrl(pairingUrl)), + { isCancelled: () => cancelled }, + ); + if (paired) attemptedPairingRef.current.add(pairingUrl); + }), + ); })(); return () => { cancelled = true; @@ -95,20 +102,35 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); useEffect(() => { - if (!SHOWCASE_ENABLED || !hasServerFixture || attemptedPendingTaskSeedRef.current) return; + if (!SHOWCASE_ENABLED || !hasServerFixture || pendingTasksReady) return; const pendingTasks = buildShowcasePendingTasks(projects, Date.now()); if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; - attemptedPendingTaskSeedRef.current = true; + let cancelled = false; for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId); - void Promise.all(pendingTasks.map((task) => enqueueThreadOutboxMessage(task))) - .then(() => setPendingTasksReady(true)) - .catch((error: unknown) => { - attemptedPendingTaskSeedRef.current = false; - console.warn("[showcase] failed to seed pending offline tasks", error); - }); - }, [hasServerFixture, projects]); + void (async () => { + const results = await Promise.all( + pendingTasks.map(async (task) => { + const messageId = String(task.messageId); + if (seededPendingTaskIdsRef.current.has(messageId)) return true; + const seeded = await retryShowcaseOperation( + async () => { + await enqueueThreadOutboxMessage(task); + return true; + }, + { isCancelled: () => cancelled }, + ); + if (seeded) seededPendingTaskIdsRef.current.add(messageId); + return seeded; + }), + ); + if (!cancelled && results.every(Boolean)) setPendingTasksReady(true); + })(); + return () => { + cancelled = true; + }; + }, [hasServerFixture, pendingTasksReady, projects]); useEffect(() => { if (!SHOWCASE_ENABLED || requestedScene === null || !hasFixture || !showcaseThread) return; @@ -173,18 +195,19 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) } if (scene === "terminal") Keyboard.dismiss(); + let renderFrame: number | null = null; let readyFrame: number | null = null; const settleTimer = setTimeout(() => { - const renderFrame = requestAnimationFrame(() => { + renderFrame = requestAnimationFrame(() => { readyFrame = requestAnimationFrame(() => { markNativeShowcaseReady(scene); setReadyScene(scene); }); }); - readyFrame = renderFrame; }, 500); return () => { clearTimeout(settleTimer); + if (renderFrame !== null) cancelAnimationFrame(renderFrame); if (readyFrame !== null) cancelAnimationFrame(readyFrame); }; }, [hasFixture, requestedScene, scene]); diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts index cbbcb848968..a459cbe1db3 100644 --- a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts @@ -2,7 +2,10 @@ import { EnvironmentId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; -import { applyShowcaseLocalEnvironmentDisplayUrls } from "./showcaseEnvironmentRows"; +import { + applyShowcaseLocalEnvironmentDisplayUrls, + resolveShowcaseEnvironmentUpdateDisplayUrl, +} from "./showcaseEnvironmentRows"; function environment( environmentId: string, @@ -46,3 +49,22 @@ it("leaves environments outside the showcase fixture unchanged", () => { assert.deepStrictEqual(applyShowcaseLocalEnvironmentDisplayUrls([original]), [original]); }); + +it("does not persist a cosmetic showcase URL when only the label is saved", () => { + assert.equal( + resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: "http://127.0.0.1:3773/", + presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + submittedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + }), + "http://127.0.0.1:3773/", + ); + assert.equal( + resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: "http://127.0.0.1:3773/", + presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + submittedDisplayUrl: "https://new-host.example.com/", + }), + "https://new-host.example.com/", + ); +}); diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts index 8cdbb3b9c20..66ec3046c67 100644 --- a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts @@ -20,6 +20,16 @@ export function applyShowcaseLocalEnvironmentDisplayUrls( })); } +export function resolveShowcaseEnvironmentUpdateDisplayUrl(input: { + readonly actualDisplayUrl: string; + readonly presentedDisplayUrl: string; + readonly submittedDisplayUrl: string; +}): string { + return input.submittedDisplayUrl === input.presentedDisplayUrl + ? input.actualDisplayUrl + : input.submittedDisplayUrl; +} + const pocketPiId = EnvironmentId.make("showcase-pocket-pi"); const pocketPiEndpoint = { httpBaseUrl: "https://pocket-pi.t3.sh", diff --git a/apps/mobile/src/features/showcase/showcaseRetry.test.ts b/apps/mobile/src/features/showcase/showcaseRetry.test.ts new file mode 100644 index 00000000000..86c2890366e --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRetry.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; + +import { retryShowcaseOperation } from "./showcaseRetry"; + +it("retries a failed showcase operation until it succeeds", async () => { + let attempts = 0; + const succeeded = await retryShowcaseOperation( + async () => { + attempts += 1; + return attempts === 3; + }, + { isCancelled: () => false, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, true); + assert.equal(attempts, 3); +}); + +it("recovers when a showcase operation attempt hangs", async () => { + let attempts = 0; + const succeeded = await retryShowcaseOperation( + () => { + attempts += 1; + return attempts === 1 ? new Promise(() => undefined) : Promise.resolve(true); + }, + { isCancelled: () => false, attemptTimeoutMs: 1, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, true); + assert.equal(attempts, 2); +}); + +it("stops retrying when the owning showcase effect is cancelled", async () => { + let cancelled = false; + const succeeded = await retryShowcaseOperation( + async () => { + cancelled = true; + return false; + }, + { isCancelled: () => cancelled, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, false); +}); diff --git a/apps/mobile/src/features/showcase/showcaseRetry.ts b/apps/mobile/src/features/showcase/showcaseRetry.ts new file mode 100644 index 00000000000..0c7aeb12019 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRetry.ts @@ -0,0 +1,46 @@ +export interface ShowcaseRetryOptions { + readonly isCancelled: () => boolean; + readonly attemptTimeoutMs?: number; + readonly retryDelayMs?: number; +} + +const DEFAULT_ATTEMPT_TIMEOUT_MS = 10_000; +const DEFAULT_RETRY_DELAY_MS = 500; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runAttemptWithTimeout( + operation: () => Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + operation(), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + } catch { + return false; + } finally { + if (timeout !== null) clearTimeout(timeout); + } +} + +/** Retry transient showcase setup work until it succeeds or the owning effect unmounts. */ +export async function retryShowcaseOperation( + operation: () => Promise, + options: ShowcaseRetryOptions, +): Promise { + const attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + + while (!options.isCancelled()) { + if (await runAttemptWithTimeout(operation, attemptTimeoutMs)) return true; + if (!options.isCancelled()) await delay(retryDelayMs); + } + return false; +} diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index fc3e5069dc9..19f89d13c51 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -101,7 +101,10 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { cause, }); } - setMessages([...currentMessages(), message]); + setMessages([ + ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), + message, + ]); }); // Rewrites an already-queued message. A no-op when the message has been diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 2ccaa471c87..6c665c432f4 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -294,6 +294,31 @@ describe("thread outbox", () => { registry.dispose(); }); + it("replaces an existing message when an enqueue retry uses the same id", async () => { + const registry = AtomRegistry.make(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async () => undefined, + remove: async () => undefined, + }, + }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const retried = { ...message, text: "retried" }; + + await manager.enqueue(message); + await manager.enqueue(retried); + + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [retried], + }); + registry.dispose(); + }); + it("updates a queued message in place but never resurrects a removed one", async () => { const registry = AtomRegistry.make(); const stored = new Map(); diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 02d0ea3f6f3..8608862489f 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -19,6 +19,7 @@ import { planShowcaseCaptures, readPngDimensions, readPngMetadata, + resolveAndroidSdkRoot, selectLanIpv4Address, showcaseSceneUrl, validateStoreAsset, @@ -94,6 +95,24 @@ it("selects an explicit CI Android ABI without changing the local default", () = assert.throws(() => resolveShowcaseAndroidAbi("mips"), /Unsupported T3_SHOWCASE_ANDROID_ABI/u); }); +it("uses platform-correct default Android SDK roots", () => { + assert.equal( + resolveAndroidSdkRoot({ HOME: "/Users/showcase" }, "darwin"), + "/Users/showcase/Library/Android/sdk", + ); + assert.equal( + resolveAndroidSdkRoot({ HOME: "/home/showcase" }, "linux"), + "/home/showcase/Android/Sdk", + ); + assert.equal( + resolveAndroidSdkRoot( + { HOME: "/home/showcase", ANDROID_SDK_ROOT: "/opt/android-sdk" }, + "linux", + ), + "/opt/android-sdk", + ); +}); + it("plans only scenes supported by each selected device", () => { const options = parseShowcaseCliArgs(["--platform", "all", "--scene", "terminal"]); const captures = planShowcaseCaptures(config, options); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 1f8ac5fa4c2..27b40193ee7 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -43,10 +43,20 @@ const ANDROID_APK_PATH = NodePath.join( MOBILE_ROOT, "android/app/build/outputs/apk/debug/app-debug.apk", ); -const DEFAULT_ANDROID_HOME = NodePath.join(NodeProcess.env.HOME ?? "", "Library/Android/sdk"); +export function resolveAndroidSdkRoot( + environment: Readonly>, + platform: NodeJS.Platform = NodeProcess.platform, +): string { + const configured = environment.ANDROID_HOME ?? environment.ANDROID_SDK_ROOT; + if (configured) return configured; + const home = environment.HOME ?? environment.USERPROFILE ?? ""; + return NodePath.join(home, platform === "darwin" ? "Library/Android/sdk" : "Android/Sdk"); +} + +const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env); const MOBILE_BUILD_ENV = { ...NodeProcess.env, - ANDROID_HOME: NodeProcess.env.ANDROID_HOME ?? DEFAULT_ANDROID_HOME, + ANDROID_HOME: ANDROID_SDK_ROOT, APP_VARIANT: "development", EXPO_NO_GIT_STATUS: "1", JAVA_HOME: @@ -73,6 +83,18 @@ export interface ShowcaseCapture { readonly scenes: ReadonlyArray; } +interface IosCaptureCleanup { + readonly udid: string; + readonly startedByRunner: boolean; + readonly createdByRunner: boolean; +} + +interface AndroidCaptureCleanup { + readonly device: ShowcaseAndroidDevice; + readonly serial: string; + readonly startedByRunner: boolean; +} + interface NetworkAddress { readonly address: string; readonly family: string; @@ -758,13 +780,11 @@ async function captureIos( config: ShowcaseConfig, metroHost: string, pairingUrls: ReadonlyArray, -): Promise<{ - readonly udid: string; - readonly startedByRunner: boolean; - readonly createdByRunner: boolean; -}> { + registerCleanup: (cleanup: IosCaptureCleanup) => void, +): Promise { const { simulator, createdByRunner } = await ensureIosSimulator(capture.device); const startedByRunner = simulator.state !== "Booted"; + 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. @@ -855,12 +875,10 @@ async function captureIos( await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); await finalizeCapture(destination, capture.device); } - return { udid: simulator.udid, startedByRunner, createdByRunner }; } function androidSdkTool(relativePath: string): string { - const sdkRoot = NodeProcess.env.ANDROID_HOME ?? DEFAULT_ANDROID_HOME; - return NodePath.join(sdkRoot, relativePath); + return NodePath.join(ANDROID_SDK_ROOT, relativePath); } async function adbOutput(serial: string, args: ReadonlyArray): Promise { @@ -1021,10 +1039,12 @@ async function captureAndroid( outputDirectory: string, config: ShowcaseConfig, pairingUrls: ReadonlyArray, -): Promise<{ readonly startedByRunner: boolean; readonly serial: string }> { + registerCleanup: (cleanup: AndroidCaptureCleanup) => void, +): Promise { const running = await runningAndroidAvds(); const existingSerial = running.get(capture.device.avd); const startedByRunner = !existingSerial; + let launchedEmulator: NodeChildProcess.ChildProcess | null = null; if (startedByRunner) { const installedAvds = (await commandOutput(androidSdkTool("emulator/emulator"), ["-list-avds"])) .split("\n") @@ -1034,13 +1054,20 @@ async function captureAndroid( `Android AVD '${capture.device.avd}' is not installed. Run emulator -list-avds.`, ); } - spawnProcess( + launchedEmulator = spawnProcess( androidSdkTool("emulator/emulator"), ["-avd", capture.device.avd, "-no-snapshot-load", "-no-boot-anim"], { stdio: "ignore", detached: true }, - ).unref(); + ); + launchedEmulator.unref(); } - const serial = existingSerial ?? (await waitForAndroidSerial(capture.device.avd)); + const serial = + existingSerial ?? + (await waitForAndroidSerial(capture.device.avd).catch(async (error: unknown) => { + if (launchedEmulator) await stopProcess(launchedEmulator); + throw error; + })); + registerCleanup({ device: capture.device, serial, startedByRunner }); await normalizeAndroidEmulator(capture.device, serial); if (apkPath) { await runAdb(serial, ["install", "-r", apkPath]); @@ -1050,7 +1077,6 @@ async function captureAndroid( await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]); const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`); const firstScene = capture.scenes[0] ?? "threads"; - await writeAndroidShowcaseScene(serial, firstScene); await runAdb(serial, [ "shell", "am", @@ -1068,8 +1094,8 @@ async function captureAndroid( firstScene, ANDROID_PACKAGE, ]); - for (const scene of capture.scenes) { - await writeAndroidShowcaseScene(serial, scene); + for (const [sceneIndex, scene] of capture.scenes.entries()) { + if (sceneIndex > 0) await writeAndroidShowcaseScene(serial, scene); await waitForAndroidShowcaseScene(serial, scene); await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000)); const destination = NodePath.join( @@ -1090,7 +1116,6 @@ async function captureAndroid( await NodeFSP.writeFile(destination, png); await finalizeCapture(destination, capture.device); } - return { startedByRunner, serial }; } async function cleanupAndroidViewport( @@ -1153,16 +1178,8 @@ async function main(): Promise { readonly port: number; }> = []; let metro: NodeChildProcess.ChildProcess | null = null; - const iosCleanups: Array<{ - readonly udid: string; - readonly startedByRunner: boolean; - readonly createdByRunner: boolean; - }> = []; - const androidCleanups: Array<{ - readonly device: ShowcaseAndroidDevice; - readonly serial: string; - readonly startedByRunner: boolean; - }> = []; + const iosCleanups: IosCaptureCleanup[] = []; + const androidCleanups: AndroidCaptureCleanup[] = []; try { for (const environment of SHOWCASE_ENVIRONMENTS) { @@ -1227,24 +1244,24 @@ async function main(): Promise { }), ); if (capture.device.platform === "ios") { - const cleanup = await captureIos( + await captureIos( capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice }, iosAppPath, outputDirectory, showcaseConfig, metroHost, pairingUrls, + (cleanup) => iosCleanups.push(cleanup), ); - iosCleanups.push(cleanup); } else { - const result = await captureAndroid( + await captureAndroid( capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, androidApkPath, outputDirectory, showcaseConfig, pairingUrls, + (cleanup) => androidCleanups.push(cleanup), ); - androidCleanups.push({ device: capture.device, ...result }); } await validateCaptureSet( capture, @@ -1257,8 +1274,6 @@ async function main(): Promise { `\nDone. Upload-ready screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/apple/ and ${NodePath.relative(REPO_ROOT, outputDirectory)}/google-play/.\n`, ); if (options.keepRunning) { - metro?.unref(); - for (const server of showcaseServers) server.unref(); const serverSummary = showcaseEnvironments .map((environment) => `${environment.label}:${environment.port}`) .join(", "); @@ -1267,7 +1282,10 @@ async function main(): Promise { ); } } finally { - if (!options.keepRunning) { + if (options.keepRunning) { + metro?.unref(); + for (const server of showcaseServers) server.unref(); + } else { for (const cleanup of androidCleanups) { await cleanupAndroidViewport(cleanup.device, cleanup.serial).catch(() => undefined); if (cleanup.startedByRunner) {