1import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
2import type { DevicePermission, DeviceSummary, DeviceTextSize } from "@t3tools/contracts";
3import { ChevronDown, X } from "lucide-react";
4import { useEffect, useState } from "react";
5
6import { Button } from "~/components/ui/button";
7import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible";
8import { Input } from "~/components/ui/input";
9import {
10 Select,
11 SelectItem,
12 SelectPopup,
13 SelectTrigger,
14 SelectValue,
15} from "~/components/ui/select";
16import { Spinner } from "~/components/ui/spinner";
17import { Switch } from "~/components/ui/switch";
18import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
19import { cn } from "~/lib/utils";
20import type { DeviceControls } from "./useDeviceControls";
21import { type DeviceEventLogEntry, subscribeDeviceEventLog } from "./deviceHubApi";
22
23const TEXT_SIZES: ReadonlyArray<{ value: DeviceTextSize; label: string }> = [
24 { value: "small", label: "Small" },
25 { value: "default", label: "Default" },
26 { value: "large", label: "Large" },
27 { value: "extra-large", label: "Extra large" },
28];
29
30const COLOR_FILTERS = [
31 { value: "none", label: "None" },
32 { value: "grayscale", label: "Grayscale" },
33 { value: "red-green", label: "Red / green (protanopia)" },
34 { value: "green-red", label: "Green / red (deuteranopia)" },
35 { value: "blue-yellow", label: "Blue / yellow (tritanopia)" },
36] as const;
37
38const ORIENTATIONS = [
39 { value: "portrait", label: "Portrait" },
40 { value: "landscape_left", label: "Landscape left" },
41 { value: "portrait_upside_down", label: "Upside down" },
42 { value: "landscape_right", label: "Landscape right" },
43] as const;
44
45const IOS_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [
46 { value: "camera", label: "Camera" },
47 { value: "microphone", label: "Microphone" },
48 { value: "photos", label: "Photos" },
49 { value: "contacts", label: "Contacts" },
50 { value: "calendar", label: "Calendar" },
51 { value: "reminders", label: "Reminders" },
52 { value: "location", label: "Location" },
53 { value: "notifications", label: "Notifications" },
54 { value: "motion", label: "Motion" },
55 { value: "media-library", label: "Media library" },
56 { value: "faceid", label: "Face ID" },
57];
58
59const ANDROID_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [
60 { value: "camera", label: "Camera" },
61 { value: "microphone", label: "Microphone" },
62 { value: "photos", label: "Photos" },
63 { value: "contacts", label: "Contacts" },
64 { value: "calendar", label: "Calendar" },
65 { value: "location", label: "Location" },
66 { value: "notifications", label: "Notifications" },
67 { value: "motion", label: "Physical activity" },
68];
69
70const LOCATION_PRESETS = [
71 { label: "San Francisco", latitude: 37.7749, longitude: -122.4194 },
72 { label: "New York", latitude: 40.7128, longitude: -74.006 },
73 { label: "London", latitude: 51.5074, longitude: -0.1278 },
74 { label: "Stockholm", latitude: 59.3293, longitude: 18.0686 },
75 { label: "Tokyo", latitude: 35.6762, longitude: 139.6503 },
76] as const;
77
78/**
79 * The Tools drawer for one open device: current settings read from the device,
80 * one control per supported action, and the read-only feeds the hub exposes.
81 * Every change is a `device.action` round trip; the returned detail replaces
82 * local state so the controls never show a value the device did not confirm.
83 */
84export function DeviceToolsPanel(props: {
85 readonly controls: DeviceControls;
86 readonly hostDiagnostics: string | undefined;
87 readonly device: DeviceSummary;
88 readonly access: DeviceHubAccess | null;
89 readonly axOverlay: boolean;
90 readonly onAxOverlayChange: (enabled: boolean) => void;
91 readonly onClose: () => void;
92 readonly className?: string;
93}) {
94 const { device, controls } = props;
95 const { detail, pending, error, foregroundApp, disabled, act } = controls;
96 const settings = detail?.settings;
97 const isIos = device.platform === "ios";
98
99 return (
100 <div
101 className={cn("flex min-h-0 flex-col border-border bg-background text-sm", props.className)}
102 >
103 <div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
104 <span className="font-medium">Tools</span>
105 {pending ? <Spinner size="sm" /> : null}
106 <Button
107 size="icon-xs"
108 variant="ghost-muted"
109 aria-label="Close tools"
110 className="ml-auto"
111 onClick={props.onClose}
112 >
113 <X />
114 </Button>
115 </div>
116 <div className="min-h-0 flex-1 overflow-y-auto">
117 {error ? (
118 <p className="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{error}</p>
119 ) : null}
120 {detail === null && !error ? (
121 <div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
122 <Spinner size="sm" /> Reading device settings…
123 </div>
124 ) : null}
125
126 {props.hostDiagnostics ? (
127 <Section title="Host diagnostics">
128 <p className="whitespace-pre-line text-xs text-muted-foreground">
129 {props.hostDiagnostics}
130 </p>
131 </Section>
132 ) : null}
133
134 <Section title="App">
135 <Row label="Foreground">
136 <span className="truncate font-mono text-xs">{foregroundApp?.id ?? "—"}</span>
137 </Row>
138 {foregroundApp ? (
139 <div className="flex gap-1.5">
140 <Button
141 size="xs"
142 variant="outline"
143 disabled={disabled}
144 onClick={() => void act({ type: "terminateApp", appId: foregroundApp.id })}
145 >
146 Terminate
147 </Button>
148 <Button
149 size="xs"
150 variant="outline"
151 disabled={disabled}
152 onClick={() => void act({ type: "launchApp", appId: foregroundApp.id })}
153 >
154 Relaunch
155 </Button>
156 </div>
157 ) : null}
158 <SubmitRow
159 placeholder="https://… or myapp://"
160 action="Open"
161 disabled={disabled}
162 onSubmit={(url) => act({ type: "openUrl", url })}
163 />
164 <SubmitRow
165 placeholder={isIos ? "Bundle ID to launch" : "Package name to launch"}
166 action="Launch"
167 disabled={disabled}
168 onSubmit={(appId) => act({ type: "launchApp", appId })}
169 />
170 </Section>
171
172 <Section title={isIos ? "Simulator" : "Emulator"}>
173 <Row label="Appearance">
174 <ToggleGroup
175 aria-label="Appearance"
176 value={settings?.appearance ? [settings.appearance] : []}
177 disabled={disabled}
178 onValueChange={(value) => {
179 const next = value[0];
180 if (next === "light" || next === "dark")
181 void act({ type: "setAppearance", value: next });
182 }}
183 >
184 <Toggle value="light">Light</Toggle>
185 <Toggle value="dark">Dark</Toggle>
186 </ToggleGroup>
187 </Row>
188 <Row label="Text size">
189 <ChoiceSelect
190 ariaLabel="Text size"
191 value={settings?.textSize ?? null}
192 options={TEXT_SIZES}
193 disabled={disabled}
194 onChange={(value) => act({ type: "setTextSize", value })}
195 />
196 </Row>
197 {isIos ? (
198 <>
199 <Row label="Liquid Glass">
200 <ToggleGroup
201 aria-label="Liquid Glass"
202 value={settings?.liquidGlass ? [settings.liquidGlass] : []}
203 disabled={disabled || settings?.liquidGlass === undefined}
204 onValueChange={(value) => {
205 const next = value[0];
206 if (next === "clear" || next === "tinted") {
207 void act({ type: "setLiquidGlass", value: next });
208 }
209 }}
210 >
211 <Toggle value="clear">Clear</Toggle>
212 <Toggle value="tinted">Tinted</Toggle>
213 </ToggleGroup>
214 </Row>
215 <Row label="Color filter">
216 <ChoiceSelect
217 ariaLabel="Color filter"
218 value={settings?.colorFilter ?? null}
219 options={COLOR_FILTERS}
220 disabled={disabled}
221 onChange={(value) => act({ type: "setColorFilter", value })}
222 />
223 </Row>
224 </>
225 ) : (
226 <Row label="Orientation">
227 <ChoiceSelect
228 ariaLabel="Orientation"
229 value={null}
230 placeholder="Rotate to…"
231 options={ORIENTATIONS}
232 disabled={disabled}
233 onChange={(value) => act({ type: "setOrientation", value })}
234 />
235 </Row>
236 )}
237 <SwitchRow
238 label="Reduce Motion"
239 checked={settings?.reduceMotion}
240 disabled={disabled}
241 onChange={(value) => act({ type: "setToggle", setting: "reduceMotion", value })}
242 />
243 {isIos ? (
244 <>
245 <SwitchRow
246 label="Increase Contrast"
247 checked={settings?.increaseContrast}
248 disabled={disabled}
249 onChange={(value) => act({ type: "setToggle", setting: "increaseContrast", value })}
250 />
251 <SwitchRow
252 label="Reduce Transparency"
253 checked={settings?.reduceTransparency}
254 disabled={disabled}
255 onChange={(value) =>
256 act({ type: "setToggle", setting: "reduceTransparency", value })
257 }
258 />
259 <SwitchRow
260 label="Show Borders"
261 checked={settings?.showBorders}
262 disabled={disabled}
263 onChange={(value) => act({ type: "setToggle", setting: "showBorders", value })}
264 />
265 <SwitchRow
266 label="VoiceOver"
267 checked={settings?.voiceOver}
268 disabled={disabled}
269 onChange={(value) => act({ type: "setToggle", setting: "voiceOver", value })}
270 />
271 </>
272 ) : (
273 <SwitchRow
274 label="Network"
275 checked={settings?.networkEnabled}
276 disabled={disabled}
277 onChange={(value) => act({ type: "setToggle", setting: "networkEnabled", value })}
278 />
279 )}
280 </Section>
281
282 <Section title="Accessibility">
283 <SwitchRow
284 label="Overlay element frames"
285 checked={props.axOverlay}
286 disabled={props.access === null}
287 onChange={(value) => {
288 props.onAxOverlayChange(value);
289 return Promise.resolve();
290 }}
291 />
292 </Section>
293
294 <LocationSection
295 disabled={disabled}
296 canClear={isIos}
297 onSet={(latitude, longitude) => act({ type: "setLocation", latitude, longitude })}
298 onClear={() => act({ type: "clearLocation" })}
299 />
300
301 <PermissionsSection
302 permissions={isIos ? IOS_PERMISSIONS : ANDROID_PERMISSIONS}
303 canReset={isIos}
304 defaultAppId={foregroundApp?.id ?? ""}
305 disabled={disabled}
306 onDecide={(appId, permission, decision) =>
307 act({ type: "setPermission", appId, permission, decision })
308 }
309 />
310
311 {isIos ? (
312 <Section title="Push notification">
313 <SubmitRow
314 placeholder="Alert text"
315 action="Send"
316 disabled={disabled || !foregroundApp}
317 onSubmit={(payload) =>
318 foregroundApp
319 ? act({ type: "sendPush", appId: foregroundApp.id, payload })
320 : Promise.resolve()
321 }
322 />
323 {!foregroundApp ? (
324 <p className="text-xs text-muted-foreground">Open an app first.</p>
325 ) : null}
326 </Section>
327 ) : null}
328
329 {isIos && props.access ? <EventLogSection access={props.access} device={device} /> : null}
330 </div>
331 </div>
332 );
333}
334
335function Section(props: { readonly title: string; readonly children: React.ReactNode }) {
336 return (
337 <section className="flex flex-col gap-2 border-b px-3 py-2.5 last:border-b-0">
338 <h3 className="text-xs font-medium text-muted-foreground">{props.title}</h3>
339 {props.children}
340 </section>
341 );
342}
343
344function Row(props: { readonly label: string; readonly children: React.ReactNode }) {
345 return (
346 <div className="flex min-h-7 items-center justify-between gap-3">
347 <span className="shrink-0 text-xs text-muted-foreground">{props.label}</span>
348 <div className="flex min-w-0 items-center justify-end">{props.children}</div>
349 </div>
350 );
351}
352
353function SwitchRow(props: {
354 readonly label: string;
355 readonly checked: boolean | undefined;
356 readonly disabled: boolean;
357 readonly onChange: (value: boolean) => Promise<void>;
358}) {
359 return (
360 <Row label={props.label}>
361 <Switch
362 size="sm"
363 aria-label={props.label}
364 checked={props.checked ?? false}
365 disabled={props.disabled || props.checked === undefined}
366 onCheckedChange={(checked) => void props.onChange(checked)}
367 />
368 </Row>
369 );
370}
371
372function ChoiceSelect<V extends string>(props: {
373 readonly ariaLabel: string;
374 readonly value: V | null;
375 readonly options: ReadonlyArray<{ readonly value: V; readonly label: string }>;
376 readonly disabled: boolean;
377 readonly placeholder?: string;
378 readonly onChange: (value: V) => Promise<void>;
379}) {
380 const current = props.options.find((option) => option.value === props.value);
381 return (
382 <Select
383 value={props.value}
384 disabled={props.disabled}
385 onValueChange={(value) => {
386 if (value !== null && value !== props.value) void props.onChange(value as V);
387 }}
388 >
389 <SelectTrigger size="xs" className="w-40" aria-label={props.ariaLabel}>
390 <SelectValue>
391 {current ? (
392 current.label
393 ) : (
394 <span className="text-muted-foreground">{props.placeholder ?? "Unknown"}</span>
395 )}
396 </SelectValue>
397 </SelectTrigger>
398 <SelectPopup align="end" alignItemWithTrigger={false}>
399 {props.options.map((option) => (
400 <SelectItem key={option.value} value={option.value}>
401 {option.label}
402 </SelectItem>
403 ))}
404 </SelectPopup>
405 </Select>
406 );
407}
408
409function SubmitRow(props: {
410 readonly placeholder: string;
411 readonly action: string;
412 readonly disabled: boolean;
413 readonly onSubmit: (value: string) => Promise<void>;
414}) {
415 const [value, setValue] = useState("");
416 const submit = () => {
417 const trimmed = value.trim();
418 if (!trimmed) return;
419 void props.onSubmit(trimmed).then(() => setValue(""));
420 };
421 return (
422 <form
423 className="flex gap-1.5"
424 onSubmit={(event) => {
425 event.preventDefault();
426 submit();
427 }}
428 >
429 <Input
430 size="compact"
431 font="mono"
432 className="min-w-0 flex-1"
433 placeholder={props.placeholder}
434 value={value}
435 disabled={props.disabled}
436 onChange={(event) => setValue(event.target.value)}
437 />
438 <Button
439 type="submit"
440 size="xs"
441 variant="outline"
442 disabled={props.disabled || value.trim().length === 0}
443 >
444 {props.action}
445 </Button>
446 </form>
447 );
448}
449
450function LocationSection(props: {
451 readonly disabled: boolean;
452 readonly canClear: boolean;
453 readonly onSet: (latitude: number, longitude: number) => Promise<void>;
454 readonly onClear: () => Promise<void>;
455}) {
456 const [latitude, setLatitude] = useState("");
457 const [longitude, setLongitude] = useState("");
458 const parsed = { latitude: Number(latitude), longitude: Number(longitude) };
459 const valid =
460 latitude.trim() !== "" &&
461 longitude.trim() !== "" &&
462 Math.abs(parsed.latitude) <= 90 &&
463 Math.abs(parsed.longitude) <= 180;
464 return (
465 <Section title="Location">
466 <div className="flex gap-1.5">
467 <Input
468 size="compact"
469 font="mono"
470 className="min-w-0 flex-1"
471 placeholder="Latitude"
472 inputMode="decimal"
473 value={latitude}
474 disabled={props.disabled}
475 onChange={(event) => setLatitude(event.target.value)}
476 />
477 <Input
478 size="compact"
479 font="mono"
480 className="min-w-0 flex-1"
481 placeholder="Longitude"
482 inputMode="decimal"
483 value={longitude}
484 disabled={props.disabled}
485 onChange={(event) => setLongitude(event.target.value)}
486 />
487 </div>
488 <div className="flex flex-wrap items-center gap-1.5">
489 <Select<string | null>
490 value={null}
491 disabled={props.disabled}
492 onValueChange={(value) => {
493 const preset = LOCATION_PRESETS.find((candidate) => candidate.label === value);
494 if (!preset) return;
495 setLatitude(String(preset.latitude));
496 setLongitude(String(preset.longitude));
497 void props.onSet(preset.latitude, preset.longitude);
498 }}
499 >
500 <SelectTrigger size="xs" className="w-32" aria-label="Location preset">
501 <SelectValue>
502 <span className="text-muted-foreground">Preset…</span>
503 </SelectValue>
504 </SelectTrigger>
505 <SelectPopup align="start" alignItemWithTrigger={false}>
506 {LOCATION_PRESETS.map((preset) => (
507 <SelectItem key={preset.label} value={preset.label}>
508 {preset.label}
509 </SelectItem>
510 ))}
511 </SelectPopup>
512 </Select>
513 <Button
514 size="xs"
515 variant="outline"
516 disabled={props.disabled || !valid}
517 onClick={() => void props.onSet(parsed.latitude, parsed.longitude)}
518 >
519 Set
520 </Button>
521 {props.canClear ? (
522 <Button
523 size="xs"
524 variant="ghost"
525 disabled={props.disabled}
526 onClick={() => {
527 setLatitude("");
528 setLongitude("");
529 void props.onClear();
530 }}
531 >
532 Clear
533 </Button>
534 ) : null}
535 </div>
536 </Section>
537 );
538}
539
540function PermissionsSection(props: {
541 readonly permissions: ReadonlyArray<{ value: DevicePermission; label: string }>;
542 readonly canReset: boolean;
543 readonly defaultAppId: string;
544 readonly disabled: boolean;
545 readonly onDecide: (
546 appId: string,
547 permission: DevicePermission,
548 decision: "grant" | "revoke" | "reset",
549 ) => Promise<void>;
550}) {
551 const [appId, setAppId] = useState("");
552 const [permission, setPermission] = useState<DevicePermission>("camera");
553 const resolvedAppId = appId.trim() || props.defaultAppId;
554 const decide = (decision: "grant" | "revoke" | "reset") =>
555 void props.onDecide(resolvedAppId, permission, decision);
556 return (
557 <Section title="Permissions">
558 <Input
559 size="compact"
560 font="mono"
561 placeholder={props.defaultAppId || "App ID"}
562 value={appId}
563 disabled={props.disabled}
564 onChange={(event) => setAppId(event.target.value)}
565 />
566 <div className="flex flex-wrap items-center gap-1.5">
567 <ChoiceSelect
568 ariaLabel="Permission"
569 value={permission}
570 options={props.permissions}
571 disabled={props.disabled}
572 onChange={(value) => {
573 setPermission(value);
574 return Promise.resolve();
575 }}
576 />
577 <Button
578 size="xs"
579 variant="outline"
580 disabled={props.disabled || !resolvedAppId}
581 onClick={() => decide("grant")}
582 >
583 Grant
584 </Button>
585 <Button
586 size="xs"
587 variant="outline"
588 disabled={props.disabled || !resolvedAppId}
589 onClick={() => decide("revoke")}
590 >
591 Revoke
592 </Button>
593 {props.canReset ? (
594 <Button
595 size="xs"
596 variant="ghost"
597 disabled={props.disabled || !resolvedAppId}
598 onClick={() => decide("reset")}
599 >
600 Reset
601 </Button>
602 ) : null}
603 </div>
604 </Section>
605 );
606}
607
608const EVENT_LOG_LIMIT = 100;
609
610function EventLogSection(props: {
611 readonly access: DeviceHubAccess;
612 readonly device: DeviceSummary;
613}) {
614 const [open, setOpen] = useState(false);
615 const [entries, setEntries] = useState<ReadonlyArray<DeviceEventLogEntry>>([]);
616
617 useEffect(() => {
618 if (!open) return;
619 const unsubscribe = subscribeDeviceEventLog(
620 { access: props.access, platform: props.device.platform, deviceId: props.device.id },
621 (incoming, reset) => {
622 setEntries((current) => {
623 const merged = reset ? [...incoming] : [...current, ...incoming];
624 return merged.length > EVENT_LOG_LIMIT ? merged.slice(-EVENT_LOG_LIMIT) : merged;
625 });
626 },
627 );
628 return () => {
629 unsubscribe();
630 setEntries([]);
631 };
632 }, [open, props.access, props.device.id, props.device.platform]);
633
634 return (
635 <Collapsible open={open} onOpenChange={setOpen}>
636 <CollapsibleTrigger className="flex w-full items-center gap-1.5 border-b px-3 py-2.5 text-left text-xs font-medium text-muted-foreground">
637 Event log
638 <ChevronDown
639 className={cn("ml-auto size-3.5 transition-transform", open && "rotate-180")}
640 />
641 </CollapsibleTrigger>
642 <CollapsiblePanel>
643 <ol className="max-h-64 overflow-y-auto px-3 py-2 font-mono text-2xs leading-relaxed">
644 {entries.length === 0 ? (
645 <li className="text-muted-foreground">No events yet.</li>
646 ) : (
647 entries.map((entry) => (
648 <li key={entry.id} className="flex gap-2">
649 <span className="shrink-0 text-muted-foreground">
650 {entry.timestamp.slice(11, 19)}
651 </span>
652 <span className="truncate">{entry.summary}</span>
653 </li>
654 ))
655 )}
656 </ol>
657 </CollapsiblePanel>
658 </Collapsible>
659 );
660}