diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 412fdff03cd..527774412b1 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1041,23 +1041,62 @@ describe("send", () => { } }); - it("does not refresh when no reply or effect is requested", async () => { - // Cache expired but no Private API features needed — skip refresh + // Plain-text sends also need the cache populated so `isMacOS26OrHigher` + // can read `os_version` from the same `serverInfoCache`. Without a + // refresh on cold/expired cache, macOS 26 detection would silently + // miss and force-route would fall back to broken AppleScript. + // (Greptile/Codex PR #69070) + it("refreshes cache for plain-text sends when status is unknown", async () => { + // First call returns null (cache cold/expired). The refresh path + // fetches server info; plain-text send still uses AppleScript when + // Private API is disabled on the server — but the refresh ran. + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false); + fetchServerInfoMock.mockResolvedValueOnce({ private_api: false }); mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-plain" } }); + mockSendResponse({ data: { guid: "msg-plain-refreshed" } }); const result = await sendMessageBlueBubbles("+15551234567", "Plain message", { serverUrl: "http://localhost:1234", password: "test", }); - expect(result.messageId).toBe("msg-plain"); - expect(fetchServerInfoMock).not.toHaveBeenCalled(); + expect(result.messageId).toBe("msg-plain-refreshed"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); const sendCall = mockFetch.mock.calls[1]; const body = JSON.parse(sendCall[1].body); expect(body.method).toBe("apple-script"); }); + // Cold cache + macOS 26 + Private API enabled on refresh — the + // refresh populates the cache, `isMacOS26OrHigher` returns true, and + // plain-text routes through Private API instead of broken AppleScript. + // (Greptile/Codex PR #69070) + it("force-routes macOS 26 plain-text through Private API after cold-cache refresh", async () => { + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); + fetchServerInfoMock.mockResolvedValueOnce({ + private_api: true, + os_version: "26.0", + }); + isMacOS26OrHigherMock.mockReturnValue(true); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-macos26-refreshed" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Plain message", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("msg-macos26-refreshed"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("private-api"); + } finally { + isMacOS26OrHigherMock.mockReturnValue(false); + } + }); + it("degrades gracefully when refresh returns null (server unreachable)", async () => { // Cache expired, refresh returns null (server info unavailable) fetchServerInfoMock.mockResolvedValueOnce(null); diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 4d6c725506e..a2506a73ca5 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -482,10 +482,14 @@ export async function sendMessageBlueBubbles( const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined; const wantsEffect = Boolean(effectId); - // Lazy refresh: when the cache has expired and Private API features are needed, - // fetch server info before making the decision. This prevents silent degradation - // of reply threading and effects after the 10-minute cache TTL expires. (#43764) - if (privateApiStatus === null && (wantsReplyThread || wantsEffect)) { + // Lazy refresh: when the cache has expired, fetch server info before + // making the decision. Originally scoped to reply/effect features (#43764) + // to avoid silent degradation after the 10-minute cache TTL expires. Now + // always fires on null status, because `isMacOS26OrHigher()` reads from + // the same cache and plain-text sends on macOS 26 need Private API too — + // without this, `forceOnMacOS26` silently falls back to broken AppleScript + // after TTL expiry or on a cold cache. (#64480, Greptile/Codex PR #69070) + if (privateApiStatus === null) { try { await fetchBlueBubblesServerInfo({ baseUrl,