import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js"; export type BlueBubblesProbe = { ok: boolean; status?: number | null; error?: string | null; }; export type BlueBubblesServerInfo = { os_version?: string; server_version?: string; private_api?: boolean; helper_connected?: boolean; proxy_service?: string; detected_icloud?: string; computer_id?: string; }; /** Cache server info by account ID to avoid repeated API calls */ const serverInfoCache = new Map(); const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes function buildCacheKey(accountId?: string): string { return accountId?.trim() || "default"; } /** * Fetch server info from BlueBubbles API and cache it. * Returns cached result if available and not expired. */ export async function fetchBlueBubblesServerInfo(params: { baseUrl?: string | null; password?: string | null; accountId?: string; timeoutMs?: number; }): Promise { const baseUrl = params.baseUrl?.trim(); const password = params.password?.trim(); if (!baseUrl || !password) return null; const cacheKey = buildCacheKey(params.accountId); const cached = serverInfoCache.get(cacheKey); if (cached && cached.expires > Date.now()) { return cached.info; } const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password }); try { const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000); if (!res.ok) return null; const payload = (await res.json().catch(() => null)) as Record | null; const data = payload?.data as BlueBubblesServerInfo | undefined; if (data) { serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS }); } return data ?? null; } catch { return null; } } /** * Get cached server info synchronously (for use in listActions). * Returns null if not cached or expired. */ export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null { const cacheKey = buildCacheKey(accountId); const cached = serverInfoCache.get(cacheKey); if (cached && cached.expires > Date.now()) { return cached.info; } return null; } /** * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. */ export function parseMacOSMajorVersion(version?: string | null): number | null { if (!version) return null; const match = /^(\d+)/.exec(version.trim()); return match ? Number.parseInt(match[1], 10) : null; } /** * Check if the cached server info indicates macOS 26 or higher. * Returns false if no cached info is available (fail open for action listing). */ export function isMacOS26OrHigher(accountId?: string): boolean { const info = getCachedBlueBubblesServerInfo(accountId); if (!info?.os_version) return false; const major = parseMacOSMajorVersion(info.os_version); return major !== null && major >= 26; } /** Clear the server info cache (for testing) */ export function clearServerInfoCache(): void { serverInfoCache.clear(); } export async function probeBlueBubbles(params: { baseUrl?: string | null; password?: string | null; timeoutMs?: number; }): Promise { const baseUrl = params.baseUrl?.trim(); const password = params.password?.trim(); if (!baseUrl) return { ok: false, error: "serverUrl not configured" }; if (!password) return { ok: false, error: "password not configured" }; const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password }); try { const res = await blueBubblesFetchWithTimeout( url, { method: "GET" }, params.timeoutMs, ); if (!res.ok) { return { ok: false, status: res.status, error: `HTTP ${res.status}` }; } return { ok: true, status: res.status }; } catch (err) { return { ok: false, status: null, error: err instanceof Error ? err.message : String(err), }; } }