Skip to content
Source
Sign inSign up

DeviceSetup.tsx

404 lines14.9 KB

1import { DeviceHostUpdates } from "./DeviceHostUpdates";
2import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
3import { Check, CircleAlert } from "lucide-react";
4import { AuthSettingsWriteScope } from "@t3tools/contracts";
5import { useAtomMount } from "@effect/atom-react";
6import { useState } from "react";
7​
8import { Button } from "~/components/ui/button";
9import { DialogClose } from "~/components/ui/dialog";
10import { WizardHeader, WizardPanel, WizardSteps, WizardFooter } from "~/components/ui/wizard";
11import { Spinner } from "~/components/ui/spinner";
12import { Switch } from "~/components/ui/switch";
13import { deviceEnvironment, deviceHostProbeAtom, NO_DEVICE_PROBE_ATOM } from "~/state/device";
14import { useAtomCommand } from "~/state/use-atom-command";
15import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
16import { cn } from "~/lib/utils";
17​
18const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android");
19​
20const deviceHubDescription =
21 "Enable this environment to open simulators and emulators, whether they run here or on a remote device host.";
22const agentDeviceDescription =
23 "Allow new agent sessions in this environment to start and control local and remote devices, with required tools set up automatically.";
24​
25/** Platforms whose latest listing on a ready host may be missing devices. */
26export const incompleteListings = (
27 status: DeviceServiceState["hostStatuses"][string] | undefined,
28) =>
29 (["ios", "android"] as const).filter(
30 (platform) =>
31 status?.status === "ready" &&
32 (status.incompletePlatforms?.includes(platform) ||
33 (platform === "android" && status.androidDiscoveryIncomplete === true)),
34 );
35​
36export const isAndroidDiscoveryUncertain = (state: DeviceServiceState) =>
37 Object.values(state.hostStatuses).some((status) =>
38 incompleteListings(status).includes("android"),
39 );
40​
41/**
42 * `hostStatus` only tracks this machine. A machine without simulator tooling never
43 * starts its own hub, so setup and the panel must also count remote device hosts.
44 */
45export function deviceHubReadiness(state: DeviceServiceState) {
46 const statuses = Object.values(state.hostStatuses).map((host) => host.status);
47 const ready = state.hostStatus === "ready" || statuses.includes("ready");
48 const busy =
49 !ready &&
50 [state.hostStatus, ...statuses].some(
51 (status) => status === "installing" || status === "starting",
52 );
53 return { ready, busy };
55​
56/** A ready host keeps its devices usable but may still carry a discovery warning. */
57function deviceHostDiagnostics(
58 hosts: ReadonlyArray<Pick<DeviceServiceState["hosts"][number], "id" | "label">>,
59 hostStatuses: DeviceServiceState["hostStatuses"],
60) {
61 return hosts.flatMap((host) => {
62 const status = hostStatuses[host.id];
63 return status?.status === "ready" && status.detail
64 ? [{ hostId: host.id, label: host.label, detail: status.detail }]
65 : [];
66 });
68​
69function DeviceHostDiagnostics(props: {
70 readonly hosts: ReadonlyArray<Pick<DeviceServiceState["hosts"][number], "id" | "label">>;
71 readonly hostStatuses: DeviceServiceState["hostStatuses"];
72 readonly className?: string;
73}) {
74 const diagnostics = deviceHostDiagnostics(props.hosts, props.hostStatuses);
75 if (diagnostics.length === 0) return null;
76 return (
77 <div role="status" className={cn("space-y-1 text-xs text-muted-foreground", props.className)}>
78 {diagnostics.map(({ hostId, label, detail }) => (
79 <p key={hostId} className="whitespace-pre-line break-words">
80 {label}: {detail}
81 </p>
82 ))}
83 </div>
84 );
86​
87export function platformSetupStatus(state: DeviceServiceState, platform: DevicePlatform) {
88 const hasDevices = state.devices.some((device) => device.platform === platform);
89 if (platform === "android" && !hasDevices && isAndroidDiscoveryUncertain(state)) {
90 return {
91 ready: false,
92 message: "The Android device list may be incomplete. Check the device host and try again.",
93 };
94 }
95 const availabilities = state.hosts.flatMap((host) =>
96 host.platforms
97 .filter((candidate) => candidate.platform === platform)
98 .map((candidate) => ({ ...candidate, host })),
99 );
100 const supportingHosts = availabilities
101 .filter((candidate) => candidate.available)
102 .map((candidate) => candidate.host);
103 if (supportingHosts.length === 0) {
104 return {
105 ready: false,
106 message: availabilities[0]?.reason ?? `${platformName(platform)} support was not detected.`,
107 };
108 }
109 const readyHosts = supportingHosts.filter(
110 (host) =>
111 state.hostStatuses[host.id]?.status === "ready" ||
112 (host.kind === "local" && state.hostStatus === "ready"),
113 );
114 if (readyHosts.length > 0 && !hasDevices) {
115 if (
116 readyHosts.some((host) =>
117 state.hostStatuses[host.id]?.incompletePlatforms?.includes(platform),
118 )
119 ) {
120 return {
121 ready: false,
122 message: `No ${platformName(platform)} devices were listed because device discovery reported a problem.`,
123 };
124 }
125 return {
126 ready: false,
127 message:
128 platform === "ios"
129 ? "Xcode is installed, but no iOS Simulator is available. Install a runtime in Xcode Settings → Components."
130 : "The Android SDK is installed, but no virtual device exists. Create one in Android Studio → Device Manager.",
131 };
132 }
133 const remoteHost = supportingHosts.every((host) => host.kind !== "local")
134 ? supportingHosts[0]
135 : undefined;
136 const location = remoteHost ? ` on ${remoteHost.label}` : "";
137 return {
138 ready: true,
139 message:
140 remoteHost?.kind === "eas"
141 ? `${remoteHost.label} starts ${platform === "ios" ? "iOS Simulators" : "Android emulators"} in the cloud with EAS Simulator.`
142 : platform === "ios"
143 ? `Xcode and iOS Simulator are available${location}.`
144 : `The Android SDK and Emulator are available${location}.`,
145 };
147​
148export function DeviceSetup(props: {
149 readonly environmentId: EnvironmentId;
150 readonly state: DeviceServiceState;
151 readonly onComplete?: () => void;
152}) {
153 const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope);
154 const configure = useAtomCommand(deviceEnvironment.configure);
155 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
156 const [pending, setPending] = useState<"hub" | "check" | "agent" | "complete" | null>(null);
157 const [step, setStep] = useState(0);
158 const enabled = props.state.hostStatus !== "disabled";
159 const { ready, busy } = deviceHubReadiness(props.state);
160 const localPlatformsUnavailable = props.state.hosts.some(
161 (host) => host.kind === "local" && !host.platforms.some((platform) => platform.available),
162 );
163​
164 useAtomMount(enabled ? deviceHostProbeAtom(props.environmentId) : NO_DEVICE_PROBE_ATOM);
165​
166 const update = async (
167 kind: NonNullable<typeof pending>,
168 input: { enabled?: boolean; agentAccessEnabled?: boolean; onboardingCompleted?: boolean },
169 ) => {
170 if (!readEnvironmentScope(props.environmentId, AuthSettingsWriteScope)) return;
171 setPending(kind);
172 const result = await configure({ environmentId: props.environmentId, input });
173 setPending(null);
174 if (kind === "complete" && result._tag === "Success") props.onComplete?.();
175 };
176​
177 return (
178 <>
179 <WizardHeader
180 title="Set up devices"
181 description="Review what runs on this environment before using simulators and emulators."
182 >
183 <WizardSteps
184 steps={["Device hub", "Simulators", "Agent access"]}
185 currentStep={step}
186 onStepChange={setStep}
187 isStepDisabled={(requested) => busy || pending !== null || requested > step}
188 />
189 </WizardHeader>
190​
191 <WizardPanel>
192 <DeviceHostUpdates state={props.state} environmentId={props.environmentId} />
193 {step === 0 ? (
194 <section className="space-y-3 text-sm">
195 <h3 className="font-medium">Enable the device hub</h3>
196 <div className="flex items-start justify-between gap-4">
197 <p className="text-muted-foreground">{deviceHubDescription}</p>
198 <Switch
199 checked={enabled}
200 disabled={!canConfigure || busy || pending !== null}
201 aria-label="Enable device hub"
202 onCheckedChange={(checked) =>
203 void update("hub", {
204 enabled: Boolean(checked),
205 ...(checked ? {} : { agentAccessEnabled: false }),
206 })
207 }
208 />
209 </div>
210 <DeviceHubSetupStatus
211 state={props.state}
212 pending={pending === "hub" || (busy && pending !== "agent")}
213 />
214 </section>
215 ) : null}
216​
217 {step === 1 || (step === 0 && enabled && localPlatformsUnavailable) ? (
218 <section className={cn("space-y-3 text-sm", step === 0 && "mt-4")}>
219 <h3 className="font-medium">Check simulator support</h3>
220 <DevicePlatformSetup
221 state={props.state}
222 checking={pending === "check"}
223 disabled={!enabled || busy || pending !== null}
224 onCheck={() => {
225 setPending("check");
226 void list({ environmentId: props.environmentId, input: {} }).finally(() =>
227 setPending(null),
228 );
229 }}
230 />
231 </section>
232 ) : null}
233​
234 {step === 2 ? (
235 <section className="space-y-3 text-sm">
236 <h3 className="font-medium">Allow agent control</h3>
237 <div className="flex items-start justify-between gap-4">
238 <p className="text-muted-foreground">{agentDeviceDescription}</p>
239 <Switch
240 checked={props.state.agentAccessEnabled}
241 disabled={!canConfigure || !enabled || busy || pending !== null}
242 aria-label="Allow agents to control devices"
243 onCheckedChange={(checked) =>
244 void update("agent", { agentAccessEnabled: Boolean(checked) })
245 }
246 />
247 </div>
248 <AgentDeviceSetupStatus state={props.state} pending={pending === "agent"} />
249 <p className="text-xs text-muted-foreground">
250 Leave this off to keep manual device controls without giving agents access.
251 </p>
252 </section>
253 ) : null}
254 {props.state.hostStatus === "failed" && props.state.hostStatusDetail ? (
255 <p role="alert" className="mt-3 text-xs text-destructive">
256 {props.state.hostStatusDetail}
257 </p>
258 ) : null}
259 </WizardPanel>
260​
261 <WizardFooter>
262 {step === 0 ? (
263 <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
264 ) : (
265 <Button
266 variant="outline"
267 disabled={busy || pending !== null}
268 onClick={() => setStep(step - 1)}
269 >
270 Back
271 </Button>
272 )}
273 {step < 2 ? (
274 <Button disabled={!ready || pending !== null} onClick={() => setStep(step + 1)}>
275 Continue
276 </Button>
277 ) : (
278 <Button
279 disabled={!canConfigure || !ready || pending !== null}
280 onClick={() => void update("complete", { onboardingCompleted: true })}
281 >
282 {pending === "complete" ? "Saving…" : "Done"}
283 </Button>
284 )}
285 </WizardFooter>
286 </>
287 );
289​
290export function DeviceHubSetupStatus({
291 state,
292 pending,
293 compact = false,
294}: {
295 readonly state: DeviceServiceState;
296 readonly pending: boolean;
297 readonly compact?: boolean;
298}) {
299 if (!pending && !deviceHubReadiness(state).ready) return null;
300 return (
301 <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
302 {pending ? <Spinner size="xs" /> : <Check className="size-3 text-success" />}
303 {pending
304 ? state.hostStatus === "installing"
305 ? compact
306 ? "Installing…"
307 : "Installing device hub…"
308 : state.hostStatus === "starting"
309 ? compact
310 ? "Starting…"
311 : "Starting device hub…"
312 : compact
313 ? "Updating…"
314 : "Updating device hub…"
315 : "Device hub is ready."}
316 </p>
317 );
319​
320function DevicePlatformSetup(props: {
321 readonly state: DeviceServiceState;
322 readonly checking: boolean;
323 readonly disabled: boolean;
324 readonly onCheck: () => void;
325}) {
326 return (
327 <div className="space-y-3">
328 <PlatformStatus platform="iOS" status={platformSetupStatus(props.state, "ios")} />
329 <PlatformStatus platform="Android" status={platformSetupStatus(props.state, "android")} />
330 <DeviceHostDiagnostics hosts={props.state.hosts} hostStatuses={props.state.hostStatuses} />
331 <p className="text-xs text-muted-foreground">
332 You can use either platform. Fixing a missing platform does not block the other one.
333 </p>
334 <Button size="compact" variant="outline" disabled={props.disabled} onClick={props.onCheck}>
335 {props.checking ? <Spinner size="xs" /> : null}
336 {props.checking ? "Checking…" : "Check again"}
337 </Button>
338 </div>
339 );
341​
342export function AgentDeviceSetupStatus(props: {
343 readonly state: DeviceServiceState;
344 readonly pending: boolean;
345 readonly compact?: boolean;
346}) {
347 if (props.pending) {
348 const label =
349 props.state.hostStatus === "installing"
350 ? props.compact
351 ? "Installing…"
352 : "Installing agent tools…"
353 : props.state.hostStatus === "starting"
354 ? props.compact
355 ? "Starting…"
356 : "Starting agent tools…"
357 : props.compact
358 ? "Updating…"
359 : "Updating agent access…";
360 return (
361 <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
362 <Spinner size="xs" />
363 {label}
364 </p>
365 );
366 }
367 if (
368 props.state.agentAccessEnabled &&
369 deviceHubReadiness(props.state).ready &&
370 props.state.hosts.some((host) => host.agentDeviceInstalled)
371 ) {
372 return (
373 <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
374 <Check className="size-3 text-success" />
375 Agent tools are ready.
376 </p>
377 );
378 }
379 return null;
381​
382function PlatformStatus(props: {
383 readonly platform: string;
384 readonly status: { readonly ready: boolean; readonly message: string };
385 readonly compact?: boolean;
386}) {
387 return (
388 <div
389 className={cn("flex gap-2", !props.compact && "rounded-md border border-border/60 px-3 py-2")}
390 >
391 {props.status.ready ? (
392 <Check className="mt-0.5 size-4 shrink-0 text-success" />
393 ) : (
394 <CircleAlert className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
395 )}
396 <div className={cn(props.compact && props.status.ready && "flex items-center gap-2")}>
397 <p className="font-medium">{props.platform}</p>
398 <p className="text-xs text-muted-foreground">
399 {props.compact && props.status.ready ? "Ready" : props.status.message}
400 </p>
401 </div>
402 </div>
403 );