Open History to see the last commit
1import {
2 SimlockDeviceHostConfig,
3 type EnvironmentId,
4 type SettingsSecretValue,
5 type SimlockDeviceHostConfig as SimlockDeviceHostConfigType,
6} from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9
10const parseSimlockDeviceHostDraft = Schema.decodeUnknownOption(SimlockDeviceHostConfig);
11
12export const newSimlockDeviceHost = (id: string): SimlockDeviceHostConfigType => ({
13 kind: "simlock",
14 id,
15 label: "Simlock",
16 gatewayUrl: "",
17 agentToken: "",
18 maxLeases: 2,
19 maxLeaseMinutes: 60,
20});
21
22/** Adds, replaces, or removes one Simlock host. */
23export function updateSimlockDeviceHosts(
24 hosts: ReadonlyArray<SimlockDeviceHostConfigType>,
25 host: SimlockDeviceHostConfigType,
26 remove: boolean,
27): ReadonlyArray<SimlockDeviceHostConfigType> {
28 if (remove) return hosts.filter((candidate) => candidate.id !== host.id);
29 return hosts.some((candidate) => candidate.id === host.id)
30 ? hosts.map((candidate) => (candidate.id === host.id ? host : candidate))
31 : [...hosts, host];
32}
33
34/** Why the shared fields keep a draft from saving, or null; tokens are checked per environment. */
35export function simlockDeviceHostProblem(host: SimlockDeviceHostConfigType): string | null {
36 const withToken = { ...host, agentToken: "token" };
37 if (Option.isSome(parseSimlockDeviceHostDraft(withToken))) return null;
38 if (!host.label.trim()) return "Give the host a label.";
39 // With in-range limits, a draft that still fails has a bad gateway URL.
40 return Option.isNone(
41 parseSimlockDeviceHostDraft({ ...withToken, maxLeases: 1, maxLeaseMinutes: 60 }),
42 )
43 ? "Enter the gateway's origin, such as https://gateway.example.ts.net:4700."
44 : "Keep the limits within the ranges shown.";
45}
46
47export function simlockLeaseLimits(host: SimlockDeviceHostConfigType) {
48 const devices = host.maxLeases === 1 ? "1 device" : `${host.maxLeases} devices`;
49 return `Up to ${devices} at once, each released after ${host.maxLeaseMinutes} min`;
50}
51
52export interface SimlockHostEnvironment {
53 readonly environmentId: EnvironmentId;
54 readonly label: string;
55 readonly hosts: ReadonlyArray<SimlockDeviceHostConfigType>;
56}
57
58/** A token field: the environment's saved token, a replacement or secret reference, or none. */
59export type SimlockTokenEntry =
60 | { kind: "saved" }
61 | { kind: "new"; value: SettingsSecretValue }
62 | { kind: "cleared" };
63
64/**
65 * The host list each environment saves: the shared fields everywhere, and each environment's own
66 * token, which may stay blank until later. Saved tokens arrive redacted; sending one back keeps it.
67 */
68export function planSimlockDeviceHostSave(
69 environments: ReadonlyArray<SimlockHostEnvironment>,
70 draft: SimlockDeviceHostConfigType,
71 tokens: ReadonlyMap<EnvironmentId, SimlockTokenEntry>,
72):
73 | { readonly problem: string }
74 | {
75 readonly patches: ReadonlyArray<{
76 environmentId: EnvironmentId;
77 hosts: ReadonlyArray<SimlockDeviceHostConfigType>;
78 }>;
79 } {
80 const shared = simlockDeviceHostProblem(draft);
81 if (shared) return { problem: shared };
82 const patches = [];
83 for (const environment of environments) {
84 const saved = environment.hosts.find((host) => host.id === draft.id)?.agentToken ?? "";
85 const entry = tokens.get(environment.environmentId) ?? { kind: "cleared" };
86 const agentToken =
87 entry.kind === "saved"
88 ? saved
89 : entry.kind !== "new"
90 ? ""
91 : typeof entry.value === "string"
92 ? entry.value.trim()
93 : entry.value;
94 patches.push({
95 environmentId: environment.environmentId,
96 hosts: updateSimlockDeviceHosts(environment.hosts, { ...draft, agentToken }, false),
97 });
98 }
99 return { patches };
100}