diff --git a/CHANGELOG.md b/CHANGELOG.md index b738c2a..6097d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/IMsgCore/IMsgBridgeClient.swift b/Sources/IMsgCore/IMsgBridgeClient.swift index 789948d..10dc261 100644 --- a/Sources/IMsgCore/IMsgBridgeClient.swift +++ b/Sources/IMsgCore/IMsgBridgeClient.swift @@ -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() @@ -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 diff --git a/Sources/IMsgCore/IMsgBridgeProtocol.swift b/Sources/IMsgCore/IMsgBridgeProtocol.swift index 1bb06ea..5c3a39c 100644 --- a/Sources/IMsgCore/IMsgBridgeProtocol.swift +++ b/Sources/IMsgCore/IMsgBridgeProtocol.swift @@ -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 diff --git a/Sources/IMsgCore/MessagesLauncher.swift b/Sources/IMsgCore/MessagesLauncher.swift index a5ef635..beb11bb 100644 --- a/Sources/IMsgCore/MessagesLauncher.swift +++ b/Sources/IMsgCore/MessagesLauncher.swift @@ -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 @@ -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 @@ -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) @@ -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() } @@ -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) @@ -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.") } diff --git a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift index 171cb85..3c8f064 100644 --- a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift +++ b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import IMsgCore @@ -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 + } }