diff --git a/CHANGELOG.md b/CHANGELOG.md index e293e6a..b738c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ### Read Commands - fix: let `chats` and `history` run without prompting for Contacts when permission is still undecided, while preserving Contacts prompts for explicit name-resolution flows (#135, thanks @cemendes). +### JSON Output +- feat: expose raw Messages `balloon_bundle_id` in message JSON/RPC payloads so consumers can recognize URL-preview rows without parsing text (#137, thanks @omarshahine). + +### 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). + ## 0.11.0 - 2026-05-31 ### Send @@ -14,9 +20,6 @@ ### Local Lookups - feat: add `--local` modes for `account`, `whois`, and `nickname` so common introspection can read local history or Contacts without launching the IMCore bridge (#132, thanks @ranaroussi). -### 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). - ## 0.10.0 - 2026-05-28 ### Watch diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 4a0fafe..ac29dbc 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -72,6 +72,7 @@ struct DecodedMessageRow { let threadOriginatorGUID: String let threadOriginatorPart: String let databaseReplyToGUID: String + let balloonBundleID: String let poll: MessagePollEvent? } @@ -217,6 +218,7 @@ extension MessageStore { replyToText: parent?.text, replyToSender: parent?.sender ), + balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID, poll: poll )) } @@ -322,6 +324,7 @@ extension MessageStore { replyToText: parent?.text, replyToSender: parent?.sender ), + balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID, reaction: Message.ReactionMetadata( isReaction: reaction.isReaction, reactionType: reaction.reactionType, @@ -395,6 +398,7 @@ extension MessageStore { replyToText: parent?.text, replyToSender: parent?.sender ), + balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID, poll: poll ) } @@ -508,6 +512,7 @@ extension MessageStore { threadOriginatorGUID: threadOriginatorGUID, threadOriginatorPart: threadOriginatorPart, databaseReplyToGUID: databaseReplyToGUID, + balloonBundleID: balloonBundleID, poll: poll ) } diff --git a/Sources/IMsgCore/MessageStore+Search.swift b/Sources/IMsgCore/MessageStore+Search.swift index d84ef4d..9bfd3a2 100644 --- a/Sources/IMsgCore/MessageStore+Search.swift +++ b/Sources/IMsgCore/MessageStore+Search.swift @@ -102,6 +102,7 @@ extension MessageStore { replyToText: parent?.text, replyToSender: parent?.sender ), + balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID, poll: poll )) } diff --git a/Sources/IMsgCore/Models.swift b/Sources/IMsgCore/Models.swift index 7ff0483..8d6cc76 100644 --- a/Sources/IMsgCore/Models.swift +++ b/Sources/IMsgCore/Models.swift @@ -315,6 +315,10 @@ public struct Message: Sendable, Equatable { /// this can help distinguish between messages actually sent by the local user vs /// messages received on a secondary phone number registered with the same Apple ID. public let destinationCallerID: String? + /// Raw Messages `message.balloon_bundle_id`, when present. Consumers can use + /// Apple-owned bundle identifiers such as URLBalloonProvider as structural + /// metadata instead of inferring message shape from user text. + public let balloonBundleID: String? /// Native Messages Polls metadata when the row is a Polls extension balloon /// or a Polls vote update. public let poll: MessagePollEvent? @@ -341,6 +345,7 @@ public struct Message: Sendable, Equatable { attachmentsCount: Int, guid: String = "", routing: RoutingMetadata = RoutingMetadata(), + balloonBundleID: String? = nil, reaction: ReactionMetadata = ReactionMetadata(), poll: MessagePollEvent? = nil ) { @@ -360,6 +365,7 @@ public struct Message: Sendable, Equatable { self.handleID = handleID self.attachmentsCount = attachmentsCount self.destinationCallerID = routing.destinationCallerID + self.balloonBundleID = balloonBundleID self.poll = poll self.isReaction = reaction.isReaction self.reactionType = reaction.reactionType @@ -382,6 +388,7 @@ public struct Message: Sendable, Equatable { threadOriginatorGUID: String? = nil, threadOriginatorPart: String? = nil, destinationCallerID: String? = nil, + balloonBundleID: String? = nil, replyToText: String? = nil, replyToSender: String? = nil, isReaction: Bool = false, @@ -409,6 +416,7 @@ public struct Message: Sendable, Equatable { replyToText: replyToText, replyToSender: replyToSender ), + balloonBundleID: balloonBundleID, reaction: ReactionMetadata( isReaction: isReaction, reactionType: reactionType, diff --git a/Sources/imsg/OutputModels.swift b/Sources/imsg/OutputModels.swift index 0836a4a..7fa71f9 100644 --- a/Sources/imsg/OutputModels.swift +++ b/Sources/imsg/OutputModels.swift @@ -77,6 +77,7 @@ struct MessagePayload: Codable { /// this can help distinguish between messages actually sent by the local user vs /// messages received on a secondary phone number registered with the same Apple ID. let destinationCallerID: String? + let balloonBundleID: String? let poll: MessagePollEvent? // Reaction event metadata (populated when this message is a reaction event) @@ -111,6 +112,7 @@ struct MessagePayload: Codable { ReactionPayload(reaction: $0, senderName: reactionSenderNames[$0.rowID]) } self.destinationCallerID = message.destinationCallerID + self.balloonBundleID = message.balloonBundleID self.poll = message.poll // Reaction event metadata @@ -146,6 +148,7 @@ struct MessagePayload: Codable { case attachments case reactions case destinationCallerID = "destination_caller_id" + case balloonBundleID = "balloon_bundle_id" case poll case isReaction = "is_reaction" case reactionType = "reaction_type" diff --git a/Tests/IMsgCoreTests/MessageStoreTests.swift b/Tests/IMsgCoreTests/MessageStoreTests.swift index 7a9804b..9988fe3 100644 --- a/Tests/IMsgCoreTests/MessageStoreTests.swift +++ b/Tests/IMsgCoreTests/MessageStoreTests.swift @@ -170,6 +170,7 @@ func messagesAfterDeduplicatesURLBalloonsAcrossPolls() throws { let store = try MessageStore(connection: db, path: ":memory:") let firstPoll = try store.messagesAfter(afterRowID: 0, chatID: 1, limit: 10) #expect(firstPoll.map(\.rowID) == [1]) + #expect(firstPoll.first?.balloonBundleID == "com.apple.messages.URLBalloonProvider") try db.run( """ @@ -200,6 +201,53 @@ func messagesAfterDeduplicatesURLBalloonsAcrossPolls() throws { let thirdPoll = try store.messagesAfter(afterRowID: 1, chatID: 1, limit: 10) #expect(thirdPoll.map(\.rowID) == [3]) + #expect(thirdPoll.first?.balloonBundleID == "com.apple.messages.URLBalloonProvider") +} + +@Test +func messagesByChatPreservesBalloonBundleID() throws { + let db = try makeInMemoryMessageDB(includeBalloonBundleID: true) + let now = Date() + try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')") + try db.run( + """ + INSERT INTO message( + ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, + balloon_bundle_id, date, is_from_me, service + ) + VALUES (1, 1, 'https://example.com', 'msg-guid-1', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage') + """, + TestDatabase.appleEpoch(now) + ) + try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)") + + let store = try MessageStore(connection: db, path: ":memory:") + let messages = try store.messages(chatID: 1, limit: 10) + #expect(messages.map(\.rowID) == [1]) + #expect(messages.first?.balloonBundleID == "com.apple.messages.URLBalloonProvider") +} + +@Test +func searchMessagesPreservesBalloonBundleID() throws { + let db = try makeInMemoryMessageDB(includeBalloonBundleID: true) + let now = Date() + try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')") + try db.run( + """ + INSERT INTO message( + ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, + balloon_bundle_id, date, is_from_me, service + ) + VALUES (1, 1, 'https://example.com/search', 'msg-guid-1', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage') + """, + TestDatabase.appleEpoch(now) + ) + try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)") + + let store = try MessageStore(connection: db, path: ":memory:") + let messages = try store.searchMessages(query: "example.com/search", match: "contains", limit: 10) + #expect(messages.map(\.rowID) == [1]) + #expect(messages.first?.balloonBundleID == "com.apple.messages.URLBalloonProvider") } @Test diff --git a/Tests/imsgTests/WatchCommandTests.swift b/Tests/imsgTests/WatchCommandTests.swift index a486ebb..3c1a114 100644 --- a/Tests/imsgTests/WatchCommandTests.swift +++ b/Tests/imsgTests/WatchCommandTests.swift @@ -97,7 +97,8 @@ func watchCommandRunsWithJsonOutput() async throws { isFromMe: false, service: "iMessage", handleID: nil, - attachmentsCount: 0 + attachmentsCount: 0, + balloonBundleID: "com.apple.messages.URLBalloonProvider" ) let (output, _) = try await StdoutCapture.capture { try await WatchCommand.run( @@ -114,6 +115,7 @@ func watchCommandRunsWithJsonOutput() async throws { #expect(payload["chat_guid"] as? String == "iMessage;+;chat123") #expect(payload["chat_name"] as? String == "Group Chat") #expect(payload["participants"] as? [String] == ["+123", "me@icloud.com"]) + #expect(payload["balloon_bundle_id"] as? String == "com.apple.messages.URLBalloonProvider") } @Test diff --git a/docs/json.md b/docs/json.md index 21984be..944de19 100644 --- a/docs/json.md +++ b/docs/json.md @@ -49,6 +49,7 @@ Returned by `imsg history`, `imsg watch`, and the JSON-RPC `messages.history` an | `guid` | string | Message GUID. Stable across machines. | | `reply_to_guid` | string | When set, this message is an inline reply to that GUID. | | `destination_caller_id` | string | Outgoing only — which of your numbers Messages routed through. | +| `balloon_bundle_id` | string | Raw Messages `message.balloon_bundle_id`, when present. URL preview rows use `com.apple.messages.URLBalloonProvider`, which lets consumers recognize link-preview payload rows without inferring from message text. | | `sender` | string | Raw handle. Empty for some self-sent messages. | | `sender_name` | string | Resolved Contacts name when permission granted. | | `is_from_me` | bool | True for outbound. |