import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; import type { DeviceActionInput, DeviceDetail, DevicePermission, DeviceSummary, DeviceTextSize, EnvironmentId, } from "@t3tools/contracts"; import { ChevronDown, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "~/components/ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible"; import { Input } from "~/components/ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { Spinner } from "~/components/ui/spinner"; import { Switch } from "~/components/ui/switch"; import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; import { cn } from "~/lib/utils"; import { deviceEnvironment } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; import { type DeviceEventLogEntry, type DeviceForegroundInfo, subscribeDeviceEventLog, subscribeDeviceForeground, } from "./deviceHubApi"; type ActionBody = DeviceActionInput extends infer A ? A extends { readonly type: string } ? Omit : never : never; const TEXT_SIZES: ReadonlyArray<{ value: DeviceTextSize; label: string }> = [ { value: "small", label: "Small" }, { value: "default", label: "Default" }, { value: "large", label: "Large" }, { value: "extra-large", label: "Extra large" }, ]; const COLOR_FILTERS = [ { value: "none", label: "None" }, { value: "grayscale", label: "Grayscale" }, { value: "red-green", label: "Red / green (protanopia)" }, { value: "green-red", label: "Green / red (deuteranopia)" }, { value: "blue-yellow", label: "Blue / yellow (tritanopia)" }, ] as const; const ORIENTATIONS = [ { value: "portrait", label: "Portrait" }, { value: "landscape_left", label: "Landscape left" }, { value: "portrait_upside_down", label: "Upside down" }, { value: "landscape_right", label: "Landscape right" }, ] as const; const IOS_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [ { value: "camera", label: "Camera" }, { value: "microphone", label: "Microphone" }, { value: "photos", label: "Photos" }, { value: "contacts", label: "Contacts" }, { value: "calendar", label: "Calendar" }, { value: "reminders", label: "Reminders" }, { value: "location", label: "Location" }, { value: "notifications", label: "Notifications" }, { value: "motion", label: "Motion" }, { value: "media-library", label: "Media library" }, { value: "faceid", label: "Face ID" }, ]; const ANDROID_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [ { value: "camera", label: "Camera" }, { value: "microphone", label: "Microphone" }, { value: "photos", label: "Photos" }, { value: "contacts", label: "Contacts" }, { value: "calendar", label: "Calendar" }, { value: "location", label: "Location" }, { value: "notifications", label: "Notifications" }, { value: "motion", label: "Physical activity" }, ]; const LOCATION_PRESETS = [ { label: "San Francisco", latitude: 37.7749, longitude: -122.4194 }, { label: "New York", latitude: 40.7128, longitude: -74.006 }, { label: "London", latitude: 51.5074, longitude: -0.1278 }, { label: "Stockholm", latitude: 59.3293, longitude: 18.0686 }, { label: "Tokyo", latitude: 35.6762, longitude: 139.6503 }, ] as const; /** * The Tools drawer for one open device: current settings read from the device, * one control per supported action, and the read-only feeds the hub exposes. * Every change is a `device.action` round trip; the returned detail replaces * local state so the controls never show a value the device did not confirm. */ export function DeviceToolsPanel(props: { readonly environmentId: EnvironmentId; readonly device: DeviceSummary; readonly access: DeviceHubAccess | null; readonly axOverlay: boolean; readonly onAxOverlayChange: (enabled: boolean) => void; readonly onClose: () => void; readonly className?: string; }) { const { environmentId, device } = props; const readDetail = useAtomCommand(deviceEnvironment.detail, { reportFailure: false }); const runAction = useAtomCommand(deviceEnvironment.action, { reportFailure: false }); const [detail, setDetail] = useState(null); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const [foreground, setForeground] = useState(undefined); const isIos = device.platform === "ios"; const target = useMemo( () => ({ hostId: device.hostId, deviceId: device.id }), [device.hostId, device.id], ); // The panel is keyed by device, so a mount is always a fresh device. useEffect(() => { let cancelled = false; void readDetail({ environmentId, input: target }).then((result) => { if (cancelled) return; if (result._tag === "Success") setDetail(result.value); else setError(formatEnvironmentQueryError(result.cause)); }); return () => { cancelled = true; }; }, [environmentId, readDetail, target]); useEffect(() => { if (!props.access) return; return subscribeDeviceForeground( { access: props.access, platform: device.platform, deviceId: device.id }, setForeground, ); }, [device.id, device.platform, props.access]); const act = useCallback( async (body: ActionBody) => { setPending(true); setError(null); try { const result = await runAction({ environmentId, input: { ...target, ...body } as DeviceActionInput, }); if (result._tag === "Success") setDetail(result.value); else setError(formatEnvironmentQueryError(result.cause)); } finally { setPending(false); } }, [environmentId, runAction, target], ); const settings = detail?.settings; const foregroundApp = foreground === undefined ? (detail?.foregroundApp ?? null) : foreground; const disabled = pending || detail === null; return (
Tools {pending ? : null}
{error ? (

{error}

) : null} {detail === null && !error ? (
Reading device settings…
) : null}
{foregroundApp?.id ?? "—"} {foregroundApp ? (
) : null} act({ type: "openUrl", url })} /> act({ type: "launchApp", appId })} />
{ const next = value[0]; if (next === "light" || next === "dark") void act({ type: "setAppearance", value: next }); }} > Light Dark act({ type: "setTextSize", value })} /> {isIos ? ( <> { const next = value[0]; if (next === "clear" || next === "tinted") { void act({ type: "setLiquidGlass", value: next }); } }} > Clear Tinted act({ type: "setColorFilter", value })} /> ) : ( act({ type: "setOrientation", value })} /> )} act({ type: "setToggle", setting: "reduceMotion", value })} /> {isIos ? ( <> act({ type: "setToggle", setting: "increaseContrast", value })} /> act({ type: "setToggle", setting: "reduceTransparency", value }) } /> act({ type: "setToggle", setting: "showBorders", value })} /> act({ type: "setToggle", setting: "voiceOver", value })} /> ) : ( act({ type: "setToggle", setting: "networkEnabled", value })} /> )}
{ props.onAxOverlayChange(value); return Promise.resolve(); }} />
act({ type: "setLocation", latitude, longitude })} onClear={() => act({ type: "clearLocation" })} /> act({ type: "setPermission", appId, permission, decision }) } /> {isIos ? (
foregroundApp ? act({ type: "sendPush", appId: foregroundApp.id, payload }) : Promise.resolve() } /> {!foregroundApp ? (

Open an app first.

) : null}
) : null} {isIos && props.access ? : null}
); } function Section(props: { readonly title: string; readonly children: React.ReactNode }) { return (

{props.title}

{props.children}
); } function Row(props: { readonly label: string; readonly children: React.ReactNode }) { return (
{props.label}
{props.children}
); } function SwitchRow(props: { readonly label: string; readonly checked: boolean | undefined; readonly disabled: boolean; readonly onChange: (value: boolean) => Promise; }) { return ( void props.onChange(checked)} /> ); } function ChoiceSelect(props: { readonly ariaLabel: string; readonly value: V | null; readonly options: ReadonlyArray<{ readonly value: V; readonly label: string }>; readonly disabled: boolean; readonly placeholder?: string; readonly onChange: (value: V) => Promise; }) { const current = props.options.find((option) => option.value === props.value); return ( ); } function SubmitRow(props: { readonly placeholder: string; readonly action: string; readonly disabled: boolean; readonly onSubmit: (value: string) => Promise; }) { const [value, setValue] = useState(""); const submit = () => { const trimmed = value.trim(); if (!trimmed) return; void props.onSubmit(trimmed).then(() => setValue("")); }; return (
{ event.preventDefault(); submit(); }} > setValue(event.target.value)} />
); } function LocationSection(props: { readonly disabled: boolean; readonly canClear: boolean; readonly onSet: (latitude: number, longitude: number) => Promise; readonly onClear: () => Promise; }) { const [latitude, setLatitude] = useState(""); const [longitude, setLongitude] = useState(""); const parsed = { latitude: Number(latitude), longitude: Number(longitude) }; const valid = latitude.trim() !== "" && longitude.trim() !== "" && Math.abs(parsed.latitude) <= 90 && Math.abs(parsed.longitude) <= 180; return (
setLatitude(event.target.value)} /> setLongitude(event.target.value)} />
value={null} disabled={props.disabled} onValueChange={(value) => { const preset = LOCATION_PRESETS.find((candidate) => candidate.label === value); if (!preset) return; setLatitude(String(preset.latitude)); setLongitude(String(preset.longitude)); void props.onSet(preset.latitude, preset.longitude); }} > Preset… {LOCATION_PRESETS.map((preset) => ( {preset.label} ))} {props.canClear ? ( ) : null}
); } function PermissionsSection(props: { readonly permissions: ReadonlyArray<{ value: DevicePermission; label: string }>; readonly canReset: boolean; readonly defaultAppId: string; readonly disabled: boolean; readonly onDecide: ( appId: string, permission: DevicePermission, decision: "grant" | "revoke" | "reset", ) => Promise; }) { const [appId, setAppId] = useState(""); const [permission, setPermission] = useState("camera"); const resolvedAppId = appId.trim() || props.defaultAppId; const decide = (decision: "grant" | "revoke" | "reset") => void props.onDecide(resolvedAppId, permission, decision); return (
setAppId(event.target.value)} />
{ setPermission(value); return Promise.resolve(); }} /> {props.canReset ? ( ) : null}
); } const EVENT_LOG_LIMIT = 100; function EventLogSection(props: { readonly access: DeviceHubAccess; readonly device: DeviceSummary; }) { const [open, setOpen] = useState(false); const [entries, setEntries] = useState>([]); useEffect(() => { if (!open) return; const unsubscribe = subscribeDeviceEventLog( { access: props.access, platform: props.device.platform, deviceId: props.device.id }, (incoming, reset) => { setEntries((current) => { const merged = reset ? [...incoming] : [...current, ...incoming]; return merged.length > EVENT_LOG_LIMIT ? merged.slice(-EVENT_LOG_LIMIT) : merged; }); }, ); return () => { unsubscribe(); setEntries([]); }; }, [open, props.access, props.device.id, props.device.platform]); return ( Event log
    {entries.length === 0 ? (
  1. No events yet.
  2. ) : ( entries.map((entry) => (
  3. {entry.timestamp.slice(11, 19)} {entry.summary}
  4. )) )}
); }