import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, type SavedRemoteConnection, toStableSavedRemoteConnection, } from "./connection"; const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; const MobileStorageKey = Schema.Literals([ CONNECTIONS_KEY, PREFERENCES_KEY, AGENT_AWARENESS_DEVICE_ID_KEY, ]); type MobileStorageKeyValue = typeof MobileStorageKey.Type; export class MobileSecureStorageError extends Schema.TaggedErrorClass()( "MobileSecureStorageError", { operation: Schema.Literals(["read", "write", "generate-device-id"]), key: MobileStorageKey, cause: Schema.Defect(), }, ) { override get message(): string { return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; } } export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( "MobileStorageDecodeError", { key: MobileStorageKey, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to decode mobile storage value for key ${this.key}.`; } } export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( "MobileStorageEncodeError", { key: MobileStorageKey, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to encode mobile storage value for key ${this.key}.`; } } export interface Preferences { readonly liveActivitiesEnabled?: boolean; readonly terminalFontSize?: number; } async function readStorageItem(key: MobileStorageKeyValue): Promise { try { return await SecureStore.getItemAsync(key); } catch (cause) { throw new MobileSecureStorageError({ operation: "read", key, cause }); } } async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise { try { await SecureStore.setItemAsync(key, value); } catch (cause) { throw new MobileSecureStorageError({ operation: "write", key, cause }); } } async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { const raw = (await readStorageItem(key)) ?? ""; if (!raw.trim()) { return null; } try { return JSON.parse(raw) as T; } catch (cause) { console.warn( "[mobile-storage] ignored invalid JSON", new MobileStorageDecodeError({ key, cause }), ); return null; } } async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { let encoded: string; try { encoded = JSON.stringify(value); } catch (cause) { throw new MobileStorageEncodeError({ key, cause }); } await writeStorageItem(key, encoded); } export async function loadSavedConnections(): Promise> { const parsed = await readJsonStorageItem<{ readonly connections?: ReadonlyArray; }>(CONNECTIONS_KEY); if (!parsed) { return []; } return pipe( parsed.connections ?? [], Arr.filter( (c) => !!c.environmentId && (!!c.bearerToken?.trim() || isRelayManagedConnection(c)), ), ); } export async function saveConnection(connection: SavedRemoteConnection): Promise { const current = await loadSavedConnections(); const stableConnection = toStableSavedRemoteConnection(connection); const next = current.some((entry) => entry.environmentId === connection.environmentId) ? pipe( current, Arr.map((entry) => entry.environmentId === connection.environmentId ? stableConnection : entry, ), ) : pipe(current, Arr.append(stableConnection)); await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } export async function clearSavedConnection(environmentId: EnvironmentId): Promise { const current = await loadSavedConnections(); const next = pipe( current, Arr.filter((entry) => entry.environmentId !== environmentId), ); await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } export async function loadPreferences(): Promise { const parsed = await readJsonStorageItem(PREFERENCES_KEY); if (!parsed || typeof parsed !== "object") { return {}; } const preferences: { liveActivitiesEnabled?: boolean; terminalFontSize?: number; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; } if (typeof parsed.terminalFontSize === "number") { preferences.terminalFontSize = parsed.terminalFontSize; } return preferences; } export async function savePreferencesPatch(patch: Partial): Promise { const current = await loadPreferences(); const next: Preferences = { ...current, ...patch, }; await writeJsonStorageItem(PREFERENCES_KEY, next); return next; } export async function loadOrCreateAgentAwarenessDeviceId(): Promise { const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); if (existing?.trim()) { return existing; } const deviceId = await import("./uuid") .then(({ uuidv4 }) => uuidv4()) .catch((cause) => { throw new MobileSecureStorageError({ operation: "generate-device-id", key: AGENT_AWARENESS_DEVICE_ID_KEY, cause, }); }); await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); return deviceId; } export async function loadAgentAwarenessDeviceId(): Promise { const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); return existing?.trim() ? existing : null; }