722 lines · 23.9 KB · TypeScript
RawDownload
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<A, "hostId" | "deviceId">    : 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<DeviceDetail | null>(null);  const [pending, setPending] = useState(false);  const [error, setError] = useState<string | null>(null);  const [foreground, setForeground] = useState<DeviceForegroundInfo | null | undefined>(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 (    <div      className={cn("flex min-h-0 flex-col border-border bg-background text-sm", props.className)}    >      <div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">        <span className="font-medium">Tools</span>        {pending ? <Spinner className="size-3.5" /> : null}        <Button          size="icon-xs"          variant="ghost-muted"          aria-label="Close tools"          className="ml-auto"          onClick={props.onClose}        >          <X />        </Button>      </div>      <div className="min-h-0 flex-1 overflow-y-auto">        {error ? (          <p className="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{error}</p>        ) : null}        {detail === null && !error ? (          <div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">            <Spinner className="size-3.5" /> Reading device settings…          </div>        ) : null}         <Section title="App">          <Row label="Foreground">            <span className="truncate font-mono text-xs">{foregroundApp?.id ?? "—"}</span>          </Row>          {foregroundApp ? (            <div className="flex gap-1.5">              <Button                size="xs"                variant="outline"                disabled={disabled}                onClick={() => void act({ type: "terminateApp", appId: foregroundApp.id })}              >                Terminate              </Button>              <Button                size="xs"                variant="outline"                disabled={disabled}                onClick={() => void act({ type: "launchApp", appId: foregroundApp.id })}              >                Relaunch              </Button>            </div>          ) : null}          <SubmitRow            placeholder="https://… or myapp://"            action="Open"            disabled={disabled}            onSubmit={(url) => act({ type: "openUrl", url })}          />          <SubmitRow            placeholder={isIos ? "Bundle ID to launch" : "Package name to launch"}            action="Launch"            disabled={disabled}            onSubmit={(appId) => act({ type: "launchApp", appId })}          />        </Section>         <Section title={isIos ? "Simulator" : "Emulator"}>          <Row label="Appearance">            <ToggleGroup              aria-label="Appearance"              value={settings?.appearance ? [settings.appearance] : []}              disabled={disabled}              onValueChange={(value) => {                const next = value[0];                if (next === "light" || next === "dark")                  void act({ type: "setAppearance", value: next });              }}            >              <Toggle value="light">Light</Toggle>              <Toggle value="dark">Dark</Toggle>            </ToggleGroup>          </Row>          <Row label="Text size">            <ChoiceSelect              ariaLabel="Text size"              value={settings?.textSize ?? null}              options={TEXT_SIZES}              disabled={disabled}              onChange={(value) => act({ type: "setTextSize", value })}            />          </Row>          {isIos ? (            <>              <Row label="Liquid Glass">                <ToggleGroup                  aria-label="Liquid Glass"                  value={settings?.liquidGlass ? [settings.liquidGlass] : []}                  disabled={disabled || settings?.liquidGlass === undefined}                  onValueChange={(value) => {                    const next = value[0];                    if (next === "clear" || next === "tinted") {                      void act({ type: "setLiquidGlass", value: next });                    }                  }}                >                  <Toggle value="clear">Clear</Toggle>                  <Toggle value="tinted">Tinted</Toggle>                </ToggleGroup>              </Row>              <Row label="Color filter">                <ChoiceSelect                  ariaLabel="Color filter"                  value={settings?.colorFilter ?? null}                  options={COLOR_FILTERS}                  disabled={disabled}                  onChange={(value) => act({ type: "setColorFilter", value })}                />              </Row>            </>          ) : (            <Row label="Orientation">              <ChoiceSelect                ariaLabel="Orientation"                value={null}                placeholder="Rotate to…"                options={ORIENTATIONS}                disabled={disabled}                onChange={(value) => act({ type: "setOrientation", value })}              />            </Row>          )}          <SwitchRow            label="Reduce Motion"            checked={settings?.reduceMotion}            disabled={disabled}            onChange={(value) => act({ type: "setToggle", setting: "reduceMotion", value })}          />          {isIos ? (            <>              <SwitchRow                label="Increase Contrast"                checked={settings?.increaseContrast}                disabled={disabled}                onChange={(value) => act({ type: "setToggle", setting: "increaseContrast", value })}              />              <SwitchRow                label="Reduce Transparency"                checked={settings?.reduceTransparency}                disabled={disabled}                onChange={(value) =>                  act({ type: "setToggle", setting: "reduceTransparency", value })                }              />              <SwitchRow                label="Show Borders"                checked={settings?.showBorders}                disabled={disabled}                onChange={(value) => act({ type: "setToggle", setting: "showBorders", value })}              />              <SwitchRow                label="VoiceOver"                checked={settings?.voiceOver}                disabled={disabled}                onChange={(value) => act({ type: "setToggle", setting: "voiceOver", value })}              />            </>          ) : (            <SwitchRow              label="Network"              checked={settings?.networkEnabled}              disabled={disabled}              onChange={(value) => act({ type: "setToggle", setting: "networkEnabled", value })}            />          )}        </Section>         <Section title="Accessibility">          <SwitchRow            label="Overlay element frames"            checked={props.axOverlay}            disabled={props.access === null}            onChange={(value) => {              props.onAxOverlayChange(value);              return Promise.resolve();            }}          />        </Section>         <LocationSection          disabled={disabled}          canClear={isIos}          onSet={(latitude, longitude) => act({ type: "setLocation", latitude, longitude })}          onClear={() => act({ type: "clearLocation" })}        />         <PermissionsSection          permissions={isIos ? IOS_PERMISSIONS : ANDROID_PERMISSIONS}          canReset={isIos}          defaultAppId={foregroundApp?.id ?? ""}          disabled={disabled}          onDecide={(appId, permission, decision) =>            act({ type: "setPermission", appId, permission, decision })          }        />         {isIos ? (          <Section title="Push notification">            <SubmitRow              placeholder="Alert text"              action="Send"              disabled={disabled || !foregroundApp}              onSubmit={(payload) =>                foregroundApp                  ? act({ type: "sendPush", appId: foregroundApp.id, payload })                  : Promise.resolve()              }            />            {!foregroundApp ? (              <p className="text-xs text-muted-foreground">Open an app first.</p>            ) : null}          </Section>        ) : null}         {isIos && props.access ? <EventLogSection access={props.access} device={device} /> : null}      </div>    </div>  );} function Section(props: { readonly title: string; readonly children: React.ReactNode }) {  return (    <section className="flex flex-col gap-2 border-b px-3 py-2.5 last:border-b-0">      <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">        {props.title}      </h3>      {props.children}    </section>  );} function Row(props: { readonly label: string; readonly children: React.ReactNode }) {  return (    <div className="flex min-h-7 items-center justify-between gap-3">      <span className="shrink-0 text-xs text-muted-foreground">{props.label}</span>      <div className="flex min-w-0 items-center justify-end">{props.children}</div>    </div>  );} function SwitchRow(props: {  readonly label: string;  readonly checked: boolean | undefined;  readonly disabled: boolean;  readonly onChange: (value: boolean) => Promise<void>;}) {  return (    <Row label={props.label}>      <Switch        size="sm"        aria-label={props.label}        checked={props.checked ?? false}        disabled={props.disabled || props.checked === undefined}        onCheckedChange={(checked) => void props.onChange(checked)}      />    </Row>  );} function ChoiceSelect<V extends string>(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<void>;}) {  const current = props.options.find((option) => option.value === props.value);  return (    <Select      value={props.value}      disabled={props.disabled}      onValueChange={(value) => {        if (value !== null && value !== props.value) void props.onChange(value as V);      }}    >      <SelectTrigger size="xs" className="w-40" aria-label={props.ariaLabel}>        <SelectValue>          {current ? (            current.label          ) : (            <span className="text-muted-foreground">{props.placeholder ?? "Unknown"}</span>          )}        </SelectValue>      </SelectTrigger>      <SelectPopup align="end" alignItemWithTrigger={false}>        {props.options.map((option) => (          <SelectItem key={option.value} value={option.value}>            {option.label}          </SelectItem>        ))}      </SelectPopup>    </Select>  );} function SubmitRow(props: {  readonly placeholder: string;  readonly action: string;  readonly disabled: boolean;  readonly onSubmit: (value: string) => Promise<void>;}) {  const [value, setValue] = useState("");  const submit = () => {    const trimmed = value.trim();    if (!trimmed) return;    void props.onSubmit(trimmed).then(() => setValue(""));  };  return (    <form      className="flex gap-1.5"      onSubmit={(event) => {        event.preventDefault();        submit();      }}    >      <Input        size="compact"        className="min-w-0 flex-1 font-mono"        placeholder={props.placeholder}        value={value}        disabled={props.disabled}        onChange={(event) => setValue(event.target.value)}

Lines 1–500 of 722.