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
9 changes: 6 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Sources/IMsgCore/MessageStore+Messages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ struct DecodedMessageRow {
let threadOriginatorGUID: String
let threadOriginatorPart: String
let databaseReplyToGUID: String
let balloonBundleID: String
let poll: MessagePollEvent?
}

Expand Down Expand Up @@ -217,6 +218,7 @@ extension MessageStore {
replyToText: parent?.text,
replyToSender: parent?.sender
),
balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID,
poll: poll
))
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -395,6 +398,7 @@ extension MessageStore {
replyToText: parent?.text,
replyToSender: parent?.sender
),
balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID,
poll: poll
)
}
Expand Down Expand Up @@ -508,6 +512,7 @@ extension MessageStore {
threadOriginatorGUID: threadOriginatorGUID,
threadOriginatorPart: threadOriginatorPart,
databaseReplyToGUID: databaseReplyToGUID,
balloonBundleID: balloonBundleID,
poll: poll
)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/IMsgCore/MessageStore+Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ extension MessageStore {
replyToText: parent?.text,
replyToSender: parent?.sender
),
balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID,
poll: poll
))
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/IMsgCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
) {
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -409,6 +416,7 @@ public struct Message: Sendable, Equatable {
replyToText: replyToText,
replyToSender: replyToSender
),
balloonBundleID: balloonBundleID,
reaction: ReactionMetadata(
isReaction: isReaction,
reactionType: reactionType,
Expand Down
3 changes: 3 additions & 0 deletions Sources/imsg/OutputModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
48 changes: 48 additions & 0 deletions Tests/IMsgCoreTests/MessageStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Tests/imsgTests/WatchCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down