Skip to content
Source
Sign inSign up

DeviceToolsPanel.tsx

652 lines21.4 KB

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