File size: 4,086 Bytes
87fc763
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
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<string, { info: BlueBubblesServerInfo; expires: number }>();
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<BlueBubblesServerInfo | null> {
  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<string, unknown> | 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<BlueBubblesProbe> {
  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),
    };
  }
}