import { Option, Schema } from "effect"; import { normalizeExecutorServerConnection, type ExecutorServerConnection, type ExecutorServerConnectionInput, } from "./server-connection"; export const EXECUTOR_SERVER_PROFILES_STORAGE_KEY = "executor.serverConnections.v1"; export interface ExecutorServerProfilesSnapshot { readonly activeKey: string | null; readonly profiles: readonly ExecutorServerConnection[]; } export interface ExecutorServerProfileStorage { readonly getItem: (key: string) => string | null; readonly setItem: (key: string, value: string) => void; } const PersistedBasicAuth = Schema.Struct({ kind: Schema.Literal("basic"), username: Schema.optional(Schema.String), password: Schema.String, }); const PersistedBearerAuth = Schema.Struct({ kind: Schema.Literal("bearer"), token: Schema.String, }); const PersistedAuth = Schema.Union([PersistedBasicAuth, PersistedBearerAuth]); const PersistedConnection = Schema.Struct({ kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), key: Schema.optional(Schema.String), origin: Schema.optional(Schema.String), apiBaseUrl: Schema.optional(Schema.String), displayName: Schema.optional(Schema.String), auth: Schema.optional(PersistedAuth), }); const PersistedProfiles = Schema.Struct({ version: Schema.Literal(1), activeKey: Schema.optional(Schema.NullOr(Schema.String)), profiles: Schema.Array(PersistedConnection), }); const decodeProfilesJson = Schema.decodeUnknownOption(Schema.fromJsonString(PersistedProfiles)); const EMPTY_PROFILES: ExecutorServerProfilesSnapshot = { activeKey: null, profiles: [], }; const hasHttpScheme = (value: string): boolean => /^https?:\/\//.test(value.trim()); const canParseOrigin = (origin: string | undefined): boolean => { if (origin === undefined) return true; const trimmed = origin.trim(); if (!trimmed) return true; return URL.canParse(hasHttpScheme(trimmed) ? trimmed : `http://${trimmed}`); }; const canParseApiBaseUrl = (apiBaseUrl: string | undefined): boolean => { if (apiBaseUrl === undefined) return true; const trimmed = apiBaseUrl.trim(); if (!trimmed) return true; return hasHttpScheme(trimmed) && URL.canParse(trimmed); }; const normalizeConnectionOption = ( input: ExecutorServerConnectionInput, ): ExecutorServerConnection | null => { if (!canParseOrigin(input.origin) || !canParseApiBaseUrl(input.apiBaseUrl)) return null; return normalizeExecutorServerConnection(input); }; const asConnectionInput = ( connection: ExecutorServerConnection, ): ExecutorServerConnectionInput => ({ kind: connection.kind, key: connection.key, origin: connection.origin, apiBaseUrl: connection.apiBaseUrl, displayName: connection.displayName, ...(connection.auth ? { auth: connection.auth } : {}), }); export const parseExecutorServerProfilesSnapshot = ( raw: string | null | undefined, ): ExecutorServerProfilesSnapshot => { if (!raw) return EMPTY_PROFILES; const decoded = decodeProfilesJson(raw); if (Option.isNone(decoded)) return EMPTY_PROFILES; return normalizeExecutorServerProfilesSnapshot(decoded.value); }; export const serializeExecutorServerProfilesSnapshot = ( snapshot: ExecutorServerProfilesSnapshot, ): string => { // Custom server auth is intentionally persisted with the profile so local-dev // and advanced remote endpoints do not force reauth on every reload. Desktop // stores this payload through its Electron store; web falls back to // localStorage for the same format. const persisted = { version: 1, activeKey: snapshot.activeKey, profiles: snapshot.profiles.map(asConnectionInput), }; return JSON.stringify(persisted); }; export const normalizeExecutorServerProfilesSnapshot = (input: { readonly activeKey?: string | null; readonly profiles?: readonly ExecutorServerConnectionInput[]; }): ExecutorServerProfilesSnapshot => { const deduped = new Map(); for (const profile of input.profiles ?? []) { const normalized = normalizeConnectionOption(profile); if (!normalized) continue; deduped.set(normalized.key, normalized); } const profiles = [...deduped.values()]; const activeKey = input.activeKey && deduped.has(input.activeKey) ? input.activeKey : (profiles[0]?.key ?? null); return { activeKey, profiles }; }; export const readExecutorServerProfiles = ( storage: ExecutorServerProfileStorage | null | undefined, storageKey = EXECUTOR_SERVER_PROFILES_STORAGE_KEY, ): ExecutorServerProfilesSnapshot => { if (!storage) return EMPTY_PROFILES; return parseExecutorServerProfilesSnapshot(storage.getItem(storageKey)); }; export const writeExecutorServerProfiles = ( storage: ExecutorServerProfileStorage | null | undefined, snapshot: ExecutorServerProfilesSnapshot, storageKey = EXECUTOR_SERVER_PROFILES_STORAGE_KEY, ): void => { if (!storage) return; storage.setItem(storageKey, serializeExecutorServerProfilesSnapshot(snapshot)); }; export const getActiveExecutorServerProfile = ( snapshot: ExecutorServerProfilesSnapshot, ): ExecutorServerConnection | null => snapshot.profiles.find((profile) => profile.key === snapshot.activeKey) ?? null; export const upsertExecutorServerProfile = ( snapshot: ExecutorServerProfilesSnapshot, input: ExecutorServerConnectionInput, options: { readonly makeActive?: boolean; } = {}, ): ExecutorServerProfilesSnapshot | null => { const normalized = normalizeConnectionOption(input); if (!normalized) return null; const profiles = new Map(snapshot.profiles.map((profile) => [profile.key, profile])); profiles.set(normalized.key, normalized); const activeKey = options.makeActive === false ? (snapshot.activeKey ?? normalized.key) : normalized.key; return normalizeExecutorServerProfilesSnapshot({ activeKey, profiles: [...profiles.values()], }); }; export const selectExecutorServerProfile = ( snapshot: ExecutorServerProfilesSnapshot, key: string, ): ExecutorServerProfilesSnapshot => { if (!snapshot.profiles.some((profile) => profile.key === key)) return snapshot; return { ...snapshot, activeKey: key }; }; export const removeExecutorServerProfile = ( snapshot: ExecutorServerProfilesSnapshot, key: string, ): ExecutorServerProfilesSnapshot => { const profiles = snapshot.profiles.filter((profile) => profile.key !== key); return normalizeExecutorServerProfilesSnapshot({ activeKey: snapshot.activeKey === key ? null : snapshot.activeKey, profiles, }); };