1import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
2import type {
3 DeviceActionInput,
4 DeviceDetail,
5 DeviceSummary,
6 EnvironmentId,
7} from "@t3tools/contracts";
8import { useEffect, useMemo, useRef, useState } from "react";
9import { deviceEnvironment } from "~/state/device";
10import { formatEnvironmentQueryError } from "~/state/query";
11import { useAtomCommand } from "~/state/use-atom-command";
12import { subscribeDeviceForeground, type DeviceForegroundInfo } from "./deviceHubApi";
13
14type ActionBody = DeviceActionInput extends infer A
15 ? A extends { readonly type: string }
16 ? Omit<A, "hostId" | "deviceId">
17 : never
18 : never;
19
20/** One confirmed settings snapshot and serialized actions for the rail and drawer. Mount keyed by device. */
21export function useDeviceControls(options: {
22 environmentId: EnvironmentId;
23 device: DeviceSummary;
24 access: DeviceHubAccess | null;
25 visible: boolean;
26}) {
27 const { environmentId, device, access, visible } = options;
28 const readDetail = useAtomCommand(deviceEnvironment.detail, { reportFailure: false });
29 const runAction = useAtomCommand(deviceEnvironment.action, { reportFailure: false });
30 const [detail, setDetail] = useState<DeviceDetail | null>(null);
31 const [pending, setPending] = useState(false);
32 const [error, setError] = useState<string | null>(null);
33 const [foreground, setForeground] = useState<DeviceForegroundInfo | null | undefined>();
34 // Hiding invalidates UI results, but does not cancel host commands. Keep them serialized
35 // until settlement rather than allowing a reopened panel to race the previous command.
36 const busy = useRef(false);
37 const generation = useRef(0);
38 const visibleRead = useRef<Parameters<typeof readDetail>[0] | null>(null);
39 const target = useMemo(
40 () => ({ hostId: device.hostId, deviceId: device.id }),
41 [device.hostId, device.id],
42 );
43
44 useEffect(() => {
45 const revision = ++generation.current;
46 if (!visible) return;
47 const request = { environmentId, input: target };
48 visibleRead.current = request;
49 void readDetail(request).then((result) => {
50 if (generation.current !== revision) return;
51 if (result._tag === "Success") {
52 setDetail(result.value);
53 setError(null);
54 } else {
55 setDetail(null);
56 setError(formatEnvironmentQueryError(result.cause));
57 }
58 });
59 return () => {
60 visibleRead.current = null;
61 generation.current++;
62 };
63 }, [environmentId, readDetail, target, visible]);
64
65 useEffect(() => {
66 if (!access || !visible) return;
67 let active = true;
68 const unsubscribe = subscribeDeviceForeground(
69 { access, platform: device.platform, deviceId: device.id },
70 (app) => {
71 if (active) setForeground(app);
72 },
73 );
74 return () => {
75 active = false;
76 unsubscribe();
77 setForeground(undefined);
78 };
79 }, [access, device.id, device.platform, visible]);
80
81 const available = detail !== null && visible;
82 const act = async (body: ActionBody) => {
83 if (busy.current || !available) return;
84 busy.current = true;
85 // A settings read started before this action must not overwrite its confirmed result.
86 const revision = ++generation.current;
87 setPending(true);
88 setError(null);
89 return runAction({
90 environmentId,
91 input: { ...target, ...body } as DeviceActionInput,
92 })
93 .then((result) => {
94 if (generation.current !== revision) {
95 const request = visibleRead.current;
96 if (!request) return;
97 // Reopening can read settings before the host command finishes. Confirm them again
98 // after it settles, keeping actions serialized through this refresh.
99 const refreshRevision = ++generation.current;
100 return readDetail(request).then((refreshed) => {
101 if (generation.current !== refreshRevision) return;
102 if (refreshed._tag === "Success") {
103 setDetail(refreshed.value);
104 setError(null);
105 } else {
106 setDetail(null);
107 setError(formatEnvironmentQueryError(refreshed.cause));
108 }
109 });
110 }
111 if (result._tag === "Success") setDetail(result.value);
112 else setError(formatEnvironmentQueryError(result.cause));
113 })
114 .finally(() => {
115 busy.current = false;
116 setPending(false);
117 });
118 };
119
120 return {
121 detail,
122 pending,
123 error,
124 act,
125 disabled: pending || !detail || !visible,
126 foregroundApp: foreground === undefined ? (detail?.foregroundApp ?? null) : foreground,
127 };
128}
129
130export type DeviceControls = ReturnType<typeof useDeviceControls>;