authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-17 11:33:40+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-17 11:33:40+02:00
log8a02c718619a9d4c6588d3ab7a89204023cfab62
tree33db736e0ba3bc06f950599094b808ad1e9e0174
parentfdca15471d92e95e4ec5501f45dbf3ce81f8d991
signature Signed by PGP key B5690EEEBB952194

[codex] Expand real-route app store screenshot harness (#4014)

Co-authored-by: codex <codex@users.noreply.github.com>

40 files changed, 3659 insertions(+), 15 deletions(-)

.github/workflows/mobile-showcase-screenshots.yml created+134
......@@ -0,0 +1,134 @@
1name: Mobile Showcase Screenshots
2
3on:
4 workflow_dispatch:
5 inputs:
6 platform:
7 description: Device platforms to capture
8 required: true
9 default: all
10 type: choice
11 options:
12 - all
13 - ios
14 - android
15 appearance:
16 description: System appearances to capture
17 required: true
18 default: both
19 type: choice
20 options:
21 - both
22 - dark
23 - light
24
25permissions:
26 contents: read
27
28env:
29 NODE_OPTIONS: --max-old-space-size=8192
30
31jobs:
32 ios:
33 name: iPhone 6.9, iPhone 6.5, and iPad 13
34 if: inputs.platform == 'all' || inputs.platform == 'ios'
35 runs-on: blacksmith-12vcpu-macos-26
36 timeout-minutes: 60
37 steps:
38 - name: Checkout
39 uses: actions/checkout@v6
40
41 - name: Setup Vite+
42 uses: voidzero-dev/setup-vp@v1
43 with:
44 node-version-file: package.json
45 cache: true
46 run-install: true
47
48 - name: Expose pnpm
49 run: |
50 pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")"
51 vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin"
52 echo "$vp_pnpm_bin" >> "$GITHUB_PATH"
53 "$vp_pnpm_bin/pnpm" --version
54
55 - name: Capture iOS showcase
56 run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}"
57
58 - name: Validate App Store Connect assets
59 run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only
60
61 - name: Upload iOS screenshots
62 if: always()
63 uses: actions/upload-artifact@v7
64 with:
65 name: app-store-connect-screenshots
66 path: artifacts/app-store/screenshots/apple/
67 if-no-files-found: warn
68 retention-days: 14
69
70 android:
71 name: Android phone, 7-inch tablet, and 10-inch tablet
72 if: inputs.platform == 'all' || inputs.platform == 'android'
73 runs-on: blacksmith-16vcpu-ubuntu-2404
74 timeout-minutes: 60
75 env:
76 T3_SHOWCASE_ANDROID_ABI: x86_64
77 steps:
78 - name: Checkout
79 uses: actions/checkout@v6
80
81 - name: Setup Vite+
82 uses: voidzero-dev/setup-vp@v1
83 with:
84 node-version-file: package.json
85 cache: true
86 run-install: true
87
88 - name: Expose pnpm
89 run: |
90 pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")"
91 vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin"
92 echo "$vp_pnpm_bin" >> "$GITHUB_PATH"
93 "$vp_pnpm_bin/pnpm" --version
94
95 - name: Setup Java
96 uses: actions/setup-java@v5
97 with:
98 distribution: temurin
99 java-version: 17
100
101 - name: Setup Gradle cache
102 uses: gradle/actions/setup-gradle@v5
103
104 - name: Enable KVM
105 run: |
106 echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS="static_node=kvm"' \
107 | sudo tee /etc/udev/rules.d/99-kvm4all.rules
108 sudo udevadm control --reload-rules
109 sudo udevadm trigger --name-match=kvm
110
111 - name: Capture Android showcase
112 uses: reactivecircus/android-emulator-runner@v2
113 with:
114 api-level: 36
115 target: google_apis
116 arch: x86_64
117 profile: pixel_7_pro
118 avd-name: Pixel_10_Pro
119 cores: 8
120 ram-size: 4096M
121 disable-animations: false
122 script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}"
123
124 - name: Validate Google Play assets
125 run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only
126
127 - name: Upload Android screenshots
128 if: always()
129 uses: actions/upload-artifact@v7
130 with:
131 name: google-play-screenshots
132 path: artifacts/app-store/screenshots/google-play/
133 if-no-files-found: warn
134 retention-days: 14
.gitignore+3
......@@ -27,6 +27,9 @@ squashfs-root/
2727.gstack/
2828dist-electron/
2929.electron-runtime/
30.showcase/
31apps/mobile/.showcase/
32artifacts/app-store/screenshots/
3033node_modules/
3134.alchemy/
3235*.log
apps/mobile/index.ts+5
......@@ -1,5 +1,6 @@
11import { registerRootComponent } from "expo";
22import "react-native-gesture-handler";
3import { LogBox } from "react-native";
34import { featureFlags } from "react-native-screens";
45
56import App from "./src/App";
......@@ -8,4 +9,8 @@ import App from "./src/App";
89// native stack is rendered inside a non-fitToContents formSheet.
910featureFlags.experiment.synchronousScreenUpdatesEnabled = true;
1011
12if (process.env.EXPO_PUBLIC_SHOWCASE === "1") {
13 LogBox.ignoreAllLogs();
14}
15
1116registerRootComponent(App);
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt+26
......@@ -7,6 +7,32 @@ class T3NativeControlsModule : Module() {
77 override fun definition() = ModuleDefinition {
88 Name("T3NativeControls")
99
10 Function("getShowcasePairingUrl") {
11 appContext.currentActivity?.intent?.getStringExtra("showcasePairingUrl")
12 }
13
14 Function("getShowcaseScene") {
15 val storedScene = appContext.reactContext
16 ?.filesDir
17 ?.resolve("t3-showcase-scene")
18 ?.takeIf { it.isFile }
19 ?.readText()
20 ?.trim()
21 ?.takeIf { it.isNotEmpty() }
22 storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene")
23 }
24
25 Function("prepareShowcaseCapture") {
26 // Android app data is cleared by the host runner before launch.
27 }
28
29 Function("markShowcaseReady") { scene: String ->
30 appContext.reactContext
31 ?.filesDir
32 ?.resolve("t3-showcase-ready")
33 ?.writeText(scene)
34 }
35
1036 View(T3HeaderButtonView::class) {
1137 Prop("label") { view: T3HeaderButtonView, label: String ->
1238 view.setLabel(label)
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+39
......@@ -1,9 +1,48 @@
11import ExpoModulesCore
2import Security
23
34public final class T3NativeControlsModule: Module {
45 public func definition() -> ModuleDefinition {
56 Name("T3NativeControls")
67
8 Function("getShowcasePairingUrl") {
9 let arguments = ProcessInfo.processInfo.arguments
10 guard
11 let flagIndex = arguments.firstIndex(of: "--showcasePairingUrl"),
12 arguments.indices.contains(flagIndex + 1)
13 else {
14 return nil as String?
15 }
16 return arguments[flagIndex + 1]
17 }
18
19 Function("getShowcaseScene") { () -> String? in
20 let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene"
21 if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8)
22 .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty {
23 return storedScene
24 }
25 let arguments = ProcessInfo.processInfo.arguments
26 guard
27 let flagIndex = arguments.firstIndex(of: "--showcaseScene"),
28 arguments.indices.contains(flagIndex + 1)
29 else {
30 return nil as String?
31 }
32 return arguments[flagIndex + 1]
33 }
34
35 Function("prepareShowcaseCapture") {
36 for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] {
37 SecItemDelete([kSecClass as String: itemClass] as CFDictionary)
38 }
39 }
40
41 Function("markShowcaseReady") { (scene: String) in
42 let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene"
43 try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8)
44 }
45
746 View(T3HeaderButtonView.self) {
847 Prop("label") { (view: T3HeaderButtonView, label: String) in
948 view.setLabel(label)
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt+4
......@@ -30,6 +30,10 @@ class T3TerminalModule : Module() {
3030 view.focusRequest = focusRequest
3131 }
3232
33 Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean ->
34 view.autoFocus = autoFocus
35 }
36
3337 Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String ->
3438 view.appearanceScheme = appearanceScheme
3539 }
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt+18
......@@ -76,6 +76,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
7676 }
7777 }
7878
79 var autoFocus: Boolean = true
80 set(value) {
81 field = value
82 if (value) {
83 requestKeyboardFocus()
84 } else {
85 inputView.clearFocus()
86 hideKeyboard()
87 }
88 }
89
7990 var backgroundColorHex: String = "#24292E"
8091 set(value) {
8192 field = value
......@@ -368,6 +379,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
368379 inputMethodManager?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT)
369380 }
370381
382 private fun hideKeyboard() {
383 val inputMethodManager = context.getSystemService(
384 Context.INPUT_METHOD_SERVICE
385 ) as? InputMethodManager
386 inputMethodManager?.hideSoftInputFromWindow(windowToken, 0)
387 }
388
371389 private fun applyTheme() {
372390 setBackgroundColor(backgroundColorValue)
373391 container.setBackgroundColor(backgroundColorValue)
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+4
......@@ -27,6 +27,10 @@ public class T3TerminalModule: Module {
2727 view.focusRequest = focusRequest
2828 }
2929
30 Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in
31 view.autoFocus = autoFocus
32 }
33
3034 Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in
3135 view.appearanceScheme = appearanceScheme
3236 }
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+12-1
......@@ -248,6 +248,17 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
248248 }
249249 }
250250
251 var autoFocus = true {
252 didSet {
253 guard oldValue != autoFocus else { return }
254 if autoFocus {
255 requestKeyboardFocus()
256 } else {
257 inputField.resignFirstResponder()
258 }
259 }
260 }
261
251262 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
252263 didSet {
253264 guard oldValue != appearanceScheme else { return }
......@@ -359,7 +370,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
359370 public override func didMoveToWindow() {
360371 super.didMoveToWindow()
361372
362 guard window != nil else { return }
373 guard window != nil, autoFocus else { return }
363374 DispatchQueue.main.async { [weak self] in
364375 self?.requestKeyboardFocus()
365376 }
apps/mobile/package.json+2
......@@ -11,6 +11,8 @@
1111 "start:dev": "APP_VARIANT=development expo start",
1212 "start:preview": "APP_VARIANT=preview expo start",
1313 "start:prod": "APP_VARIANT=production expo start",
14 "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear",
15 "screenshots": "node ../../scripts/mobile-showcase.ts",
1416 "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
1517 "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android",
1618 "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
apps/mobile/src/App.tsx+5
......@@ -11,6 +11,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati
1111import { RegistryContext } from "@effect/atom-react";
1212import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
1313import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
14import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
1415import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider";
1516import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider";
1617import { RootStack } from "./Stack";
......@@ -21,6 +22,10 @@ import { useThemeColor } from "./lib/useThemeColor";
2122
2223import "../global.css";
2324
25if (process.env.EXPO_PUBLIC_SHOWCASE === "1") {
26 prepareNativeShowcaseCapture();
27}
28
2429const appLinking = {
2530 prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],
2631 // The Expo dev client launches the app via
apps/mobile/src/Stack.tsx+2
......@@ -48,6 +48,7 @@ import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnv
4848import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
4949import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
5050import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen";
51import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
5152import {
5253 SettingsLegalDocumentCloseHeaderButton,
5354 SettingsLegalDocumentExternalHeaderButton,
......@@ -313,6 +314,7 @@ function RootStackLayout(props: {
313314
314315 return (
315316 <HardwareKeyboardCommandProvider pathname={pathname}>
317 <ShowcaseCaptureCoordinator pathname={pathname} />
316318 <ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
317319 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
318320 {props.children}
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+5-2
......@@ -33,6 +33,8 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect
3333export function CloudEnvironmentRows(props: {
3434 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
3535 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
36 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;
37 readonly showcaseSignedIn?: boolean;
3638 /**
3739 * Hide the "T3 Connect" section title + refresh button for hosts that
3840 * provide their own chrome (the onboarding sheet's native header and
......@@ -43,7 +45,8 @@ export function CloudEnvironmentRows(props: {
4345 const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
4446 const controller = useConnectionController();
4547 const iconColor = useThemeColor("--color-icon");
46 const availableCloudEnvironments = controller.availableRelayEnvironments;
48 const availableCloudEnvironments =
49 props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments;
4750 const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
4851 const hasCloudRows =
4952 props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0;
......@@ -64,7 +67,7 @@ export function CloudEnvironmentRows(props: {
6467
6568 const showHeader = props.showHeader ?? true;
6669
67 if (!isSignedIn) return null;
70 if (!(props.showcaseSignedIn ?? isSignedIn)) return null;
6871
6972 return (
7073 <View collapsable={false} className={cn("gap-3", showHeader && "mt-5")}>
apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts+17
......@@ -96,5 +96,22 @@ describe("isPendingNativeViewRegistration", () => {
9696 new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"),
9797 ),
9898 ).toBe(false);
99 expect(
100 isPendingNativeViewRegistration(
101 new Error(
102 "Unable to find the class expo.modules.t3reviewdiff.T3ReviewDiffView view with tag 1150",
103 ),
104 ),
105 ).toBe(true);
106 });
107});
108
109describe("isNativeReviewDiffDrawEvent", () => {
110 it("accepts only native events emitted after diff rows draw", async () => {
111 const { isNativeReviewDiffDrawEvent } = await import("./nativeReviewDiffSurface");
112
113 expect(isNativeReviewDiffDrawEvent({ message: "draw-metrics" })).toBe(true);
114 expect(isNativeReviewDiffDrawEvent({ message: "visible-range" })).toBe(true);
115 expect(isNativeReviewDiffDrawEvent({ message: "rows-decoded" })).toBe(false);
99116 });
100117});
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+8-2
......@@ -149,6 +149,10 @@ export interface NativeReviewDiffViewHandle {
149149 readonly scrollToTop: (animated?: boolean) => Promise<void>;
150150}
151151
152export function isNativeReviewDiffDrawEvent(payload: Readonly<Record<string, unknown>>): boolean {
153 return payload.message === "draw-metrics" || payload.message === "visible-range";
154}
155
152156interface NativeReviewDiffViewRef {
153157 readonly setRowsJson: (rowsJson: string) => Promise<void>;
154158 readonly setTokensJson: (tokensJson: string) => Promise<void>;
......@@ -170,9 +174,11 @@ let nativeReviewDiffViewResolutionFailed = false;
170174type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson";
171175
172176export function isPendingNativeViewRegistration(error: unknown): boolean {
177 if (!(error instanceof Error)) return false;
173178 return (
174 error instanceof Error &&
175 error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`)
179 error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) ||
180 (error.message.includes("Unable to find the class") &&
181 error.message.includes("T3ReviewDiffView view with tag"))
176182 );
177183}
178184
apps/mobile/src/features/home/HomeScreen.tsx-1
......@@ -167,7 +167,6 @@ export function HomeScreen(props: HomeScreenProps) {
167167 const listRef = useRef<LegendListRef | null>(null);
168168 const insets = useSafeAreaInsets();
169169 const accentColor = useThemeColor("--color-icon-muted");
170
171170 const effectiveGroupDisplayStates = useMemo(() => {
172171 const next = new Map(groupDisplayStates);
173172 if (!AsyncResult.isSuccess(preferencesResult)) {
apps/mobile/src/features/review/ReviewSheet.tsx+24-1
......@@ -56,6 +56,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
5656import { ThreadGitMenu } from "../threads/ThreadGitControls";
5757import { useReviewCacheForThread } from "./reviewState";
5858import {
59 isNativeReviewDiffDrawEvent,
5960 type NativeReviewDiffViewHandle,
6061 resolveNativeReviewDiffView,
6162} from "../diffs/nativeReviewDiffSurface";
......@@ -71,8 +72,10 @@ import { resolveReviewAvailability } from "./reviewAvailability";
7172import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
7273import { buildReviewSectionMenu } from "./review-section-menu";
7374import type { ReviewSectionItem } from "./reviewModel";
75import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene";
7476
7577const REVIEW_HEADER_SPACING = 0;
78const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
7679
7780const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
7881 return (
......@@ -394,6 +397,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
394397 });
395398 const NativeReviewDiffView = resolveNativeReviewDiffView()!;
396399 const nativeReviewDiffViewRef = useRef<NativeReviewDiffViewHandle>(null);
400 const showcasedReviewDrawRef = useRef<string | null>(null);
397401 // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item).
398402 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
399403 const handlePullToRefresh = useCallback(async () => {
......@@ -435,6 +439,25 @@ export function ReviewSheet(props: ReviewSheetProps) {
435439 selectedRowIds: commentSelection.selectedRowIds,
436440 canHighlight: parsedDiff.kind === "files",
437441 });
442 const showcaseReviewKey =
443 SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection
444 ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}`
445 : null;
446 const handleNativeDebug = useCallback(
447 (event: NativeSyntheticEvent<Record<string, unknown>>) => {
448 nativeBridge.onDebug(event);
449 if (
450 showcaseReviewKey === null ||
451 showcasedReviewDrawRef.current === showcaseReviewKey ||
452 !isNativeReviewDiffDrawEvent(event.nativeEvent)
453 ) {
454 return;
455 }
456 showcasedReviewDrawRef.current = showcaseReviewKey;
457 markNativeShowcaseReady("review");
458 },
459 [nativeBridge.onDebug, showcaseReviewKey],
460 );
438461
439462 const handleSelectFile = useCallback(
440463 (fileId: string | null) => {
......@@ -788,7 +811,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
788811 tokensPatchJson={nativeBridge.tokensPatchJson}
789812 tokensResetKey={nativeBridge.tokensResetKey}
790813 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
791 onDebug={nativeBridge.onDebug}
814 onDebug={handleNativeDebug}
792815 onPressLine={commentSelection.onPressLine}
793816 onVisibleFileChange={handleVisibleFileChange}
794817 onToggleComment={nativeBridge.onToggleComment}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+57-4
......@@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
22import { useNavigation } from "@react-navigation/native";
33import { SymbolView } from "../../components/AppSymbol";
44import type { EnvironmentId } from "@t3tools/contracts";
5import { useCallback, useState } from "react";
5import { useCallback, useEffect, useState } from "react";
66import { Platform, ScrollView, View } from "react-native";
77import { useSafeAreaInsets } from "react-native-safe-area-context";
88
......@@ -15,6 +15,15 @@ import { splitEnvironmentSections } from "../connection/environmentSections";
1515import { cn } from "../../lib/cn";
1616import { useThemeColor } from "../../lib/useThemeColor";
1717import { useRemoteConnections } from "../../state/use-remote-environment-registry";
18import {
19 applyShowcaseLocalEnvironmentDisplayUrls,
20 resolveShowcaseEnvironmentUpdateDisplayUrl,
21 SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
22 SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS,
23} from "../showcase/showcaseEnvironmentRows";
24import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene";
25
26const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
1827
1928export function SettingsEnvironmentsRouteScreen() {
2029 const {
......@@ -25,18 +34,56 @@ export function SettingsEnvironmentsRouteScreen() {
2534 } = useRemoteConnections();
2635 const navigation = useNavigation();
2736 const insets = useSafeAreaInsets();
28 const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({
37 const environmentSections = splitEnvironmentSections({
2938 connectedEnvironments,
3039 cloudEnvironments: null,
3140 });
41 const localEnvironments = SHOWCASE_ENABLED
42 ? applyShowcaseLocalEnvironmentDisplayUrls(environmentSections.localEnvironments)
43 : environmentSections.localEnvironments;
44 const connectedCloudEnvironments = SHOWCASE_ENABLED
45 ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS
46 : environmentSections.connectedCloudEnvironments;
3247 const hasLocalEnvironments = localEnvironments.length > 0;
3348 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
3449 const accentColor = useThemeColor("--color-icon-muted");
3550 const headerIconColor = useThemeColor("--color-icon");
3651
52 useEffect(() => {
53 if (!SHOWCASE_ENABLED) return;
54 const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500);
55 return () => clearTimeout(timer);
56 }, []);
57
3758 const handleToggle = useCallback((environmentId: EnvironmentId) => {
3859 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
3960 }, []);
61 const handleUpdateEnvironment = useCallback(
62 (
63 environmentId: EnvironmentId,
64 updates: { readonly label: string; readonly displayUrl: string },
65 ) => {
66 if (!SHOWCASE_ENABLED) return onUpdateEnvironment(environmentId, updates);
67 const actualEnvironment = environmentSections.localEnvironments.find(
68 (environment) => environment.environmentId === environmentId,
69 );
70 const presentedEnvironment = localEnvironments.find(
71 (environment) => environment.environmentId === environmentId,
72 );
73 return onUpdateEnvironment(environmentId, {
74 ...updates,
75 displayUrl:
76 actualEnvironment && presentedEnvironment
77 ? resolveShowcaseEnvironmentUpdateDisplayUrl({
78 actualDisplayUrl: actualEnvironment.displayUrl,
79 presentedDisplayUrl: presentedEnvironment.displayUrl,
80 submittedDisplayUrl: updates.displayUrl,
81 })
82 : updates.displayUrl,
83 });
84 },
85 [environmentSections.localEnvironments, localEnvironments, onUpdateEnvironment],
86 );
4087
4188 return (
4289 <View collapsable={false} className="flex-1 bg-sheet">
......@@ -92,7 +139,7 @@ export function SettingsEnvironmentsRouteScreen() {
92139 onToggle={() => handleToggle(environment.environmentId)}
93140 onReconnect={onReconnectEnvironment}
94141 onRemove={onRemoveEnvironmentPress}
95 onUpdate={onUpdateEnvironment}
142 onUpdate={handleUpdateEnvironment}
96143 />
97144 </View>
98145 ))}
......@@ -114,10 +161,16 @@ export function SettingsEnvironmentsRouteScreen() {
114161 </View>
115162 )}
116163
117 {hasCloudPublicConfig() ? (
164 {hasCloudPublicConfig() || SHOWCASE_ENABLED ? (
118165 <CloudEnvironmentRows
119166 connectedCloudEnvironments={connectedCloudEnvironments}
120167 onReconnectEnvironment={onReconnectEnvironment}
168 {...(SHOWCASE_ENABLED
169 ? {
170 showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
171 showcaseSignedIn: true,
172 }
173 : {})}
121174 />
122175 ) : null}
123176 </ScrollView>
apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx created+224
......@@ -0,0 +1,224 @@
1import { useEffect, useRef, useState } from "react";
2import { Keyboard, View } from "react-native";
3import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
4import { AsyncResult } from "effect/unstable/reactivity";
5
6import { useConnectionController } from "../connection/useConnectionController";
7import { useProjects, useThreadShells } from "../../state/entities";
8import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
9import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";
10import { useWorkspaceState } from "../../state/workspace";
11import {
12 getNativeShowcasePairingUrls,
13 getNativeShowcaseScene,
14 markNativeShowcaseReady,
15 type ShowcaseScene,
16} from "./nativeShowcaseScene";
17import {
18 buildShowcasePendingTasks,
19 SHOWCASE_PENDING_TASK_DEFINITIONS,
20} from "./showcasePendingTasks";
21import { retryShowcaseOperation } from "./showcaseRetry";
22
23const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
24const SHOWCASE_THREAD_ID = "remote-command-center";
25
26function sceneFromPathname(pathname: string): ShowcaseScene | null {
27 const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname;
28 if (routePath === "/settings" || routePath.endsWith("/settings/environments")) {
29 return "environments";
30 }
31 if (routePath.endsWith("/terminal")) return "terminal";
32 if (routePath.endsWith("/review")) return "review";
33 if (routePath.startsWith("/threads/")) return "thread";
34 if (routePath === "/") return "threads";
35 return null;
36}
37
38export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) {
39 const navigation = useNavigation();
40 const { connectPairingUrl } = useConnectionController();
41 const workspace = useWorkspaceState();
42 const projects = useProjects();
43 const threads = useThreadShells();
44 const attemptedPairingRef = useRef(new Set<string>());
45 const seededPendingTaskIdsRef = useRef(new Set<string>());
46 const [pairingUrls, setPairingUrls] = useState<ReadonlyArray<string>>([]);
47 const [pendingTasksReady, setPendingTasksReady] = useState(false);
48 const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);
49 const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);
50
51 useEffect(() => {
52 if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;
53
54 const readPairingUrls = () => {
55 const values = getNativeShowcasePairingUrls();
56 if (values.length > 0) setPairingUrls(values);
57 };
58 readPairingUrls();
59 const interval = setInterval(readPairingUrls, 250);
60 return () => clearInterval(interval);
61 }, [pairingUrls.length]);
62
63 useEffect(() => {
64 if (!SHOWCASE_ENABLED) return;
65
66 const readRequestedScene = () => {
67 const value = getNativeShowcaseScene();
68 if (value) setRequestedScene(value);
69 };
70 readRequestedScene();
71 const interval = setInterval(readRequestedScene, 250);
72 return () => clearInterval(interval);
73 }, []);
74
75 useEffect(() => {
76 if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return;
77 let cancelled = false;
78 void (async () => {
79 await Promise.all(
80 pairingUrls.map(async (pairingUrl) => {
81 if (cancelled || attemptedPairingRef.current.has(pairingUrl)) return;
82 const paired = await retryShowcaseOperation(
83 async () => AsyncResult.isSuccess(await connectPairingUrl(pairingUrl)),
84 { isCancelled: () => cancelled },
85 );
86 if (paired) attemptedPairingRef.current.add(pairingUrl);
87 }),
88 );
89 })();
90 return () => {
91 cancelled = true;
92 };
93 }, [connectPairingUrl, pairingUrls]);
94
95 const scene = sceneFromPathname(props.pathname);
96 const hasServerFixture =
97 workspace.state.hasReadyEnvironment &&
98 workspace.environments.length >= 3 &&
99 projects.length >= 3 &&
100 threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID);
101 const hasFixture = hasServerFixture && pendingTasksReady;
102 const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID);
103
104 useEffect(() => {
105 if (!SHOWCASE_ENABLED || !hasServerFixture || pendingTasksReady) return;
106
107 const pendingTasks = buildShowcasePendingTasks(projects, Date.now());
108 if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return;
109
110 let cancelled = false;
111 for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId);
112 void (async () => {
113 const results = await Promise.all(
114 pendingTasks.map(async (task) => {
115 const messageId = String(task.messageId);
116 if (seededPendingTaskIdsRef.current.has(messageId)) return true;
117 const seeded = await retryShowcaseOperation(
118 async () => {
119 await enqueueThreadOutboxMessage(task);
120 return true;
121 },
122 { isCancelled: () => cancelled },
123 );
124 if (seeded) seededPendingTaskIdsRef.current.add(messageId);
125 return seeded;
126 }),
127 );
128 if (!cancelled && results.every(Boolean)) setPendingTasksReady(true);
129 })();
130 return () => {
131 cancelled = true;
132 };
133 }, [hasServerFixture, pendingTasksReady, projects]);
134
135 useEffect(() => {
136 if (!SHOWCASE_ENABLED || requestedScene === null || !hasFixture || !showcaseThread) return;
137 if (scene === requestedScene) return;
138
139 const params = {
140 environmentId: String(showcaseThread.environmentId),
141 threadId: SHOWCASE_THREAD_ID,
142 };
143 if (requestedScene === "threads") {
144 navigation.dispatch(StackActions.popToTop());
145 return;
146 }
147 const routes: Array<{
148 name: string;
149 params?: Record<string, unknown>;
150 state?: { index: number; routes: Array<{ name: string }> };
151 }> = [{ name: "Home" }];
152 if (requestedScene === "environments") {
153 routes.push({
154 name: "SettingsSheet",
155 state: {
156 index: 1,
157 routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }],
158 },
159 });
160 } else {
161 routes.push({ name: "Thread", params });
162 if (requestedScene === "terminal") {
163 routes.push({
164 name: "ThreadTerminal",
165 params: { ...params, terminalId: "term-1" },
166 });
167 } else if (requestedScene === "review") {
168 routes.push({ name: "ThreadReview", params });
169 }
170 }
171 navigation.dispatch(
172 CommonActions.reset({
173 index: routes.length - 1,
174 routes,
175 }),
176 );
177 }, [hasFixture, navigation, requestedScene, scene, showcaseThread]);
178
179 useEffect(() => {
180 if (
181 !SHOWCASE_ENABLED ||
182 scene === null ||
183 requestedScene === null ||
184 scene !== requestedScene ||
185 !hasFixture
186 ) {
187 setReadyScene(null);
188 return;
189 }
190 // Review owns its readiness marker because route activation happens before
191 // the VCS request is parsed and the native diff surface is mounted.
192 if (scene === "review") {
193 setReadyScene(null);
194 return;
195 }
196 if (scene === "terminal") Keyboard.dismiss();
197
198 let renderFrame: number | null = null;
199 let readyFrame: number | null = null;
200 const settleTimer = setTimeout(() => {
201 renderFrame = requestAnimationFrame(() => {
202 readyFrame = requestAnimationFrame(() => {
203 markNativeShowcaseReady(scene);
204 setReadyScene(scene);
205 });
206 });
207 }, 500);
208 return () => {
209 clearTimeout(settleTimer);
210 if (renderFrame !== null) cancelAnimationFrame(renderFrame);
211 if (readyFrame !== null) cancelAnimationFrame(readyFrame);
212 };
213 }, [hasFixture, requestedScene, scene]);
214
215 if (!SHOWCASE_ENABLED || readyScene === null) return null;
216
217 return (
218 <View
219 pointerEvents="none"
220 testID={`showcase-ready-${readyScene}`}
221 style={{ position: "absolute", width: 1, height: 1, opacity: 0.01 }}
222 />
223 );
224}
apps/mobile/src/features/showcase/nativeShowcaseScene.ts created+68
......@@ -0,0 +1,68 @@
1import { requireOptionalNativeModule } from "expo";
2
3export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;
4export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
5
6interface NativeShowcaseControls {
7 readonly getShowcasePairingUrl?: () => string | null;
8 readonly getShowcaseScene?: () => string | null;
9 readonly prepareShowcaseCapture?: () => void;
10 readonly markShowcaseReady?: (scene: ShowcaseScene) => void;
11}
12
13function nativeShowcaseControls(): NativeShowcaseControls | null {
14 return requireOptionalNativeModule<NativeShowcaseControls>("T3NativeControls");
15}
16
17export function getNativeShowcasePairingUrls(): ReadonlyArray<string> {
18 try {
19 let raw = nativeShowcaseControls()?.getShowcasePairingUrl?.()?.trim();
20 if (!raw) return [];
21 if (raw.startsWith("json-uri:")) {
22 try {
23 raw = decodeURIComponent(raw.slice("json-uri:".length));
24 } catch {
25 return [];
26 }
27 }
28 try {
29 const parsed: unknown = JSON.parse(raw);
30 if (Array.isArray(parsed)) {
31 return parsed.filter(
32 (candidate): candidate is string =>
33 typeof candidate === "string" && candidate.trim().length > 0,
34 );
35 }
36 } catch {
37 // Older runners pass a single URL rather than a JSON array.
38 }
39 return [raw];
40 } catch {
41 return [];
42 }
43}
44
45export function getNativeShowcaseScene(): ShowcaseScene | null {
46 try {
47 const scene = nativeShowcaseControls()?.getShowcaseScene?.()?.trim();
48 return SHOWCASE_SCENES.find((candidate) => candidate === scene) ?? null;
49 } catch {
50 return null;
51 }
52}
53
54export function prepareNativeShowcaseCapture(): void {
55 try {
56 nativeShowcaseControls()?.prepareShowcaseCapture?.();
57 } catch {
58 // The harness still works when a development build predates this helper.
59 }
60}
61
62export function markNativeShowcaseReady(scene: ShowcaseScene): void {
63 try {
64 nativeShowcaseControls()?.markShowcaseReady?.(scene);
65 } catch {
66 // The readiness marker is capture-runner metadata, never app functionality.
67 }
68}
apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts created+70
......@@ -0,0 +1,70 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3
4import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
5import {
6 applyShowcaseLocalEnvironmentDisplayUrls,
7 resolveShowcaseEnvironmentUpdateDisplayUrl,
8} from "./showcaseEnvironmentRows";
9
10function environment(
11 environmentId: string,
12 environmentLabel: string,
13 displayUrl = "http://127.0.0.1:3773/",
14): ConnectedEnvironmentSummary {
15 return {
16 environmentId: EnvironmentId.make(environmentId),
17 environmentLabel,
18 displayUrl,
19 isRelayManaged: false,
20 connectionState: "connected",
21 connectionError: null,
22 connectionErrorTraceId: null,
23 };
24}
25
26it("presents showcase transports as remote endpoints", () => {
27 const environments = applyShowcaseLocalEnvironmentDisplayUrls([
28 environment("runtime-id-1", "Moonbase Terminal"),
29 environment("runtime-id-2", "Suspense Station"),
30 environment("runtime-id-3", "Kernel Cabin"),
31 ]);
32
33 assert.deepStrictEqual(
34 environments.map(({ displayUrl }) => displayUrl),
35 [
36 "https://moonbase.tail9f3a.ts.net/",
37 "https://suspense-vps.hel1.t3.sh/",
38 "http://100.82.16.5:3773/",
39 ],
40 );
41});
42
43it("leaves environments outside the showcase fixture unchanged", () => {
44 const original = environment(
45 "runtime-id-4",
46 "My Workstation",
47 "https://workstation.example.test/",
48 );
49
50 assert.deepStrictEqual(applyShowcaseLocalEnvironmentDisplayUrls([original]), [original]);
51});
52
53it("does not persist a cosmetic showcase URL when only the label is saved", () => {
54 assert.equal(
55 resolveShowcaseEnvironmentUpdateDisplayUrl({
56 actualDisplayUrl: "http://127.0.0.1:3773/",
57 presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/",
58 submittedDisplayUrl: "https://moonbase.tail9f3a.ts.net/",
59 }),
60 "http://127.0.0.1:3773/",
61 );
62 assert.equal(
63 resolveShowcaseEnvironmentUpdateDisplayUrl({
64 actualDisplayUrl: "http://127.0.0.1:3773/",
65 presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/",
66 submittedDisplayUrl: "https://new-host.example.com/",
67 }),
68 "https://new-host.example.com/",
69 );
70});
apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts created+70
......@@ -0,0 +1,70 @@
1import { EnvironmentId } from "@t3tools/contracts";
2
3import type { RelayEnvironmentView } from "../connection/useConnectionController";
4import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
5
6const SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS: Readonly<Record<string, string>> = {
7 "Moonbase Terminal": "https://moonbase.tail9f3a.ts.net/",
8 "Suspense Station": "https://suspense-vps.hel1.t3.sh/",
9 "Kernel Cabin": "http://100.82.16.5:3773/",
10};
11
12export function applyShowcaseLocalEnvironmentDisplayUrls(
13 environments: ReadonlyArray<ConnectedEnvironmentSummary>,
14): ReadonlyArray<ConnectedEnvironmentSummary> {
15 return environments.map((environment) => ({
16 ...environment,
17 displayUrl:
18 SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS[environment.environmentLabel] ??
19 environment.displayUrl,
20 }));
21}
22
23export function resolveShowcaseEnvironmentUpdateDisplayUrl(input: {
24 readonly actualDisplayUrl: string;
25 readonly presentedDisplayUrl: string;
26 readonly submittedDisplayUrl: string;
27}): string {
28 return input.submittedDisplayUrl === input.presentedDisplayUrl
29 ? input.actualDisplayUrl
30 : input.submittedDisplayUrl;
31}
32
33const pocketPiId = EnvironmentId.make("showcase-pocket-pi");
34const pocketPiEndpoint = {
35 httpBaseUrl: "https://pocket-pi.t3.sh",
36 wsBaseUrl: "wss://pocket-pi.t3.sh",
37 providerKind: "t3_relay" as const,
38};
39
40export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray<ConnectedEnvironmentSummary> = [
41 {
42 environmentId: EnvironmentId.make("showcase-aurora-gpu"),
43 environmentLabel: "Aurora GPU Pod",
44 displayUrl: "https://aurora-gpu.t3.sh",
45 isRelayManaged: true,
46 connectionState: "connected",
47 connectionError: null,
48 connectionErrorTraceId: null,
49 },
50];
51
52export const SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS: ReadonlyArray<RelayEnvironmentView> = [
53 {
54 environment: {
55 environmentId: pocketPiId,
56 label: "Pocket Pi",
57 endpoint: pocketPiEndpoint,
58 linkedAt: "2026-07-16T08:00:00.000Z",
59 },
60 availability: "online",
61 status: {
62 environmentId: pocketPiId,
63 endpoint: pocketPiEndpoint,
64 status: "online",
65 checkedAt: "2026-07-16T08:41:00.000Z",
66 },
67 error: null,
68 traceId: null,
69 },
70];
apps/mobile/src/features/showcase/showcasePendingTasks.test.ts created+72
......@@ -0,0 +1,72 @@
1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts";
3import { assert, it } from "@effect/vitest";
4
5import {
6 buildShowcasePendingTasks,
7 SHOWCASE_PENDING_TASK_DEFINITIONS,
8} from "./showcasePendingTasks";
9
10const projects: ReadonlyArray<EnvironmentProject> = [
11 {
12 environmentId: EnvironmentId.make("moonbase-terminal"),
13 id: ProjectId.make("t3code"),
14 title: "T3 Code",
15 workspaceRoot: "/workspace/t3code",
16 repositoryIdentity: null,
17 defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
18 scripts: [],
19 createdAt: "2026-07-16T08:00:00.000Z",
20 updatedAt: "2026-07-16T08:00:00.000Z",
21 },
22 {
23 environmentId: EnvironmentId.make("suspense-station"),
24 id: ProjectId.make("react"),
25 title: "React",
26 workspaceRoot: "/workspace/react",
27 repositoryIdentity: null,
28 defaultModelSelection: null,
29 scripts: [],
30 createdAt: "2026-07-16T08:00:00.000Z",
31 updatedAt: "2026-07-16T08:00:00.000Z",
32 },
33];
34
35it("builds sendable-looking pending tasks against real showcase projects", () => {
36 const tasks = buildShowcasePendingTasks(projects, Date.parse("2026-07-16T09:00:00.000Z"));
37
38 assert.equal(tasks.length, SHOWCASE_PENDING_TASK_DEFINITIONS.length);
39 assert.deepStrictEqual(
40 tasks.map((task) => ({
41 environmentId: String(task.environmentId),
42 projectId: task.creation ? String(task.creation.projectId) : undefined,
43 title: task.creation?.projectTitle,
44 branch: task.creation?.branch,
45 createdAt: task.createdAt,
46 })),
47 [
48 {
49 environmentId: "moonbase-terminal",
50 projectId: "t3code",
51 title: "T3 Code",
52 branch: "feat/offline-launchpad",
53 createdAt: "2026-07-16T08:52:00.000Z",
54 },
55 {
56 environmentId: "suspense-station",
57 projectId: "react",
58 title: "React",
59 branch: "perf/tunnel-handoff",
60 createdAt: "2026-07-16T08:33:00.000Z",
61 },
62 ],
63 );
64 assert.equal(
65 tasks.every((task) => task.modelSelection !== undefined),
66 true,
67 );
68});
69
70it("waits until every referenced project has hydrated", () => {
71 assert.equal(buildShowcasePendingTasks(projects.slice(0, 1), Date.now()).length, 1);
72});
apps/mobile/src/features/showcase/showcasePendingTasks.ts created+66
......@@ -0,0 +1,66 @@
1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import {
3 CommandId,
4 DEFAULT_PROVIDER_INTERACTION_MODE,
5 DEFAULT_RUNTIME_MODE,
6 MessageId,
7 ProviderInstanceId,
8 ThreadId,
9} from "@t3tools/contracts";
10
11import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
12
13export const SHOWCASE_PENDING_TASK_DEFINITIONS = [
14 {
15 projectId: "t3code",
16 id: "offline-launch-checklist",
17 text: "Ship the offline launch checklist before touchdown ✈️",
18 branch: "feat/offline-launchpad",
19 minutesAgo: 8,
20 },
21 {
22 projectId: "react",
23 id: "train-tunnel-suspense",
24 text: "Polish the Suspense handoff for the train tunnel 🚇",
25 branch: "perf/tunnel-handoff",
26 minutesAgo: 27,
27 },
28] as const;
29
30const FALLBACK_MODEL_SELECTION = {
31 instanceId: ProviderInstanceId.make("codex"),
32 model: "gpt-5.4",
33} as const;
34
35export function buildShowcasePendingTasks(
36 projects: ReadonlyArray<EnvironmentProject>,
37 now: number,
38): ReadonlyArray<QueuedThreadMessage> {
39 return SHOWCASE_PENDING_TASK_DEFINITIONS.flatMap((definition) => {
40 const project = projects.find((candidate) => String(candidate.id) === definition.projectId);
41 if (!project) return [];
42
43 return [
44 {
45 environmentId: project.environmentId,
46 threadId: ThreadId.make(`showcase-pending-${definition.id}`),
47 messageId: MessageId.make(`showcase-pending-message-${definition.id}`),
48 commandId: CommandId.make(`showcase-pending-command-${definition.id}`),
49 text: definition.text,
50 attachments: [],
51 modelSelection: project.defaultModelSelection ?? FALLBACK_MODEL_SELECTION,
52 runtimeMode: DEFAULT_RUNTIME_MODE,
53 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
54 creation: {
55 projectId: project.id,
56 projectTitle: project.title,
57 projectCwd: project.workspaceRoot,
58 workspaceMode: "local" as const,
59 branch: definition.branch,
60 worktreePath: project.workspaceRoot,
61 },
62 createdAt: new Date(now - definition.minutesAgo * 60_000).toISOString(),
63 },
64 ];
65 });
66}
apps/mobile/src/features/showcase/showcaseRetry.test.ts created+44
......@@ -0,0 +1,44 @@
1import { assert, it } from "@effect/vitest";
2
3import { retryShowcaseOperation } from "./showcaseRetry";
4
5it("retries a failed showcase operation until it succeeds", async () => {
6 let attempts = 0;
7 const succeeded = await retryShowcaseOperation(
8 async () => {
9 attempts += 1;
10 return attempts === 3;
11 },
12 { isCancelled: () => false, retryDelayMs: 0 },
13 );
14
15 assert.equal(succeeded, true);
16 assert.equal(attempts, 3);
17});
18
19it("recovers when a showcase operation attempt hangs", async () => {
20 let attempts = 0;
21 const succeeded = await retryShowcaseOperation(
22 () => {
23 attempts += 1;
24 return attempts === 1 ? new Promise<boolean>(() => undefined) : Promise.resolve(true);
25 },
26 { isCancelled: () => false, attemptTimeoutMs: 1, retryDelayMs: 0 },
27 );
28
29 assert.equal(succeeded, true);
30 assert.equal(attempts, 2);
31});
32
33it("stops retrying when the owning showcase effect is cancelled", async () => {
34 let cancelled = false;
35 const succeeded = await retryShowcaseOperation(
36 async () => {
37 cancelled = true;
38 return false;
39 },
40 { isCancelled: () => cancelled, retryDelayMs: 0 },
41 );
42
43 assert.equal(succeeded, false);
44});
apps/mobile/src/features/showcase/showcaseRetry.ts created+46
......@@ -0,0 +1,46 @@
1export interface ShowcaseRetryOptions {
2 readonly isCancelled: () => boolean;
3 readonly attemptTimeoutMs?: number;
4 readonly retryDelayMs?: number;
5}
6
7const DEFAULT_ATTEMPT_TIMEOUT_MS = 10_000;
8const DEFAULT_RETRY_DELAY_MS = 500;
9
10function delay(ms: number): Promise<void> {
11 return new Promise((resolve) => setTimeout(resolve, ms));
12}
13
14async function runAttemptWithTimeout(
15 operation: () => Promise<boolean>,
16 timeoutMs: number,
17): Promise<boolean> {
18 let timeout: ReturnType<typeof setTimeout> | null = null;
19 try {
20 return await Promise.race([
21 operation(),
22 new Promise<boolean>((resolve) => {
23 timeout = setTimeout(() => resolve(false), timeoutMs);
24 }),
25 ]);
26 } catch {
27 return false;
28 } finally {
29 if (timeout !== null) clearTimeout(timeout);
30 }
31}
32
33/** Retry transient showcase setup work until it succeeds or the owning effect unmounts. */
34export async function retryShowcaseOperation(
35 operation: () => Promise<boolean>,
36 options: ShowcaseRetryOptions,
37): Promise<boolean> {
38 const attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
39 const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
40
41 while (!options.isCancelled()) {
42 if (await runAttemptWithTimeout(operation, attemptTimeoutMs)) return true;
43 if (!options.isCancelled()) await delay(retryDelayMs);
44 }
45 return false;
46}
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+2
......@@ -37,6 +37,7 @@ interface TerminalSurfaceProps extends ViewProps {
3737 readonly buffer: string;
3838 readonly fontSize?: number;
3939 readonly isRunning: boolean;
40 readonly autoFocus?: boolean;
4041 readonly keyboardFocusRequest?: number;
4142 readonly theme?: TerminalTheme;
4243 readonly onInput: (data: string) => void;
......@@ -215,6 +216,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
215216 <View style={props.style}>
216217 <NativeTerminalSurfaceView
217218 appearanceScheme={appearanceScheme}
219 autoFocus={props.autoFocus ?? true}
218220 backgroundColor={theme.background}
219221 focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}
220222 foregroundColor={theme.foreground}
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+2
......@@ -70,6 +70,7 @@ import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiSt
7070const DEFAULT_TERMINAL_COLS = 80;
7171const DEFAULT_TERMINAL_ROWS = 24;
7272const TERMINAL_ACCESSORY_HEIGHT = 52;
73const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
7374
7475type PendingModifier = "ctrl" | "meta";
7576type HostPlatform = "mac" | "linux" | "windows" | "unknown";
......@@ -1218,6 +1219,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12181219 <>
12191220 <View className="flex-1" style={{ paddingBottom: terminalBottomInset }}>
12201221 <TerminalSurface
1222 autoFocus={!SHOWCASE_ENABLED}
12211223 buffer={terminalSurfaceBuffer}
12221224 fontSize={fontSize}
12231225 isRunning={isRunning}
apps/mobile/src/features/terminal/nativeTerminalModule.ts+1
......@@ -23,6 +23,7 @@ interface TerminalResizeEvent {
2323
2424export interface NativeTerminalSurfaceProps extends ViewProps {
2525 readonly appearanceScheme?: "light" | "dark";
26 readonly autoFocus?: boolean;
2627 readonly focusRequest?: number;
2728 readonly themeConfig?: string;
2829 readonly backgroundColor?: string;
apps/mobile/src/state/thread-outbox-manager.ts+4-1
......@@ -101,7 +101,10 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
101101 cause,
102102 });
103103 }
104 setMessages([...currentMessages(), message]);
104 setMessages([
105 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
106 message,
107 ]);
105108 });
106109
107110 // Rewrites an already-queued message. A no-op when the message has been
apps/mobile/src/state/thread-outbox.test.ts+25
......@@ -294,6 +294,31 @@ describe("thread outbox", () => {
294294 registry.dispose();
295295 });
296296
297 it("replaces an existing message when an enqueue retry uses the same id", async () => {
298 const registry = AtomRegistry.make();
299 const manager = createThreadOutboxManager({
300 registry,
301 storage: {
302 load: async () => [],
303 write: async () => undefined,
304 remove: async () => undefined,
305 },
306 });
307 const message = queuedMessage({
308 messageId: "message-1",
309 createdAt: "2026-06-08T10:00:01.000Z",
310 });
311 const retried = { ...message, text: "retried" };
312
313 await manager.enqueue(message);
314 await manager.enqueue(retried);
315
316 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
317 "environment-1:thread-1": [retried],
318 });
319 registry.dispose();
320 });
321
297322 it("updates a queued message in place but never resurrects a removed one", async () => {
298323 const registry = AtomRegistry.make();
299324 const stored = new Map<MessageId, QueuedThreadMessage>();
docs/operations/mobile-app-store-screenshots.md created+159
......@@ -0,0 +1,159 @@
1# Mobile app-store screenshot harness
2
3The screenshot harness runs the real mobile application against three disposable local T3
4environments. It creates an isolated base directory and server for each environment, real Git
5projects with deterministic content, seeded orchestration projections, and persisted terminal
6history. The app pairs with every server through its normal connection flow and React Navigation
7opens the production Home, Thread, ThreadTerminal, ThreadReview, and SettingsEnvironments routes.
8
9No screenshot-specific screen recreates application UI. `EXPO_PUBLIC_SHOWCASE=1` only enables the
10non-rendering pairing/readiness coordinator, disables terminal autofocus so captures do not contain
11the software keyboard, and supplies deterministic T3 Connect discovery rows to the real
12Environments screen. The local environment cards always come from real paired servers.
13
14## Capture the default matrix
15
16From the repository root:
17
18 pnpm screenshots:mobile
19
20The command:
21
221. Creates three temporary T3 base directories and starts a local server for each on an available
23 port.
242. Creates T3 Code, React, and Linux Git repositories with recognizable favicons, feature branches,
25 and a deterministic T3 Code review diff.
263. Seeds each server's migrated SQLite database with playful threads, messages, activities, and
27 terminal history, then adds two persisted mobile-outbox tasks waiting to send.
284. Starts an isolated Metro server, builds the selected native apps, and boots each device.
295. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin.
306. Navigates to the real application route for every requested scene.
317. Sets the requested system appearance and normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and
32 validates dimensions, aspect ratio, file size, and screenshot count before succeeding.
338. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded
34 directly to App Store Connect or Google Play Console.
35
36The servers, Metro, temporary root directory, and devices started by the runner are cleaned up after
37capture. Pass `--keep-running` to retain them for inspection; the runner prints the base-directory
38paths and server ports.
39
40Captures wait for the real environment snapshot to hydrate and for the requested route to become
41active. Both platforms record readiness in the simulator/emulator app container. A final settle
42delay allows native terminal and Git review data to finish rendering.
43
44A full capture regenerates the selected native project with Expo's clean development prebuild before
45building it. Use --skip-build for repeated captures after the first build.
46
47The harness uses its own Metro port (8199 by default), so an ordinary mobile server or another
48worktree cannot accidentally provide the bundle being photographed.
49
50The default matrix is:
51
52| Output folder | Capture target | Upload dimensions | Store slot |
53| ------------------------------------- | ------------------------- | ----------------- | ----------------------------------------- |
54| `apple/iphone-6.9/{light,dark}/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch |
55| `apple/iphone-6.5/{light,dark}/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch |
56| `apple/ipad-13/{light,dark}/` | iPad Pro 13-inch (M5) | 2064×2752 | App Store Connect iPad 13-inch |
57| `google-play/phone/{light,dark}/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 |
58| `google-play/tablet-7/{light,dark}/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 |
59| `google-play/tablet-10/{light,dark}/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 |
60
61Each target captures thread, terminal, review, thread list, and environments, producing 30 PNG
62files for one appearance or 60 for both. Each appearance folder's five screenshots satisfy the configured Apple limit of 1–10, Google
63phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8.
64
65The generated tree is deliberately aligned with the store upload fields:
66
67 artifacts/app-store/screenshots/
68 ├── apple/
69 │ ├── iphone-6.9/{light,dark}/{thread,terminal,review,threads,environments}.png
70 │ ├── iphone-6.5/{light,dark}/{thread,terminal,review,threads,environments}.png
71 │ └── ipad-13/{light,dark}/{thread,terminal,review,threads,environments}.png
72 └── google-play/
73 ├── phone/{light,dark}/{thread,terminal,review,threads,environments}.png
74 ├── tablet-7/{light,dark}/{thread,terminal,review,threads,environments}.png
75 └── tablet-10/{light,dark}/{thread,terminal,review,threads,environments}.png
76
77Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD
78names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport.
79
80## Capture in GitHub Actions
81
82Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or
83`android`, and select `light`, `dark`, or `both`. The default dispatch captures both appearances and
84runs iOS and Android concurrently: iPhone and iPad capture on a
8512-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a
8616-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator.
87
88Every job uploads its PNGs even when a later capture fails, which makes partial runs useful for
89diagnosis. Download `app-store-connect-screenshots` and `google-play-screenshots` from the workflow
90run's Artifacts section. Each job runs validation again immediately before upload. Artifacts are
91retained for 14 days.
92
93The workflow uses the same checked-in device and scene matrix as local capture. Android remains
94ARM64 by default for local Apple Silicon development; CI sets `T3_SHOWCASE_ANDROID_ABI=x86_64` so the
95debug APK matches its accelerated emulator.
96
97## Fast iteration
98
99Capture one scene or device:
100
101 pnpm screenshots:mobile --device iphone-6.9 --scene thread
102 pnpm screenshots:mobile --platform android --scene review
103
104Override the configured appearance or capture both variants:
105
106 pnpm screenshots:mobile --appearance light
107 pnpm screenshots:mobile --appearance dark
108 pnpm screenshots:mobile --appearance both
109
110Reuse the native build and retain the disposable environment:
111
112 pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running
113
114Run Metro separately:
115
116 pnpm --filter @t3tools/mobile showcase
117 pnpm screenshots:mobile --skip-build --skip-metro --device iphone-6.9
118
119List the matrix and flags:
120
121 pnpm screenshots:mobile --list
122
123Validate existing files without starting Metro, servers, simulators, or emulators:
124
125 pnpm screenshots:mobile --validate-only
126 pnpm screenshots:mobile --platform ios --validate-only
127
128## Customize the seeded environment
129
130- Project repository, thread projections, conversation, terminal transcript, and Git changes:
131 [mobile-showcase-environment.ts](../../scripts/mobile-showcase-environment.ts)
132- Device and capture matrix:
133 [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts)
134- Simulator/emulator orchestration:
135 [mobile-showcase.ts](../../scripts/mobile-showcase.ts)
136
137Fixture timestamps are generated relative to capture startup so every route shows stable relative
138labels while the server still receives valid current data. The same deterministic three-environment
139ensemble serves iPhone, iPad, Android phone, and Android tablet captures; responsive differences
140come entirely from the production app layout.
141
142The Pending rows use the production offline outbox and point at the real T3 Code and React fixture
143projects. Showcase coordination holds those two entries in the outbox for capture, just like a task
144currently open for editing, so reconnecting the seeded environments cannot deliver and remove them
145before the screenshot is taken.
146
147The Environments capture presents the three local fixture transports as a Tailscale HTTPS hostname,
148a Helsinki VPS hostname, and a Tailnet IPv4 address. This display-only substitution keeps the cards
149remote-first while the harness retains reliable loopback connections to its ephemeral servers.
150
151## Local prerequisites
152
153- iOS: Xcode command-line tools, the configured simulator runtimes, and installed CocoaPods.
154- Android: ANDROID_HOME (or the default macOS SDK path), adb, emulator, and the configured AVD.
155
156The harness is the source of truth for upload dimensions; do not resize its output. If store rules
157change, update the target's `storeAsset` specification. Capture fails when a PNG is the wrong size,
158has alpha, is not 8-bit RGB, exceeds the configured file-size limit, violates Google Play's 9:16
159shape/bounds, or leaves a full output set below its store minimum.
package.json+1
......@@ -13,6 +13,7 @@
1313 "start:desktop": "vp run --filter @t3tools/desktop start",
1414 "start:marketing": "vp run --filter @t3tools/marketing preview",
1515 "start:mock-update-server": "node scripts/mock-update-server.ts",
16 "screenshots:mobile": "node scripts/mobile-showcase.ts",
1617 "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",
1718 "build:marketing": "vp run --filter @t3tools/marketing build",
1819 "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",
pnpm-lock.yaml+20-3
......@@ -66,7 +66,7 @@ overrides:
6666 vite: npm:@voidzero-dev/vite-plus-core@0.2.2
6767 yaml: ^2.9.0
6868
69packageExtensionsChecksum: sha256-FV3+2NW/9MFbunJaPsMxvO0LAzyfccoPtPiKoIXAwUU=
69packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE=
7070
7171patchedDependencies:
7272 '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f
......@@ -193,7 +193,7 @@ importers:
193193 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)
194194 '@clerk/expo':
195195 specifier: 3.7.2
196 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)
196 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)
197197 '@effect/atom-react':
198198 specifier: 4.0.0-beta.78
199199 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:
902902 effect:
903903 specifier: 4.0.0-beta.78
904904 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
905 pngjs:
906 specifier: 7.0.0
907 version: 7.0.0
905908 yaml:
906909 specifier: ^2.9.0
907910 version: 2.9.0
......@@ -909,6 +912,9 @@ importers:
909912 '@effect/vitest':
910913 specifier: 4.0.0-beta.78
911914 version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
915 '@types/pngjs':
916 specifier: 6.0.5
917 version: 6.0.5
912918 vite-plus:
913919 specifier: 'catalog:'
914920 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:
48984904 '@types/node@24.12.4':
48994905 resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
49004906
4907 '@types/pngjs@6.0.5':
4908 resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
4909
49014910 '@types/react-dom@19.2.3':
49024911 resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
49034912 peerDependencies:
......@@ -11472,11 +11481,12 @@ snapshots:
1147211481 electron-store: 8.2.0
1147311482 react-dom: 19.2.6(react@19.2.6)
1147411483
11475 '@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)':
11484 '@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)':
1147611485 dependencies:
1147711486 '@clerk/clerk-js': 6.25.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
1147811487 '@clerk/react': 6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
1147911488 '@clerk/shared': 4.25.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
11489 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
1148011490 base-64: 1.0.0
1148111491 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
1148211492 react: 19.2.3
......@@ -11490,6 +11500,9 @@ snapshots:
1149011500 expo-secure-store: 56.0.4(expo@56.0.12)
1149111501 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))
1149211502 react-dom: 19.2.3(react@19.2.3)
11503 transitivePeerDependencies:
11504 - supports-color
11505 - typescript
1149311506
1149411507 '@clerk/react@6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
1149511508 dependencies:
......@@ -14925,6 +14938,10 @@ snapshots:
1492514938 dependencies:
1492614939 undici-types: 7.16.0
1492714940
14941 '@types/pngjs@6.0.5':
14942 dependencies:
14943 '@types/node': 24.12.4
14944
1492814945 '@types/react-dom@19.2.3(@types/react@19.2.16)':
1492914946 dependencies:
1493014947 '@types/react': 19.2.16
pnpm-workspace.yaml+3
......@@ -92,6 +92,9 @@ overrides:
9292 yaml: "catalog:"
9393
9494packageExtensions:
95 "@clerk/expo@*":
96 dependencies:
97 "@expo/config-plugins": 56.0.9
9598 "@effect/vitest@*":
9699 dependencies:
97100 vite-plus: "catalog:"
scripts/mobile-showcase-environment.ts created+569
......@@ -0,0 +1,569 @@
1// @effect-diagnostics nodeBuiltinImport:off globalDate:off - This host-side fixture creates an isolated local T3 environment.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as NodeSqlite from "node:sqlite";
6import * as NodeUtil from "node:util";
7
8const execFile = NodeUtil.promisify(NodeChildProcess.execFile);
9
10export const SHOWCASE_PROJECT_ID = "t3code";
11export const SHOWCASE_THREAD_ID = "remote-command-center";
12export const SHOWCASE_TERMINAL_ID = "term-1";
13
14export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;
15export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
16
17const PROJECTOR_NAMES = [
18 "projection.projects",
19 "projection.threads",
20 "projection.thread-messages",
21 "projection.thread-proposed-plans",
22 "projection.thread-activities",
23 "projection.thread-sessions",
24 "projection.thread-turns",
25 "projection.checkpoints",
26 "projection.pending-approvals",
27] as const;
28
29const MODEL_SELECTION = JSON.stringify({ instanceId: "codex", model: "gpt-5.4" });
30const PROJECT_SCRIPTS = JSON.stringify([
31 {
32 id: "dev",
33 name: "Dev",
34 command: "pnpm dev",
35 icon: "play",
36 runOnWorktreeCreate: false,
37 },
38 {
39 id: "test",
40 name: "Tests",
41 command: "pnpm test",
42 icon: "test",
43 runOnWorktreeCreate: false,
44 },
45]);
46
47export const SHOWCASE_TERMINAL_BUFFER = [
48 "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m",
49 "$ vp test run --changed",
50 "",
51 " \u001b[38;5;117mt3code-mobile\u001b[0m 184 passed",
52 " \u001b[38;5;213mclient-runtime\u001b[0m 263 passed",
53 " \u001b[38;5;221mserver\u001b[0m 165 passed",
54 "",
55 "\u001b[32m✨ 612 tests passed\u001b[0m · 3 environments online",
56 "",
57 "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m $ ",
58].join("\r\n");
59
60const BASE_ENVIRONMENT_PRESENCE = `export function environmentLabel(count: number): string {
61 return \`${"${count}"} environments\`;
62}
63`;
64
65const UPDATED_ENVIRONMENT_PRESENCE = `const PULSE = ["✦", "✧", "·", "✧"] as const;
66
67export function environmentLabel(connected: number, total: number, frame: number): string {
68 const pulse = PULSE[frame % PULSE.length];
69 return \`${"${pulse} ${connected}/${total}"} ready\`;
70}
71`;
72
73const REMOTE_HANDOFF_CARD = `import { View, Text } from "react-native";
74
75export function RemoteHandoffCard(props: { machine: string; latencyMs: number }) {
76 return (
77 <View className="rounded-2xl bg-surface-2 p-4">
78 <Text className="font-semibold">Ready on {props.machine}</Text>
79 <Text className="text-success">Handoff in {props.latencyMs}ms</Text>
80 </View>
81 );
82}
83`;
84
85const PROJECT_FAVICONS = {
86 t3code: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
87 <rect width="128" height="128" rx="10" fill="#000"/>
88 <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="#fff"/>
89</svg>`,
90 react: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
91 <rect width="64" height="64" rx="15" fill="#20232a"/>
92 <g fill="none" stroke="#61dafb" stroke-width="2.8"><ellipse cx="32" cy="32" rx="25" ry="9"/><ellipse cx="32" cy="32" rx="25" ry="9" transform="rotate(60 32 32)"/><ellipse cx="32" cy="32" rx="25" ry="9" transform="rotate(120 32 32)"/></g>
93 <circle cx="32" cy="32" r="4.8" fill="#61dafb"/>
94</svg>`,
95 linux: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
96 <rect width="64" height="64" rx="15" fill="#f7c948"/>
97 <ellipse cx="32" cy="35" rx="17" ry="22" fill="#202124"/>
98 <ellipse cx="32" cy="40" rx="12" ry="14" fill="#f5f5f2"/>
99 <circle cx="27" cy="24" r="5" fill="white"/><circle cx="37" cy="24" r="5" fill="white"/>
100 <circle cx="28" cy="25" r="2"/><circle cx="36" cy="25" r="2"/>
101 <path d="M27 31l5-4 5 4-5 4z" fill="#f28c28"/><path d="M16 55h14l-7-5zM34 55h14l-7-5z" fill="#f28c28"/>
102</svg>`,
103} as const;
104
105export const SHOWCASE_PROJECTS = [
106 {
107 id: "t3code",
108 title: "T3 Code",
109 directory: "t3code",
110 repositoryUrl: "https://github.com/pingdotgg/t3code.git",
111 favicon: PROJECT_FAVICONS.t3code,
112 },
113 {
114 id: "react",
115 title: "React",
116 directory: "react",
117 repositoryUrl: "https://github.com/facebook/react.git",
118 favicon: PROJECT_FAVICONS.react,
119 },
120 {
121 id: "linux",
122 title: "Linux",
123 directory: "linux",
124 repositoryUrl: "https://github.com/torvalds/linux.git",
125 favicon: PROJECT_FAVICONS.linux,
126 },
127] as const;
128
129export const SHOWCASE_ENVIRONMENTS = [
130 {
131 id: "moonbase-terminal",
132 label: "Moonbase Terminal",
133 projectIds: ["t3code"],
134 },
135 {
136 id: "suspense-station",
137 label: "Suspense Station",
138 projectIds: ["react"],
139 },
140 {
141 id: "kernel-cabin",
142 label: "Kernel Cabin",
143 projectIds: ["linux"],
144 },
145] as const;
146
147export const SHOWCASE_THREADS = [
148 {
149 id: SHOWCASE_THREAD_ID,
150 projectId: "t3code",
151 title: "Make remote coding feel local ✦",
152 branch: "feat/remote-command-center",
153 minutesAgo: 3,
154 request:
155 "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.",
156 response:
157 "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**.",
158 },
159 {
160 id: "pocket-command-center",
161 projectId: "t3code",
162 title: "Put the command center in your pocket",
163 branch: "feat/pocket-command-center",
164 minutesAgo: 21,
165 state: "approval" as const,
166 request: "Make switching between desktop, phone, and tablet feel like one continuous session.",
167 response:
168 "The handoff flow preserves the selected thread, terminal buffer, and working diff. The final motion treatment is ready for approval.",
169 },
170 {
171 id: "buttery-suspense",
172 projectId: "react",
173 title: "Make Suspense transitions buttery",
174 branch: "perf/buttery-suspense",
175 minutesAgo: 12,
176 state: "working" as const,
177 request:
178 "Trace the last few dropped frames in nested Suspense transitions and make them disappear.",
179 response: null,
180 },
181 {
182 id: "hydration-haikus",
183 projectId: "react",
184 title: "Turn hydration warnings into haikus",
185 branch: "dev/hydration-haikus",
186 minutesAgo: 44,
187 request:
188 "Keep hydration errors precise, but make the development copy unexpectedly delightful.",
189 response:
190 "The diagnostics still lead with the exact mismatch and component stack. A tiny optional haiku now closes the expanded explanation.",
191 },
192 {
193 id: "beautiful-boot",
194 projectId: "linux",
195 title: "Make boot logs oddly beautiful",
196 branch: "feat/beautiful-boot",
197 minutesAgo: 34,
198 state: "plan" as const,
199 request:
200 "Design a clearer boot timeline that remains useful over serial and never hides kernel detail.",
201 response:
202 "The plan groups milestones without changing the underlying log stream, preserves plain-text output, and adds zero work to the hot path.",
203 },
204 {
205 id: "scheduler-breathe",
206 projectId: "linux",
207 title: "Let the scheduler breathe",
208 branch: "perf/scheduler-breathe",
209 minutesAgo: 76,
210 request:
211 "Find a calmer balancing strategy for bursty mixed workloads without hurting tail latency.",
212 response:
213 "The new heuristic reduces needless migrations during short bursts while preserving the existing latency guardrails.",
214 },
215] as const;
216
217function minutesBefore(now: number, minutes: number): string {
218 return new Date(now - minutes * 60_000).toISOString();
219}
220
221async function runGit(workspaceRoot: string, args: ReadonlyArray<string>): Promise<void> {
222 await execFile("git", [...args], {
223 cwd: workspaceRoot,
224 env: {
225 ...process.env,
226 GIT_AUTHOR_NAME: "Alex Rivera",
227 GIT_AUTHOR_EMAIL: "alex@lumen.test",
228 GIT_COMMITTER_NAME: "Alex Rivera",
229 GIT_COMMITTER_EMAIL: "alex@lumen.test",
230 },
231 });
232}
233
234async function initializeRepository(input: {
235 readonly workspaceRoot: string;
236 readonly repositoryUrl: string;
237 readonly commitMessage: string;
238}): Promise<void> {
239 await runGit(input.workspaceRoot, ["init", "-b", "main"]);
240 await runGit(input.workspaceRoot, ["remote", "add", "origin", input.repositoryUrl]);
241 await runGit(input.workspaceRoot, ["add", "."]);
242 await runGit(input.workspaceRoot, ["commit", "-m", input.commitMessage]);
243}
244
245async function seedT3CodeWorkspace(workspaceRoot: string): Promise<void> {
246 await NodeFSP.mkdir(NodePath.join(workspaceRoot, "apps/mobile/src/features/home"), {
247 recursive: true,
248 });
249 await NodeFSP.writeFile(
250 NodePath.join(workspaceRoot, "package.json"),
251 `${JSON.stringify({ name: "t3code", private: true, scripts: { test: "vp test" } }, null, 2)}\n`,
252 );
253 await NodeFSP.writeFile(NodePath.join(workspaceRoot, "favicon.svg"), PROJECT_FAVICONS.t3code);
254 await NodeFSP.writeFile(
255 NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"),
256 BASE_ENVIRONMENT_PRESENCE,
257 );
258 await initializeRepository({
259 workspaceRoot,
260 repositoryUrl: "https://github.com/pingdotgg/t3code.git",
261 commitMessage: "Show connected environments",
262 });
263 await runGit(workspaceRoot, ["checkout", "-b", "feat/remote-command-center"]);
264 await NodeFSP.writeFile(
265 NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"),
266 UPDATED_ENVIRONMENT_PRESENCE,
267 );
268 await NodeFSP.writeFile(
269 NodePath.join(workspaceRoot, "apps/mobile/src/features/home/RemoteHandoffCard.tsx"),
270 REMOTE_HANDOFF_CARD,
271 );
272}
273
274async function seedCompanionWorkspace(input: {
275 readonly workspaceRoot: string;
276 readonly title: string;
277 readonly repositoryUrl: string;
278 readonly favicon: string;
279}): Promise<void> {
280 await NodeFSP.mkdir(input.workspaceRoot, { recursive: true });
281 await NodeFSP.writeFile(NodePath.join(input.workspaceRoot, "favicon.svg"), input.favicon);
282 await NodeFSP.writeFile(
283 NodePath.join(input.workspaceRoot, "README.md"),
284 `# ${input.title}\n\nSeeded by the T3 Code mobile screenshot harness.\n`,
285 );
286 await initializeRepository({
287 workspaceRoot: input.workspaceRoot,
288 repositoryUrl: input.repositoryUrl,
289 commitMessage: `Seed ${input.title} workspace`,
290 });
291}
292
293function insertThread(
294 database: NodeSqlite.DatabaseSync,
295 now: number,
296 input: {
297 readonly id: string;
298 readonly projectId: string;
299 readonly title: string;
300 readonly branch: string;
301 readonly minutesAgo: number;
302 readonly state?: "working" | "approval" | "plan";
303 readonly workspaceRoot: string;
304 },
305): void {
306 const turnId = `${input.id}-turn`;
307 const updatedAt = minutesBefore(now, input.minutesAgo);
308 const isWorking = input.state === "working";
309 database
310 .prepare(
311 `INSERT INTO projection_threads (
312 thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode,
313 branch, worktree_path, latest_turn_id, latest_user_message_at, pending_approval_count,
314 pending_user_input_count, has_actionable_proposed_plan, created_at, updated_at,
315 archived_at, deleted_at
316 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL)`,
317 )
318 .run(
319 input.id,
320 input.projectId,
321 input.title,
322 MODEL_SELECTION,
323 "full-access",
324 input.state === "plan" ? "plan" : "default",
325 input.branch,
326 input.workspaceRoot,
327 turnId,
328 minutesBefore(now, input.minutesAgo + 1),
329 input.state === "approval" ? 1 : 0,
330 input.state === "plan" ? 1 : 0,
331 minutesBefore(now, input.minutesAgo + 120),
332 updatedAt,
333 );
334 database
335 .prepare(
336 `INSERT INTO projection_turns (
337 thread_id, turn_id, pending_message_id, assistant_message_id, state, requested_at,
338 started_at, completed_at, checkpoint_turn_count, checkpoint_ref, checkpoint_status,
339 checkpoint_files_json, source_proposed_plan_thread_id, source_proposed_plan_id
340 ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, NULL, NULL, NULL, '[]', NULL, NULL)`,
341 )
342 .run(
343 input.id,
344 turnId,
345 isWorking ? null : `${input.id}-answer`,
346 isWorking ? "running" : "completed",
347 minutesBefore(now, input.minutesAgo + 2),
348 minutesBefore(now, input.minutesAgo + 2),
349 isWorking ? null : updatedAt,
350 );
351 database
352 .prepare(
353 `INSERT INTO projection_thread_sessions (
354 thread_id, status, provider_name, provider_instance_id, provider_session_id,
355 provider_thread_id, runtime_mode, active_turn_id, last_error, updated_at
356 ) VALUES (?, ?, 'Codex', 'codex', NULL, NULL, 'full-access', ?, NULL, ?)`,
357 )
358 .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt);
359}
360
361function seedDatabase(
362 dbPath: string,
363 workspaceRoots: ReadonlyMap<string, string>,
364 projects: ReadonlyArray<(typeof SHOWCASE_PROJECTS)[number]>,
365 threads: ReadonlyArray<(typeof SHOWCASE_THREADS)[number]>,
366 now: number,
367): void {
368 const database = new NodeSqlite.DatabaseSync(dbPath);
369 try {
370 database.exec("BEGIN IMMEDIATE");
371 for (const table of [
372 "projection_pending_approvals",
373 "projection_thread_proposed_plans",
374 "projection_thread_activities",
375 "projection_thread_messages",
376 "projection_thread_sessions",
377 "projection_turns",
378 "projection_threads",
379 "projection_projects",
380 "projection_state",
381 ]) {
382 database.exec(`DELETE FROM ${table}`);
383 }
384 const insertProject = database.prepare(
385 `INSERT INTO projection_projects (
386 project_id, title, workspace_root, default_model_selection_json, scripts_json,
387 created_at, updated_at, deleted_at
388 ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`,
389 );
390 for (const [index, project] of projects.entries()) {
391 const workspaceRoot = workspaceRoots.get(project.id);
392 if (!workspaceRoot) throw new Error(`Missing workspace root for ${project.id}.`);
393 const latestThreadMinutes = Math.min(
394 ...threads
395 .filter((thread) => thread.projectId === project.id)
396 .map((thread) => thread.minutesAgo),
397 );
398 insertProject.run(
399 project.id,
400 project.title,
401 workspaceRoot,
402 MODEL_SELECTION,
403 PROJECT_SCRIPTS,
404 minutesBefore(now, 60 * 24 * (90 - index * 12)),
405 minutesBefore(now, latestThreadMinutes),
406 );
407 }
408
409 for (const thread of threads) {
410 const workspaceRoot = workspaceRoots.get(thread.projectId);
411 if (!workspaceRoot) throw new Error(`Missing workspace root for ${thread.projectId}.`);
412 insertThread(database, now, {
413 ...thread,
414 ...("state" in thread ? { state: thread.state } : {}),
415 workspaceRoot,
416 });
417 }
418
419 const insertMessage = database.prepare(
420 `INSERT INTO projection_thread_messages (
421 message_id, thread_id, turn_id, role, text, is_streaming, attachments_json,
422 created_at, updated_at
423 ) VALUES (?, ?, ?, ?, ?, 0, NULL, ?, ?)`,
424 );
425 for (const thread of threads) {
426 const turnId = `${thread.id}-turn`;
427 const requestTime = minutesBefore(now, thread.minutesAgo + 5);
428 insertMessage.run(
429 `${thread.id}-request`,
430 thread.id,
431 turnId,
432 "user",
433 thread.request,
434 requestTime,
435 requestTime,
436 );
437 if (thread.response !== null) {
438 const responseTime = minutesBefore(now, thread.minutesAgo);
439 insertMessage.run(
440 `${thread.id}-answer`,
441 thread.id,
442 turnId,
443 "assistant",
444 thread.response,
445 responseTime,
446 responseTime,
447 );
448 }
449 }
450
451 const turnId = `${SHOWCASE_THREAD_ID}-turn`;
452 const insertActivity = database.prepare(
453 `INSERT INTO projection_thread_activities (
454 activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at
455 ) VALUES (?, ?, ?, 'tool', 'tool.completed', ?, ?, ?, ?)`,
456 );
457 insertActivity.run(
458 "trace-remote-handoff",
459 SHOWCASE_THREAD_ID,
460 turnId,
461 "Traced the remote handoff path",
462 JSON.stringify({
463 itemType: "command_execution",
464 title: "Traced the remote handoff path",
465 detail: "Three environments, one continuous workspace",
466 status: "completed",
467 }),
468 1,
469 minutesBefore(now, 8),
470 );
471 insertActivity.run(
472 "sync-command-center",
473 SHOWCASE_THREAD_ID,
474 turnId,
475 "Synced the command center",
476 JSON.stringify({
477 itemType: "file_change",
478 title: "Synced the command center",
479 detail: "2 files changed · instant handoffs · calm reconnects",
480 status: "completed",
481 }),
482 2,
483 minutesBefore(now, 6),
484 );
485 insertActivity.run(
486 "run-changed-suite",
487 SHOWCASE_THREAD_ID,
488 turnId,
489 "Ran the changed workspace",
490 JSON.stringify({
491 itemType: "command_execution",
492 title: "Ran the changed workspace",
493 detail: "612 tests passed · 3 environments online",
494 status: "completed",
495 }),
496 3,
497 minutesBefore(now, 4),
498 );
499
500 for (const [index, projector] of PROJECTOR_NAMES.entries()) {
501 database
502 .prepare(
503 "INSERT INTO projection_state (projector, last_applied_sequence, updated_at) VALUES (?, ?, ?)",
504 )
505 .run(projector, index + 1, minutesBefore(now, 1));
506 }
507 database.exec("COMMIT");
508 } catch (error) {
509 database.exec("ROLLBACK");
510 throw error;
511 } finally {
512 database.close();
513 }
514}
515
516export async function seedShowcaseEnvironment(input: {
517 readonly baseDir: string;
518 readonly projectIds?: ReadonlyArray<string>;
519 readonly now?: number;
520}): Promise<{ readonly dbPath: string; readonly workspaceRoot: string }> {
521 const now = input.now ?? Date.now();
522 const selectedProjectIds = new Set(
523 input.projectIds ?? SHOWCASE_PROJECTS.map((project) => project.id),
524 );
525 const projects = SHOWCASE_PROJECTS.filter((project) => selectedProjectIds.has(project.id));
526 if (projects.length === 0) throw new Error("At least one showcase project must be selected.");
527 const threads = SHOWCASE_THREADS.filter((thread) => selectedProjectIds.has(thread.projectId));
528 const workspaceBase = NodePath.join(input.baseDir, "workspace");
529 const workspaceRoots = new Map(
530 projects.map(
531 (project) => [project.id, NodePath.join(workspaceBase, project.directory)] as const,
532 ),
533 );
534 const primaryProject =
535 projects.find((project) => project.id === SHOWCASE_PROJECT_ID) ?? projects[0];
536 if (!primaryProject) throw new Error("The primary showcase workspace is not configured.");
537 const workspaceRoot = workspaceRoots.get(primaryProject.id);
538 if (!workspaceRoot) throw new Error("The primary showcase workspace is not configured.");
539 const dbPath = NodePath.join(input.baseDir, "userdata", "state.sqlite");
540 if (primaryProject.id === SHOWCASE_PROJECT_ID) {
541 await seedT3CodeWorkspace(workspaceRoot);
542 }
543 await Promise.all(
544 projects
545 .filter((project) => project.id !== SHOWCASE_PROJECT_ID)
546 .map(async (project) => {
547 const projectWorkspaceRoot = workspaceRoots.get(project.id);
548 if (!projectWorkspaceRoot) throw new Error(`Missing workspace root for ${project.id}.`);
549 await seedCompanionWorkspace({
550 workspaceRoot: projectWorkspaceRoot,
551 title: project.title,
552 repositoryUrl: project.repositoryUrl,
553 favicon: project.favicon,
554 });
555 }),
556 );
557 seedDatabase(dbPath, workspaceRoots, projects, threads, now);
558
559 const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals");
560 if (selectedProjectIds.has(SHOWCASE_PROJECT_ID)) {
561 const safeThreadId = Buffer.from(SHOWCASE_THREAD_ID).toString("base64url");
562 await NodeFSP.mkdir(terminalDirectory, { recursive: true });
563 await NodeFSP.writeFile(
564 NodePath.join(terminalDirectory, `terminal_${safeThreadId}.log`),
565 SHOWCASE_TERMINAL_BUFFER,
566 );
567 }
568 return { dbPath, workspaceRoot };
569}
scripts/mobile-showcase.config.ts created+206
......@@ -0,0 +1,206 @@
1import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts";
2
3export { SHOWCASE_SCENES };
4export type { ShowcaseScene };
5
6export type ShowcaseAppearance = "light" | "dark";
7
8export interface ShowcaseStoreAssetSpec {
9 readonly store: "apple" | "google-play";
10 /** Device directory relative to ShowcaseConfig.outputDirectory. */
11 readonly directory: string;
12 readonly width: number;
13 readonly height: number;
14 readonly minimumUploadCount: number;
15 readonly maximumUploadCount: number;
16 readonly maximumFileSizeBytes?: number;
17}
18
19export interface ShowcaseIosDevice {
20 readonly id: string;
21 readonly platform: "ios";
22 /** Exact name from `xcrun simctl list devices available`. */
23 readonly simulator: string;
24 /** Device type used to create a disposable simulator when the named one is absent. */
25 readonly simulatorDeviceType?: string;
26 /** Appearance used when the CLI does not pass --appearance. */
27 readonly appearance: ShowcaseAppearance;
28 readonly scenes: ReadonlyArray<ShowcaseScene>;
29 readonly storeAsset: ShowcaseStoreAssetSpec;
30}
31
32export interface ShowcaseAndroidDevice {
33 readonly id: string;
34 readonly platform: "android";
35 /** Exact name from `emulator -list-avds`. */
36 readonly avd: string;
37 /** Appearance used when the CLI does not pass --appearance. */
38 readonly appearance: ShowcaseAppearance;
39 /** Native ABI used by the AVD, from its config.ini `abi.type`. */
40 readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a";
41 readonly scenes: ReadonlyArray<ShowcaseScene>;
42 /** Optional capture viewport. Omit to use the AVD's native size and density. */
43 readonly viewport?: {
44 readonly width: number;
45 readonly height: number;
46 readonly density?: number;
47 };
48 readonly storeAsset: ShowcaseStoreAssetSpec;
49}
50
51export type ShowcaseDevice = ShowcaseIosDevice | ShowcaseAndroidDevice;
52
53export interface ShowcaseConfig {
54 readonly outputDirectory: string;
55 readonly metroPort: number;
56 readonly settleDelayMs: number;
57 readonly devices: ReadonlyArray<ShowcaseDevice>;
58}
59
60const ANDROID_ABIS = ["arm64-v8a", "x86_64", "x86", "armeabi-v7a"] as const;
61
62export function resolveShowcaseAndroidAbi(
63 value: string | undefined,
64): NonNullable<ShowcaseAndroidDevice["abi"]> {
65 if (!value) return "arm64-v8a";
66 if (ANDROID_ABIS.some((abi) => abi === value)) {
67 return value as NonNullable<ShowcaseAndroidDevice["abi"]>;
68 }
69 throw new Error(
70 `Unsupported T3_SHOWCASE_ANDROID_ABI '${value}'. Use ${ANDROID_ABIS.join(", ")}.`,
71 );
72}
73
74/**
75 * The defaults cover every App Store Connect and Google Play upload slot used
76 * by the mobile app. Edit this matrix (or pass --device / --scene) without
77 * changing the runner. Every target declares and validates its exact upload
78 * dimensions so SDK or emulator changes cannot silently produce invalid files.
79 */
80const config: ShowcaseConfig = {
81 outputDirectory: "artifacts/app-store/screenshots",
82 // Dedicated port so the harness cannot attach to a normal mobile dev server
83 // (or a second worktree) and capture the wrong bundle.
84 metroPort: 8199,
85 settleDelayMs: 2_500,
86 devices: [
87 {
88 id: "iphone-6.9",
89 platform: "ios",
90 simulator: "iPhone 17 Pro Max",
91 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max",
92 appearance: "dark",
93 scenes: ["thread", "terminal", "review", "threads", "environments"],
94 storeAsset: {
95 store: "apple",
96 directory: "apple/iphone-6.9",
97 width: 1320,
98 height: 2868,
99 minimumUploadCount: 1,
100 maximumUploadCount: 10,
101 },
102 },
103 {
104 id: "iphone-6.5",
105 platform: "ios",
106 simulator: "T3 Showcase iPhone 14 Plus",
107 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus",
108 appearance: "dark",
109 scenes: ["thread", "terminal", "review", "threads", "environments"],
110 storeAsset: {
111 store: "apple",
112 directory: "apple/iphone-6.5",
113 width: 1284,
114 height: 2778,
115 minimumUploadCount: 1,
116 maximumUploadCount: 10,
117 },
118 },
119 {
120 id: "ipad-13",
121 platform: "ios",
122 simulator: "iPad Pro 13-inch (M5)",
123 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB",
124 appearance: "dark",
125 scenes: ["thread", "terminal", "review", "threads", "environments"],
126 storeAsset: {
127 store: "apple",
128 directory: "apple/ipad-13",
129 width: 2064,
130 height: 2752,
131 minimumUploadCount: 1,
132 maximumUploadCount: 10,
133 },
134 },
135 {
136 id: "pixel",
137 platform: "android",
138 avd: "Pixel_10_Pro",
139 // Apple Silicon uses ARM64 locally; CI overrides this with x86_64 so its
140 // Blacksmith Linux runner can use KVM acceleration.
141 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
142 appearance: "dark",
143 viewport: {
144 width: 1080,
145 height: 1920,
146 density: 420,
147 },
148 scenes: ["thread", "terminal", "review", "threads", "environments"],
149 storeAsset: {
150 store: "google-play",
151 directory: "google-play/phone",
152 width: 1080,
153 height: 1920,
154 minimumUploadCount: 2,
155 maximumUploadCount: 8,
156 maximumFileSizeBytes: 8 * 1024 * 1024,
157 },
158 },
159 {
160 id: "android-tablet-7",
161 platform: "android",
162 avd: "Pixel_10_Pro",
163 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
164 appearance: "dark",
165 viewport: {
166 width: 1080,
167 height: 1920,
168 density: 288,
169 },
170 scenes: ["thread", "terminal", "review", "threads", "environments"],
171 storeAsset: {
172 store: "google-play",
173 directory: "google-play/tablet-7",
174 width: 1080,
175 height: 1920,
176 minimumUploadCount: 4,
177 maximumUploadCount: 8,
178 maximumFileSizeBytes: 8 * 1024 * 1024,
179 },
180 },
181 {
182 id: "android-tablet-10",
183 platform: "android",
184 avd: "Pixel_10_Pro",
185 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
186 appearance: "dark",
187 viewport: {
188 width: 1440,
189 height: 2560,
190 density: 288,
191 },
192 scenes: ["thread", "terminal", "review", "threads", "environments"],
193 storeAsset: {
194 store: "google-play",
195 directory: "google-play/tablet-10",
196 width: 1440,
197 height: 2560,
198 minimumUploadCount: 4,
199 maximumUploadCount: 8,
200 maximumFileSizeBytes: 8 * 1024 * 1024,
201 },
202 },
203 ],
204};
205
206export default config;
scripts/mobile-showcase.test.ts created+296
......@@ -0,0 +1,296 @@
1import { assert, it } from "@effect/vitest";
2import { PNG } from "pngjs";
3
4import showcaseConfig, {
5 resolveShowcaseAndroidAbi,
6 type ShowcaseConfig,
7 type ShowcaseStoreAssetSpec,
8} from "./mobile-showcase.config.ts";
9import {
10 SHOWCASE_ENVIRONMENTS,
11 SHOWCASE_PROJECTS,
12 SHOWCASE_THREADS,
13} from "./mobile-showcase-environment.ts";
14import {
15 encodeAndroidPairingUrls,
16 normalizeStorePng,
17 parseShowcaseCliArgs,
18 parsePairingCredentialOutput,
19 planShowcaseCaptures,
20 readPngDimensions,
21 readPngMetadata,
22 resolveAndroidSdkRoot,
23 selectLanIpv4Address,
24 showcaseCaptureDirectory,
25 showcaseSceneUrl,
26 validateStoreAsset,
27 validateStoreAssetCount,
28} from "./mobile-showcase.ts";
29
30const appleSpec: ShowcaseStoreAssetSpec = {
31 store: "apple",
32 directory: "apple/iphone-test",
33 width: 1284,
34 height: 2778,
35 minimumUploadCount: 1,
36 maximumUploadCount: 10,
37};
38
39const googleSpec: ShowcaseStoreAssetSpec = {
40 store: "google-play",
41 directory: "google-play/phone",
42 width: 1080,
43 height: 1920,
44 minimumUploadCount: 2,
45 maximumUploadCount: 8,
46 maximumFileSizeBytes: 8 * 1024 * 1024,
47};
48
49const config: ShowcaseConfig = {
50 outputDirectory: "artifacts",
51 metroPort: 8199,
52 settleDelayMs: 1,
53 devices: [
54 {
55 id: "phone",
56 platform: "ios",
57 simulator: "iPhone Test",
58 appearance: "dark",
59 scenes: ["thread", "review"],
60 storeAsset: appleSpec,
61 },
62 {
63 id: "pixel",
64 platform: "android",
65 avd: "Pixel_Test",
66 appearance: "light",
67 scenes: ["thread", "terminal"],
68 storeAsset: googleSpec,
69 },
70 ],
71};
72
73it("parses repeatable capture filters", () => {
74 const options = parseShowcaseCliArgs([
75 "--platform",
76 "ios",
77 "--device",
78 "phone",
79 "--scene",
80 "review",
81 "--appearance",
82 "both",
83 "--skip-build",
84 ]);
85 assert.deepStrictEqual([...options.platforms], ["ios"]);
86 assert.deepStrictEqual([...options.deviceIds], ["phone"]);
87 assert.deepStrictEqual([...options.scenes], ["review"]);
88 assert.deepStrictEqual([...options.appearances], ["light", "dark"]);
89 assert.equal(options.skipBuild, true);
90});
91
92it("rejects unsupported system appearances", () => {
93 assert.throws(
94 () => parseShowcaseCliArgs(["--appearance", "sepia"]),
95 /Unsupported appearance 'sepia'/u,
96 );
97});
98
99it("parses validation-only mode", () => {
100 assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true);
101});
102
103it("selects an explicit CI Android ABI without changing the local default", () => {
104 assert.equal(resolveShowcaseAndroidAbi(undefined), "arm64-v8a");
105 assert.equal(resolveShowcaseAndroidAbi("x86_64"), "x86_64");
106 assert.throws(() => resolveShowcaseAndroidAbi("mips"), /Unsupported T3_SHOWCASE_ANDROID_ABI/u);
107});
108
109it("uses platform-correct default Android SDK roots", () => {
110 assert.equal(
111 resolveAndroidSdkRoot({ HOME: "/Users/showcase" }, "darwin"),
112 "/Users/showcase/Library/Android/sdk",
113 );
114 assert.equal(
115 resolveAndroidSdkRoot({ HOME: "/home/showcase" }, "linux"),
116 "/home/showcase/Android/Sdk",
117 );
118 assert.equal(
119 resolveAndroidSdkRoot(
120 { HOME: "/home/showcase", ANDROID_SDK_ROOT: "/opt/android-sdk" },
121 "linux",
122 ),
123 "/opt/android-sdk",
124 );
125});
126
127it("plans only scenes supported by each selected device", () => {
128 const options = parseShowcaseCliArgs(["--platform", "all", "--scene", "terminal"]);
129 const captures = planShowcaseCaptures(config, options);
130 assert.deepStrictEqual(
131 captures.map((capture) => ({
132 id: capture.device.id,
133 appearance: capture.appearance,
134 scenes: capture.scenes,
135 })),
136 [{ id: "pixel", appearance: "light", scenes: ["terminal"] }],
137 );
138});
139
140it("expands both appearances into independent upload-ready directories", () => {
141 const options = parseShowcaseCliArgs(["--device", "phone", "--appearance", "both"]);
142 const captures = planShowcaseCaptures(config, options);
143
144 assert.deepStrictEqual(
145 captures.map((capture) => ({
146 appearance: capture.appearance,
147 directory: showcaseCaptureDirectory("/captures", capture),
148 })),
149 [
150 { appearance: "light", directory: "/captures/apple/iphone-test/light" },
151 { appearance: "dark", directory: "/captures/apple/iphone-test/dark" },
152 ],
153 );
154});
155
156it("rejects unknown devices instead of silently capturing another target", () => {
157 const options = parseShowcaseCliArgs(["--device", "missing"]);
158 assert.throws(() => planShowcaseCaptures(config, options), /Unknown device 'missing'/u);
159});
160
161it("reads captured PNG dimensions from the IHDR header", () => {
162 const bytes = new Uint8Array(26);
163 bytes.set([137, 80, 78, 71, 13, 10, 26, 10]);
164 const view = new DataView(bytes.buffer);
165 view.setUint32(16, 1320);
166 view.setUint32(20, 2868);
167 view.setUint8(24, 8);
168 view.setUint8(25, 2);
169 assert.deepStrictEqual(readPngDimensions(bytes), { width: 1320, height: 2868 });
170 assert.deepStrictEqual(readPngMetadata(bytes), {
171 width: 1320,
172 height: 2868,
173 bitDepth: 8,
174 colorType: 2,
175 hasAlpha: false,
176 });
177});
178
179function rgbaPng(width: number, height: number): Buffer {
180 const png = new PNG({ width, height });
181 png.data.fill(255);
182 return PNG.sync.write(png);
183}
184
185it("converts simulator RGBA captures to upload-safe 24-bit RGB PNGs", () => {
186 const normalized = normalizeStorePng(rgbaPng(2, 3));
187 assert.deepStrictEqual(readPngMetadata(normalized), {
188 width: 2,
189 height: 3,
190 bitDepth: 8,
191 colorType: 2,
192 hasAlpha: false,
193 });
194});
195
196it("validates exact Apple and Google Play upload assets", () => {
197 const apple = normalizeStorePng(rgbaPng(appleSpec.width, appleSpec.height));
198 const google = normalizeStorePng(rgbaPng(googleSpec.width, googleSpec.height));
199 assert.equal(validateStoreAsset(appleSpec, apple).width, 1284);
200 assert.equal(validateStoreAsset(googleSpec, google).height, 1920);
201});
202
203it("rejects wrong dimensions and alpha-bearing PNGs", () => {
204 const wrongSize = normalizeStorePng(rgbaPng(1242, 2688));
205 assert.throws(() => validateStoreAsset(appleSpec, wrongSize), /requires 1284×2778/u);
206 assert.throws(() => validateStoreAsset(appleSpec, rgbaPng(1284, 2778)), /without alpha/u);
207});
208
209it("enforces store screenshot count limits", () => {
210 assert.doesNotThrow(() => validateStoreAssetCount(googleSpec, 5, true));
211 assert.throws(() => validateStoreAssetCount(googleSpec, 1, true), /requires at least 2/u);
212 assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u);
213});
214
215it("configures every default device with an exact upload-ready store target", () => {
216 assert.deepStrictEqual(
217 showcaseConfig.devices.map((device) => [
218 device.id,
219 device.storeAsset.directory,
220 device.storeAsset.width,
221 device.storeAsset.height,
222 ]),
223 [
224 ["iphone-6.9", "apple/iphone-6.9", 1320, 2868],
225 ["iphone-6.5", "apple/iphone-6.5", 1284, 2778],
226 ["ipad-13", "apple/ipad-13", 2064, 2752],
227 ["pixel", "google-play/phone", 1080, 1920],
228 ["android-tablet-7", "google-play/tablet-7", 1080, 1920],
229 ["android-tablet-10", "google-play/tablet-10", 1440, 2560],
230 ],
231 );
232});
233
234it("selects a reachable LAN IPv4 address", () => {
235 assert.equal(
236 selectLanIpv4Address([
237 { address: "127.0.0.1", family: "IPv4", internal: true },
238 { address: "fe80::1", family: "IPv6", internal: false },
239 { address: "169.254.2.4", family: "IPv4", internal: false },
240 { address: "192.168.1.80", family: "IPv4", internal: false },
241 ]),
242 "192.168.1.80",
243 );
244});
245
246it("maps capture scenes to the real application routes", () => {
247 assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code-dev://");
248 assert.equal(
249 showcaseSceneUrl("environments", "environment-1"),
250 "t3code-dev://settings/environments",
251 );
252 assert.equal(
253 showcaseSceneUrl("thread", "environment-1"),
254 "t3code-dev://threads/environment-1/remote-command-center",
255 );
256 assert.equal(
257 showcaseSceneUrl("terminal", "environment-1"),
258 "t3code-dev://threads/environment-1/remote-command-center/terminal?terminalId=term-1",
259 );
260 assert.equal(
261 showcaseSceneUrl("review", "environment-1"),
262 "t3code-dev://threads/environment-1/remote-command-center/review",
263 );
264});
265
266it("seeds a playful multi-environment project spectrum", () => {
267 assert.deepStrictEqual(
268 SHOWCASE_PROJECTS.map((project) => project.title),
269 ["T3 Code", "React", "Linux"],
270 );
271 assert.deepStrictEqual(
272 SHOWCASE_ENVIRONMENTS.map((environment) => environment.label),
273 ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"],
274 );
275 assert.equal(SHOWCASE_THREADS.length, 6);
276 assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3);
277 assert.equal(
278 SHOWCASE_PROJECTS.every((project) => project.favicon.includes("<svg")),
279 true,
280 );
281});
282
283it("reads multiline JSON from the pairing CLI", () => {
284 assert.equal(
285 parsePairingCredentialOutput('server log\n{\n "credential": "PAIR-ME"\n}\n'),
286 "PAIR-ME",
287 );
288});
289
290it("encodes Android pairing URLs without shell-sensitive JSON quotes", () => {
291 const urls = ["http://10.0.2.2:65164/#token=ONE", "http://10.0.2.2:65198/#token=TWO"];
292 const encoded = encodeAndroidPairingUrls(urls);
293 assert.equal(encoded.startsWith("json-uri:"), true);
294 assert.deepStrictEqual(JSON.parse(decodeURIComponent(encoded.slice("json-uri:".length))), urls);
295 assert.equal(encoded.includes('"'), false);
296});
scripts/mobile-showcase.ts created+1344
......@@ -0,0 +1,1344 @@
1#!/usr/bin/env node
2// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Host-side simulator and emulator automation uses Node subprocess and timing APIs directly.
3
4import * as NodeChildProcess from "node:child_process";
5import * as NodeFSP from "node:fs/promises";
6import * as NodeNet from "node:net";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import * as NodeProcess from "node:process";
10import * as NodeURL from "node:url";
11
12import { PNG } from "pngjs";
13
14import showcaseConfig, {
15 type ShowcaseAppearance,
16 type ShowcaseAndroidDevice,
17 type ShowcaseConfig,
18 type ShowcaseDevice,
19 type ShowcaseIosDevice,
20 type ShowcaseStoreAssetSpec,
21 SHOWCASE_SCENES,
22 type ShowcaseScene,
23} from "./mobile-showcase.config.ts";
24import {
25 SHOWCASE_ENVIRONMENTS,
26 SHOWCASE_PROJECTS,
27 SHOWCASE_TERMINAL_ID,
28 SHOWCASE_THREAD_ID,
29 seedShowcaseEnvironment,
30} from "./mobile-showcase-environment.ts";
31
32const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");
33const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile");
34const ANDROID_PACKAGE = "com.t3tools.t3code.dev";
35const APP_SCHEME = "t3code-dev";
36const IOS_READY_FILENAME = "T3ShowcaseReadyScene";
37const SERVER_HOST = "0.0.0.0";
38const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64";
39const IOS_APP_PATH = NodePath.join(
40 MOBILE_ROOT,
41 ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3CodeDev.app",
42);
43const ANDROID_APK_PATH = NodePath.join(
44 MOBILE_ROOT,
45 "android/app/build/outputs/apk/debug/app-debug.apk",
46);
47export function resolveAndroidSdkRoot(
48 environment: Readonly<Record<string, string | undefined>>,
49 platform: NodeJS.Platform = NodeProcess.platform,
50): string {
51 const configured = environment.ANDROID_HOME ?? environment.ANDROID_SDK_ROOT;
52 if (configured) return configured;
53 const home = environment.HOME ?? environment.USERPROFILE ?? "";
54 return NodePath.join(home, platform === "darwin" ? "Library/Android/sdk" : "Android/Sdk");
55}
56
57const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env);
58const MOBILE_BUILD_ENV = {
59 ...NodeProcess.env,
60 ANDROID_HOME: ANDROID_SDK_ROOT,
61 APP_VARIANT: "development",
62 EXPO_NO_GIT_STATUS: "1",
63 JAVA_HOME:
64 NodeProcess.env.JAVA_HOME ??
65 (NodeProcess.platform === "darwin"
66 ? "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
67 : undefined),
68 NODE_ENV: "development",
69};
70
71interface CliOptions {
72 readonly platforms: ReadonlySet<ShowcaseDevice["platform"]>;
73 readonly deviceIds: ReadonlySet<string>;
74 readonly scenes: ReadonlySet<ShowcaseScene>;
75 readonly appearances: ReadonlySet<ShowcaseAppearance>;
76 readonly skipBuild: boolean;
77 readonly skipMetro: boolean;
78 readonly keepRunning: boolean;
79 readonly validateOnly: boolean;
80 readonly list: boolean;
81}
82
83export interface ShowcaseCapture {
84 readonly device: ShowcaseDevice;
85 readonly scenes: ReadonlyArray<ShowcaseScene>;
86 readonly appearance: ShowcaseAppearance;
87}
88
89interface IosCaptureCleanup {
90 readonly udid: string;
91 readonly startedByRunner: boolean;
92 readonly createdByRunner: boolean;
93}
94
95interface AndroidCaptureCleanup {
96 readonly device: ShowcaseAndroidDevice;
97 readonly serial: string;
98 readonly startedByRunner: boolean;
99}
100
101interface NetworkAddress {
102 readonly address: string;
103 readonly family: string;
104 readonly internal: boolean;
105}
106
107export function selectLanIpv4Address(addresses: ReadonlyArray<NetworkAddress>): string | null {
108 return (
109 addresses.find(
110 ({ address, family, internal }) =>
111 family === "IPv4" && !internal && !address.startsWith("169.254."),
112 )?.address ?? null
113 );
114}
115
116function lanIpv4Address(): string {
117 const address = selectLanIpv4Address(
118 Object.values(NodeOS.networkInterfaces()).flatMap((addresses) => addresses ?? []),
119 );
120 if (!address) {
121 throw new Error("No LAN IPv4 address is available for the iOS Simulator to reach Metro.");
122 }
123 return address;
124}
125
126export interface PngMetadata {
127 readonly width: number;
128 readonly height: number;
129 readonly bitDepth: number;
130 readonly colorType: number;
131 readonly hasAlpha: boolean;
132}
133
134export function readPngMetadata(bytes: Uint8Array): PngMetadata {
135 const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
136 if (bytes.byteLength < 26 || !pngSignature.every((value, index) => bytes[index] === value)) {
137 throw new Error("Captured file is not a valid PNG.");
138 }
139 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
140 const colorType = view.getUint8(25);
141 return {
142 width: view.getUint32(16),
143 height: view.getUint32(20),
144 bitDepth: view.getUint8(24),
145 colorType,
146 hasAlpha: colorType === 4 || colorType === 6,
147 };
148}
149
150export function readPngDimensions(bytes: Uint8Array): {
151 readonly width: number;
152 readonly height: number;
153} {
154 const { width, height } = readPngMetadata(bytes);
155 return { width, height };
156}
157
158export function normalizeStorePng(bytes: Uint8Array): Buffer {
159 const png = PNG.sync.read(Buffer.from(bytes));
160 return PNG.sync.write(png, {
161 bitDepth: 8,
162 colorType: 2,
163 inputColorType: 6,
164 inputHasAlpha: true,
165 });
166}
167
168export function validateStoreAsset(
169 spec: ShowcaseStoreAssetSpec,
170 bytes: Uint8Array,
171 label = "Screenshot",
172): PngMetadata {
173 const metadata = readPngMetadata(bytes);
174 if (metadata.width !== spec.width || metadata.height !== spec.height) {
175 throw new Error(
176 `${label} is ${metadata.width}×${metadata.height}; ${spec.store} requires ${spec.width}×${spec.height}.`,
177 );
178 }
179 if (metadata.bitDepth !== 8 || metadata.colorType !== 2 || metadata.hasAlpha) {
180 throw new Error(
181 `${label} must be an 8-bit, 24-bit RGB PNG without alpha (found bit depth ${metadata.bitDepth}, color type ${metadata.colorType}).`,
182 );
183 }
184 if (spec.maximumFileSizeBytes && bytes.byteLength > spec.maximumFileSizeBytes) {
185 throw new Error(
186 `${label} is ${bytes.byteLength} bytes; ${spec.store} allows at most ${spec.maximumFileSizeBytes} bytes.`,
187 );
188 }
189 if (spec.store === "google-play") {
190 const shortestSide = Math.min(metadata.width, metadata.height);
191 const longestSide = Math.max(metadata.width, metadata.height);
192 if (shortestSide < 320 || longestSide > 3_840 || longestSide > shortestSide * 2) {
193 throw new Error(
194 `${label} does not meet Google Play's 320–3,840 px bounds and 2:1 maximum aspect ratio.`,
195 );
196 }
197 if (metadata.width * 16 !== metadata.height * 9) {
198 throw new Error(`${label} must use Google Play's recommended portrait 9:16 aspect ratio.`);
199 }
200 }
201 return metadata;
202}
203
204export function validateStoreAssetCount(
205 spec: ShowcaseStoreAssetSpec,
206 count: number,
207 requireMinimum: boolean,
208): void {
209 if (count > spec.maximumUploadCount) {
210 throw new Error(
211 `${spec.directory} contains ${count} screenshots; ${spec.store} allows at most ${spec.maximumUploadCount}.`,
212 );
213 }
214 if (requireMinimum && count < spec.minimumUploadCount) {
215 throw new Error(
216 `${spec.directory} contains ${count} screenshots; ${spec.store} requires at least ${spec.minimumUploadCount}.`,
217 );
218 }
219}
220
221export function showcaseCaptureDirectory(
222 outputDirectory: string,
223 capture: Pick<ShowcaseCapture, "device" | "appearance">,
224): string {
225 return NodePath.join(outputDirectory, capture.device.storeAsset.directory, capture.appearance);
226}
227
228async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise<void> {
229 const normalized = normalizeStorePng(await NodeFSP.readFile(destination));
230 await NodeFSP.writeFile(destination, normalized);
231 const metadata = validateStoreAsset(
232 device.storeAsset,
233 normalized,
234 NodePath.basename(destination),
235 );
236 NodeProcess.stdout.write(
237 `Captured ${NodePath.relative(REPO_ROOT, destination)} (${metadata.width}×${metadata.height}, 24-bit RGB, validated for ${device.storeAsset.store})\n`,
238 );
239}
240
241async function validateCaptureSet(
242 capture: ShowcaseCapture,
243 outputDirectory: string,
244 requireMinimum: boolean,
245): Promise<void> {
246 const directory = showcaseCaptureDirectory(outputDirectory, capture);
247 const files = (await NodeFSP.readdir(directory)).filter((file) => file.endsWith(".png")).sort();
248 const expectedFiles = capture.scenes.map((scene) => `${scene}.png`).sort();
249 const missingFiles = expectedFiles.filter((file) => !files.includes(file));
250 if (missingFiles.length > 0) {
251 throw new Error(`${capture.device.id} is missing ${missingFiles.join(", ")} in ${directory}.`);
252 }
253 validateStoreAssetCount(capture.device.storeAsset, files.length, requireMinimum);
254 for (const file of files) {
255 const bytes = await NodeFSP.readFile(NodePath.join(directory, file));
256 validateStoreAsset(capture.device.storeAsset, bytes, `${capture.device.id}/${file}`);
257 }
258 NodeProcess.stdout.write(
259 `Validated ${files.length} upload-ready ${capture.device.storeAsset.store} screenshots in ${NodePath.relative(REPO_ROOT, directory)}/\n`,
260 );
261}
262
263function argumentValue(args: ReadonlyArray<string>, index: number, flag: string): string {
264 const value = args[index + 1];
265 if (!value || value.startsWith("--")) {
266 throw new Error(`${flag} requires a value.`);
267 }
268 return value;
269}
270
271export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
272 const platforms = new Set<ShowcaseDevice["platform"]>();
273 const deviceIds = new Set<string>();
274 const scenes = new Set<ShowcaseScene>();
275 const appearances = new Set<ShowcaseAppearance>();
276 let skipBuild = false;
277 let skipMetro = false;
278 let keepRunning = false;
279 let validateOnly = false;
280 let list = false;
281
282 for (let index = 0; index < args.length; index += 1) {
283 const argument = args[index];
284 if (argument === "--platform") {
285 const value = argumentValue(args, index, argument);
286 if (value !== "ios" && value !== "android" && value !== "all") {
287 throw new Error(`Unsupported platform '${value}'. Use ios, android, or all.`);
288 }
289 if (value === "all") {
290 platforms.add("ios");
291 platforms.add("android");
292 } else {
293 platforms.add(value);
294 }
295 index += 1;
296 } else if (argument === "--device") {
297 deviceIds.add(argumentValue(args, index, argument));
298 index += 1;
299 } else if (argument === "--scene") {
300 const value = argumentValue(args, index, argument);
301 if (!SHOWCASE_SCENES.includes(value as ShowcaseScene)) {
302 throw new Error(`Unsupported scene '${value}'. Use ${SHOWCASE_SCENES.join(", ")}.`);
303 }
304 scenes.add(value as ShowcaseScene);
305 index += 1;
306 } else if (argument === "--appearance") {
307 const value = argumentValue(args, index, argument);
308 if (value !== "light" && value !== "dark" && value !== "both") {
309 throw new Error(`Unsupported appearance '${value}'. Use light, dark, or both.`);
310 }
311 if (value === "both") {
312 appearances.add("light");
313 appearances.add("dark");
314 } else {
315 appearances.add(value);
316 }
317 index += 1;
318 } else if (argument === "--skip-build") {
319 skipBuild = true;
320 } else if (argument === "--skip-metro") {
321 skipMetro = true;
322 } else if (argument === "--keep-running") {
323 keepRunning = true;
324 } else if (argument === "--validate-only") {
325 validateOnly = true;
326 } else if (argument === "--list") {
327 list = true;
328 } else if (argument === "--help" || argument === "-h") {
329 list = true;
330 } else {
331 throw new Error(`Unknown option '${argument}'.`);
332 }
333 }
334
335 return {
336 platforms,
337 deviceIds,
338 scenes,
339 appearances,
340 skipBuild,
341 skipMetro,
342 keepRunning,
343 validateOnly,
344 list,
345 };
346}
347
348export function planShowcaseCaptures(
349 config: ShowcaseConfig,
350 options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances">,
351): ReadonlyArray<ShowcaseCapture> {
352 const captures = config.devices
353 .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform))
354 .filter((device) => options.deviceIds.size === 0 || options.deviceIds.has(device.id))
355 .flatMap((device) => {
356 const appearances =
357 options.appearances.size === 0 ? [device.appearance] : options.appearances;
358 return [...appearances].map((appearance) => ({
359 device,
360 appearance,
361 scenes:
362 options.scenes.size === 0
363 ? device.scenes
364 : device.scenes.filter((scene) => options.scenes.has(scene)),
365 }));
366 })
367 .filter((capture) => capture.scenes.length > 0);
368
369 const knownDeviceIds = new Set(config.devices.map((device) => device.id));
370 for (const id of options.deviceIds) {
371 if (!knownDeviceIds.has(id)) {
372 throw new Error(`Unknown device '${id}'. Run with --list to see configured devices.`);
373 }
374 }
375 if (captures.length === 0) {
376 throw new Error("No captures match the selected platform, device, and scene filters.");
377 }
378 return captures;
379}
380
381function printUsage(config: ShowcaseConfig): void {
382 NodeProcess.stdout.write(`App screenshot showcase
383
384Usage:
385 pnpm --filter @t3tools/mobile screenshots [options]
386
387Options:
388 --platform ios|android|all Capture one platform (repeatable)
389 --device <id> Capture one configured device (repeatable)
390 --scene <name> Capture one scene (repeatable)
391 --appearance light|dark|both
392 Override the configured appearance
393 --skip-build Reuse the existing simulator app / debug APK
394 --skip-metro Reuse an already running showcase Metro server
395 --keep-running Leave devices and Metro running after capture
396 --validate-only Validate existing upload assets without capturing
397 --list Print this help and the configured matrix
398
399Scenes: ${SHOWCASE_SCENES.join(", ")}
400
401Configured devices:
402${config.devices
403 .map((device) => {
404 const target = device.platform === "ios" ? device.simulator : device.avd;
405 return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark} (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance}) [${device.scenes.join(", ")}]`;
406 })
407 .join("\n")}
408`);
409}
410
411function spawnProcess(
412 command: string,
413 args: ReadonlyArray<string>,
414 options: NodeChildProcess.SpawnOptions = {},
415): NodeChildProcess.ChildProcess {
416 return NodeChildProcess.spawn(command, args, {
417 cwd: REPO_ROOT,
418 env: NodeProcess.env,
419 stdio: "inherit",
420 ...options,
421 });
422}
423
424async function runCommand(
425 command: string,
426 args: ReadonlyArray<string>,
427 options: NodeChildProcess.SpawnOptions = {},
428): Promise<void> {
429 await new Promise<void>((resolve, reject) => {
430 const child = spawnProcess(command, args, options);
431 child.once("error", reject);
432 child.once("exit", (code, signal) => {
433 if (code === 0) {
434 resolve();
435 } else {
436 reject(
437 new Error(
438 `${command} ${args.join(" ")} failed ${signal ? `with signal ${signal}` : `with code ${String(code)}`}.`,
439 ),
440 );
441 }
442 });
443 });
444}
445
446async function commandOutput(
447 command: string,
448 args: ReadonlyArray<string>,
449 options: NodeChildProcess.ExecFileOptions = {},
450): Promise<string> {
451 return await new Promise<string>((resolve, reject) => {
452 NodeChildProcess.execFile(
453 command,
454 [...args],
455 { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, ...options },
456 (error, stdout) => {
457 if (error) reject(error);
458 else resolve(String(stdout));
459 },
460 );
461 });
462}
463
464function delay(milliseconds: number): Promise<void> {
465 return new Promise((resolve) => setTimeout(resolve, milliseconds));
466}
467
468async function stopProcess(child: NodeChildProcess.ChildProcess): Promise<void> {
469 if (child.exitCode !== null || child.signalCode !== null) return;
470
471 const exited = new Promise<void>((resolve) => {
472 child.once("exit", () => resolve());
473 });
474 child.kill("SIGTERM");
475 await Promise.race([exited, delay(5_000)]);
476 if (child.exitCode !== null || child.signalCode !== null) return;
477
478 child.kill("SIGKILL");
479 await Promise.race([exited, delay(1_000)]);
480}
481
482async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise<void> {
483 const deadline = Date.now() + timeoutMs;
484 while (Date.now() < deadline) {
485 const open = await new Promise<boolean>((resolve) => {
486 const socket = NodeNet.createConnection({ host: "127.0.0.1", port });
487 socket.once("connect", () => {
488 socket.destroy();
489 resolve(true);
490 });
491 socket.once("error", () => resolve(false));
492 socket.setTimeout(500, () => {
493 socket.destroy();
494 resolve(false);
495 });
496 });
497 if (open) return;
498 await delay(500);
499 }
500 throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`);
501}
502
503async function reserveAvailablePort(): Promise<number> {
504 return await new Promise<number>((resolve, reject) => {
505 const server = NodeNet.createServer();
506 server.once("error", reject);
507 server.listen(0, "127.0.0.1", () => {
508 const address = server.address();
509 if (!address || typeof address === "string") {
510 server.close();
511 reject(new Error("Could not reserve a local port for the showcase environment."));
512 return;
513 }
514 server.close((error) => (error ? reject(error) : resolve(address.port)));
515 });
516 });
517}
518
519async function createShowcaseShell(baseDir: string): Promise<string> {
520 const shellPath = NodePath.join(baseDir, "showcase-shell");
521 await NodeFSP.writeFile(
522 shellPath,
523 `#!/bin/sh
524if [ "$1" = "-ilc" ] || [ "$1" = "-lic" ]; then
525 exec /bin/sh -c "$2"
526fi
527exec /bin/cat
528`,
529 { mode: 0o755 },
530 );
531 return shellPath;
532}
533
534async function createShowcaseLabelProbe(baseDir: string, label: string): Promise<string> {
535 const binDirectory = NodePath.join(baseDir, "showcase-bin");
536 await NodeFSP.mkdir(binDirectory, { recursive: true });
537 const probeScript = `#!/bin/sh
538if [ "$1" = "--get" ] && [ "$2" = "ComputerName" ]; then
539 printf '%s\\n' ${JSON.stringify(label)}
540 exit 0
541fi
542if [ "$1" = "--pretty" ]; then
543 printf '%s\\n' ${JSON.stringify(label)}
544 exit 0
545fi
546exit 1
547`;
548 await Promise.all(
549 ["scutil", "hostnamectl"].map((executable) =>
550 NodeFSP.writeFile(NodePath.join(binDirectory, executable), probeScript, { mode: 0o755 }),
551 ),
552 );
553 return binDirectory;
554}
555
556function startShowcaseServer(
557 baseDir: string,
558 workspaceRoot: string,
559 port: number,
560 shellPath: string,
561 labelProbeDirectory: string,
562): NodeChildProcess.ChildProcess {
563 return spawnProcess(
564 "node",
565 [
566 "apps/server/src/bin.ts",
567 "serve",
568 "--host",
569 SERVER_HOST,
570 "--port",
571 String(port),
572 "--base-dir",
573 baseDir,
574 "--no-browser",
575 "--log-level",
576 "error",
577 workspaceRoot,
578 ],
579 {
580 env: {
581 ...NodeProcess.env,
582 PATH: `${labelProbeDirectory}:${NodeProcess.env.PATH ?? ""}`,
583 SHELL: shellPath,
584 },
585 },
586 );
587}
588
589export function parsePairingCredentialOutput(output: string): string {
590 const jsonStart = output.indexOf("{");
591 const jsonEnd = output.lastIndexOf("}");
592 if (jsonStart === -1 || jsonEnd < jsonStart) {
593 throw new Error("Pairing credential command did not return JSON.");
594 }
595 const parsed = JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as {
596 readonly credential?: unknown;
597 };
598 if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
599 throw new Error("Pairing credential command returned no credential.");
600 }
601 return parsed.credential;
602}
603
604async function issuePairingCredential(baseDir: string): Promise<string> {
605 const output = await commandOutput(
606 "node",
607 ["apps/server/src/bin.ts", "auth", "pairing", "create", "--base-dir", baseDir, "--json"],
608 { env: { ...NodeProcess.env, NO_COLOR: "1" } },
609 );
610 return parsePairingCredentialOutput(output);
611}
612
613function buildShowcasePairingUrl(host: string, port: number, credential: string): string {
614 const url = new URL(`http://${host}:${port}/`);
615 url.hash = new URLSearchParams([["token", credential]]).toString();
616 return url.toString();
617}
618
619export function showcaseSceneUrl(scene: ShowcaseScene, environmentId: string): string {
620 if (scene === "threads") return `${APP_SCHEME}://`;
621 if (scene === "environments") return `${APP_SCHEME}://settings/environments`;
622 const threadPath = `threads/${encodeURIComponent(environmentId)}/${SHOWCASE_THREAD_ID}`;
623 if (scene === "thread") return `${APP_SCHEME}://${threadPath}`;
624 if (scene === "terminal") {
625 return `${APP_SCHEME}://${threadPath}/terminal?terminalId=${SHOWCASE_TERMINAL_ID}`;
626 }
627 return `${APP_SCHEME}://${threadPath}/review`;
628}
629
630export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray<string>): string {
631 return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`;
632}
633
634function startMetro(config: ShowcaseConfig): NodeChildProcess.ChildProcess {
635 return spawnProcess(
636 "pnpm",
637 ["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort)],
638 {
639 cwd: MOBILE_ROOT,
640 env: {
641 ...MOBILE_BUILD_ENV,
642 EXPO_PUBLIC_SHOWCASE: "1",
643 },
644 },
645 );
646}
647
648async function warmMetroBundle(
649 platform: ShowcaseDevice["platform"],
650 host: string,
651 config: ShowcaseConfig,
652): Promise<void> {
653 const url = `http://${host}:${config.metroPort}/apps/mobile/index.bundle?platform=${platform}&dev=true&minify=false`;
654 await runCommand("curl", ["--fail", "--silent", "--show-error", "--output", "/dev/null", url]);
655}
656
657async function buildIos(): Promise<string> {
658 const derivedData = NodePath.join(MOBILE_ROOT, ".showcase/ios-derived-data");
659 await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "ios"], {
660 cwd: MOBILE_ROOT,
661 env: MOBILE_BUILD_ENV,
662 });
663 await runCommand(
664 "xcodebuild",
665 [
666 "-workspace",
667 NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"),
668 "-scheme",
669 "T3CodeDev",
670 "-configuration",
671 "Debug",
672 "-sdk",
673 "iphonesimulator",
674 "-derivedDataPath",
675 derivedData,
676 "-quiet",
677 `ARCHS=${IOS_SIMULATOR_ARCH}`,
678 "ONLY_ACTIVE_ARCH=YES",
679 "build",
680 ],
681 { cwd: MOBILE_ROOT, env: MOBILE_BUILD_ENV },
682 );
683 return IOS_APP_PATH;
684}
685
686async function buildAndroid(abis: ReadonlyArray<string>): Promise<string> {
687 await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "android"], {
688 cwd: MOBILE_ROOT,
689 env: MOBILE_BUILD_ENV,
690 });
691 await runCommand(
692 "./gradlew",
693 [
694 "app:assembleDebug",
695 ...(abis.length > 0 ? [`-PreactNativeArchitectures=${abis.join(",")}`] : []),
696 ],
697 {
698 cwd: NodePath.join(MOBILE_ROOT, "android"),
699 env: MOBILE_BUILD_ENV,
700 },
701 );
702 return ANDROID_APK_PATH;
703}
704
705async function existingArtifact(path: string): Promise<string | null> {
706 return await NodeFSP.access(path).then(
707 () => path,
708 () => null,
709 );
710}
711
712interface SimctlDevice {
713 readonly name: string;
714 readonly udid: string;
715 readonly state: "Booted" | "Shutdown" | string;
716 readonly isAvailable: boolean;
717}
718
719async function findIosSimulator(name: string): Promise<SimctlDevice | null> {
720 const parsed = JSON.parse(
721 await commandOutput("xcrun", ["simctl", "list", "devices", "available", "-j"]),
722 ) as {
723 readonly devices: Readonly<Record<string, ReadonlyArray<SimctlDevice>>>;
724 };
725 const candidates = Object.entries(parsed.devices)
726 .filter(([runtime]) => runtime.includes("iOS"))
727 .flatMap(([, devices]) => devices)
728 .filter((device) => device.isAvailable && device.name === name);
729 return candidates.at(-1) ?? null;
730}
731
732async function ensureIosSimulator(device: ShowcaseIosDevice): Promise<{
733 readonly simulator: SimctlDevice;
734 readonly createdByRunner: boolean;
735}> {
736 const existing = await findIosSimulator(device.simulator);
737 if (existing) return { simulator: existing, createdByRunner: false };
738 if (!device.simulatorDeviceType) {
739 throw new Error(
740 `iOS simulator '${device.simulator}' is not installed and has no simulatorDeviceType configured.`,
741 );
742 }
743 const udid = (
744 await commandOutput("xcrun", ["simctl", "create", device.simulator, device.simulatorDeviceType])
745 ).trim();
746 if (!udid) throw new Error(`Could not create iOS simulator '${device.simulator}'.`);
747 return {
748 simulator: {
749 name: device.simulator,
750 udid,
751 state: "Shutdown",
752 isAvailable: true,
753 },
754 createdByRunner: true,
755 };
756}
757
758async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: string): Promise<void> {
759 await runCommand("xcrun", ["simctl", "ui", udid, "appearance", appearance]);
760 await runCommand("xcrun", [
761 "simctl",
762 "status_bar",
763 udid,
764 "override",
765 "--time",
766 "9:41",
767 "--batteryState",
768 "charged",
769 "--batteryLevel",
770 "100",
771 "--wifiBars",
772 "3",
773 "--cellularBars",
774 "4",
775 ]);
776}
777
778async function iosAppContainer(udid: string): Promise<string> {
779 return (
780 await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"])
781 ).trim();
782}
783
784async function waitForIosShowcaseScene(
785 udid: string,
786 scene: ShowcaseScene,
787 timeoutMs = 90_000,
788): Promise<void> {
789 const readyPath = NodePath.join(
790 await iosAppContainer(udid),
791 "Library/Caches",
792 IOS_READY_FILENAME,
793 );
794 const deadline = Date.now() + timeoutMs;
795 while (Date.now() < deadline) {
796 const readyScene = await NodeFSP.readFile(readyPath, "utf8").catch(() => "");
797 if (readyScene.trim() === scene) return;
798 await delay(500);
799 }
800 throw new Error(`iOS showcase scene '${scene}' did not render within ${timeoutMs}ms.`);
801}
802
803async function captureIos(
804 capture: ShowcaseCapture & { readonly device: ShowcaseIosDevice },
805 appPath: string | null,
806 outputDirectory: string,
807 config: ShowcaseConfig,
808 metroHost: string,
809 pairingUrls: ReadonlyArray<string>,
810 registerCleanup: (cleanup: IosCaptureCleanup) => void,
811): Promise<void> {
812 const { simulator, createdByRunner } = await ensureIosSimulator(capture.device);
813 const startedByRunner = simulator.state !== "Booted";
814 registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner });
815 if (!startedByRunner) {
816 // Clear transient SpringBoard state (permission prompts, stale URL-open
817 // confirmations, keyboards) without erasing the developer's simulator.
818 await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]);
819 }
820 await runCommand("xcrun", ["simctl", "boot", simulator.udid]);
821 await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]);
822 await normalizeIosSimulator(capture.appearance, simulator.udid);
823 if (appPath) {
824 await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch(
825 () => undefined,
826 );
827 await runCommand("xcrun", ["simctl", "install", simulator.udid, appPath]);
828 }
829
830 for (const [key, value] of [
831 ["EXDevMenuIsOnboardingFinished", "true"],
832 ["EXDevMenuShowFloatingActionButton", "false"],
833 ["EXDevMenuShowsAtLaunch", "false"],
834 ] as const) {
835 await runCommand("xcrun", [
836 "simctl",
837 "spawn",
838 simulator.udid,
839 "defaults",
840 "write",
841 ANDROID_PACKAGE,
842 key,
843 "-bool",
844 value,
845 ]);
846 }
847
848 const metroUrl = `http://${metroHost}:${config.metroPort}?disableOnboarding=1`;
849 const scenePath = NodePath.join(
850 await iosAppContainer(simulator.udid),
851 "Library/Caches/T3ShowcaseScene",
852 );
853 const readyPath = NodePath.join(
854 await iosAppContainer(simulator.udid),
855 "Library/Caches",
856 IOS_READY_FILENAME,
857 );
858 const firstScene = capture.scenes[0] ?? "threads";
859 const launchShowcaseApp = async (terminateRunningProcess: boolean) => {
860 await runCommand("xcrun", [
861 "simctl",
862 "launch",
863 ...(terminateRunningProcess ? ["--terminate-running-process"] : []),
864 simulator.udid,
865 ANDROID_PACKAGE,
866 "--initialUrl",
867 metroUrl,
868 "--showcasePairingUrl",
869 JSON.stringify(pairingUrls),
870 "--showcaseScene",
871 firstScene,
872 ]);
873 };
874 await NodeFSP.rm(readyPath, { force: true });
875 await NodeFSP.writeFile(scenePath, firstScene);
876 await launchShowcaseApp(false);
877 for (const [sceneIndex, scene] of capture.scenes.entries()) {
878 if (sceneIndex > 0) await NodeFSP.rm(readyPath, { force: true });
879 await NodeFSP.writeFile(scenePath, scene);
880 if (sceneIndex === 0) {
881 for (let attempt = 0; attempt < 2; attempt += 1) {
882 const isLastAttempt = attempt === 1;
883 try {
884 // A freshly installed Expo development build can spend well over 30s
885 // applying an already-bundled update after it reaches 100%. Killing it
886 // at that point sends the next capture back to the dev launcher.
887 await waitForIosShowcaseScene(simulator.udid, scene, 120_000);
888 break;
889 } catch (error) {
890 if (isLastAttempt) throw error;
891 await launchShowcaseApp(true);
892 }
893 }
894 } else {
895 await waitForIosShowcaseScene(simulator.udid, scene);
896 }
897 await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs);
898 const destination = NodePath.join(
899 showcaseCaptureDirectory(outputDirectory, capture),
900 `${scene}.png`,
901 );
902 await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]);
903 await finalizeCapture(destination, capture.device);
904 }
905}
906
907function androidSdkTool(relativePath: string): string {
908 return NodePath.join(ANDROID_SDK_ROOT, relativePath);
909}
910
911async function adbOutput(serial: string, args: ReadonlyArray<string>): Promise<string> {
912 return await commandOutput(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]);
913}
914
915async function runAdb(serial: string, args: ReadonlyArray<string>): Promise<void> {
916 await runCommand(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]);
917}
918
919async function runningAndroidAvds(): Promise<ReadonlyMap<string, string>> {
920 const adb = androidSdkTool("platform-tools/adb");
921 const devices = (await commandOutput(adb, ["devices"]))
922 .split("\n")
923 .map((line) => line.trim().split(/\s+/u))
924 .filter((parts) => parts[0]?.startsWith("emulator-") && parts[1] === "device")
925 .map((parts) => parts[0] as string);
926 const result = new Map<string, string>();
927 for (const serial of devices) {
928 const avdName = (await adbOutput(serial, ["emu", "avd", "name"])).split("\n")[0]?.trim();
929 if (avdName) result.set(avdName, serial);
930 }
931 return result;
932}
933
934async function waitForAndroidSerial(avd: string, timeoutMs = 120_000): Promise<string> {
935 const deadline = Date.now() + timeoutMs;
936 while (Date.now() < deadline) {
937 const serial = (await runningAndroidAvds()).get(avd);
938 if (serial) {
939 await runAdb(serial, ["wait-for-device"]);
940 const bootCompleted = (
941 await adbOutput(serial, ["shell", "getprop", "sys.boot_completed"])
942 ).trim();
943 if (bootCompleted === "1") return serial;
944 }
945 await delay(1_000);
946 }
947 throw new Error(`Android AVD '${avd}' did not finish booting within ${timeoutMs}ms.`);
948}
949
950async function normalizeAndroidEmulator(
951 device: ShowcaseAndroidDevice,
952 appearance: ShowcaseAppearance,
953 serial: string,
954): Promise<void> {
955 await runAdb(serial, ["shell", "settings", "put", "global", "window_animation_scale", "0"]);
956 await runAdb(serial, ["shell", "settings", "put", "global", "transition_animation_scale", "0"]);
957 await runAdb(serial, ["shell", "settings", "put", "global", "animator_duration_scale", "0"]);
958 await runAdb(serial, ["shell", "cmd", "uimode", "night", appearance === "dark" ? "yes" : "no"]);
959 await runAdb(serial, ["shell", "settings", "put", "system", "time_12_24", "12"]);
960 await runAdb(serial, ["emu", "power", "capacity", "100"]);
961 await runAdb(serial, ["shell", "settings", "put", "global", "sysui_demo_allowed", "1"]);
962 await runAdb(serial, [
963 "shell",
964 "am",
965 "broadcast",
966 "-a",
967 "com.android.systemui.demo",
968 "-e",
969 "command",
970 "enter",
971 ]);
972 await runAdb(serial, [
973 "shell",
974 "am",
975 "broadcast",
976 "-a",
977 "com.android.systemui.demo",
978 "-e",
979 "command",
980 "clock",
981 "-e",
982 "hhmm",
983 "0941",
984 ]);
985 await runAdb(serial, [
986 "shell",
987 "am",
988 "broadcast",
989 "-a",
990 "com.android.systemui.demo",
991 "-e",
992 "command",
993 "battery",
994 "-e",
995 "level",
996 "100",
997 "-e",
998 "plugged",
999 "false",
1000 ]);
1001 if (device.viewport) {
1002 await runAdb(serial, [
1003 "shell",
1004 "wm",
1005 "size",
1006 `${device.viewport.width}x${device.viewport.height}`,
1007 ]);
1008 if (device.viewport.density) {
1009 await runAdb(serial, ["shell", "wm", "density", String(device.viewport.density)]);
1010 }
1011 }
1012}
1013
1014async function waitForAndroidShowcaseScene(
1015 serial: string,
1016 scene: ShowcaseScene,
1017 timeoutMs = 90_000,
1018): Promise<void> {
1019 const deadline = Date.now() + timeoutMs;
1020 while (Date.now() < deadline) {
1021 const readyScene = await adbOutput(serial, [
1022 "shell",
1023 "run-as",
1024 ANDROID_PACKAGE,
1025 "cat",
1026 "files/t3-showcase-ready",
1027 ]).catch(() => "");
1028 if (readyScene.trim() === scene) return;
1029 await delay(500);
1030 }
1031 throw new Error(`Android showcase scene '${scene}' did not render within ${timeoutMs}ms.`);
1032}
1033
1034async function writeAndroidShowcaseScene(serial: string, scene: ShowcaseScene): Promise<void> {
1035 await runAdb(serial, [
1036 "shell",
1037 `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p files && rm -f files/t3-showcase-ready && printf %s ${scene} > files/t3-showcase-scene'`,
1038 ]);
1039}
1040
1041async function prepareAndroidShowcaseApp(serial: string): Promise<void> {
1042 const preferences = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
1043<map>
1044 <boolean name="isOnboardingFinished" value="true" />
1045 <boolean name="showsAtLaunch" value="false" />
1046 <boolean name="showFab" value="false" />
1047 <boolean name="motionGestureEnabled" value="false" />
1048 <boolean name="touchGestureEnabled" value="false" />
1049 <boolean name="keyCommandsEnabled" value="false" />
1050</map>`;
1051 const encodedPreferences = Buffer.from(preferences).toString("base64");
1052 await runAdb(serial, [
1053 "shell",
1054 `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p shared_prefs && printf %s ${encodedPreferences} | base64 -d > shared_prefs/expo.modules.devmenu.sharedpreferences.xml'`,
1055 ]);
1056}
1057
1058async function captureAndroid(
1059 capture: ShowcaseCapture & { readonly device: ShowcaseAndroidDevice },
1060 apkPath: string | null,
1061 outputDirectory: string,
1062 config: ShowcaseConfig,
1063 pairingUrls: ReadonlyArray<string>,
1064 registerCleanup: (cleanup: AndroidCaptureCleanup) => void,
1065): Promise<void> {
1066 const running = await runningAndroidAvds();
1067 const existingSerial = running.get(capture.device.avd);
1068 const startedByRunner = !existingSerial;
1069 let launchedEmulator: NodeChildProcess.ChildProcess | null = null;
1070 if (startedByRunner) {
1071 const installedAvds = (await commandOutput(androidSdkTool("emulator/emulator"), ["-list-avds"]))
1072 .split("\n")
1073 .map((value) => value.trim());
1074 if (!installedAvds.includes(capture.device.avd)) {
1075 throw new Error(
1076 `Android AVD '${capture.device.avd}' is not installed. Run emulator -list-avds.`,
1077 );
1078 }
1079 launchedEmulator = spawnProcess(
1080 androidSdkTool("emulator/emulator"),
1081 ["-avd", capture.device.avd, "-no-snapshot-load", "-no-boot-anim"],
1082 { stdio: "ignore", detached: true },
1083 );
1084 launchedEmulator.unref();
1085 }
1086 const serial =
1087 existingSerial ??
1088 (await waitForAndroidSerial(capture.device.avd).catch(async (error: unknown) => {
1089 if (launchedEmulator) await stopProcess(launchedEmulator);
1090 throw error;
1091 }));
1092 registerCleanup({ device: capture.device, serial, startedByRunner });
1093 await normalizeAndroidEmulator(capture.device, capture.appearance, serial);
1094 if (apkPath) {
1095 await runAdb(serial, ["install", "-r", apkPath]);
1096 }
1097 await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]);
1098 await prepareAndroidShowcaseApp(serial);
1099 await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]);
1100 const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`);
1101 const firstScene = capture.scenes[0] ?? "threads";
1102 await runAdb(serial, [
1103 "shell",
1104 "am",
1105 "start",
1106 "-W",
1107 "-a",
1108 "android.intent.action.VIEW",
1109 "-d",
1110 `${APP_SCHEME}://expo-development-client/?url=${metroUrl}`,
1111 "--es",
1112 "showcasePairingUrl",
1113 encodeAndroidPairingUrls(pairingUrls),
1114 "--es",
1115 "showcaseScene",
1116 firstScene,
1117 ANDROID_PACKAGE,
1118 ]);
1119 for (const [sceneIndex, scene] of capture.scenes.entries()) {
1120 if (sceneIndex > 0) await writeAndroidShowcaseScene(serial, scene);
1121 await waitForAndroidShowcaseScene(serial, scene);
1122 await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000));
1123 const destination = NodePath.join(
1124 showcaseCaptureDirectory(outputDirectory, capture),
1125 `${scene}.png`,
1126 );
1127 const png = await new Promise<Buffer>((resolve, reject) => {
1128 NodeChildProcess.execFile(
1129 androidSdkTool("platform-tools/adb"),
1130 ["-s", serial, "exec-out", "screencap", "-p"],
1131 { cwd: REPO_ROOT, encoding: "buffer", maxBuffer: 64 * 1024 * 1024 },
1132 (error, stdout) => {
1133 if (error) reject(error);
1134 else resolve(stdout);
1135 },
1136 );
1137 });
1138 await NodeFSP.writeFile(destination, png);
1139 await finalizeCapture(destination, capture.device);
1140 }
1141}
1142
1143async function cleanupAndroidViewport(
1144 device: ShowcaseAndroidDevice,
1145 serial: string,
1146): Promise<void> {
1147 await runAdb(serial, [
1148 "shell",
1149 "am",
1150 "broadcast",
1151 "-a",
1152 "com.android.systemui.demo",
1153 "-e",
1154 "command",
1155 "exit",
1156 ]);
1157 if (!device.viewport) return;
1158 await runAdb(serial, ["shell", "wm", "size", "reset"]);
1159 if (device.viewport.density) {
1160 await runAdb(serial, ["shell", "wm", "density", "reset"]);
1161 }
1162}
1163
1164async function main(): Promise<void> {
1165 const options = parseShowcaseCliArgs(NodeProcess.argv.slice(2));
1166 if (options.list) {
1167 printUsage(showcaseConfig);
1168 return;
1169 }
1170 const captures = planShowcaseCaptures(showcaseConfig, options);
1171 const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory);
1172 if (options.validateOnly) {
1173 for (const capture of captures) {
1174 await validateCaptureSet(
1175 capture,
1176 outputDirectory,
1177 capture.scenes.length === capture.device.scenes.length,
1178 );
1179 }
1180 return;
1181 }
1182 const hasIos = captures.some((capture) => capture.device.platform === "ios");
1183 const hasAndroid = captures.some((capture) => capture.device.platform === "android");
1184 const metroHost = hasIos ? lanIpv4Address() : "127.0.0.1";
1185 await NodeFSP.mkdir(outputDirectory, { recursive: true });
1186 for (const capture of captures) {
1187 const directory = showcaseCaptureDirectory(outputDirectory, capture);
1188 await NodeFSP.rm(directory, { recursive: true, force: true });
1189 await NodeFSP.mkdir(directory, { recursive: true });
1190 }
1191
1192 const showcaseRootDir = await NodeFSP.mkdtemp(
1193 NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"),
1194 );
1195 const showcaseServers: NodeChildProcess.ChildProcess[] = [];
1196 const showcaseEnvironments: Array<{
1197 readonly baseDir: string;
1198 readonly environmentId: string;
1199 readonly label: string;
1200 readonly port: number;
1201 }> = [];
1202 let metro: NodeChildProcess.ChildProcess | null = null;
1203 const iosCleanups: IosCaptureCleanup[] = [];
1204 const androidCleanups: AndroidCaptureCleanup[] = [];
1205
1206 try {
1207 for (const environment of SHOWCASE_ENVIRONMENTS) {
1208 const projectId = environment.projectIds[0];
1209 const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId);
1210 if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`);
1211
1212 const baseDir = NodePath.join(showcaseRootDir, "environments", environment.id);
1213 const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory);
1214 const port = await reserveAvailablePort();
1215 await NodeFSP.mkdir(workspaceRoot, { recursive: true });
1216 const shellPath = await createShowcaseShell(baseDir);
1217 const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label);
1218 const server = startShowcaseServer(
1219 baseDir,
1220 workspaceRoot,
1221 port,
1222 shellPath,
1223 labelProbeDirectory,
1224 );
1225 showcaseServers.push(server);
1226 await waitForPort(port, `${environment.label} server`);
1227 await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds });
1228 const environmentId = (
1229 await NodeFSP.readFile(NodePath.join(baseDir, "userdata", "environment-id"), "utf8")
1230 ).trim();
1231 if (!environmentId) {
1232 throw new Error(`${environment.label} did not persist an environment id.`);
1233 }
1234 showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port });
1235 }
1236
1237 if (!options.skipMetro) {
1238 metro = startMetro(showcaseConfig);
1239 await waitForPort(showcaseConfig.metroPort, "Metro");
1240 await Promise.all([
1241 hasIos ? warmMetroBundle("ios", metroHost, showcaseConfig) : Promise.resolve(),
1242 hasAndroid ? warmMetroBundle("android", "127.0.0.1", showcaseConfig) : Promise.resolve(),
1243 ]);
1244 }
1245
1246 const iosAppPath = hasIos
1247 ? options.skipBuild
1248 ? await existingArtifact(IOS_APP_PATH)
1249 : await buildIos()
1250 : null;
1251 const androidAbis = captures.flatMap((capture) =>
1252 capture.device.platform === "android" && capture.device.abi ? [capture.device.abi] : [],
1253 );
1254 const androidApkPath = hasAndroid
1255 ? options.skipBuild
1256 ? await existingArtifact(ANDROID_APK_PATH)
1257 : await buildAndroid([...new Set(androidAbis)])
1258 : null;
1259
1260 for (const capture of captures) {
1261 const pairingHost = capture.device.platform === "ios" ? "127.0.0.1" : "10.0.2.2";
1262 const pairingUrls = await Promise.all(
1263 showcaseEnvironments.map(async (environment) => {
1264 const credential = await issuePairingCredential(environment.baseDir);
1265 return buildShowcasePairingUrl(pairingHost, environment.port, credential);
1266 }),
1267 );
1268 if (capture.device.platform === "ios") {
1269 await captureIos(
1270 capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice },
1271 iosAppPath,
1272 outputDirectory,
1273 showcaseConfig,
1274 metroHost,
1275 pairingUrls,
1276 (cleanup) => iosCleanups.push(cleanup),
1277 );
1278 } else {
1279 await captureAndroid(
1280 capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice },
1281 androidApkPath,
1282 outputDirectory,
1283 showcaseConfig,
1284 pairingUrls,
1285 (cleanup) => androidCleanups.push(cleanup),
1286 );
1287 }
1288 await validateCaptureSet(
1289 capture,
1290 outputDirectory,
1291 capture.scenes.length === capture.device.scenes.length,
1292 );
1293 }
1294
1295 NodeProcess.stdout.write(
1296 `\nDone. Upload-ready screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/apple/ and ${NodePath.relative(REPO_ROOT, outputDirectory)}/google-play/.\n`,
1297 );
1298 if (options.keepRunning) {
1299 const serverSummary = showcaseEnvironments
1300 .map((environment) => `${environment.label}:${environment.port}`)
1301 .join(", ");
1302 NodeProcess.stdout.write(
1303 `Showcase environments kept at ${showcaseRootDir} (${serverSummary}).\n`,
1304 );
1305 }
1306 } finally {
1307 if (options.keepRunning) {
1308 metro?.unref();
1309 for (const server of showcaseServers) server.unref();
1310 } else {
1311 for (const cleanup of androidCleanups) {
1312 await cleanupAndroidViewport(cleanup.device, cleanup.serial).catch(() => undefined);
1313 if (cleanup.startedByRunner) {
1314 await runAdb(cleanup.serial, ["emu", "kill"]).catch(() => undefined);
1315 }
1316 }
1317 for (const cleanup of iosCleanups) {
1318 if (cleanup.startedByRunner || cleanup.createdByRunner) {
1319 await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined);
1320 }
1321 if (cleanup.createdByRunner) {
1322 await runCommand("xcrun", ["simctl", "delete", cleanup.udid]).catch(() => undefined);
1323 }
1324 }
1325 await Promise.all([
1326 ...(metro ? [stopProcess(metro)] : []),
1327 ...showcaseServers.map((server) => stopProcess(server)),
1328 ]);
1329 await NodeFSP.rm(showcaseRootDir, {
1330 recursive: true,
1331 force: true,
1332 maxRetries: 5,
1333 retryDelay: 100,
1334 });
1335 }
1336 }
1337}
1338
1339if (import.meta.main) {
1340 void main().catch((error: unknown) => {
1341 NodeProcess.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1342 NodeProcess.exit(1);
1343 });
1344}
scripts/package.json+2
......@@ -11,10 +11,12 @@
1111 "@t3tools/contracts": "workspace:*",
1212 "@t3tools/shared": "workspace:*",
1313 "effect": "catalog:",
14 "pngjs": "7.0.0",
1415 "yaml": "catalog:"
1516 },
1617 "devDependencies": {
1718 "@effect/vitest": "catalog:",
19 "@types/pngjs": "6.0.5",
1820 "vite-plus": "catalog:"
1921 }
2022}