| 1 | import { requireOptionalNativeModule } from "expo"; |
| 2 | |
| 3 | import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme"; |
| 4 | |
| 5 | export const SHOWCASE_SCENES = [ |
| 6 | "threads", |
| 7 | "thread", |
| 8 | "terminal", |
| 9 | "review", |
| 10 | "environments", |
| 11 | "agent-activity", |
| 12 | ] as const; |
| 13 | export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; |
| 14 | |
| 15 | export type ShowcaseOrientation = "portrait" | "landscape"; |
| 16 | |
| 17 | interface NativeShowcaseControls { |
| 18 | readonly getShowcasePairingUrl?: () => string | null; |
| 19 | readonly getShowcaseScene?: () => string | null; |
| 20 | readonly getShowcaseTheme?: () => string | null; |
| 21 | readonly getShowcaseOrientation?: () => string | null; |
| 22 | readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>; |
| 23 | readonly getInterfaceOrientation?: () => Promise<string>; |
| 24 | readonly prepareShowcaseCapture?: () => void; |
| 25 | readonly markShowcaseReady?: (scene: ShowcaseScene) => void; |
| 26 | } |
| 27 | |
| 28 | function nativeShowcaseControls(): NativeShowcaseControls | null { |
| 29 | return requireOptionalNativeModule<NativeShowcaseControls>("T3NativeControls"); |
| 30 | } |
| 31 | |
| 32 | export function getNativeShowcasePairingUrls(): ReadonlyArray<string> { |
| 33 | try { |
| 34 | let raw = nativeShowcaseControls()?.getShowcasePairingUrl?.()?.trim(); |
| 35 | if (!raw) return []; |
| 36 | if (raw.startsWith("json-uri:")) { |
| 37 | try { |
| 38 | raw = decodeURIComponent(raw.slice("json-uri:".length)); |
| 39 | } catch { |
| 40 | return []; |
| 41 | } |
| 42 | } |
| 43 | try { |
| 44 | const parsed: unknown = JSON.parse(raw); |
| 45 | if (Array.isArray(parsed)) { |
| 46 | return parsed.filter( |
| 47 | (candidate): candidate is string => |
| 48 | typeof candidate === "string" && candidate.trim().length > 0, |
| 49 | ); |
| 50 | } |
| 51 | } catch { |
| 52 | // Older runners pass a single URL rather than a JSON array. |
| 53 | } |
| 54 | return [raw]; |
| 55 | } catch { |
| 56 | return []; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | export function getNativeShowcaseScene(): ShowcaseScene | null { |
| 61 | try { |
| 62 | const scene = nativeShowcaseControls()?.getShowcaseScene?.()?.trim(); |
| 63 | return SHOWCASE_SCENES.find((candidate) => candidate === scene) ?? null; |
| 64 | } catch { |
| 65 | return null; |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Returns null when the runner requested no palette, which leaves the stored |
| 71 | * theme preference untouched. An unknown id also reads as null rather than |
| 72 | * silently falling back, so a capture never claims to show a theme it does not. |
| 73 | */ |
| 74 | export function getNativeShowcaseTheme(): MobileThemeId | null { |
| 75 | try { |
| 76 | const theme = nativeShowcaseControls()?.getShowcaseTheme?.()?.trim(); |
| 77 | return MOBILE_THEME_IDS.find((candidate) => candidate === theme) ?? null; |
| 78 | } catch { |
| 79 | return null; |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | export function prepareNativeShowcaseCapture(): void { |
| 84 | try { |
| 85 | nativeShowcaseControls()?.prepareShowcaseCapture?.(); |
| 86 | } catch { |
| 87 | // The harness still works when a development build predates this helper. |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | export function getNativeShowcaseOrientation(): ShowcaseOrientation | null { |
| 92 | try { |
| 93 | const orientation = nativeShowcaseControls()?.getShowcaseOrientation?.()?.trim(); |
| 94 | return orientation === "portrait" || orientation === "landscape" ? orientation : null; |
| 95 | } catch { |
| 96 | return null; |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | export async function applyNativeShowcaseOrientation( |
| 101 | orientation: ShowcaseOrientation, |
| 102 | ): Promise<boolean> { |
| 103 | const controls = nativeShowcaseControls(); |
| 104 | if (!controls?.applyShowcaseOrientation || !controls.getInterfaceOrientation) { |
| 105 | // A development build that predates this helper keeps its default |
| 106 | // orientation; report success so callers do not retry forever. |
| 107 | return true; |
| 108 | } |
| 109 | try { |
| 110 | await controls.applyShowcaseOrientation(orientation); |
| 111 | // The geometry request settles asynchronously; confirm it took effect so |
| 112 | // callers can retry attempts made before the scene was foreground-active. |
| 113 | await new Promise((resolve) => setTimeout(resolve, 500)); |
| 114 | return (await controls.getInterfaceOrientation()) === orientation; |
| 115 | } catch { |
| 116 | return false; |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | export function markNativeShowcaseReady(scene: ShowcaseScene): void { |
| 121 | try { |
| 122 | nativeShowcaseControls()?.markShowcaseReady?.(scene); |
| 123 | } catch { |
| 124 | // The readiness marker is capture-runner metadata, never app functionality. |
| 125 | } |
| 126 | } |