Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

### Advanced IMCore
- fix: defer injected bridge bootstrap until after Messages startup so macOS 26 dyld constructor ordering cannot touch ObjC/Foundation/IMCore before the process is ready (#138, thanks @omarshahine).
- fix: let bridge-backed sends and initial chat creation wait longer for slow Messages private-send completions while keeping short timeouts for probe and mutation calls (#139, thanks @omarshahine).

## 0.11.0 - 2026-05-31

Expand Down
27 changes: 23 additions & 4 deletions Sources/IMsgCore/IMsgBridgeClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,28 @@ public final class IMsgBridgeClient: @unchecked Sendable {
/// Invoke a v2 bridge action and return its `data` payload on success.
/// Legacy single-file IPC is only used when explicitly requested through
/// `IMSG_BRIDGE_LEGACY_IPC=1`.
public func invoke(
action: BridgeAction,
params: [String: Any] = [:]
) async throws -> [String: Any] {
try await invoke(
action: action,
params: params,
timeout: IMsgBridgeProtocol.defaultResponseTimeout(for: action)
)
}

/// Invoke a v2 bridge action with an explicit timeout.
/// Legacy single-file IPC is only used when explicitly requested through
/// `IMSG_BRIDGE_LEGACY_IPC=1`.
public func invoke(
action: BridgeAction,
params: [String: Any] = [:],
timeout: TimeInterval = IMsgBridgeProtocol.defaultResponseTimeout
timeout: TimeInterval
) async throws -> [String: Any] {
if useLegacyIPC {
try launcher.ensureRunning()
return try await invokeLegacy(action: action, params: params)
return try await invokeLegacy(action: action, params: params, timeout: timeout)
}

try launcher.ensureLaunched()
Expand Down Expand Up @@ -111,10 +125,15 @@ public final class IMsgBridgeClient: @unchecked Sendable {

private func invokeLegacy(
action: BridgeAction,
params: [String: Any]
params: [String: Any],
timeout: TimeInterval
) async throws -> [String: Any] {
do {
let raw = try await launcher.sendCommand(action: action.rawValue, params: params)
let raw = try await launcher.sendCommand(
action: action.rawValue,
params: params,
timeout: timeout
)
let response = try BridgeResponse.parse(raw)
if response.success {
return response.data
Expand Down
16 changes: 15 additions & 1 deletion Sources/IMsgCore/IMsgBridgeProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,22 @@ public enum IMsgBridgeProtocol {
public static let rotatedEventsFileName: String = ".imsg-events.jsonl.1"
public static let eventsRotationBytes: Int = 1 * 1024 * 1024

/// Default per-request timeout for synchronous RPC waits.
/// Default per-request timeout for synchronous non-send RPC waits.
public static let defaultResponseTimeout: TimeInterval = 10.0

/// Default timeout for send-style bridge waits. macOS 26 private sends can
/// stall for well over the probe window before Messages.app returns.
public static let defaultSendResponseTimeout: TimeInterval = 150.0

public static func defaultResponseTimeout(for action: BridgeAction) -> TimeInterval {
switch action {
case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendReaction,
.createChat:
return defaultSendResponseTimeout
default:
return defaultResponseTimeout
}
}
}

/// All action verbs exposed by the v2 bridge. Names match the BlueBubbles
Expand Down
38 changes: 34 additions & 4 deletions Sources/IMsgCore/MessagesLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ import Foundation
return false
}
do {
let response = try sendCommandSync(action: "ping", params: [:])
let response = try sendCommandSync(
action: "ping",
params: [:],
timeout: IMsgBridgeProtocol.defaultResponseTimeout
)
return response["success"] as? Bool == true
} catch {
return false
Expand Down Expand Up @@ -171,6 +175,17 @@ import Foundation
/// Send a command asynchronously.
public func sendCommand(
action: String, params: [String: Any]
) async throws -> [String: Any] {
try await sendCommand(
action: action,
params: params,
timeout: IMsgBridgeProtocol.defaultResponseTimeout
)
}

/// Send a command asynchronously with an explicit response timeout.
public func sendCommand(
action: String, params: [String: Any], timeout: TimeInterval
) async throws -> [String: Any] {
try ensureRunning()
// Serialize params to JSON data to cross the Sendable boundary safely
Expand All @@ -182,7 +197,11 @@ import Foundation
let deserializedParams =
(try? JSONSerialization.jsonObject(with: paramsData, options: []))
as? [String: Any] ?? [:]
let response = try self.sendCommandSync(action: action, params: deserializedParams)
let response = try self.sendCommandSync(
action: action,
params: deserializedParams,
timeout: timeout
)
continuation.resume(returning: response)
} catch {
continuation.resume(throwing: error)
Expand Down Expand Up @@ -273,7 +292,7 @@ import Foundation
}

private func sendCommandSync(
action: String, params: [String: Any]
action: String, params: [String: Any], timeout: TimeInterval
) throws -> [String: Any] {
lock.lock()
defer { lock.unlock() }
Expand All @@ -287,7 +306,7 @@ import Foundation
let jsonData = try JSONSerialization.data(withJSONObject: command, options: [])
try jsonData.write(to: URL(fileURLWithPath: commandFile))

let deadline = Date().addingTimeInterval(10.0)
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)

Expand Down Expand Up @@ -342,8 +361,19 @@ import Foundation
public func killMessages() {}

public func sendCommand(action: String, params: [String: Any]) async throws -> [String: Any] {
try await sendCommand(
action: action,
params: params,
timeout: IMsgBridgeProtocol.defaultResponseTimeout
)
}

public func sendCommand(
action: String, params: [String: Any], timeout: TimeInterval
) async throws -> [String: Any] {
_ = action
_ = params
_ = timeout
throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.")
}

Expand Down
57 changes: 57 additions & 0 deletions Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing

@testable import IMsgCore
Expand Down Expand Up @@ -82,4 +83,60 @@ struct IMsgBridgeProtocolTests {
#expect(response.success == false)
#expect(response.error == "Chat not found")
}

@Test
func bridgeProtocolUsesLongerDefaultForSendActions() {
#expect(IMsgBridgeProtocol.defaultResponseTimeout == 10.0)
#expect(IMsgBridgeProtocol.defaultSendResponseTimeout == 150.0)

for action in [
BridgeAction.sendMessage,
.sendMultipart,
.sendAttachment,
.sendPoll,
.sendReaction,
.createChat,
] {
#expect(
IMsgBridgeProtocol.defaultResponseTimeout(for: action)
== IMsgBridgeProtocol.defaultSendResponseTimeout
)
}
}

@Test
func bridgeProtocolKeepsShortDefaultForNonSendActions() {
for action in [
BridgeAction.status,
.typing,
.read,
.editMessage,
.unsendMessage,
.deleteMessage,
.notifyAnyways,
] {
#expect(
IMsgBridgeProtocol.defaultResponseTimeout(for: action)
== IMsgBridgeProtocol.defaultResponseTimeout
)
}
}

@Test
func bridgeClientKeepsExplicitTimeoutInvokeSignature() {
let explicitTimeoutInvoke:
(BridgeAction, [String: Any], TimeInterval) async throws -> [String: Any] =
IMsgBridgeClient.shared.invoke
_ = explicitTimeoutInvoke
}

@Test
func messagesLauncherKeepsLegacyCommandSignatureAndAllowsExplicitTimeout() {
let defaultTimeoutSend: (String, [String: Any]) async throws -> [String: Any] =
MessagesLauncher.shared.sendCommand
let explicitTimeoutSend: (String, [String: Any], TimeInterval) async throws -> [String: Any] =
MessagesLauncher.shared.sendCommand
_ = defaultTimeoutSend
_ = explicitTimeoutSend
}
}