1import { requireOptionalNativeModule } from "expo";
2
3import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme";
4
5export const SHOWCASE_SCENES = [
6 "threads",
7 "thread",
8 "terminal",
9 "review",
10 "environments",
11 "agent-activity",
12] as const;
13export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
14
15export type ShowcaseOrientation = "portrait" | "landscape";
16
17interface 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
28function nativeShowcaseControls(): NativeShowcaseControls | null {
29 return requireOptionalNativeModule<NativeShowcaseControls>("T3NativeControls");
30}
31
32export 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
60export 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 */
74export 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
83export 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
91export 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
100export 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
120export 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}