1import { EnvironmentId } from "@t3tools/contracts";
2import * as Arr from "effect/Array";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import { pipe } from "effect/Function";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8
9import {
10 isRelayManagedConnection,
11 type SavedRemoteConnection,
12 toStableSavedRemoteConnection,
13} from "../lib/connection";
14import * as MobileSecureStorage from "./mobile-secure-storage";
15
16const CONNECTIONS_KEY = "t3code.connections";
17const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
18const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
19const RECENT_THREAD_SHORTCUTS_KEY = "t3code.recent-thread-shortcuts";
20
21export class MobileStorageDecodeError extends Schema.TaggedError<MobileStorageDecodeError>()(
22 "MobileStorageDecodeError",
23 {
24 key: Schema.String,
25 cause: Schema.Defect(),
26 },
27) {
28 override get message(): string {
29 return `Failed to decode mobile storage value for key ${this.key}.`;
30 }
31}
32
33export class MobileStorageEncodeError extends Schema.TaggedError<MobileStorageEncodeError>()(
34 "MobileStorageEncodeError",
35 {
36 key: Schema.String,
37 cause: Schema.Defect(),
38 },
39) {
40 override get message(): string {
41 return `Failed to encode mobile storage value for key ${this.key}.`;
42 }
43}
44
45export class MobileDeviceIdGenerationError extends Schema.TaggedError<MobileDeviceIdGenerationError>()(
46 "MobileDeviceIdGenerationError",
47 { cause: Schema.Defect() },
48) {
49 override get message(): string {
50 return "Failed to generate the mobile agent-awareness device id.";
51 }
52}
53
54export interface AgentAwarenessRegistrationRecord {
55 readonly identity: string;
56 readonly signature: string;
57 readonly pushToStartToken?: string;
58}
59
60export interface RecentThreadShortcut {
61 readonly environmentId: string;
62 readonly threadId: string;
63 readonly title: string;
64}
65
66export class MobileStorage extends Context.Service<
67 MobileStorage,
68 {
69 readonly loadSavedConnections: Effect.Effect<
70 ReadonlyArray<SavedRemoteConnection>,
71 MobileSecureStorage.MobileSecureStorageError
72 >;
73 readonly saveConnection: (
74 connection: SavedRemoteConnection,
75 ) => Effect.Effect<
76 void,
77 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
78 >;
79 readonly clearSavedConnection: (
80 environmentId: EnvironmentId,
81 ) => Effect.Effect<
82 void,
83 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
84 >;
85 readonly loadOrCreateAgentAwarenessDeviceId: Effect.Effect<
86 string,
87 MobileSecureStorage.MobileSecureStorageError | MobileDeviceIdGenerationError
88 >;
89 readonly loadAgentAwarenessDeviceId: Effect.Effect<
90 string | null,
91 MobileSecureStorage.MobileSecureStorageError
92 >;
93 readonly loadAgentAwarenessRegistrationRecord: Effect.Effect<
94 AgentAwarenessRegistrationRecord | null,
95 MobileSecureStorage.MobileSecureStorageError
96 >;
97 readonly saveAgentAwarenessRegistrationRecord: (
98 record: AgentAwarenessRegistrationRecord,
99 ) => Effect.Effect<
100 void,
101 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
102 >;
103 readonly clearAgentAwarenessRegistrationRecord: Effect.Effect<
104 void,
105 MobileSecureStorage.MobileSecureStorageError
106 >;
107 readonly loadRecentThreadShortcuts: Effect.Effect<
108 ReadonlyArray<RecentThreadShortcut>,
109 MobileSecureStorage.MobileSecureStorageError
110 >;
111 readonly saveRecentThreadShortcuts: (
112 threads: ReadonlyArray<RecentThreadShortcut>,
113 ) => Effect.Effect<
114 void,
115 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
116 >;
117 }
118>()("@t3tools/mobile/persistence/MobileStorage") {}
119
120export const make = Effect.fn("MobileStorage.make")(function* () {
121 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
122
123 const parseJson = <A>(key: string, raw: string): A | null => {
124 if (!raw.trim()) return null;
125 try {
126 return JSON.parse(raw) as A;
127 } catch (cause) {
128 console.warn(
129 "[mobile-storage] ignored invalid JSON",
130 new MobileStorageDecodeError({ key, cause }),
131 );
132 return null;
133 }
134 };
135
136 const readJson = Effect.fn("MobileStorage.readJson")(function* <A>(key: string) {
137 const raw = (yield* secureStorage.getItem(key)) ?? "";
138 return parseJson<A>(key, raw);
139 });
140
141 const writeJson = Effect.fn("MobileStorage.writeJson")(function* (key: string, value: unknown) {
142 const encoded = yield* Effect.try({
143 try: () => JSON.stringify(value),
144 catch: (cause) => new MobileStorageEncodeError({ key, cause }),
145 });
146 yield* secureStorage.setItem(key, encoded);
147 });
148
149 const loadSavedConnections = readJson<{
150 readonly connections?: ReadonlyArray<SavedRemoteConnection>;
151 }>(CONNECTIONS_KEY).pipe(
152 Effect.map((parsed) =>
153 pipe(
154 parsed?.connections ?? [],
155 Arr.filter(
156 (connection) =>
157 !!connection.environmentId &&
158 (!!connection.bearerToken?.trim() || isRelayManagedConnection(connection)),
159 ),
160 ),
161 ),
162 );
163
164 const saveConnection = Effect.fn("MobileStorage.saveConnection")(function* (
165 connection: SavedRemoteConnection,
166 ) {
167 const current = yield* loadSavedConnections;
168 const stableConnection = toStableSavedRemoteConnection(connection);
169 const next = current.some((entry) => entry.environmentId === connection.environmentId)
170 ? pipe(
171 current,
172 Arr.map((entry) =>
173 entry.environmentId === connection.environmentId ? stableConnection : entry,
174 ),
175 )
176 : pipe(current, Arr.append(stableConnection));
177 yield* writeJson(CONNECTIONS_KEY, { connections: next });
178 });
179
180 const clearSavedConnection = Effect.fn("MobileStorage.clearSavedConnection")(function* (
181 environmentId: EnvironmentId,
182 ) {
183 const current = yield* loadSavedConnections;
184 const next = pipe(
185 current,
186 Arr.filter((entry) => entry.environmentId !== environmentId),
187 );
188 yield* writeJson(CONNECTIONS_KEY, { connections: next });
189 });
190
191 const loadOrCreateAgentAwarenessDeviceId = Effect.gen(function* () {
192 const existing = yield* secureStorage.getItem(AGENT_AWARENESS_DEVICE_ID_KEY);
193 if (existing?.trim()) return existing;
194 const deviceId = yield* Effect.tryPromise({
195 try: () => import("../lib/uuid").then(({ uuidv4 }) => uuidv4()),
196 catch: (cause) => new MobileDeviceIdGenerationError({ cause }),
197 });
198 yield* secureStorage.setItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId);
199 return deviceId;
200 });
201
202 const loadAgentAwarenessDeviceId = secureStorage
203 .getItem(AGENT_AWARENESS_DEVICE_ID_KEY)
204 .pipe(Effect.map((existing) => (existing?.trim() ? existing : null)));
205
206 const loadAgentAwarenessRegistrationRecord = readJson<AgentAwarenessRegistrationRecord>(
207 AGENT_AWARENESS_REGISTRATION_KEY,
208 ).pipe(
209 Effect.map((parsed) => {
210 if (
211 !parsed ||
212 typeof parsed !== "object" ||
213 typeof parsed.identity !== "string" ||
214 typeof parsed.signature !== "string"
215 ) {
216 return null;
217 }
218 return {
219 identity: parsed.identity,
220 signature: parsed.signature,
221 ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken
222 ? { pushToStartToken: parsed.pushToStartToken }
223 : {}),
224 };
225 }),
226 );
227
228 // Threads most recently opened on this device, newest first — the source
229 // for the launcher's dynamic "recent thread" app shortcuts.
230 const loadRecentThreadShortcuts = readJson<{
231 readonly threads?: ReadonlyArray<RecentThreadShortcut>;
232 }>(RECENT_THREAD_SHORTCUTS_KEY).pipe(
233 Effect.map((parsed) =>
234 pipe(
235 parsed?.threads ?? [],
236 Arr.filter(
237 (thread) =>
238 typeof thread?.environmentId === "string" &&
239 thread.environmentId.length > 0 &&
240 typeof thread.threadId === "string" &&
241 thread.threadId.length > 0 &&
242 typeof thread.title === "string",
243 ),
244 ),
245 ),
246 );
247
248 return MobileStorage.of({
249 loadSavedConnections,
250 saveConnection,
251 clearSavedConnection,
252 loadOrCreateAgentAwarenessDeviceId,
253 loadAgentAwarenessDeviceId,
254 loadAgentAwarenessRegistrationRecord,
255 saveAgentAwarenessRegistrationRecord: (record) =>
256 writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record),
257 clearAgentAwarenessRegistrationRecord: secureStorage.setItem(
258 AGENT_AWARENESS_REGISTRATION_KEY,
259 "",
260 ),
261 loadRecentThreadShortcuts,
262 saveRecentThreadShortcuts: (threads) => writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }),
263 });
264});
265
266export const layer = Layer.effect(MobileStorage, make());