1import type { DevicePlatform } from "@t3tools/contracts";
2
3export type DeviceModelId = "iphone-18-pro" | "iphone-18-pro-max" | "ipad-pro-13-m5" | "iphone-duo";
4
5export interface DeviceModelSource {
6 readonly id: DeviceModelId;
7 readonly url: string;
8}
9
10export interface DeviceAccessorySource {
11 readonly id: "ipad-pro-13-m5-magic-keyboard";
12 readonly modelId: "ipad-pro-13-m5";
13 readonly url: string;
14}
15
16export type DeviceAssetSource = DeviceModelSource | DeviceAccessorySource;
17
18/** Match actual hardware, never stretch an available model to impersonate another device. */
19export function resolveDeviceModelId(platform: DevicePlatform, name: string): DeviceModelId | null {
20 if (platform !== "ios") return null;
21 if (/^iPhone Duo$/i.test(name)) return "iphone-duo";
22 if (/^iPhone 18 Pro Max$/i.test(name)) return "iphone-18-pro-max";
23 if (/^iPhone 18 Pro$/i.test(name)) return "iphone-18-pro";
24 if (/^iPad Pro 13-inch \(M5\)$/i.test(name)) return "ipad-pro-13-m5";
25 return null;
26}
27
28/** Each request owns its result. A cancelled or superseded parse must still release its resources. */
29export function createDeviceModelSlot<
30 T extends { dispose: () => void },
31 Source extends DeviceAssetSource = DeviceModelSource,
32>(options: {
33 load: (source: Source, signal: AbortSignal) => Promise<T>;
34 install: (model: T | null) => void;
35 onError?: ((cause: unknown) => void) | undefined;
36}) {
37 let request: { source: Source; controller: AbortController } | null = null;
38 let current: T | null = null;
39 let disposed = false;
40 const clear = () => {
41 if (current) options.install(null);
42 current?.dispose();
43 current = null;
44 };
45 return {
46 set(source: Source | null) {
47 if (disposed) return;
48 if (source?.id === request?.source.id && source?.url === request?.source.url) return;
49 request?.controller.abort();
50 request = null;
51 clear();
52 if (!source) return;
53 const next = { source, controller: new AbortController() };
54 request = next;
55 void options.load(source, next.controller.signal).then(
56 (model) => {
57 if (disposed || request !== next) {
58 model.dispose();
59 return;
60 }
61 try {
62 options.install(model);
63 current = model;
64 } catch (cause) {
65 options.install(null);
66 model.dispose();
67 request = null;
68 options.onError?.(cause);
69 }
70 },
71 (cause: unknown) => {
72 if (!disposed && request === next) {
73 request = null;
74 options.onError?.(cause);
75 }
76 // The procedural body stays installed on download, decoding or validation failure.
77 },
78 );
79 },
80 dispose() {
81 if (disposed) return;
82 disposed = true;
83 request?.controller.abort();
84 request = null;
85 clear();
86 },
87 };
88}