| author | |
| committer | |
| log | 8a02c718619a9d4c6588d3ab7a89204023cfab62 |
| tree | 33db736e0ba3bc06f950599094b808ad1e9e0174 |
| parent | fdca15471d92e95e4ec5501f45dbf3ce81f8d991 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | name: Mobile Showcase Screenshots | |
| 2 | ||
| 3 | on: | |
| 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 | ||
| 25 | permissions: | |
| 26 | contents: read | |
| 27 | ||
| 28 | env: | |
| 29 | NODE_OPTIONS: --max-old-space-size=8192 | |
| 30 | ||
| 31 | jobs: | |
| 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/ |
| 27 | 27 | .gstack/ |
| 28 | 28 | dist-electron/ |
| 29 | 29 | .electron-runtime/ |
| 30 | .showcase/ | |
| 31 | apps/mobile/.showcase/ | |
| 32 | artifacts/app-store/screenshots/ | |
| 30 | 33 | node_modules/ |
| 31 | 34 | .alchemy/ |
| 32 | 35 | *.log |
apps/mobile/index.ts+5| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { registerRootComponent } from "expo"; |
| 2 | 2 | import "react-native-gesture-handler"; |
| 3 | import { LogBox } from "react-native"; | |
| 3 | 4 | import { featureFlags } from "react-native-screens"; |
| 4 | 5 | |
| 5 | 6 | import App from "./src/App"; |
| ... | ... | @@ -8,4 +9,8 @@ import App from "./src/App"; |
| 8 | 9 | // native stack is rendered inside a non-fitToContents formSheet. |
| 9 | 10 | featureFlags.experiment.synchronousScreenUpdatesEnabled = true; |
| 10 | 11 | |
| 12 | if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { | |
| 13 | LogBox.ignoreAllLogs(); | |
| 14 | } | |
| 15 | ||
| 11 | 16 | registerRootComponent(App); |
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt+26| ... | ... | @@ -7,6 +7,32 @@ class T3NativeControlsModule : Module() { |
| 7 | 7 | override fun definition() = ModuleDefinition { |
| 8 | 8 | Name("T3NativeControls") |
| 9 | 9 | |
| 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 | ||
| 10 | 36 | View(T3HeaderButtonView::class) { |
| 11 | 37 | Prop("label") { view: T3HeaderButtonView, label: String -> |
| 12 | 38 | view.setLabel(label) |
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+39| ... | ... | @@ -1,9 +1,48 @@ |
| 1 | 1 | import ExpoModulesCore |
| 2 | import Security | |
| 2 | 3 | |
| 3 | 4 | public final class T3NativeControlsModule: Module { |
| 4 | 5 | public func definition() -> ModuleDefinition { |
| 5 | 6 | Name("T3NativeControls") |
| 6 | 7 | |
| 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 | ||
| 7 | 46 | View(T3HeaderButtonView.self) { |
| 8 | 47 | Prop("label") { (view: T3HeaderButtonView, label: String) in |
| 9 | 48 | 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() { |
| 30 | 30 | view.focusRequest = focusRequest |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean -> | |
| 34 | view.autoFocus = autoFocus | |
| 35 | } | |
| 36 | ||
| 33 | 37 | Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> |
| 34 | 38 | view.appearanceScheme = appearanceScheme |
| 35 | 39 | } |
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 |
| 76 | 76 | } |
| 77 | 77 | } |
| 78 | 78 | |
| 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 | ||
| 79 | 90 | var backgroundColorHex: String = "#24292E" |
| 80 | 91 | set(value) { |
| 81 | 92 | field = value |
| ... | ... | @@ -368,6 +379,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex |
| 368 | 379 | inputMethodManager?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) |
| 369 | 380 | } |
| 370 | 381 | |
| 382 | private fun hideKeyboard() { | |
| 383 | val inputMethodManager = context.getSystemService( | |
| 384 | Context.INPUT_METHOD_SERVICE | |
| 385 | ) as? InputMethodManager | |
| 386 | inputMethodManager?.hideSoftInputFromWindow(windowToken, 0) | |
| 387 | } | |
| 388 | ||
| 371 | 389 | private fun applyTheme() { |
| 372 | 390 | setBackgroundColor(backgroundColorValue) |
| 373 | 391 | container.setBackgroundColor(backgroundColorValue) |
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+4| ... | ... | @@ -27,6 +27,10 @@ public class T3TerminalModule: Module { |
| 27 | 27 | view.focusRequest = focusRequest |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in | |
| 31 | view.autoFocus = autoFocus | |
| 32 | } | |
| 33 | ||
| 30 | 34 | Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in |
| 31 | 35 | view.appearanceScheme = appearanceScheme |
| 32 | 36 | } |
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+12-1| ... | ... | @@ -248,6 +248,17 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { |
| 248 | 248 | } |
| 249 | 249 | } |
| 250 | 250 | |
| 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 | ||
| 251 | 262 | var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { |
| 252 | 263 | didSet { |
| 253 | 264 | guard oldValue != appearanceScheme else { return } |
| ... | ... | @@ -359,7 +370,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { |
| 359 | 370 | public override func didMoveToWindow() { |
| 360 | 371 | super.didMoveToWindow() |
| 361 | 372 | |
| 362 | guard window != nil else { return } | |
| 373 | guard window != nil, autoFocus else { return } | |
| 363 | 374 | DispatchQueue.main.async { [weak self] in |
| 364 | 375 | self?.requestKeyboardFocus() |
| 365 | 376 | } |
apps/mobile/package.json+2| ... | ... | @@ -11,6 +11,8 @@ |
| 11 | 11 | "start:dev": "APP_VARIANT=development expo start", |
| 12 | 12 | "start:preview": "APP_VARIANT=preview expo start", |
| 13 | 13 | "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", | |
| 14 | 16 | "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", |
| 15 | 17 | "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", |
| 16 | 18 | "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 |
| 11 | 11 | import { RegistryContext } from "@effect/atom-react"; |
| 12 | 12 | import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; |
| 13 | 13 | import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; |
| 14 | import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; | |
| 14 | 15 | import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; |
| 15 | 16 | import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; |
| 16 | 17 | import { RootStack } from "./Stack"; |
| ... | ... | @@ -21,6 +22,10 @@ import { useThemeColor } from "./lib/useThemeColor"; |
| 21 | 22 | |
| 22 | 23 | import "../global.css"; |
| 23 | 24 | |
| 25 | if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { | |
| 26 | prepareNativeShowcaseCapture(); | |
| 27 | } | |
| 28 | ||
| 24 | 29 | const appLinking = { |
| 25 | 30 | prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], |
| 26 | 31 | // The Expo dev client launches the app via |
apps/mobile/src/Stack.tsx+2| ... | ... | @@ -48,6 +48,7 @@ import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnv |
| 48 | 48 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 49 | 49 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 50 | 50 | import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; |
| 51 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; | |
| 51 | 52 | import { |
| 52 | 53 | SettingsLegalDocumentCloseHeaderButton, |
| 53 | 54 | SettingsLegalDocumentExternalHeaderButton, |
| ... | ... | @@ -313,6 +314,7 @@ function RootStackLayout(props: { |
| 313 | 314 | |
| 314 | 315 | return ( |
| 315 | 316 | <HardwareKeyboardCommandProvider pathname={pathname}> |
| 317 | <ShowcaseCaptureCoordinator pathname={pathname} /> | |
| 316 | 318 | <ClerkSettingsSheetDetentProvider initiallyExpanded={false}> |
| 317 | 319 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> |
| 318 | 320 | {props.children} |
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+5-2| ... | ... | @@ -33,6 +33,8 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect |
| 33 | 33 | export function CloudEnvironmentRows(props: { |
| 34 | 34 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; |
| 35 | 35 | readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; |
| 36 | readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>; | |
| 37 | readonly showcaseSignedIn?: boolean; | |
| 36 | 38 | /** |
| 37 | 39 | * Hide the "T3 Connect" section title + refresh button for hosts that |
| 38 | 40 | * provide their own chrome (the onboarding sheet's native header and |
| ... | ... | @@ -43,7 +45,8 @@ export function CloudEnvironmentRows(props: { |
| 43 | 45 | const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 44 | 46 | const controller = useConnectionController(); |
| 45 | 47 | const iconColor = useThemeColor("--color-icon"); |
| 46 | const availableCloudEnvironments = controller.availableRelayEnvironments; | |
| 48 | const availableCloudEnvironments = | |
| 49 | props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments; | |
| 47 | 50 | const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null); |
| 48 | 51 | const hasCloudRows = |
| 49 | 52 | props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; |
| ... | ... | @@ -64,7 +67,7 @@ export function CloudEnvironmentRows(props: { |
| 64 | 67 | |
| 65 | 68 | const showHeader = props.showHeader ?? true; |
| 66 | 69 | |
| 67 | if (!isSignedIn) return null; | |
| 70 | if (!(props.showcaseSignedIn ?? isSignedIn)) return null; | |
| 68 | 71 | |
| 69 | 72 | return ( |
| 70 | 73 | <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", () => { |
| 96 | 96 | new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), |
| 97 | 97 | ), |
| 98 | 98 | ).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 | ||
| 109 | describe("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); | |
| 99 | 116 | }); |
| 100 | 117 | }); |
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+8-2| ... | ... | @@ -149,6 +149,10 @@ export interface NativeReviewDiffViewHandle { |
| 149 | 149 | readonly scrollToTop: (animated?: boolean) => Promise<void>; |
| 150 | 150 | } |
| 151 | 151 | |
| 152 | export function isNativeReviewDiffDrawEvent(payload: Readonly<Record<string, unknown>>): boolean { | |
| 153 | return payload.message === "draw-metrics" || payload.message === "visible-range"; | |
| 154 | } | |
| 155 | ||
| 152 | 156 | interface NativeReviewDiffViewRef { |
| 153 | 157 | readonly setRowsJson: (rowsJson: string) => Promise<void>; |
| 154 | 158 | readonly setTokensJson: (tokensJson: string) => Promise<void>; |
| ... | ... | @@ -170,9 +174,11 @@ let nativeReviewDiffViewResolutionFailed = false; |
| 170 | 174 | type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; |
| 171 | 175 | |
| 172 | 176 | export function isPendingNativeViewRegistration(error: unknown): boolean { |
| 177 | if (!(error instanceof Error)) return false; | |
| 173 | 178 | 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")) | |
| 176 | 182 | ); |
| 177 | 183 | } |
| 178 | 184 |
apps/mobile/src/features/home/HomeScreen.tsx-1| ... | ... | @@ -167,7 +167,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 167 | 167 | const listRef = useRef<LegendListRef | null>(null); |
| 168 | 168 | const insets = useSafeAreaInsets(); |
| 169 | 169 | const accentColor = useThemeColor("--color-icon-muted"); |
| 170 | ||
| 171 | 170 | const effectiveGroupDisplayStates = useMemo(() => { |
| 172 | 171 | const next = new Map(groupDisplayStates); |
| 173 | 172 | if (!AsyncResult.isSuccess(preferencesResult)) { |
apps/mobile/src/features/review/ReviewSheet.tsx+24-1| ... | ... | @@ -56,6 +56,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 56 | 56 | import { ThreadGitMenu } from "../threads/ThreadGitControls"; |
| 57 | 57 | import { useReviewCacheForThread } from "./reviewState"; |
| 58 | 58 | import { |
| 59 | isNativeReviewDiffDrawEvent, | |
| 59 | 60 | type NativeReviewDiffViewHandle, |
| 60 | 61 | resolveNativeReviewDiffView, |
| 61 | 62 | } from "../diffs/nativeReviewDiffSurface"; |
| ... | ... | @@ -71,8 +72,10 @@ import { resolveReviewAvailability } from "./reviewAvailability"; |
| 71 | 72 | import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; |
| 72 | 73 | import { buildReviewSectionMenu } from "./review-section-menu"; |
| 73 | 74 | import type { ReviewSectionItem } from "./reviewModel"; |
| 75 | import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; | |
| 74 | 76 | |
| 75 | 77 | const REVIEW_HEADER_SPACING = 0; |
| 78 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; | |
| 76 | 79 | |
| 77 | 80 | const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { |
| 78 | 81 | return ( |
| ... | ... | @@ -394,6 +397,7 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 394 | 397 | }); |
| 395 | 398 | const NativeReviewDiffView = resolveNativeReviewDiffView()!; |
| 396 | 399 | const nativeReviewDiffViewRef = useRef<NativeReviewDiffViewHandle>(null); |
| 400 | const showcasedReviewDrawRef = useRef<string | null>(null); | |
| 397 | 401 | // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item). |
| 398 | 402 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); |
| 399 | 403 | const handlePullToRefresh = useCallback(async () => { |
| ... | ... | @@ -435,6 +439,25 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 435 | 439 | selectedRowIds: commentSelection.selectedRowIds, |
| 436 | 440 | canHighlight: parsedDiff.kind === "files", |
| 437 | 441 | }); |
| 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 | ); | |
| 438 | 461 | |
| 439 | 462 | const handleSelectFile = useCallback( |
| 440 | 463 | (fileId: string | null) => { |
| ... | ... | @@ -788,7 +811,7 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 788 | 811 | tokensPatchJson={nativeBridge.tokensPatchJson} |
| 789 | 812 | tokensResetKey={nativeBridge.tokensResetKey} |
| 790 | 813 | viewedFileIdsJson={nativeBridge.viewedFileIdsJson} |
| 791 | onDebug={nativeBridge.onDebug} | |
| 814 | onDebug={handleNativeDebug} | |
| 792 | 815 | onPressLine={commentSelection.onPressLine} |
| 793 | 816 | onVisibleFileChange={handleVisibleFileChange} |
| 794 | 817 | onToggleComment={nativeBridge.onToggleComment} |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+57-4| ... | ... | @@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac |
| 2 | 2 | import { useNavigation } from "@react-navigation/native"; |
| 3 | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | import { useCallback, useState } from "react"; | |
| 5 | import { useCallback, useEffect, useState } from "react"; | |
| 6 | 6 | import { Platform, ScrollView, View } from "react-native"; |
| 7 | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 8 | |
| ... | ... | @@ -15,6 +15,15 @@ import { splitEnvironmentSections } from "../connection/environmentSections"; |
| 15 | 15 | import { cn } from "../../lib/cn"; |
| 16 | 16 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 17 | 17 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 18 | import { | |
| 19 | applyShowcaseLocalEnvironmentDisplayUrls, | |
| 20 | resolveShowcaseEnvironmentUpdateDisplayUrl, | |
| 21 | SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, | |
| 22 | SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS, | |
| 23 | } from "../showcase/showcaseEnvironmentRows"; | |
| 24 | import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; | |
| 25 | ||
| 26 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; | |
| 18 | 27 | |
| 19 | 28 | export function SettingsEnvironmentsRouteScreen() { |
| 20 | 29 | const { |
| ... | ... | @@ -25,18 +34,56 @@ export function SettingsEnvironmentsRouteScreen() { |
| 25 | 34 | } = useRemoteConnections(); |
| 26 | 35 | const navigation = useNavigation(); |
| 27 | 36 | const insets = useSafeAreaInsets(); |
| 28 | const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ | |
| 37 | const environmentSections = splitEnvironmentSections({ | |
| 29 | 38 | connectedEnvironments, |
| 30 | 39 | cloudEnvironments: null, |
| 31 | 40 | }); |
| 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; | |
| 32 | 47 | const hasLocalEnvironments = localEnvironments.length > 0; |
| 33 | 48 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); |
| 34 | 49 | const accentColor = useThemeColor("--color-icon-muted"); |
| 35 | 50 | const headerIconColor = useThemeColor("--color-icon"); |
| 36 | 51 | |
| 52 | useEffect(() => { | |
| 53 | if (!SHOWCASE_ENABLED) return; | |
| 54 | const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500); | |
| 55 | return () => clearTimeout(timer); | |
| 56 | }, []); | |
| 57 | ||
| 37 | 58 | const handleToggle = useCallback((environmentId: EnvironmentId) => { |
| 38 | 59 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); |
| 39 | 60 | }, []); |
| 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 | ); | |
| 40 | 87 | |
| 41 | 88 | return ( |
| 42 | 89 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | ... | @@ -92,7 +139,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 92 | 139 | onToggle={() => handleToggle(environment.environmentId)} |
| 93 | 140 | onReconnect={onReconnectEnvironment} |
| 94 | 141 | onRemove={onRemoveEnvironmentPress} |
| 95 | onUpdate={onUpdateEnvironment} | |
| 142 | onUpdate={handleUpdateEnvironment} | |
| 96 | 143 | /> |
| 97 | 144 | </View> |
| 98 | 145 | ))} |
| ... | ... | @@ -114,10 +161,16 @@ export function SettingsEnvironmentsRouteScreen() { |
| 114 | 161 | </View> |
| 115 | 162 | )} |
| 116 | 163 | |
| 117 | {hasCloudPublicConfig() ? ( | |
| 164 | {hasCloudPublicConfig() || SHOWCASE_ENABLED ? ( | |
| 118 | 165 | <CloudEnvironmentRows |
| 119 | 166 | connectedCloudEnvironments={connectedCloudEnvironments} |
| 120 | 167 | onReconnectEnvironment={onReconnectEnvironment} |
| 168 | {...(SHOWCASE_ENABLED | |
| 169 | ? { | |
| 170 | showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, | |
| 171 | showcaseSignedIn: true, | |
| 172 | } | |
| 173 | : {})} | |
| 121 | 174 | /> |
| 122 | 175 | ) : null} |
| 123 | 176 | </ScrollView> |
apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx created+224| ... | ... | @@ -0,0 +1,224 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | |
| 2 | import { Keyboard, View } from "react-native"; | |
| 3 | import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; | |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 5 | ||
| 6 | import { useConnectionController } from "../connection/useConnectionController"; | |
| 7 | import { useProjects, useThreadShells } from "../../state/entities"; | |
| 8 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; | |
| 9 | import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; | |
| 10 | import { useWorkspaceState } from "../../state/workspace"; | |
| 11 | import { | |
| 12 | getNativeShowcasePairingUrls, | |
| 13 | getNativeShowcaseScene, | |
| 14 | markNativeShowcaseReady, | |
| 15 | type ShowcaseScene, | |
| 16 | } from "./nativeShowcaseScene"; | |
| 17 | import { | |
| 18 | buildShowcasePendingTasks, | |
| 19 | SHOWCASE_PENDING_TASK_DEFINITIONS, | |
| 20 | } from "./showcasePendingTasks"; | |
| 21 | import { retryShowcaseOperation } from "./showcaseRetry"; | |
| 22 | ||
| 23 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; | |
| 24 | const SHOWCASE_THREAD_ID = "remote-command-center"; | |
| 25 | ||
| 26 | function 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 | ||
| 38 | export 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 @@ |
| 1 | import { requireOptionalNativeModule } from "expo"; | |
| 2 | ||
| 3 | export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; | |
| 4 | export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; | |
| 5 | ||
| 6 | interface NativeShowcaseControls { | |
| 7 | readonly getShowcasePairingUrl?: () => string | null; | |
| 8 | readonly getShowcaseScene?: () => string | null; | |
| 9 | readonly prepareShowcaseCapture?: () => void; | |
| 10 | readonly markShowcaseReady?: (scene: ShowcaseScene) => void; | |
| 11 | } | |
| 12 | ||
| 13 | function nativeShowcaseControls(): NativeShowcaseControls | null { | |
| 14 | return requireOptionalNativeModule<NativeShowcaseControls>("T3NativeControls"); | |
| 15 | } | |
| 16 | ||
| 17 | export 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 | ||
| 45 | export 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 | ||
| 54 | export 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 | ||
| 62 | export 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 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | ||
| 4 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | |
| 5 | import { | |
| 6 | applyShowcaseLocalEnvironmentDisplayUrls, | |
| 7 | resolveShowcaseEnvironmentUpdateDisplayUrl, | |
| 8 | } from "./showcaseEnvironmentRows"; | |
| 9 | ||
| 10 | function 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 | ||
| 26 | it("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 | ||
| 43 | it("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 | ||
| 53 | it("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 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import type { RelayEnvironmentView } from "../connection/useConnectionController"; | |
| 4 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | |
| 5 | ||
| 6 | const 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 | ||
| 12 | export 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 | ||
| 23 | export 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 | ||
| 33 | const pocketPiId = EnvironmentId.make("showcase-pocket-pi"); | |
| 34 | const pocketPiEndpoint = { | |
| 35 | httpBaseUrl: "https://pocket-pi.t3.sh", | |
| 36 | wsBaseUrl: "wss://pocket-pi.t3.sh", | |
| 37 | providerKind: "t3_relay" as const, | |
| 38 | }; | |
| 39 | ||
| 40 | export 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 | ||
| 52 | export 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 @@ |
| 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | |
| 2 | import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | import { assert, it } from "@effect/vitest"; | |
| 4 | ||
| 5 | import { | |
| 6 | buildShowcasePendingTasks, | |
| 7 | SHOWCASE_PENDING_TASK_DEFINITIONS, | |
| 8 | } from "./showcasePendingTasks"; | |
| 9 | ||
| 10 | const 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 | ||
| 35 | it("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 | ||
| 70 | it("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 @@ |
| 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | |
| 2 | import { | |
| 3 | CommandId, | |
| 4 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 5 | DEFAULT_RUNTIME_MODE, | |
| 6 | MessageId, | |
| 7 | ProviderInstanceId, | |
| 8 | ThreadId, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | ||
| 11 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; | |
| 12 | ||
| 13 | export 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 | ||
| 30 | const FALLBACK_MODEL_SELECTION = { | |
| 31 | instanceId: ProviderInstanceId.make("codex"), | |
| 32 | model: "gpt-5.4", | |
| 33 | } as const; | |
| 34 | ||
| 35 | export 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { retryShowcaseOperation } from "./showcaseRetry"; | |
| 4 | ||
| 5 | it("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 | ||
| 19 | it("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 | ||
| 33 | it("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 @@ |
| 1 | export interface ShowcaseRetryOptions { | |
| 2 | readonly isCancelled: () => boolean; | |
| 3 | readonly attemptTimeoutMs?: number; | |
| 4 | readonly retryDelayMs?: number; | |
| 5 | } | |
| 6 | ||
| 7 | const DEFAULT_ATTEMPT_TIMEOUT_MS = 10_000; | |
| 8 | const DEFAULT_RETRY_DELAY_MS = 500; | |
| 9 | ||
| 10 | function delay(ms: number): Promise<void> { | |
| 11 | return new Promise((resolve) => setTimeout(resolve, ms)); | |
| 12 | } | |
| 13 | ||
| 14 | async 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. */ | |
| 34 | export 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 { |
| 37 | 37 | readonly buffer: string; |
| 38 | 38 | readonly fontSize?: number; |
| 39 | 39 | readonly isRunning: boolean; |
| 40 | readonly autoFocus?: boolean; | |
| 40 | 41 | readonly keyboardFocusRequest?: number; |
| 41 | 42 | readonly theme?: TerminalTheme; |
| 42 | 43 | readonly onInput: (data: string) => void; |
| ... | ... | @@ -215,6 +216,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf |
| 215 | 216 | <View style={props.style}> |
| 216 | 217 | <NativeTerminalSurfaceView |
| 217 | 218 | appearanceScheme={appearanceScheme} |
| 219 | autoFocus={props.autoFocus ?? true} | |
| 218 | 220 | backgroundColor={theme.background} |
| 219 | 221 | focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0} |
| 220 | 222 | foregroundColor={theme.foreground} |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+2| ... | ... | @@ -70,6 +70,7 @@ import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiSt |
| 70 | 70 | const DEFAULT_TERMINAL_COLS = 80; |
| 71 | 71 | const DEFAULT_TERMINAL_ROWS = 24; |
| 72 | 72 | const TERMINAL_ACCESSORY_HEIGHT = 52; |
| 73 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; | |
| 73 | 74 | |
| 74 | 75 | type PendingModifier = "ctrl" | "meta"; |
| 75 | 76 | type HostPlatform = "mac" | "linux" | "windows" | "unknown"; |
| ... | ... | @@ -1218,6 +1219,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1218 | 1219 | <> |
| 1219 | 1220 | <View className="flex-1" style={{ paddingBottom: terminalBottomInset }}> |
| 1220 | 1221 | <TerminalSurface |
| 1222 | autoFocus={!SHOWCASE_ENABLED} | |
| 1221 | 1223 | buffer={terminalSurfaceBuffer} |
| 1222 | 1224 | fontSize={fontSize} |
| 1223 | 1225 | isRunning={isRunning} |
apps/mobile/src/features/terminal/nativeTerminalModule.ts+1| ... | ... | @@ -23,6 +23,7 @@ interface TerminalResizeEvent { |
| 23 | 23 | |
| 24 | 24 | export interface NativeTerminalSurfaceProps extends ViewProps { |
| 25 | 25 | readonly appearanceScheme?: "light" | "dark"; |
| 26 | readonly autoFocus?: boolean; | |
| 26 | 27 | readonly focusRequest?: number; |
| 27 | 28 | readonly themeConfig?: string; |
| 28 | 29 | readonly backgroundColor?: string; |
apps/mobile/src/state/thread-outbox-manager.ts+4-1| ... | ... | @@ -101,7 +101,10 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 101 | 101 | cause, |
| 102 | 102 | }); |
| 103 | 103 | } |
| 104 | setMessages([...currentMessages(), message]); | |
| 104 | setMessages([ | |
| 105 | ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), | |
| 106 | message, | |
| 107 | ]); | |
| 105 | 108 | }); |
| 106 | 109 | |
| 107 | 110 | // 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", () => { |
| 294 | 294 | registry.dispose(); |
| 295 | 295 | }); |
| 296 | 296 | |
| 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 | ||
| 297 | 322 | it("updates a queued message in place but never resurrects a removed one", async () => { |
| 298 | 323 | const registry = AtomRegistry.make(); |
| 299 | 324 | 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 | ||
| 3 | The screenshot harness runs the real mobile application against three disposable local T3 | |
| 4 | environments. It creates an isolated base directory and server for each environment, real Git | |
| 5 | projects with deterministic content, seeded orchestration projections, and persisted terminal | |
| 6 | history. The app pairs with every server through its normal connection flow and React Navigation | |
| 7 | opens the production Home, Thread, ThreadTerminal, ThreadReview, and SettingsEnvironments routes. | |
| 8 | ||
| 9 | No screenshot-specific screen recreates application UI. `EXPO_PUBLIC_SHOWCASE=1` only enables the | |
| 10 | non-rendering pairing/readiness coordinator, disables terminal autofocus so captures do not contain | |
| 11 | the software keyboard, and supplies deterministic T3 Connect discovery rows to the real | |
| 12 | Environments screen. The local environment cards always come from real paired servers. | |
| 13 | ||
| 14 | ## Capture the default matrix | |
| 15 | ||
| 16 | From the repository root: | |
| 17 | ||
| 18 | pnpm screenshots:mobile | |
| 19 | ||
| 20 | The command: | |
| 21 | ||
| 22 | 1. Creates three temporary T3 base directories and starts a local server for each on an available | |
| 23 | port. | |
| 24 | 2. Creates T3 Code, React, and Linux Git repositories with recognizable favicons, feature branches, | |
| 25 | and a deterministic T3 Code review diff. | |
| 26 | 3. 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. | |
| 28 | 4. Starts an isolated Metro server, builds the selected native apps, and boots each device. | |
| 29 | 5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin. | |
| 30 | 6. Navigates to the real application route for every requested scene. | |
| 31 | 7. 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. | |
| 33 | 8. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded | |
| 34 | directly to App Store Connect or Google Play Console. | |
| 35 | ||
| 36 | The servers, Metro, temporary root directory, and devices started by the runner are cleaned up after | |
| 37 | capture. Pass `--keep-running` to retain them for inspection; the runner prints the base-directory | |
| 38 | paths and server ports. | |
| 39 | ||
| 40 | Captures wait for the real environment snapshot to hydrate and for the requested route to become | |
| 41 | active. Both platforms record readiness in the simulator/emulator app container. A final settle | |
| 42 | delay allows native terminal and Git review data to finish rendering. | |
| 43 | ||
| 44 | A full capture regenerates the selected native project with Expo's clean development prebuild before | |
| 45 | building it. Use --skip-build for repeated captures after the first build. | |
| 46 | ||
| 47 | The harness uses its own Metro port (8199 by default), so an ordinary mobile server or another | |
| 48 | worktree cannot accidentally provide the bundle being photographed. | |
| 49 | ||
| 50 | The 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 | ||
| 61 | Each target captures thread, terminal, review, thread list, and environments, producing 30 PNG | |
| 62 | files for one appearance or 60 for both. Each appearance folder's five screenshots satisfy the configured Apple limit of 1–10, Google | |
| 63 | phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8. | |
| 64 | ||
| 65 | The 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 | ||
| 77 | Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD | |
| 78 | names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport. | |
| 79 | ||
| 80 | ## Capture in GitHub Actions | |
| 81 | ||
| 82 | Run 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 | |
| 84 | runs iOS and Android concurrently: iPhone and iPad capture on a | |
| 85 | 12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a | |
| 86 | 16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator. | |
| 87 | ||
| 88 | Every job uploads its PNGs even when a later capture fails, which makes partial runs useful for | |
| 89 | diagnosis. Download `app-store-connect-screenshots` and `google-play-screenshots` from the workflow | |
| 90 | run's Artifacts section. Each job runs validation again immediately before upload. Artifacts are | |
| 91 | retained for 14 days. | |
| 92 | ||
| 93 | The workflow uses the same checked-in device and scene matrix as local capture. Android remains | |
| 94 | ARM64 by default for local Apple Silicon development; CI sets `T3_SHOWCASE_ANDROID_ABI=x86_64` so the | |
| 95 | debug APK matches its accelerated emulator. | |
| 96 | ||
| 97 | ## Fast iteration | |
| 98 | ||
| 99 | Capture one scene or device: | |
| 100 | ||
| 101 | pnpm screenshots:mobile --device iphone-6.9 --scene thread | |
| 102 | pnpm screenshots:mobile --platform android --scene review | |
| 103 | ||
| 104 | Override 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 | ||
| 110 | Reuse the native build and retain the disposable environment: | |
| 111 | ||
| 112 | pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running | |
| 113 | ||
| 114 | Run Metro separately: | |
| 115 | ||
| 116 | pnpm --filter @t3tools/mobile showcase | |
| 117 | pnpm screenshots:mobile --skip-build --skip-metro --device iphone-6.9 | |
| 118 | ||
| 119 | List the matrix and flags: | |
| 120 | ||
| 121 | pnpm screenshots:mobile --list | |
| 122 | ||
| 123 | Validate 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 | ||
| 137 | Fixture timestamps are generated relative to capture startup so every route shows stable relative | |
| 138 | labels while the server still receives valid current data. The same deterministic three-environment | |
| 139 | ensemble serves iPhone, iPad, Android phone, and Android tablet captures; responsive differences | |
| 140 | come entirely from the production app layout. | |
| 141 | ||
| 142 | The Pending rows use the production offline outbox and point at the real T3 Code and React fixture | |
| 143 | projects. Showcase coordination holds those two entries in the outbox for capture, just like a task | |
| 144 | currently open for editing, so reconnecting the seeded environments cannot deliver and remove them | |
| 145 | before the screenshot is taken. | |
| 146 | ||
| 147 | The Environments capture presents the three local fixture transports as a Tailscale HTTPS hostname, | |
| 148 | a Helsinki VPS hostname, and a Tailnet IPv4 address. This display-only substitution keeps the cards | |
| 149 | remote-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 | ||
| 156 | The harness is the source of truth for upload dimensions; do not resize its output. If store rules | |
| 157 | change, update the target's `storeAsset` specification. Capture fails when a PNG is the wrong size, | |
| 158 | has alpha, is not 8-bit RGB, exceeds the configured file-size limit, violates Google Play's 9:16 | |
| 159 | shape/bounds, or leaves a full output set below its store minimum. |
package.json+1| ... | ... | @@ -13,6 +13,7 @@ |
| 13 | 13 | "start:desktop": "vp run --filter @t3tools/desktop start", |
| 14 | 14 | "start:marketing": "vp run --filter @t3tools/marketing preview", |
| 15 | 15 | "start:mock-update-server": "node scripts/mock-update-server.ts", |
| 16 | "screenshots:mobile": "node scripts/mobile-showcase.ts", | |
| 16 | 17 | "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", |
| 17 | 18 | "build:marketing": "vp run --filter @t3tools/marketing build", |
| 18 | 19 | "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", |
pnpm-lock.yaml+20-3| ... | ... | @@ -66,7 +66,7 @@ overrides: |
| 66 | 66 | vite: npm:@voidzero-dev/vite-plus-core@0.2.2 |
| 67 | 67 | yaml: ^2.9.0 |
| 68 | 68 | |
| 69 | packageExtensionsChecksum: sha256-FV3+2NW/9MFbunJaPsMxvO0LAzyfccoPtPiKoIXAwUU= | |
| 69 | packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= | |
| 70 | 70 | |
| 71 | 71 | patchedDependencies: |
| 72 | 72 | '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f |
| ... | ... | @@ -193,7 +193,7 @@ importers: |
| 193 | 193 | 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) |
| 194 | 194 | '@clerk/expo': |
| 195 | 195 | 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) | |
| 197 | 197 | '@effect/atom-react': |
| 198 | 198 | specifier: 4.0.0-beta.78 |
| 199 | 199 | 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: |
| 902 | 902 | effect: |
| 903 | 903 | specifier: 4.0.0-beta.78 |
| 904 | 904 | version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) |
| 905 | pngjs: | |
| 906 | specifier: 7.0.0 | |
| 907 | version: 7.0.0 | |
| 905 | 908 | yaml: |
| 906 | 909 | specifier: ^2.9.0 |
| 907 | 910 | version: 2.9.0 |
| ... | ... | @@ -909,6 +912,9 @@ importers: |
| 909 | 912 | '@effect/vitest': |
| 910 | 913 | specifier: 4.0.0-beta.78 |
| 911 | 914 | 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 | |
| 912 | 918 | vite-plus: |
| 913 | 919 | specifier: 'catalog:' |
| 914 | 920 | 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: |
| 4898 | 4904 | '@types/node@24.12.4': |
| 4899 | 4905 | resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} |
| 4900 | 4906 | |
| 4907 | '@types/pngjs@6.0.5': | |
| 4908 | resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} | |
| 4909 | ||
| 4901 | 4910 | '@types/react-dom@19.2.3': |
| 4902 | 4911 | resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} |
| 4903 | 4912 | peerDependencies: |
| ... | ... | @@ -11472,11 +11481,12 @@ snapshots: |
| 11472 | 11481 | electron-store: 8.2.0 |
| 11473 | 11482 | react-dom: 19.2.6(react@19.2.6) |
| 11474 | 11483 | |
| 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)': | |
| 11476 | 11485 | dependencies: |
| 11477 | 11486 | '@clerk/clerk-js': 6.25.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11478 | 11487 | '@clerk/react': 6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11479 | 11488 | '@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) | |
| 11480 | 11490 | base-64: 1.0.0 |
| 11481 | 11491 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 11482 | 11492 | react: 19.2.3 |
| ... | ... | @@ -11490,6 +11500,9 @@ snapshots: |
| 11490 | 11500 | expo-secure-store: 56.0.4(expo@56.0.12) |
| 11491 | 11501 | 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)) |
| 11492 | 11502 | react-dom: 19.2.3(react@19.2.3) |
| 11503 | transitivePeerDependencies: | |
| 11504 | - supports-color | |
| 11505 | - typescript | |
| 11493 | 11506 | |
| 11494 | 11507 | '@clerk/react@6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 11495 | 11508 | dependencies: |
| ... | ... | @@ -14925,6 +14938,10 @@ snapshots: |
| 14925 | 14938 | dependencies: |
| 14926 | 14939 | undici-types: 7.16.0 |
| 14927 | 14940 | |
| 14941 | '@types/pngjs@6.0.5': | |
| 14942 | dependencies: | |
| 14943 | '@types/node': 24.12.4 | |
| 14944 | ||
| 14928 | 14945 | '@types/react-dom@19.2.3(@types/react@19.2.16)': |
| 14929 | 14946 | dependencies: |
| 14930 | 14947 | '@types/react': 19.2.16 |
pnpm-workspace.yaml+3| ... | ... | @@ -92,6 +92,9 @@ overrides: |
| 92 | 92 | yaml: "catalog:" |
| 93 | 93 | |
| 94 | 94 | packageExtensions: |
| 95 | "@clerk/expo@*": | |
| 96 | dependencies: | |
| 97 | "@expo/config-plugins": 56.0.9 | |
| 95 | 98 | "@effect/vitest@*": |
| 96 | 99 | dependencies: |
| 97 | 100 | 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. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | import * as NodeFSP from "node:fs/promises"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import * as NodeSqlite from "node:sqlite"; | |
| 6 | import * as NodeUtil from "node:util"; | |
| 7 | ||
| 8 | const execFile = NodeUtil.promisify(NodeChildProcess.execFile); | |
| 9 | ||
| 10 | export const SHOWCASE_PROJECT_ID = "t3code"; | |
| 11 | export const SHOWCASE_THREAD_ID = "remote-command-center"; | |
| 12 | export const SHOWCASE_TERMINAL_ID = "term-1"; | |
| 13 | ||
| 14 | export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; | |
| 15 | export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; | |
| 16 | ||
| 17 | const 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 | ||
| 29 | const MODEL_SELECTION = JSON.stringify({ instanceId: "codex", model: "gpt-5.4" }); | |
| 30 | const 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 | ||
| 47 | export 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 | ||
| 60 | const BASE_ENVIRONMENT_PRESENCE = `export function environmentLabel(count: number): string { | |
| 61 | return \`${"${count}"} environments\`; | |
| 62 | } | |
| 63 | `; | |
| 64 | ||
| 65 | const UPDATED_ENVIRONMENT_PRESENCE = `const PULSE = ["✦", "✧", "·", "✧"] as const; | |
| 66 | ||
| 67 | export 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 | ||
| 73 | const REMOTE_HANDOFF_CARD = `import { View, Text } from "react-native"; | |
| 74 | ||
| 75 | export 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 | ||
| 85 | const 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 | ||
| 105 | export 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 | ||
| 129 | export 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 | ||
| 147 | export 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 | ||
| 217 | function minutesBefore(now: number, minutes: number): string { | |
| 218 | return new Date(now - minutes * 60_000).toISOString(); | |
| 219 | } | |
| 220 | ||
| 221 | async 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 | ||
| 234 | async 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 | ||
| 245 | async 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 | ||
| 274 | async 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 | ||
| 293 | function 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 | ||
| 361 | function 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 | ||
| 516 | export 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 @@ |
| 1 | import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts"; | |
| 2 | ||
| 3 | export { SHOWCASE_SCENES }; | |
| 4 | export type { ShowcaseScene }; | |
| 5 | ||
| 6 | export type ShowcaseAppearance = "light" | "dark"; | |
| 7 | ||
| 8 | export 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 | ||
| 19 | export 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 | ||
| 32 | export 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 | ||
| 51 | export type ShowcaseDevice = ShowcaseIosDevice | ShowcaseAndroidDevice; | |
| 52 | ||
| 53 | export interface ShowcaseConfig { | |
| 54 | readonly outputDirectory: string; | |
| 55 | readonly metroPort: number; | |
| 56 | readonly settleDelayMs: number; | |
| 57 | readonly devices: ReadonlyArray<ShowcaseDevice>; | |
| 58 | } | |
| 59 | ||
| 60 | const ANDROID_ABIS = ["arm64-v8a", "x86_64", "x86", "armeabi-v7a"] as const; | |
| 61 | ||
| 62 | export 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 | */ | |
| 80 | const 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 | ||
| 206 | export default config; |
scripts/mobile-showcase.test.ts created+296| ... | ... | @@ -0,0 +1,296 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { PNG } from "pngjs"; | |
| 3 | ||
| 4 | import showcaseConfig, { | |
| 5 | resolveShowcaseAndroidAbi, | |
| 6 | type ShowcaseConfig, | |
| 7 | type ShowcaseStoreAssetSpec, | |
| 8 | } from "./mobile-showcase.config.ts"; | |
| 9 | import { | |
| 10 | SHOWCASE_ENVIRONMENTS, | |
| 11 | SHOWCASE_PROJECTS, | |
| 12 | SHOWCASE_THREADS, | |
| 13 | } from "./mobile-showcase-environment.ts"; | |
| 14 | import { | |
| 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 | ||
| 30 | const appleSpec: ShowcaseStoreAssetSpec = { | |
| 31 | store: "apple", | |
| 32 | directory: "apple/iphone-test", | |
| 33 | width: 1284, | |
| 34 | height: 2778, | |
| 35 | minimumUploadCount: 1, | |
| 36 | maximumUploadCount: 10, | |
| 37 | }; | |
| 38 | ||
| 39 | const 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 | ||
| 49 | const 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 | ||
| 73 | it("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 | ||
| 92 | it("rejects unsupported system appearances", () => { | |
| 93 | assert.throws( | |
| 94 | () => parseShowcaseCliArgs(["--appearance", "sepia"]), | |
| 95 | /Unsupported appearance 'sepia'/u, | |
| 96 | ); | |
| 97 | }); | |
| 98 | ||
| 99 | it("parses validation-only mode", () => { | |
| 100 | assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true); | |
| 101 | }); | |
| 102 | ||
| 103 | it("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 | ||
| 109 | it("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 | ||
| 127 | it("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 | ||
| 140 | it("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 | ||
| 156 | it("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 | ||
| 161 | it("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 | ||
| 179 | function 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 | ||
| 185 | it("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 | ||
| 196 | it("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 | ||
| 203 | it("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 | ||
| 209 | it("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 | ||
| 215 | it("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 | ||
| 234 | it("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 | ||
| 246 | it("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 | ||
| 266 | it("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 | ||
| 283 | it("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 | ||
| 290 | it("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 | ||
| 4 | import * as NodeChildProcess from "node:child_process"; | |
| 5 | import * as NodeFSP from "node:fs/promises"; | |
| 6 | import * as NodeNet from "node:net"; | |
| 7 | import * as NodeOS from "node:os"; | |
| 8 | import * as NodePath from "node:path"; | |
| 9 | import * as NodeProcess from "node:process"; | |
| 10 | import * as NodeURL from "node:url"; | |
| 11 | ||
| 12 | import { PNG } from "pngjs"; | |
| 13 | ||
| 14 | import 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"; | |
| 24 | import { | |
| 25 | SHOWCASE_ENVIRONMENTS, | |
| 26 | SHOWCASE_PROJECTS, | |
| 27 | SHOWCASE_TERMINAL_ID, | |
| 28 | SHOWCASE_THREAD_ID, | |
| 29 | seedShowcaseEnvironment, | |
| 30 | } from "./mobile-showcase-environment.ts"; | |
| 31 | ||
| 32 | const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); | |
| 33 | const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); | |
| 34 | const ANDROID_PACKAGE = "com.t3tools.t3code.dev"; | |
| 35 | const APP_SCHEME = "t3code-dev"; | |
| 36 | const IOS_READY_FILENAME = "T3ShowcaseReadyScene"; | |
| 37 | const SERVER_HOST = "0.0.0.0"; | |
| 38 | const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64"; | |
| 39 | const IOS_APP_PATH = NodePath.join( | |
| 40 | MOBILE_ROOT, | |
| 41 | ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3CodeDev.app", | |
| 42 | ); | |
| 43 | const ANDROID_APK_PATH = NodePath.join( | |
| 44 | MOBILE_ROOT, | |
| 45 | "android/app/build/outputs/apk/debug/app-debug.apk", | |
| 46 | ); | |
| 47 | export 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 | ||
| 57 | const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env); | |
| 58 | const 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 | ||
| 71 | interface 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 | ||
| 83 | export interface ShowcaseCapture { | |
| 84 | readonly device: ShowcaseDevice; | |
| 85 | readonly scenes: ReadonlyArray<ShowcaseScene>; | |
| 86 | readonly appearance: ShowcaseAppearance; | |
| 87 | } | |
| 88 | ||
| 89 | interface IosCaptureCleanup { | |
| 90 | readonly udid: string; | |
| 91 | readonly startedByRunner: boolean; | |
| 92 | readonly createdByRunner: boolean; | |
| 93 | } | |
| 94 | ||
| 95 | interface AndroidCaptureCleanup { | |
| 96 | readonly device: ShowcaseAndroidDevice; | |
| 97 | readonly serial: string; | |
| 98 | readonly startedByRunner: boolean; | |
| 99 | } | |
| 100 | ||
| 101 | interface NetworkAddress { | |
| 102 | readonly address: string; | |
| 103 | readonly family: string; | |
| 104 | readonly internal: boolean; | |
| 105 | } | |
| 106 | ||
| 107 | export 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 | ||
| 116 | function 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 | ||
| 126 | export interface PngMetadata { | |
| 127 | readonly width: number; | |
| 128 | readonly height: number; | |
| 129 | readonly bitDepth: number; | |
| 130 | readonly colorType: number; | |
| 131 | readonly hasAlpha: boolean; | |
| 132 | } | |
| 133 | ||
| 134 | export 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 | ||
| 150 | export 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 | ||
| 158 | export 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 | ||
| 168 | export 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 | ||
| 204 | export 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 | ||
| 221 | export 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 | ||
| 228 | async 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 | ||
| 241 | async 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 | ||
| 263 | function 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 | ||
| 271 | export 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 | ||
| 348 | export 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 | ||
| 381 | function printUsage(config: ShowcaseConfig): void { | |
| 382 | NodeProcess.stdout.write(`App screenshot showcase | |
| 383 | ||
| 384 | Usage: | |
| 385 | pnpm --filter @t3tools/mobile screenshots [options] | |
| 386 | ||
| 387 | Options: | |
| 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 | ||
| 399 | Scenes: ${SHOWCASE_SCENES.join(", ")} | |
| 400 | ||
| 401 | Configured 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 | ||
| 411 | function 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 | ||
| 424 | async 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 | ||
| 446 | async 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 | ||
| 464 | function delay(milliseconds: number): Promise<void> { | |
| 465 | return new Promise((resolve) => setTimeout(resolve, milliseconds)); | |
| 466 | } | |
| 467 | ||
| 468 | async 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 | ||
| 482 | async 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 | ||
| 503 | async 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 | ||
| 519 | async function createShowcaseShell(baseDir: string): Promise<string> { | |
| 520 | const shellPath = NodePath.join(baseDir, "showcase-shell"); | |
| 521 | await NodeFSP.writeFile( | |
| 522 | shellPath, | |
| 523 | `#!/bin/sh | |
| 524 | if [ "$1" = "-ilc" ] || [ "$1" = "-lic" ]; then | |
| 525 | exec /bin/sh -c "$2" | |
| 526 | fi | |
| 527 | exec /bin/cat | |
| 528 | `, | |
| 529 | { mode: 0o755 }, | |
| 530 | ); | |
| 531 | return shellPath; | |
| 532 | } | |
| 533 | ||
| 534 | async 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 | |
| 538 | if [ "$1" = "--get" ] && [ "$2" = "ComputerName" ]; then | |
| 539 | printf '%s\\n' ${JSON.stringify(label)} | |
| 540 | exit 0 | |
| 541 | fi | |
| 542 | if [ "$1" = "--pretty" ]; then | |
| 543 | printf '%s\\n' ${JSON.stringify(label)} | |
| 544 | exit 0 | |
| 545 | fi | |
| 546 | exit 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 | ||
| 556 | function 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 | ||
| 589 | export 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 | ||
| 604 | async 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 | ||
| 613 | function 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 | ||
| 619 | export 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 | ||
| 630 | export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray<string>): string { | |
| 631 | return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`; | |
| 632 | } | |
| 633 | ||
| 634 | function 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 | ||
| 648 | async 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 | ||
| 657 | async 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 | ||
| 686 | async 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 | ||
| 705 | async function existingArtifact(path: string): Promise<string | null> { | |
| 706 | return await NodeFSP.access(path).then( | |
| 707 | () => path, | |
| 708 | () => null, | |
| 709 | ); | |
| 710 | } | |
| 711 | ||
| 712 | interface SimctlDevice { | |
| 713 | readonly name: string; | |
| 714 | readonly udid: string; | |
| 715 | readonly state: "Booted" | "Shutdown" | string; | |
| 716 | readonly isAvailable: boolean; | |
| 717 | } | |
| 718 | ||
| 719 | async 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 | ||
| 732 | async 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 | ||
| 758 | async 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 | ||
| 778 | async function iosAppContainer(udid: string): Promise<string> { | |
| 779 | return ( | |
| 780 | await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) | |
| 781 | ).trim(); | |
| 782 | } | |
| 783 | ||
| 784 | async 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 | ||
| 803 | async 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 | ||
| 907 | function androidSdkTool(relativePath: string): string { | |
| 908 | return NodePath.join(ANDROID_SDK_ROOT, relativePath); | |
| 909 | } | |
| 910 | ||
| 911 | async function adbOutput(serial: string, args: ReadonlyArray<string>): Promise<string> { | |
| 912 | return await commandOutput(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]); | |
| 913 | } | |
| 914 | ||
| 915 | async function runAdb(serial: string, args: ReadonlyArray<string>): Promise<void> { | |
| 916 | await runCommand(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]); | |
| 917 | } | |
| 918 | ||
| 919 | async 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 | ||
| 934 | async 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 | ||
| 950 | async 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 | ||
| 1014 | async 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 | ||
| 1034 | async 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 | ||
| 1041 | async 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 | ||
| 1058 | async 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 | ||
| 1143 | async 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 | ||
| 1164 | async 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 | ||
| 1339 | if (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 @@ |
| 11 | 11 | "@t3tools/contracts": "workspace:*", |
| 12 | 12 | "@t3tools/shared": "workspace:*", |
| 13 | 13 | "effect": "catalog:", |
| 14 | "pngjs": "7.0.0", | |
| 14 | 15 | "yaml": "catalog:" |
| 15 | 16 | }, |
| 16 | 17 | "devDependencies": { |
| 17 | 18 | "@effect/vitest": "catalog:", |
| 19 | "@types/pngjs": "6.0.5", | |
| 18 | 20 | "vite-plus": "catalog:" |
| 19 | 21 | } |
| 20 | 22 | } |