diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml new file mode 100644 index 00000000000..f47d343d939 --- /dev/null +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -0,0 +1,125 @@ +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 6.9, iPhone 6.5, and iPad 13 + if: inputs.platform == 'all' || inputs.platform == 'ios' + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 60 + 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: 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-connect-screenshots + path: artifacts/app-store/screenshots/apple/ + if-no-files-found: warn + retention-days: 14 + + android: + 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: 60 + 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: 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: google-play-screenshots + path: artifacts/app-store/screenshots/google-play/ + if-no-files-found: warn + retention-days: 14 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/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 33e1dc9086c..7781b164a2c 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,9 +1,48 @@ import ExpoModulesCore +import Security public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Name("T3NativeControls") + 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"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + Function("prepareShowcaseCapture") { + for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { + SecItemDelete([kSecClass as String: itemClass] as CFDictionary) + } + } + + Function("markShowcaseReady") { (scene: String) in + let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" + try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) + } + 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 1c8978d4e5b..f68cc6b4a11 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 191d08db28d..659837fc7ab 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -248,6 +248,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 } @@ -359,7 +370,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 5c5046cc582..c99351547be 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 1f198c33bf3..835f54491f6 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 { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; @@ -21,6 +22,10 @@ import { useThemeColor } from "./lib/useThemeColor"; import "../global.css"; +if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { + prepareNativeShowcaseCapture(); +} + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], // The Expo dev client launches the app via diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 9d66839e040..4eb4a5061e8 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -48,6 +48,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 { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, SettingsLegalDocumentExternalHeaderButton, @@ -313,6 +314,7 @@ function RootStackLayout(props: { return ( + {props.children} 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..438b50a27ee 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -96,5 +96,22 @@ 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); + }); +}); + +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 551e577b294..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; @@ -170,9 +174,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/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0807304631d..456afc438d9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -167,7 +167,6 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); - const effectiveGroupDisplayStates = useMemo(() => { const next = new Map(groupDisplayStates); if (!AsyncResult.isSuccess(preferencesResult)) { diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index cafc0326d2f..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"; @@ -71,8 +72,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 ( @@ -394,6 +397,7 @@ export function ReviewSheet(props: ReviewSheetProps) { }); 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 () => { @@ -435,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) => { @@ -788,7 +811,7 @@ export function ReviewSheet(props: ReviewSheetProps) { tokensPatchJson={nativeBridge.tokensPatchJson} tokensResetKey={nativeBridge.tokensResetKey} viewedFileIdsJson={nativeBridge.viewedFileIdsJson} - onDebug={nativeBridge.onDebug} + onDebug={handleNativeDebug} onPressLine={commentSelection.onPressLine} onVisibleFileChange={handleVisibleFileChange} onToggleComment={nativeBridge.onToggleComment} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index ccf049e0c21..93b806f6487 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,15 @@ import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { + applyShowcaseLocalEnvironmentDisplayUrls, + resolveShowcaseEnvironmentUpdateDisplayUrl, + 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,18 +34,56 @@ export function SettingsEnvironmentsRouteScreen() { } = useRemoteConnections(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ + const environmentSections = splitEnvironmentSections({ connectedEnvironments, cloudEnvironments: null, }); + const localEnvironments = SHOWCASE_ENABLED + ? applyShowcaseLocalEnvironmentDisplayUrls(environmentSections.localEnvironments) + : environmentSections.localEnvironments; + 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)); }, []); + 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 ( @@ -92,7 +139,7 @@ export function SettingsEnvironmentsRouteScreen() { onToggle={() => handleToggle(environment.environmentId)} onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} - onUpdate={onUpdateEnvironment} + onUpdate={handleUpdateEnvironment} /> ))} @@ -114,10 +161,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 new file mode 100644 index 00000000000..9a4272824ba --- /dev/null +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -0,0 +1,224 @@ +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"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; +import { useWorkspaceState } from "../../state/workspace"; +import { + getNativeShowcasePairingUrls, + getNativeShowcaseScene, + markNativeShowcaseReady, + type ShowcaseScene, +} from "./nativeShowcaseScene"; +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"; + +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"; + if (routePath === "/") return "threads"; + return null; +} + +export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { + const navigation = useNavigation(); + const { connectPairingUrl } = useConnectionController(); + const workspace = useWorkspaceState(); + const projects = useProjects(); + const threads = useThreadShells(); + const attemptedPairingRef = useRef(new Set()); + const seededPendingTaskIdsRef = useRef(new Set()); + const [pairingUrls, setPairingUrls] = useState>([]); + const [pendingTasksReady, setPendingTasksReady] = useState(false); + const [requestedScene, setRequestedScene] = useState(null); + const [readyScene, setReadyScene] = useState(null); + + useEffect(() => { + if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; + + const readPairingUrls = () => { + const values = getNativeShowcasePairingUrls(); + if (values.length > 0) setPairingUrls(values); + }; + readPairingUrls(); + const interval = setInterval(readPairingUrls, 250); + return () => clearInterval(interval); + }, [pairingUrls.length]); + + 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 || pairingUrls.length === 0) return; + let cancelled = false; + void (async () => { + 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; + }; + }, [connectPairingUrl, pairingUrls]); + + const scene = sceneFromPathname(props.pathname); + 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 || pendingTasksReady) return; + + const pendingTasks = buildShowcasePendingTasks(projects, Date.now()); + if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; + + let cancelled = false; + for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId); + 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; + if (scene === requestedScene) return; + + const params = { + environmentId: String(showcaseThread.environmentId), + threadId: SHOWCASE_THREAD_ID, + }; + if (requestedScene === "threads") { + 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(() => { + 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 renderFrame: number | null = null; + let readyFrame: number | null = null; + const settleTimer = setTimeout(() => { + renderFrame = requestAnimationFrame(() => { + readyFrame = requestAnimationFrame(() => { + markNativeShowcaseReady(scene); + setReadyScene(scene); + }); + }); + }, 500); + return () => { + clearTimeout(settleTimer); + if (renderFrame !== null) cancelAnimationFrame(renderFrame); + if (readyFrame !== null) cancelAnimationFrame(readyFrame); + }; + }, [hasFixture, requestedScene, scene]); + + if (!SHOWCASE_ENABLED || readyScene === null) return null; + + return ( + + ); +} diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts new file mode 100644 index 00000000000..1f2e263ebf1 --- /dev/null +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -0,0 +1,68 @@ +import { requireOptionalNativeModule } from "expo"; + +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] 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; +} + +function nativeShowcaseControls(): NativeShowcaseControls | null { + return requireOptionalNativeModule("T3NativeControls"); +} + +export function getNativeShowcasePairingUrls(): ReadonlyArray { + try { + 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 []; + } +} + +export function getNativeShowcaseScene(): ShowcaseScene | null { + try { + const scene = nativeShowcaseControls()?.getShowcaseScene?.()?.trim(); + return SHOWCASE_SCENES.find((candidate) => candidate === scene) ?? null; + } catch { + return null; + } +} + +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 { + // The readiness marker is capture-runner metadata, never app functionality. + } +} 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..a459cbe1db3 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts @@ -0,0 +1,70 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { + applyShowcaseLocalEnvironmentDisplayUrls, + resolveShowcaseEnvironmentUpdateDisplayUrl, +} 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]); +}); + +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 new file mode 100644 index 00000000000..66ec3046c67 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts @@ -0,0 +1,70 @@ +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, + })); +} + +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", + 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/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/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/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 69037514d0d..b205b4df72c 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; @@ -215,6 +216,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf 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/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md new file mode 100644 index 00000000000..144a8ee686c --- /dev/null +++ b/docs/operations/mobile-app-store-screenshots.md @@ -0,0 +1,152 @@ +# Mobile app-store screenshot harness + +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, 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 + +From the repository root: + + pnpm screenshots:mobile + +The command: + +1. Creates three temporary T3 base directories and starts a local server for each on an available + port. +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, 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. +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 +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 +delay allows native terminal and Git review data to finish rendering. + +A full capture regenerates the selected native project with Expo's clean development prebuild before +building it. Use --skip-build for repeated captures after the first build. + +The harness uses its own Metro port (8199 by default), so an ordinary mobile server or another +worktree cannot accidentally provide the bundle being photographed. + +The default matrix is: + +| 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. + +## 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 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-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 +debug APK matches its accelerated emulator. + +## Fast iteration + +Capture one scene or device: + + pnpm screenshots:mobile --device iphone-6.9 --scene thread + pnpm screenshots:mobile --platform android --scene review + +Reuse the native build and retain the disposable environment: + + pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running + +Run Metro separately: + + pnpm --filter @t3tools/mobile showcase + pnpm screenshots:mobile --skip-build --skip-metro --device iphone-6.9 + +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: + [mobile-showcase-environment.ts](../../scripts/mobile-showcase-environment.ts) +- Device and capture matrix: + [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) +- Simulator/emulator orchestration: + [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 deterministic three-environment +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. + +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. +- Android: ANDROID_HOME (or the default macOS SDK path), adb, emulator, and the configured AVD. + +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/package.json b/package.json index 5e5365d2c1a..f0ffcb3349c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "start:desktop": "vp run --filter @t3tools/desktop start", "start:marketing": "vp run --filter @t3tools/marketing preview", "start:mock-update-server": "node scripts/mock-update-server.ts", + "screenshots:mobile": "node scripts/mobile-showcase.ts", "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", "build:marketing": "vp run --filter @t3tools/marketing build", "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", 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-environment.ts b/scripts/mobile-showcase-environment.ts new file mode 100644 index 00000000000..c42239e427f --- /dev/null +++ b/scripts/mobile-showcase-environment.ts @@ -0,0 +1,569 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - This host-side fixture creates an isolated local T3 environment. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeUtil from "node:util"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +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; +export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; + +const PROJECTOR_NAMES = [ + "projection.projects", + "projection.threads", + "projection.thread-messages", + "projection.thread-proposed-plans", + "projection.thread-activities", + "projection.thread-sessions", + "projection.thread-turns", + "projection.checkpoints", + "projection.pending-approvals", +] as const; + +const MODEL_SELECTION = JSON.stringify({ instanceId: "codex", model: "gpt-5.4" }); +const PROJECT_SCRIPTS = JSON.stringify([ + { + id: "dev", + name: "Dev", + command: "pnpm dev", + icon: "play", + runOnWorktreeCreate: false, + }, + { + id: "test", + name: "Tests", + command: "pnpm test", + icon: "test", + runOnWorktreeCreate: false, + }, +]); + +export const SHOWCASE_TERMINAL_BUFFER = [ + "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m", + "$ vp test run --changed", + "", + " \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โœจ 612 tests passed\u001b[0m ยท 3 environments online", + "", + "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m $ ", +].join("\r\n"); + +const BASE_ENVIRONMENT_PRESENCE = `export function environmentLabel(count: number): string { + return \`${"${count}"} environments\`; +} +`; + +const UPDATED_ENVIRONMENT_PRESENCE = `const PULSE = ["โœฆ", "โœง", "ยท", "โœง"] as const; + +export function environmentLabel(connected: number, total: number, frame: number): string { + const pulse = PULSE[frame % PULSE.length]; + return \`${"${pulse} ${connected}/${total}"} ready\`; +} +`; + +const REMOTE_HANDOFF_CARD = `import { View, Text } from "react-native"; + +export function RemoteHandoffCard(props: { machine: string; latencyMs: number }) { + return ( + + Ready on {props.machine} + Handoff in {props.latencyMs}ms + + ); +} +`; + +const PROJECT_FAVICONS = { + t3code: ` + + +`, + react: ` + + + +`, + linux: ` + + + + + + +`, +} as const; + +export const SHOWCASE_PROJECTS = [ + { + id: "t3code", + title: "T3 Code", + directory: "t3code", + repositoryUrl: "https://github.com/pingdotgg/t3code.git", + favicon: PROJECT_FAVICONS.t3code, + }, + { + 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: ["t3code"], + }, + { + 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: "t3code", + title: "Make remote coding feel local โœฆ", + branch: "feat/remote-command-center", + minutesAgo: 3, + request: + "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: + "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: "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 switching between desktop, phone, and tablet feel like one continuous session.", + response: + "The handoff flow preserves the selected thread, terminal buffer, and working diff. The final motion 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(); +} + +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 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 seedT3CodeWorkspace(workspaceRoot: string): Promise { + await NodeFSP.mkdir(NodePath.join(workspaceRoot, "apps/mobile/src/features/home"), { + recursive: true, + }); + await NodeFSP.writeFile( + 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.t3code); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"), + BASE_ENVIRONMENT_PRESENCE, + ); + await initializeRepository({ + workspaceRoot, + repositoryUrl: "https://github.com/pingdotgg/t3code.git", + commitMessage: "Show connected environments", + }); + await runGit(workspaceRoot, ["checkout", "-b", "feat/remote-command-center"]); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"), + UPDATED_ENVIRONMENT_PRESENCE, + ); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "apps/mobile/src/features/home/RemoteHandoffCard.tsx"), + REMOTE_HANDOFF_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; + 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, + input.projectId, + 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, + 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"); + 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}`); + } + 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)`, + ); + 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 * (90 - index * 12)), + minutesBefore(now, latestThreadMinutes), + ); + } + + 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 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, ?, ?)`, + ); + 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( + "trace-remote-handoff", + SHOWCASE_THREAD_ID, + turnId, + "Traced the remote handoff path", + JSON.stringify({ + itemType: "command_execution", + title: "Traced the remote handoff path", + detail: "Three environments, one continuous workspace", + status: "completed", + }), + 1, + minutesBefore(now, 8), + ); + insertActivity.run( + "sync-command-center", + SHOWCASE_THREAD_ID, + turnId, + "Synced the command center", + JSON.stringify({ + itemType: "file_change", + title: "Synced the command center", + detail: "2 files changed ยท instant handoffs ยท calm reconnects", + status: "completed", + }), + 2, + minutesBefore(now, 6), + ); + insertActivity.run( + "run-changed-suite", + SHOWCASE_THREAD_ID, + turnId, + "Ran the changed workspace", + JSON.stringify({ + itemType: "command_execution", + title: "Ran the changed workspace", + detail: "612 tests passed ยท 3 environments online", + status: "completed", + }), + 3, + minutesBefore(now, 4), + ); + + 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 projectIds?: ReadonlyArray; + readonly now?: number; +}): Promise<{ readonly dbPath: string; readonly workspaceRoot: string }> { + const now = input.now ?? Date.now(); + 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"); + if (primaryProject.id === SHOWCASE_PROJECT_ID) { + await seedT3CodeWorkspace(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"); + 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 new file mode 100644 index 00000000000..154d005f37b --- /dev/null +++ b/scripts/mobile-showcase.config.ts @@ -0,0 +1,202 @@ +import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts"; + +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 { + 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; + }; + readonly storeAsset: ShowcaseStoreAssetSpec; +} + +export type ShowcaseDevice = ShowcaseIosDevice | ShowcaseAndroidDevice; + +export interface ShowcaseConfig { + readonly outputDirectory: string; + readonly metroPort: number; + readonly settleDelayMs: number; + 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 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", + // 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", + 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", + platform: "android", + avd: "Pixel_10_Pro", + // 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: 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-10", + platform: "android", + avd: "Pixel_10_Pro", + abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), + appearance: "dark", + viewport: { + width: 1440, + height: 2560, + 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, + }, + }, + ], +}; + +export default config; diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts new file mode 100644 index 00000000000..8608862489f --- /dev/null +++ b/scripts/mobile-showcase.test.ts @@ -0,0 +1,265 @@ +import { assert, it } from "@effect/vitest"; +import { PNG } from "pngjs"; + +import showcaseConfig, { + resolveShowcaseAndroidAbi, + type ShowcaseConfig, + type ShowcaseStoreAssetSpec, +} from "./mobile-showcase.config.ts"; +import { + SHOWCASE_ENVIRONMENTS, + SHOWCASE_PROJECTS, + SHOWCASE_THREADS, +} from "./mobile-showcase-environment.ts"; +import { + encodeAndroidPairingUrls, + normalizeStorePng, + parseShowcaseCliArgs, + parsePairingCredentialOutput, + planShowcaseCaptures, + readPngDimensions, + readPngMetadata, + resolveAndroidSdkRoot, + 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, + settleDelayMs: 1, + devices: [ + { + id: "phone", + platform: "ios", + simulator: "iPhone Test", + appearance: "dark", + scenes: ["thread", "review"], + storeAsset: appleSpec, + }, + { + id: "pixel", + platform: "android", + avd: "Pixel_Test", + appearance: "light", + scenes: ["thread", "terminal"], + storeAsset: googleSpec, + }, + ], +}; + +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("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"); + 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); + 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(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", () => { + 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", + ); +}); + +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/remote-command-center", + ); + assert.equal( + showcaseSceneUrl("terminal", "environment-1"), + "t3code-dev://threads/environment-1/remote-command-center/terminal?terminalId=term-1", + ); + assert.equal( + showcaseSceneUrl("review", "environment-1"), + "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), + ["T3 Code", "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(" { + assert.equal( + parsePairingCredentialOutput('server log\n{\n "credential": "PAIR-ME"\n}\n'), + "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 new file mode 100644 index 00000000000..27b40193ee7 --- /dev/null +++ b/scripts/mobile-showcase.ts @@ -0,0 +1,1322 @@ +#!/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 { PNG } from "pngjs"; + +import showcaseConfig, { + type ShowcaseAndroidDevice, + type ShowcaseConfig, + type ShowcaseDevice, + type ShowcaseIosDevice, + type ShowcaseStoreAssetSpec, + SHOWCASE_SCENES, + type ShowcaseScene, +} from "./mobile-showcase.config.ts"; +import { + SHOWCASE_ENVIRONMENTS, + SHOWCASE_PROJECTS, + 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_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, + ".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", +); +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: ANDROID_SDK_ROOT, + 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 { + readonly platforms: ReadonlySet; + readonly deviceIds: ReadonlySet; + readonly scenes: ReadonlySet; + readonly skipBuild: boolean; + readonly skipMetro: boolean; + readonly keepRunning: boolean; + readonly validateOnly: boolean; + readonly list: boolean; +} + +export interface ShowcaseCapture { + readonly device: ShowcaseDevice; + 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; + 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 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 < 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); + const colorType = view.getUint8(25); + return { + width: view.getUint32(16), + height: view.getUint32(20), + bitDepth: view.getUint8(24), + colorType, + hasAlpha: colorType === 4 || colorType === 6, + }; +} + +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( + `Validated ${files.length} upload-ready ${capture.device.storeAsset.store} screenshots in ${NodePath.relative(REPO_ROOT, directory)}/\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 validateOnly = 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 === "--validate-only") { + validateOnly = 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, + validateOnly, + 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 + --validate-only Validate existing upload assets without capturing + --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(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory} (${device.storeAsset.width}ร—${device.storeAsset.height}) [${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, + options: NodeChildProcess.ExecFileOptions = {}, +): Promise { + return await new Promise((resolve, reject) => { + NodeChildProcess.execFile( + command, + [...args], + { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, ...options }, + (error, stdout) => { + if (error) reject(error); + else resolve(String(stdout)); + }, + ); + }); +} + +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) { + 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(`${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; +} + +async function createShowcaseLabelProbe(baseDir: string, label: string): Promise { + const binDirectory = NodePath.join(baseDir, "showcase-bin"); + await NodeFSP.mkdir(binDirectory, { recursive: true }); + 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 +`; + await Promise.all( + ["scutil", "hostnamectl"].map((executable) => + NodeFSP.writeFile(NodePath.join(binDirectory, executable), probeScript, { mode: 0o755 }), + ), + ); + return binDirectory; +} + +function startShowcaseServer( + baseDir: string, + workspaceRoot: string, + port: number, + shellPath: string, + labelProbeDirectory: 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, + PATH: `${labelProbeDirectory}:${NodeProcess.env.PATH ?? ""}`, + 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}://`; + 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") { + return `${APP_SCHEME}://${threadPath}/terminal?terminalId=${SHOWCASE_TERMINAL_ID}`; + } + 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", + ["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort)], + { + 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", + "build", + ], + { cwd: MOBILE_ROOT, env: MOBILE_BUILD_ENV }, + ); + 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"), + env: MOBILE_BUILD_ENV, + }, + ); + 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); + 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 '${device.simulator}' is not installed and has no simulatorDeviceType configured.`, + ); + } + 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 { + 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 iosAppContainer(udid: string): Promise { + return ( + await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) + ).trim(); +} + +async function waitForIosShowcaseScene( + udid: string, + scene: ShowcaseScene, + timeoutMs = 90_000, +): Promise { + const readyPath = NodePath.join( + await iosAppContainer(udid), + "Library/Caches", + IOS_READY_FILENAME, + ); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const readyScene = await NodeFSP.readFile(readyPath, "utf8").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, + pairingUrls: ReadonlyArray, + 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. + 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]); + } + + 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`; + 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", + JSON.stringify(pairingUrls), + "--showcaseScene", + firstScene, + ]); + }; + 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( + storeAssetDirectory(outputDirectory, capture.device), + `${scene}.png`, + ); + await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); + await finalizeCapture(destination, capture.device); + } +} + +function androidSdkTool(relativePath: string): string { + return NodePath.join(ANDROID_SDK_ROOT, 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 deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const readyScene = await adbOutput(serial, [ + "shell", + "run-as", + ANDROID_PACKAGE, + "cat", + "files/t3-showcase-ready", + ]).catch(() => ""); + 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, + pairingUrls: ReadonlyArray, + 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") + .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.`, + ); + } + launchedEmulator = spawnProcess( + androidSdkTool("emulator/emulator"), + ["-avd", capture.device.avd, "-no-snapshot-load", "-no-boot-anim"], + { stdio: "ignore", detached: true }, + ); + launchedEmulator.unref(); + } + 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]); + } + await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]); + await prepareAndroidShowcaseApp(serial); + 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 runAdb(serial, [ + "shell", + "am", + "start", + "-W", + "-a", + "android.intent.action.VIEW", + "-d", + `${APP_SCHEME}://expo-development-client/?url=${metroUrl}`, + "--es", + "showcasePairingUrl", + encodeAndroidPairingUrls(pairingUrls), + "--es", + "showcaseScene", + firstScene, + ANDROID_PACKAGE, + ]); + 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( + storeAssetDirectory(outputDirectory, capture.device), + `${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 finalizeCapture(destination, capture.device); + } +} + +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 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"; + 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-"), + ); + 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 iosCleanups: IosCaptureCleanup[] = []; + const androidCleanups: AndroidCaptureCleanup[] = []; + + try { + 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) { + metro = startMetro(showcaseConfig); + 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(), + ]); + } + + 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) { + const pairingHost = capture.device.platform === "ios" ? "127.0.0.1" : "10.0.2.2"; + 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") { + await captureIos( + capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice }, + iosAppPath, + outputDirectory, + showcaseConfig, + metroHost, + pairingUrls, + (cleanup) => iosCleanups.push(cleanup), + ); + } else { + await captureAndroid( + capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, + androidApkPath, + outputDirectory, + showcaseConfig, + pairingUrls, + (cleanup) => androidCleanups.push(cleanup), + ); + } + await validateCaptureSet( + capture, + outputDirectory, + capture.scenes.length === capture.device.scenes.length, + ); + } + + NodeProcess.stdout.write( + `\nDone. Upload-ready screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/apple/ and ${NodePath.relative(REPO_ROOT, outputDirectory)}/google-play/.\n`, + ); + if (options.keepRunning) { + const serverSummary = showcaseEnvironments + .map((environment) => `${environment.label}:${environment.port}`) + .join(", "); + NodeProcess.stdout.write( + `Showcase environments kept at ${showcaseRootDir} (${serverSummary}).\n`, + ); + } + } finally { + 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) { + await runAdb(cleanup.serial, ["emu", "kill"]).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)] : []), + ...showcaseServers.map((server) => stopProcess(server)), + ]); + await NodeFSP.rm(showcaseRootDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); + } + } +} + +if (import.meta.main) { + void main().catch((error: unknown) => { + NodeProcess.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + NodeProcess.exit(1); + }); +} 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:" } }