import * as Arr from "effect/Array";import { pipe } from "effect/Function";import * as Option from "effect/Option";import * as Schema from "effect/Schema";import * as SecureStore from "expo-secure-store";import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; import type { SavedRemoteConnection } from "./connection"; const CONNECTIONS_KEY = "t3code.connections";const PREFERENCES_KEY = "t3code.preferences";const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; export interface CachedShellSnapshot { readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION; readonly environmentId: EnvironmentId; readonly snapshotReceivedAt: string; readonly snapshot: OrchestrationShellSnapshot;} export interface MobilePreferences { readonly terminalFontSize?: number;} const CachedShellSnapshotSchema = Schema.Struct({ schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), environmentId: EnvironmentId, snapshotReceivedAt: Schema.String, snapshot: OrchestrationShellSnapshot,});const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema); async function readStorageItem(key: string): Promise<string | null> { return await SecureStore.getItemAsync(key);} async function writeStorageItem(key: string, value: string): Promise<void> { await SecureStore.setItemAsync(key, value);} async function readJsonStorageItem<T>(key: string): Promise<T | null> { const raw = (await readStorageItem(key)) ?? ""; if (!raw.trim()) { return null; } try { return JSON.parse(raw) as T; } catch { return null; }} function cachedShellSnapshotFileName(environmentId: EnvironmentId): string { return `${encodeURIComponent(environmentId)}.json`;} async function getShellSnapshotCacheDirectory() { const { Directory, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, SHELL_SNAPSHOT_CACHE_DIRECTORY); directory.create({ idempotent: true, intermediates: true }); return directory;} export async function loadCachedShellSnapshot( environmentId: EnvironmentId,): Promise<CachedShellSnapshot | null> { try { const { File } = await import("expo-file-system"); const directory = await getShellSnapshotCacheDirectory(); const file = new File(directory, cachedShellSnapshotFileName(environmentId)); if (!file.exists) { return null; } const parsed = JSON.parse(await file.text()) as unknown; const decoded = decodeCachedShellSnapshot(parsed); if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) { return null; } return decoded.value; } catch { return null; }} export async function saveCachedShellSnapshot( environmentId: EnvironmentId, snapshot: OrchestrationShellSnapshot,): Promise<void> { try { const { File } = await import("expo-file-system"); const directory = await getShellSnapshotCacheDirectory(); const file = new File(directory, cachedShellSnapshotFileName(environmentId)); const document: CachedShellSnapshot = { schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, environmentId, snapshotReceivedAt: new Date().toISOString(), snapshot, }; if (!file.exists) { file.create({ intermediates: true, overwrite: true }); } file.write(JSON.stringify(document)); } catch { // Cache persistence is best-effort and should never block live data. }} export async function clearCachedShellSnapshot(environmentId: EnvironmentId): Promise<void> { try { const { File } = await import("expo-file-system"); const directory = await getShellSnapshotCacheDirectory(); const file = new File(directory, cachedShellSnapshotFileName(environmentId)); if (file.exists) { file.delete(); } } catch { // Ignore cache cleanup failures. }} export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> { const parsed = await readJsonStorageItem<{ readonly connections?: ReadonlyArray<SavedRemoteConnection>; }>(CONNECTIONS_KEY); if (!parsed) { return []; } return pipe( parsed.connections ?? [], Arr.filter((c) => !!c.environmentId && !!c.bearerToken?.trim()), );} export async function saveConnection(connection: SavedRemoteConnection): Promise<void> { const current = await loadSavedConnections(); const next = current.some((entry) => entry.environmentId === connection.environmentId) ? pipe( current, Arr.map((entry) => (entry.environmentId === connection.environmentId ? connection : entry)), ) : pipe(current, Arr.append(connection)); await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));} export async function clearSavedConnection(environmentId: EnvironmentId): Promise<void> { const current = await loadSavedConnections(); const next = pipe( current, Arr.filter((entry) => entry.environmentId !== environmentId), ); await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));} export async function loadPreferences(): Promise<MobilePreferences> { const parsed = await readJsonStorageItem<MobilePreferences>(PREFERENCES_KEY); if (!parsed || typeof parsed !== "object") { return {}; } if (typeof parsed.terminalFontSize === "number") { return { terminalFontSize: parsed.terminalFontSize }; } return {};} export async function savePreferencesPatch( patch: Partial<MobilePreferences>,): Promise<MobilePreferences> { const current = await loadPreferences(); const next: MobilePreferences = { ...current, ...patch, }; await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); return next;}
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as SecureStore from "expo-secure-store";
import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";
import type { SavedRemoteConnection } from "./connection";
const CONNECTIONS_KEY = "t3code.connections";
const PREFERENCES_KEY = "t3code.preferences";
const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
export interface CachedShellSnapshot {
readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION;
readonly environmentId: EnvironmentId;
readonly snapshotReceivedAt: string;
readonly snapshot: OrchestrationShellSnapshot;
}
export interface MobilePreferences {
readonly terminalFontSize?: number;
}
const CachedShellSnapshotSchema = Schema.Struct({
schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
environmentId: EnvironmentId,
snapshotReceivedAt: Schema.String,
snapshot: OrchestrationShellSnapshot,
});
const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema);
async function readStorageItem(key: string): Promise<string | null> {
return await SecureStore.getItemAsync(key);
}
async function writeStorageItem(key: string, value: string): Promise<void> {
await SecureStore.setItemAsync(key, value);
}
async function readJsonStorageItem<T>(key: string): Promise<T | null> {
const raw = (await readStorageItem(key)) ?? "";
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function cachedShellSnapshotFileName(environmentId: EnvironmentId): string {
return `${encodeURIComponent(environmentId)}.json`;
}
async function getShellSnapshotCacheDirectory() {
const { Directory, Paths } = await import("expo-file-system");
const directory = new Directory(Paths.document, SHELL_SNAPSHOT_CACHE_DIRECTORY);
directory.create({ idempotent: true, intermediates: true });
return directory;
}
export async function loadCachedShellSnapshot(
environmentId: EnvironmentId,
): Promise<CachedShellSnapshot | null> {
try {
const { File } = await import("expo-file-system");
const directory = await getShellSnapshotCacheDirectory();
const file = new File(directory, cachedShellSnapshotFileName(environmentId));
if (!file.exists) {
return null;
}
const parsed = JSON.parse(await file.text()) as unknown;
const decoded = decodeCachedShellSnapshot(parsed);
if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) {
return null;
}
return decoded.value;
} catch {
return null;
}
}
export async function saveCachedShellSnapshot(
environmentId: EnvironmentId,
snapshot: OrchestrationShellSnapshot,
): Promise<void> {
try {
const { File } = await import("expo-file-system");
const directory = await getShellSnapshotCacheDirectory();
const file = new File(directory, cachedShellSnapshotFileName(environmentId));
const document: CachedShellSnapshot = {
schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
environmentId,
snapshotReceivedAt: new Date().toISOString(),
snapshot,
};
if (!file.exists) {
file.create({ intermediates: true, overwrite: true });
}
file.write(JSON.stringify(document));
} catch {
// Cache persistence is best-effort and should never block live data.
}
}
export async function clearCachedShellSnapshot(environmentId: EnvironmentId): Promise<void> {
try {
const { File } = await import("expo-file-system");
const directory = await getShellSnapshotCacheDirectory();
const file = new File(directory, cachedShellSnapshotFileName(environmentId));
if (file.exists) {
file.delete();
}
} catch {
// Ignore cache cleanup failures.
}
}
export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {
const parsed = await readJsonStorageItem<{
readonly connections?: ReadonlyArray<SavedRemoteConnection>;
}>(CONNECTIONS_KEY);
if (!parsed) {
return [];
}
return pipe(
parsed.connections ?? [],
Arr.filter((c) => !!c.environmentId && !!c.bearerToken?.trim()),
);
}
export async function saveConnection(connection: SavedRemoteConnection): Promise<void> {
const current = await loadSavedConnections();
const next = current.some((entry) => entry.environmentId === connection.environmentId)
? pipe(
current,
Arr.map((entry) => (entry.environmentId === connection.environmentId ? connection : entry)),
)
: pipe(current, Arr.append(connection));
await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
}
export async function clearSavedConnection(environmentId: EnvironmentId): Promise<void> {
const current = await loadSavedConnections();
const next = pipe(
current,
Arr.filter((entry) => entry.environmentId !== environmentId),
);
await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
}
export async function loadPreferences(): Promise<MobilePreferences> {
const parsed = await readJsonStorageItem<MobilePreferences>(PREFERENCES_KEY);
if (!parsed || typeof parsed !== "object") {
return {};
}
if (typeof parsed.terminalFontSize === "number") {
return { terminalFontSize: parsed.terminalFontSize };
}
return {};
}
export async function savePreferencesPatch(
patch: Partial<MobilePreferences>,
): Promise<MobilePreferences> {
const current = await loadPreferences();
const next: MobilePreferences = {
...current,
...patch,
};
await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next));
return next;
}