1import { DeviceHostUpdates } from "./DeviceHostUpdates";
2import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
3import { Check } from "lucide-react";
4import { Check as CheckGlyph, CircleAlert } from "lucide";
5import { AuthSettingsWriteScope } from "@t3tools/contracts";
6import { useState } from "react";
7
8import { Button } from "~/components/ui/button";
9import { DialogClose } from "~/components/ui/dialog";
10import { MorphIcon } from "~/components/MorphIcon";
11import { WizardHeader, WizardPanel, WizardSteps, WizardFooter } from "~/components/ui/wizard";
12import { Spinner } from "~/components/ui/spinner";
13import { Switch } from "~/components/ui/switch";
14import { deviceEnvironment } from "~/state/device";
15import { useAtomCommand } from "~/state/use-atom-command";
16import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
17import { cn } from "~/lib/utils";
18
19const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android");
20
21export const deviceHubDescription =
22 "Enable this environment to open simulators and emulators, whether they run here or on a remote device host.";
23export const agentDeviceDescription =
24 "Allow new agent sessions in this environment to start and control local and remote devices, with required tools set up automatically.";
25
26export function platformSetupStatus(state: DeviceServiceState, platform: DevicePlatform) {
27 const availability = state.hosts
28 .flatMap((host) => host.platforms)
29 .find((candidate) => candidate.platform === platform);
30 if (!availability?.available) {
31 return {
32 ready: false,
33 message: availability?.reason ?? `${platformName(platform)} support was not detected.`,
34 };
35 }
36 if (
37 state.hostStatus === "ready" &&
38 !state.devices.some((device) => device.platform === platform)
39 ) {
40 return {
41 ready: false,
42 message:
43 platform === "ios"
44 ? "Xcode is installed, but no iOS Simulator is available. Install a runtime in Xcode Settings → Components."
45 : "The Android SDK is installed, but no virtual device exists. Create one in Android Studio → Device Manager.",
46 };
47 }
48 return {
49 ready: true,
50 message:
51 platform === "ios"
52 ? "Xcode and iOS Simulator are available."
53 : "The Android SDK and Emulator are available.",
54 };
55}
56
57export function DeviceSetup(props: {
58 readonly environmentId: EnvironmentId;
59 readonly state: DeviceServiceState;
60 readonly onComplete?: () => void;
61}) {
62 const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope);
63 const configure = useAtomCommand(deviceEnvironment.configure);
64 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
65 const [pending, setPending] = useState<"hub" | "check" | "agent" | "complete" | null>(null);
66 const [step, setStep] = useState(0);
67 const enabled = props.state.hostStatus !== "disabled";
68 const busy = props.state.hostStatus === "installing" || props.state.hostStatus === "starting";
69 const localPlatformsUnavailable = props.state.hosts.some(
70 (host) => host.kind === "local" && !host.platforms.some((platform) => platform.available),
71 );
72
73 const update = async (
74 kind: NonNullable<typeof pending>,
75 input: { enabled?: boolean; agentAccessEnabled?: boolean; onboardingCompleted?: boolean },
76 ) => {
77 if (!readEnvironmentScope(props.environmentId, AuthSettingsWriteScope)) return;
78 setPending(kind);
79 try {
80 const result = await configure({ environmentId: props.environmentId, input });
81 if (kind === "complete" && result._tag === "Success") props.onComplete?.();
82 } finally {
83 setPending(null);
84 }
85 };
86
87 return (
88 <>
89 <WizardHeader
90 title="Set up devices"
91 description="Review what runs on this environment before using simulators and emulators."
92 >
93 <WizardSteps
94 steps={["Device hub", "Simulators", "Agent access"]}
95 currentStep={step}
96 onStepChange={setStep}
97 isStepDisabled={(requested) => busy || pending !== null || requested > step}
98 />
99 </WizardHeader>
100
101 <WizardPanel>
102 <DeviceHostUpdates state={props.state} environmentId={props.environmentId} />
103 {step === 0 ? (
104 <section className="space-y-3 text-sm">
105 <h3 className="font-medium">Enable the device hub</h3>
106 <div className="flex items-start justify-between gap-4">
107 <p className="text-muted-foreground">{deviceHubDescription}</p>
108 <Switch
109 checked={enabled}
110 disabled={!canConfigure || busy || pending !== null}
111 aria-label="Enable device hub"
112 onCheckedChange={(checked) =>
113 void update("hub", {
114 enabled: Boolean(checked),
115 ...(checked ? {} : { agentAccessEnabled: false }),
116 })
117 }
118 />
119 </div>
120 <DeviceHubSetupStatus
121 state={props.state}
122 pending={pending === "hub" || (busy && pending !== "agent")}
123 />
124 </section>
125 ) : null}
126
127 {step === 1 || (step === 0 && enabled && localPlatformsUnavailable) ? (
128 <section className={cn("space-y-3 text-sm", step === 0 && "mt-4")}>
129 <h3 className="font-medium">Check simulator support</h3>
130 <DevicePlatformSetup
131 state={props.state}
132 checking={pending === "check"}
133 disabled={!enabled || busy || pending !== null}
134 onCheck={() => {
135 setPending("check");
136 void list({ environmentId: props.environmentId, input: {} }).finally(() =>
137 setPending(null),
138 );
139 }}
140 />
141 </section>
142 ) : null}
143
144 {step === 2 ? (
145 <section className="space-y-3 text-sm">
146 <h3 className="font-medium">Allow agent control</h3>
147 <div className="flex items-start justify-between gap-4">
148 <p className="text-muted-foreground">{agentDeviceDescription}</p>
149 <Switch
150 checked={props.state.agentAccessEnabled}
151 disabled={!canConfigure || !enabled || busy || pending !== null}
152 aria-label="Allow agents to control devices"
153 onCheckedChange={(checked) =>
154 void update("agent", { agentAccessEnabled: Boolean(checked) })
155 }
156 />
157 </div>
158 <AgentDeviceSetupStatus state={props.state} pending={pending === "agent"} />
159 <p className="text-xs text-muted-foreground">
160 Leave this off to keep manual device controls without giving agents access.
161 </p>
162 </section>
163 ) : null}
164 {props.state.hostStatus === "failed" && props.state.hostStatusDetail ? (
165 <p role="alert" className="mt-3 text-xs text-destructive">
166 {props.state.hostStatusDetail}
167 </p>
168 ) : null}
169 </WizardPanel>
170
171 <WizardFooter>
172 {step === 0 ? (
173 <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
174 ) : (
175 <Button
176 variant="outline"
177 disabled={busy || pending !== null}
178 onClick={() => setStep(step - 1)}
179 >
180 Back
181 </Button>
182 )}
183 {step < 2 ? (
184 <Button
185 disabled={props.state.hostStatus !== "ready" || pending !== null}
186 onClick={() => setStep(step + 1)}
187 >
188 Continue
189 </Button>
190 ) : (
191 <Button
192 disabled={!canConfigure || props.state.hostStatus !== "ready" || pending !== null}
193 onClick={() => void update("complete", { onboardingCompleted: true })}
194 >
195 {pending === "complete" ? "Saving…" : "Done"}
196 </Button>
197 )}
198 </WizardFooter>
199 </>
200 );
201}
202
203export function DeviceHubSetupStatus({
204 state,
205 pending,
206 compact = false,
207}: {
208 readonly state: DeviceServiceState;
209 readonly pending: boolean;
210 readonly compact?: boolean;
211}) {
212 if (!pending && state.hostStatus !== "ready") return null;
213 return (
214 <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
215 {pending ? <Spinner size="xs" /> : <Check className="size-3 text-success" />}
216 {pending
217 ? state.hostStatus === "installing"
218 ? compact
219 ? "Installing…"
220 : "Installing device hub…"
221 : state.hostStatus === "starting"
222 ? compact
223 ? "Starting…"
224 : "Starting device hub…"
225 : compact
226 ? "Updating…"
227 : "Updating device hub…"
228 : "Device hub is ready."}
229 </p>
230 );
231}
232
233function DevicePlatformSetup(props: {
234 readonly state: DeviceServiceState;
235 readonly checking: boolean;
236 readonly disabled: boolean;
237 readonly onCheck: () => void;
238}) {
239 return (
240 <div className="space-y-3">
241 <PlatformStatus platform="iOS" status={platformSetupStatus(props.state, "ios")} />
242 <PlatformStatus platform="Android" status={platformSetupStatus(props.state, "android")} />
243 <p className="text-xs text-muted-foreground">
244 You can use either platform. Fixing a missing platform does not block the other one.
245 </p>
246 <Button size="compact" variant="outline" disabled={props.disabled} onClick={props.onCheck}>
247 {props.checking ? <Spinner size="xs" /> : null}
248 {props.checking ? "Checking…" : "Check again"}
249 </Button>
250 </div>
251 );
252}
253
254export function AgentDeviceSetupStatus(props: {
255 readonly state: DeviceServiceState;
256 readonly pending: boolean;
257 readonly compact?: boolean;
258}) {
259 if (props.pending) {
260 const label =
261 props.state.hostStatus === "installing"
262 ? props.compact
263 ? "Installing…"
264 : "Installing agent tools…"
265 : props.state.hostStatus === "starting"
266 ? props.compact
267 ? "Starting…"
268 : "Starting agent tools…"
269 : props.compact
270 ? "Updating…"
271 : "Updating agent access…";
272 return (
273 <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
274 <Spinner size="xs" />
275 {label}
276 </p>
277 );
278 }
279 if (
280 props.state.agentAccessEnabled &&
281 props.state.hostStatus === "ready" &&
282 props.state.hosts.some((host) => host.agentDeviceInstalled)
283 ) {
284 return (
285 <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
286 <Check className="size-3 text-success" />
287 Agent tools are ready.
288 </p>
289 );
290 }
291 return null;
292}
293
294export function PlatformStatus(props: {
295 readonly platform: string;
296 readonly status: { readonly ready: boolean; readonly message: string };
297 readonly compact?: boolean;
298}) {
299 return (
300 <div
301 className={cn("flex gap-2", !props.compact && "rounded-md border border-border/60 px-3 py-2")}
302 >
303 <MorphIcon
304 className={cn(
305 "mt-0.5 size-4 shrink-0",
306 props.status.ready ? "text-success" : "text-muted-foreground",
307 )}
308 icon={props.status.ready ? CheckGlyph : CircleAlert}
309 />
310 <div className={cn(props.compact && props.status.ready && "flex items-center gap-2")}>
311 <p className="font-medium">{props.platform}</p>
312 <p className="text-xs text-muted-foreground">
313 {props.compact && props.status.ready ? "Ready" : props.status.message}
314 </p>
315 </div>
316 </div>
317 );
318}