Skip to content
Source
Sign inSign up

device.ts

191 lines6.2 KB

1import { useAtomValue } from "@effect/atom-react";
2import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device";
3import {
4 type DeviceHubAccess,
5 resolveDeviceHubAccess,
6} from "@t3tools/client-runtime/state/deviceHubAccess";
7import {
8 createEnvironmentRpcQueryAtomFamily,
9 runAtomCommand,
10} from "@t3tools/client-runtime/state/runtime";
11import {
12 type DeviceDetail,
13 type DeviceServiceState,
14 type EnvironmentId,
15 WS_METHODS,
16} from "@t3tools/contracts";
17import * as Effect from "effect/Effect";
18import * as Option from "effect/Option";
19import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
20​
21import { connectionAtomRuntime } from "../connection/runtime";
22import { appAtomRegistry } from "../rpc/atomRegistry";
23import { environmentSession } from "./session";
24import { formatEnvironmentQueryError, useEnvironmentQuery } from "./query";
25​
26export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRuntime);
27​
28const EMPTY_DEVICE_STATE: DeviceServiceState = {
29 hosts: [],
30 hostStatus: "disabled",
31 hostStatuses: {},
32 devices: [],
33 sessions: [],
34 onboardingCompleted: false,
35 agentAccessEnabled: false,
36 hubBasePath: "/api/device-hub",
37 revision: 0,
38};
39​
40export function useDeviceState(environmentId: EnvironmentId | null): {
41 readonly state: DeviceServiceState;
42 readonly loaded: boolean;
43 readonly error: string | null;
44 readonly refresh: () => void;
45} {
46 const query = useEnvironmentQuery(
47 environmentId === null ? null : deviceEnvironment.state({ environmentId, input: {} }),
48 );
49 return {
50 state: query.data ?? EMPTY_DEVICE_STATE,
51 loaded: query.data !== null,
52 error: query.error,
53 refresh: query.refresh,
54 };
56​
57const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) =>
58 connectionAtomRuntime
59 .atom((get) => {
60 const prepared = Option.getOrNull(
61 get(environmentSession.preparedConnectionValueAtom(environmentId)),
62 );
63 if (prepared === null) return Effect.never;
64 return resolveDeviceHubAccess({ prepared, hubBasePath: EMPTY_DEVICE_STATE.hubBasePath });
65 })
66 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)),
67);
68​
69export function useDeviceHubAccess(
70 environmentId: EnvironmentId | null,
71 hostId = "local",
72): DeviceHubAccess | null {
73 const result = useAtomValue(
74 environmentId === null ? EMPTY_ACCESS_ATOM : deviceHubAccessAtom(environmentId),
75 );
76 return AsyncResult.isSuccess(result)
77 ? { ...result.value, query: { ...result.value.query, hostId } }
78 : null;
80​
81const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial<DeviceHubAccess, never>()).pipe(
82 Atom.withLabel("device-hub-access:empty"),
83);
84​
85export function refreshDeviceHubAccess(environmentId: EnvironmentId): void {
86 appAtomRegistry.refresh(deviceHubAccessAtom(environmentId));
88​
89/** Mints a fresh hub ticket for a one-off request that failed with 401. Resolves null on failure. */
90export function renewDeviceHubAccess(
91 environmentId: EnvironmentId,
92 hostId = "local",
93): Promise<DeviceHubAccess | null> {
94 const atom = deviceHubAccessAtom(environmentId);
95 appAtomRegistry.refresh(atom);
96 return Effect.runPromise(
97 AtomRegistry.getResult(appAtomRegistry, atom, { suspendOnWaiting: true }).pipe(
98 Effect.map((access) => ({ ...access, query: { ...access.query, hostId } })),
99 Effect.timeout("10 seconds"),
100 Effect.orElseSucceed(() => null),
101 ),
102 );
104​
105export interface DeviceDetailKey {
106 readonly environmentId: EnvironmentId;
107 readonly hostId: string;
108 readonly deviceId: string;
110​
111export interface DeviceDetailView {
112 /** Settings the host last confirmed, by a read or by an action's answer. */
113 readonly detail: DeviceDetail | null;
114 readonly error: string | null;
116​
117export const deviceDetailAtom = Atom.family((key: DeviceDetailKey) =>
118 Atom.make<DeviceDetailView>({ detail: null, error: null }).pipe(
119 Atom.withLabel(`device-detail:${key.hostId}:${key.deviceId}`),
120 ),
121);
122​
123/**
124 * Reads a device's settings into its detail view while mounted. Reads and
125 * actions share one serial lane per environment, so a read never answers
126 * before an action that was sent ahead of it.
127 */
128export const deviceDetailReadAtom = Atom.family((key: DeviceDetailKey) =>
129 Atom.make((get) =>
130 Effect.promise(() =>
131 runAtomCommand(
132 get.registry,
133 deviceEnvironment.detail,
134 { environmentId: key.environmentId, input: { hostId: key.hostId, deviceId: key.deviceId } },
135 { reportFailure: false },
136 ),
137 ).pipe(
138 Effect.map((result) =>
139 get.set(
140 deviceDetailAtom(key),
141 result._tag === "Success"
142 ? { detail: result.value, error: null }
143 : { detail: null, error: formatEnvironmentQueryError(result.cause) },
144 ),
145 ),
146 ),
147 ).pipe(Atom.withLabel(`device-detail-read:${key.hostId}:${key.deviceId}`)),
148);
149​
150/**
151 * Probes the environment's device hosts while mounted. Remote hosts report
152 * their platforms only after a probe. Probing never installs or starts helpers.
153 */
154export const deviceHostProbeAtom = Atom.family((environmentId: EnvironmentId) =>
155 Atom.make((get) =>
156 Effect.promise(() =>
157 runAtomCommand(
158 get.registry,
159 deviceEnvironment.list,
160 { environmentId, input: {} },
161 { reportFailure: false },
162 ),
163 ).pipe(Effect.asVoid),
164 ).pipe(Atom.withLabel(`device-host-probe:${environmentId}`)),
165);
166​
167export const NO_DEVICE_PROBE_ATOM = Atom.make(AsyncResult.initial<void, never>()).pipe(
168 Atom.withLabel("device-host-probe:none"),
169);
170​
171/**
172 * The Expo account an EAS host editor shows as it opens, for the saved
173 * credential. Each open checks again; edits and manual checks use the command.
174 */
175export const deviceEasAccountCheck = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, {
176 label: "environment-data:device:eas-account-check",
177 tag: WS_METHODS.deviceEasAccount,
178 staleTimeMs: 0,
179 idleTtlMs: 0,
180});
181​
182/** Who a Simlock token is, checked as the host editor's URL and token fields change. */
183export const deviceSimlockIdentityCheck = createEnvironmentRpcQueryAtomFamily(
184 connectionAtomRuntime,
185 {
186 label: "environment-data:device:simlock-identity-check",
187 tag: WS_METHODS.deviceSimlockIdentity,
188 staleTimeMs: 0,
189 idleTtlMs: 0,
190 },
191);