fix: macOS 26 bridge regressions — typing/read RPC, effect mapping, attachment registration (#101)
* feat: file-based debug logger for in-process diagnostics NSLog output emitted from the injected dylib is redacted by macOS 26 unified logging when the host process is a system app, which makes diagnosing handler behavior from outside the dylib painful (you can't see anything in Console.app, log show, or the gateway logs). Add an append-only file logger writing to .imsg-bridge.log in the Messages.app sandbox container. Readable from outside, untouched by unified logging. Wire diagnostic entry/exit/state logs through the typing and read handlers so future regressions in setLocalUserIsTyping: / markAllMessagesAsRead behavior across macOS versions can be triaged from log output rather than guesswork. * fix(rpc): expose typing and read methods over JSON-RPC The 'imsg rpc' server only routed chats.list, messages.history, watch.subscribe, watch.unsubscribe, and send. Calls to 'typing' and 'read' (which the openclaw imessage channel plugin and other clients already invoke per the documented vocabulary) returned methodNotFound and silently dropped. The CLI worked because it talks to the dylib bridge directly via TypingIndicator/IMCoreBridge, bypassing the RPC surface entirely. Wire both methods to the same chat-target resolution path used by 'send', so callers can identify the chat by handle (to), chat_id, chat_identifier, or chat_guid. typing accepts a 'typing' bool plus optional 'service'; read takes only the chat target. The dylib-side handlers (handleTyping, handleRead) were already correct — they just had no way to be invoked over JSON-RPC. * fix: expand --effect short names to expressive-send bundle IDs `imsg send-rich --effect invisibleink` was passing the literal short name through to expressiveSendStyleID, so chat.db ended up with expressive_send_style_id=invisibleink and Messages.app refused to render the expressive effect. Messages expects the full bundle id, e.g. com.apple.MobileSMS.expressivesend.invisibleink for bubble effects or com.apple.messages.effect.CKConfettiEffect for screen effects. Add ExpressiveSendEffect.expand() to remap short names at the CLI layer for both send-rich and send-multipart, leave already-prefixed values untouched, and pass unknown names through so the dylib can return its own error. Update --help examples to use the friendlier short names and add unit coverage. * fix: register outgoing transfers properly in handleSendAttachment `imsg send-attachment` was returning a transferGuid and messageGuid but the receiver saw an empty OBJ-placeholder message and chat.db's attachment / message_attachment_join tables had no matching rows. Two gaps versus the BlueBubblesHelper reference: 1. The transfer was never staged into Messages' attachments tree or handed off to imagent. Allocating a guid via `guidForNewOutgoingTransferWithLocalURL:` only reserves the id; the daemon needs `IMDPersistentAttachmentController._persistentPath...` plus `retargetTransfer:toPath:` and `registerTransferWithDaemon:` before it will persist the attachment row. 2. The IMMessage body was a bare `` placeholder with no IM attributes, so even after registration Messages could not link the attachment to the message part. Add `__kIMFileTransferGUIDAttributeName`, `__kIMFilenameAttributeName`, `__kIMMessagePartAttributeName`, and `__kIMBaseWritingDirectionAttributeName` to the placeholder run. Factor the staging path into `prepareOutgoingTransfer` for readability and so future multipart attachment work can reuse it. Tighten the IMFileTransfer / IMFileTransferCenter forward declarations and add the IMDPersistentAttachmentController interface. * fix(rpc): advertise rpc_methods capability in status --json The openclaw imessage channel plugin reads `rpc_methods` from `imsg status --json` to gate which JSON-RPC calls it issues. Older imsg builds don't ship this field, so consumers fall back to a small foundational set (chats.list/messages.history/watch.*/send) and refuse to invoke typing/read/group.* until the user upgrades. The previous commit added `typing` and `read` handlers to RPCServer but didn't advertise them, so the openclaw plugin still gated them off with the message: imessage: typing indicators / read receipts gated off (imsg build pre-dates the rpc_methods capability list). Upgrade imsg (current bridge needs typing+read in rpc_methods). Add `rpc_methods` to StatusPayload, sourced from a top-level constant `kSupportedRPCMethods` in RPCServer.swift so the dispatch switch and the advertised list can't drift apart. * fix: build IMMessageItem first to survive macOS 26 send pipeline On macOS 26 the high-level `+initIMMessageWith…:expressiveSendStyleID:` factories return an IMMessage whose underlying IMMessageItem has empty `bodyData`. imagent reads bodyData (NSArchiver typedstream) when shipping to chat.db; an empty payload gets silently dropped, so every `imsg send-rich` call returned 'Could not construct IMMessage' and any bridge-routed send was a no-op. Lobster and other JSON-RPC consumers that use send-rich/send-multipart inherit the failure. Port 10ce6ab's IMMessageItem-first approach into buildIMMessage: 1. Allocate an IMMessageItem via the 9-arg `initWithSender:time:body:attributes:fileTransferGUIDs🎏error:guid:threadIdentifier:` 2. NSArchiver-archive the attributed body and `setBodyData:` it onto the item (the daemon reads bodyData, not body). Fall back to a plain-text retry if NSPresentationIntent breaks the archive. 3. Apply the item-level extended fields (expressiveSendStyleID, subject, associatedMessageGUID/Type/Range, summaryInfo) via setters BEFORE wrapping (post-wrap _imMessageItem returns a transient item whose setters don't persist). 4. Wrap with `+[IMMessage messageFromIMMessageItem:sender:subject:]`. 5. Dispatch via `-[IMChat _sendMessage:adjustingSender:shouldQueue:]` — public sendMessage: silently no-ops on items with sender = nil on macOS 26. The legacy `initIMMessageWithSender:…:expressiveSendStyleID:` path is preserved as a fallback for older OSes that don't expose the modern item-construction selectors. Smoke-tested on macOS 26.4.1: send-rich now lands in chat.db with attributedBody populated and expressive_send_style_id correctly set. Known follow-ups (existing failure modes, not introduced here): - reply threading via selectedMessageGuid stores no thread_originator link; needs `IMCreateThreadIdentifierForMessagePartChatItem`-derived threadIdentifier. - tapback (reaction) handler still routes through buildIMMessage; the associated-message fields don't survive the IMMessageItem 9-arg init. A dedicated reaction constructor is the right fix. * fix: derive thread identifier for replies + dedicated reaction constructor Replies via `selectedMessageGuid` previously sent as standalone messages on macOS 26 because the receiver also needs the `threadIdentifier` string to render the in-line reply UI; the associated_message_guid + type=100 combination alone isn't sufficient on Tahoe. Load the parent message via `-[IMChatHistoryController loadMessageWithGUID:completionBlock:]`, walk to its first IMMessagePartChatItem, and call the IMCore C function `IMCreateThreadIdentifierForMessagePartChatItem` (resolved via dlsym since the symbol lives only in the dyld shared cache on macOS 26) to derive the canonical thread id. Set both setThreadIdentifier: and setThreadOriginator: on the wrapped IMMessage. Tapbacks: replace the buildIMMessage path with the dedicated `+[IMMessage instantMessageWithAssociatedMessageContent:associatedMessageGUID:associatedMessageType:associatedMessageRange:associatedMessageEmoji:messageSummaryInfo:threadIdentifier:]` class method, prefixing the parent guid with `p:<part>/` to match iMessage's canonical part-targeted reference format (`p:0/<parent-guid>`). Seed the underlying IMMessageItem's bodyData manually so imagent has a payload to ship. Smoke-tested on macOS 26.4.1: replies now persist with thread_originator_guid pointing back at the parent, and the expressive_send_style_id from the previous fix continues to land correctly. Tapback persistence in chat.db remains finicky on this particular Messages session — visible behavior on the receiver still needs human verification. * fix(react): route reactions through legacy initIMMessageWithSender:…:associatedMessageGUID: path The IMMessageItem-first path doesn't preserve associated-message fields (the 9-arg item initializer doesn't accept them, and post-init setters don't survive the IMMessage wrap on macOS 26 — verified by tapbacks dispatching cleanly but not landing in chat.db). Skip the IMMessageItem-first short-circuit when associatedMessageGuid + associatedMessageType > 0 are set, falling through to the long initIMMessageWith…:associatedMessageGUID:… initializer that takes all reaction metadata atomically. Mirror what upstream's reaction path was doing at chat.db row 5078 in the dev-machine smoke test (the last successful outgoing tapback before the macOS 26 regression cluster). Use the public sendMessage: dispatch for reactions — _sendMessage:adjustingSender:shouldQueue: appears to interfere with the reaction-message flow on macOS 26 even when the public path works elsewhere. handleSendReaction also adds the canonical p:<part>/<guid> prefix to associatedMessageGUID, matching the format chat.db stores for working tapbacks (was 'raw guid' before; iMessage's part-targeted reference format is mandatory for the receiver to render the heart). Smoke-tested on macOS 26.4.1 post-reboot: dispatches without exception but doesn't always persist in chat.db on this Messages session. Marked as known follow-up — the reaction selector path on macOS 26 likely needs further reverse-engineering (a class-dump pass on IMMessage and IMMessageItem to find the modern reaction constructor). * fix: BlueBubblesHelper-verified macOS 26 selectors + reaction body After a fine-tooth audit against BlueBubblesHelper's macOS-11+ tree, several deltas in our IMCore use were causing macOS 26 regressions: 1. Reaction init signature was wrong on macOS 26. Use IMMessage's 13-arg `initWithSender:time:text:messageSubject:fileTransferGUIDs🎏error:guid:subject:associatedMessageGUID:associatedMessageType:associatedMessageRange:messageSummaryInfo:` (the BB-verified macOS 26 selector — no balloonBundleID/payloadData/ expressiveSendStyleID args). The 17-arg `initIMMessageWith…` we were using doesn't exist on macOS 26 (instancesRespondToSelector returns NO), which is why every tapback returned 'Could not build reaction IMMessage' or silently no-op'd. 2. Reaction body was empty — imagent silently dropped reactions. Reactions need a verb-style attributedBody (`Loved "parent text"`) not an empty string. Mirror BB's reactionToVerb mapping for love/like/dislike/laugh/emphasize/question and their remove-* forms. Best-effort load the parent message via deriveThreadIdentifier (which we already had wired up for replies) so we have its text to quote; fall back to a generic `Loved a message` phrase if the parent can't be resolved. 3. Reaction associatedMessageGUID needs the `p:<part>/<guid>` prefix. The receiver pipeline ignores reaction messages whose associatedMessageGUID is a bare guid (no part prefix). chat.db rows for working tapbacks (e.g. row 5078 in the dev-machine smoke test) show `p:0/<parent-guid>`. 4. Send/attachment flags were wrong — 0x5 instead of 0x100005. The 0x100000 bit is what tells imagent to finalize the payload (vs treating the item as a non-finalized internal staging record). With 0x5 the message dispatched but the receiver got a malformed attachment; with 0x100005 the daemon properly finalizes. BB-verified: isAudioMessage ? 0x300005 : (subject ? 0x10000d : 0x100005). 5. Send init signature: prefer BB's 12-arg `initWithSender:…:expressiveSendStyleID:` over the legacy 12-arg `initIMMessageWithSender:` form (same args, different prefix). Fall through to the legacy form if the macOS 26 selector isn't available. 6. Use 2-step init (`[[IMMessage alloc] init]` then re-init) for reactions, matching BB's pattern. The single-step alloc + invocation pattern can leave the message partially deallocated under macOS 26's stricter ARC. Smoke-tested on macOS 26.4.1: tapbacks now persist in chat.db with the correct associated_message_guid + type, and the heart renders on the iPhone. send-attachment still has a separate IMFileTransfer-side registration gap that doesn't link the attachment row in chat.db, but the receiver-visible behavior should be correct with the new flags. * fix(attachment): tighten ARC retention + skip IMMessageItem-first Per the BB-helper audit: 1. `_persistentPathForTransfer:…` returns its NSString via NSInvocation.getReturnValue, which puts the result into an __unsafe_unretained slot. Under macOS 26's stricter ARC the returned string can be released before we copy the file, leaving prepareOutgoingTransfer with a zombie pointer or nil. Take a strong reference immediately after getReturnValue. 2. Attachments shouldn't go through the IMMessageItem-first path — BB-helper builds attachment messages via the regular IMMessage `initWithSender:…:expressiveSendStyleID:` initializer, which handles fileTransferGUIDs natively and finalizes the payload correctly with the 0x100005 flag set. Routing through the IMMessageItem 9-arg init left the transfer registered but the payload unfinalized in some macOS 26 states. Smoke-tested on macOS 26.4.1: prepareOutgoingTransfer's persistentPath diagnostic still logs `(nil)` on this machine — the IMDPersistentAttachmentController._persistentPathForTransfer:… selector is exposed but returns nil for our IMFileTransfer object, which is a deeper macOS 26 staging-API change that needs separate investigation (possibly _saveAttachmentForTransfer:highQuality:copyWithinAttachmentStore:chatGUID:storeAtExternalPath: is the modern entry point). Documenting as a known follow-up; the AppleScript path (imsg send --file) remains a reliable workaround. * fix: BlueBubblesHelper-aligned selectors for group ops, mark-unread, notify-anyways, reaction summary Six independent functional bugs the BB-helper audit surfaced; all single-selector fixes that bring our IMCore handling back in line with the canonical BB MacOS-11+ tree. 1. Add participant: selector was `addParticipantsToiMessageChat:reason:` (not declared on IMChat). Use BB-verified `inviteParticipantsToiMessageChat:reason:`. Group join was error-failing. 2. Set chat display name: `setDisplayName:` is just the public KVO setter (local-only mutation that doesn't post the IDS update). BB uses `_setDisplayName:` (underscore-prefixed) for the daemon-aware path that propagates to all chat members. Renames were sender-only before. Also fixed the second call site in handleCreateChat. 3. Update group photo: was `setGroupPhotoData:` with raw NSData (not declared on IMChat). BB stages the image via the file-transfer pipeline (prepareOutgoingTransfer) and calls `sendGroupPhotoUpdate:transferGUID`. Group photo was no-op'ing. 4. Notify-anyways: was wired to `sendMessageAcknowledgment:forChatItem:withMessageSummaryInfo:withGuid:` with ack=1000 — that's a tapback ack, not a notify-anyway. BB-verified selector is `markChatItemAsNotifyRecipient:` (single arg). notify-anyways wasn't actually bypassing focus mode before. 5. Mark chat unread: `setUnreadCount:1` only mutates a local KVO counter that doesn't sync to chat.db or propagate. BB uses `markLastMessageAsUnread` for the daemon-aware path. 6. Edit / unsend / delete-message lookup: `findMessageItem` walked `chat.chatItems` synchronously, which only covers the live IMChat window. Edit-after-scroll-back failed with 'Message not found'. Try `IMChatHistoryController.loadMessageWithGUID:completionBlock:` first (BB-verified macOS 11+ path), fall back to the sync walk for older OSes. Reuses the loadParentFirstChatItem helper. 7. Reaction `messageSummaryInfo` shape: `amc` was the parent guid as a string. BB sets `amc: @1` (NSNumber count). Wrong type made the bplist-encoded summary malformed and imagent dropped reactions on macOS 26 even with all other fields right. `ams` continues to carry the parent text for the receiver-side notification preview. 8. Reaction `associatedMessageRange`: was hardcoded `{0, 1}`. BB derives from the parent's first chat item via `messagePartRange`, which lets tapbacks target non-zero parts (e.g. the second image of a multipart photo grid). Loaded via the new loadParentFirstChatItem helper. 9. Send dispatch: `dispatchIMMessageInChat` preferred the private `_sendMessage:adjustingSender:shouldQueue:` over public `sendMessage:`. BB never touches the private path — every text/attachment/reaction/reply goes through public `sendMessage:`. The private selector signature may have shifted on macOS 26 in ways that drop edge-case items. Simplified dispatchIMMessageInChat to just call `[chat sendMessage:]`. Smoke-tested on macOS 26.4.1 post-reboot: parent + tapback both persist with the corrected message_summary_info shape (`amc=@1`, `ams=parent text`); no regression in sends, effects, replies, or typing/read. * fix(rpc): restore chat/group lifecycle handlers cut during PR #100 merge PR #100 was squash-merged with 8 RPC handler functions silently dropped from the dispatch surface plus the corresponding `supportedMethods` capability list. This commit reinstates them so the openclaw imessage channel plugin (and any other JSON-RPC consumer) can call them again without hitting methodNotFound. Restored RPC methods: - chats.create — create a 1:1 or group chat - chats.delete — delete a chat from Messages.app - chats.markUnread — mark last message in a chat as unread - group.rename — set a group chat's display name - group.setIcon — set or clear a group chat photo - group.addParticipant - group.removeParticipant - group.leave — leave a group chat The handler functions all dispatch into v2 bridge actions the dylib already implements (createChat, deleteChat, markChatUnread, leaveChat, etc.), so no IMCore-side work is needed beyond what's in this PR's existing dylib commits. Each method is also added to the `kSupportedRPCMethods` list advertised via `imsg status --json`'s `rpc_methods` field. The handler implementations come from the original PR #100 branch (`omarshahine:feat/private-api-port` at 638efbd), which had them in a dedicated `RPCServer+ChatHandlers.swift` extension. Restored verbatim since they reference the surviving `invokeBridge` / BridgeAction enum infrastructure. Verified `imsg status --json` now lists 15 methods (was 7), and each restored case routes through to the correct BridgeAction. * docs: note macOS 26 bridge fixes --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
020e3de20e
commit
2d7b506d17
@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Private API Bridge
|
||||
- fix: restore macOS 26 bridge sends, replies, tapbacks, typing/read RPC, and
|
||||
chat/group lifecycle RPC methods after the BlueBubbles-inspired bridge port
|
||||
regressed on Tahoe (#101, thanks @omarshahine).
|
||||
|
||||
## 0.7.2 - 2026-05-06
|
||||
|
||||
### Release Packaging
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,43 @@ import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
/// Expand short expressive-send names (e.g. `invisibleink`, `confetti`) to the
|
||||
/// full bundle identifiers Messages.app expects on `expressiveSendStyleID`.
|
||||
/// Already-prefixed strings (anything starting with `com.apple.`) and unknown
|
||||
/// names pass through untouched so the dylib can return its own error.
|
||||
enum ExpressiveSendEffect {
|
||||
/// Bubble effects render on the message bubble itself.
|
||||
static let bubbleNames: Set<String> = ["impact", "loud", "gentle", "invisibleink"]
|
||||
|
||||
/// Screen effects play a full-screen animation. Map the short name to the
|
||||
/// `CK<TitleCase>Effect` token used in the bundle id.
|
||||
static let screenNames: [String: String] = [
|
||||
"confetti": "Confetti",
|
||||
"lasers": "Lasers",
|
||||
"fireworks": "Fireworks",
|
||||
"balloons": "Balloons",
|
||||
"sparkles": "Sparkles",
|
||||
"spotlight": "Spotlight",
|
||||
"echo": "Echo",
|
||||
"love": "Love",
|
||||
"celebration": "Celebration",
|
||||
]
|
||||
|
||||
static func expand(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return raw }
|
||||
if trimmed.hasPrefix("com.apple.") { return trimmed }
|
||||
let key = trimmed.lowercased()
|
||||
if bubbleNames.contains(key) {
|
||||
return "com.apple.MobileSMS.expressivesend.\(key)"
|
||||
}
|
||||
if let token = screenNames[key] {
|
||||
return "com.apple.messages.effect.CK\(token)Effect"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers shared by all bridge-backed commands.
|
||||
enum BridgeOutput {
|
||||
struct EmittedError: Error {}
|
||||
@ -81,7 +118,8 @@ enum SendRichCommand {
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi'",
|
||||
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'BOOM' --effect com.apple.MobileSMS.expressivesend.impact",
|
||||
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'BOOM' --effect impact",
|
||||
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'pew pew' --effect lasers",
|
||||
"imsg send-rich --chat ... --text 'hello world' --format '[{\"start\":0,\"length\":5,\"styles\":[\"bold\"]}]'",
|
||||
]
|
||||
) { values, runtime in
|
||||
@ -99,7 +137,9 @@ enum SendRichCommand {
|
||||
"partIndex": Int(values.option("part") ?? "0") ?? 0,
|
||||
"ddScan": !values.flag("noDDScan"),
|
||||
]
|
||||
if let effect = values.option("effect"), !effect.isEmpty { params["effectId"] = effect }
|
||||
if let effect = values.option("effect"), !effect.isEmpty {
|
||||
params["effectId"] = ExpressiveSendEffect.expand(effect)
|
||||
}
|
||||
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
|
||||
if let reply = values.option("replyTo"), !reply.isEmpty {
|
||||
params["selectedMessageGuid"] = reply
|
||||
@ -185,7 +225,9 @@ enum SendMultipartCommand {
|
||||
throw ParsedValuesError.invalidOption("parts")
|
||||
}
|
||||
var params: [String: Any] = ["chatGuid": chat, "parts": parts]
|
||||
if let effect = values.option("effect"), !effect.isEmpty { params["effectId"] = effect }
|
||||
if let effect = values.option("effect"), !effect.isEmpty {
|
||||
params["effectId"] = ExpressiveSendEffect.expand(effect)
|
||||
}
|
||||
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
|
||||
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
|
||||
@ -60,7 +60,8 @@ enum StatusCommand {
|
||||
message: availability.message,
|
||||
bridgeVersion: bridgeVersion,
|
||||
v2Ready: v2Ready,
|
||||
selectors: selectors
|
||||
selectors: selectors,
|
||||
rpcMethods: kSupportedRPCMethods
|
||||
)
|
||||
try JSONLines.print(payload)
|
||||
} else {
|
||||
@ -137,6 +138,7 @@ private struct StatusPayload: Encodable {
|
||||
let bridgeVersion: Int
|
||||
let v2Ready: Bool
|
||||
let selectors: [String: Bool]
|
||||
let rpcMethods: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case basicFeatures = "basic_features"
|
||||
@ -148,5 +150,6 @@ private struct StatusPayload: Encodable {
|
||||
case bridgeVersion = "bridge_version"
|
||||
case v2Ready = "v2_ready"
|
||||
case selectors
|
||||
case rpcMethods = "rpc_methods"
|
||||
}
|
||||
}
|
||||
|
||||
136
Sources/imsg/RPCServer+ChatHandlers.swift
Normal file
136
Sources/imsg/RPCServer+ChatHandlers.swift
Normal file
@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
/// Chat/group lifecycle and management methods. Each handler resolves the
|
||||
/// caller's chat target (`chat_guid` / `chat_identifier` / `chat_id`) into a
|
||||
/// chat GUID and then dispatches into the v2 bridge action that the dylib
|
||||
/// already implements.
|
||||
extension RPCServer {
|
||||
func handleChatsCreate(id: Any?, params: [String: Any]) async throws {
|
||||
let addresses = stringArrayParam(params["addresses"])
|
||||
guard !addresses.isEmpty else {
|
||||
throw RPCError.invalidParams("addresses is required (non-empty array of phone/email)")
|
||||
}
|
||||
let service = stringParam(params["service"]) ?? "iMessage"
|
||||
var bridgeParams: [String: Any] = [
|
||||
"addresses": addresses,
|
||||
"service": service,
|
||||
]
|
||||
if let name = stringParam(params["name"]), !name.isEmpty {
|
||||
bridgeParams["displayName"] = name
|
||||
}
|
||||
if let text = stringParam(params["text"]), !text.isEmpty {
|
||||
bridgeParams["message"] = text
|
||||
}
|
||||
let data = try await invokeBridge(action: .createChat, params: bridgeParams)
|
||||
var result: [String: Any] = ["ok": true]
|
||||
if let guid = data["chatGuid"] as? String, !guid.isEmpty {
|
||||
result["chat_guid"] = guid
|
||||
}
|
||||
respond(id: id, result: result)
|
||||
}
|
||||
|
||||
func handleChatsDelete(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
_ = try await invokeBridge(action: .deleteChat, params: ["chatGuid": chatGUID])
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleChatsMarkUnread(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
_ = try await invokeBridge(action: .markChatUnread, params: ["chatGuid": chatGUID])
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupRename(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
guard let name = stringParam(params["name"]) else {
|
||||
throw RPCError.invalidParams("name is required")
|
||||
}
|
||||
_ = try await invokeBridge(
|
||||
action: .setDisplayName,
|
||||
params: ["chatGuid": chatGUID, "newName": name]
|
||||
)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupSetIcon(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
var bridgeParams: [String: Any] = ["chatGuid": chatGUID]
|
||||
if let file = stringParam(params["file"]), !file.isEmpty {
|
||||
bridgeParams["filePath"] = (file as NSString).expandingTildeInPath
|
||||
}
|
||||
_ = try await invokeBridge(action: .updateGroupPhoto, params: bridgeParams)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupAddParticipant(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
guard let address = stringParam(params["address"]), !address.isEmpty else {
|
||||
throw RPCError.invalidParams("address is required")
|
||||
}
|
||||
_ = try await invokeBridge(
|
||||
action: .addParticipant,
|
||||
params: ["chatGuid": chatGUID, "address": address]
|
||||
)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupRemoveParticipant(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
guard let address = stringParam(params["address"]), !address.isEmpty else {
|
||||
throw RPCError.invalidParams("address is required")
|
||||
}
|
||||
_ = try await invokeBridge(
|
||||
action: .removeParticipant,
|
||||
params: ["chatGuid": chatGUID, "address": address]
|
||||
)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupLeave(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
_ = try await invokeBridge(action: .leaveChat, params: ["chatGuid": chatGUID])
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Resolve a chat GUID from `chat_guid`, `chat_identifier`, or `chat_id`.
|
||||
/// Bridge management actions (rename/leave/etc.) require a real chat GUID;
|
||||
/// rejecting up-front gives callers a clearer error than the dylib's
|
||||
/// downstream "chat not found".
|
||||
private func resolveChatGUIDParam(_ params: [String: Any]) async throws -> String {
|
||||
let input = ChatTargetInput(
|
||||
recipient: "",
|
||||
chatID: int64Param(params["chat_id"]),
|
||||
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
|
||||
chatGUID: stringParam(params["chat_guid"]) ?? ""
|
||||
)
|
||||
if !input.hasChatTarget {
|
||||
throw RPCError.invalidParams("chat_guid, chat_identifier, or chat_id is required")
|
||||
}
|
||||
let resolved = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in try await cache.info(chatID: chatID) },
|
||||
unknownChatError: { chatID in RPCError.invalidParams("unknown chat_id \(chatID)") }
|
||||
)
|
||||
if !resolved.chatGUID.isEmpty {
|
||||
return resolved.chatGUID
|
||||
}
|
||||
if !resolved.chatIdentifier.isEmpty {
|
||||
return resolved.chatIdentifier
|
||||
}
|
||||
throw RPCError.invalidParams("could not resolve chat GUID for chat target")
|
||||
}
|
||||
|
||||
private func invokeBridge(
|
||||
action: BridgeAction, params: [String: Any]
|
||||
) async throws -> [String: Any] {
|
||||
do {
|
||||
return try await IMsgBridgeClient.shared.invoke(action: action, params: params)
|
||||
} catch {
|
||||
throw RPCError.internalError(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -233,6 +233,87 @@ extension RPCServer {
|
||||
}
|
||||
respond(id: id, result: result)
|
||||
}
|
||||
|
||||
/// `typing` — start/stop the local-user typing indicator. Mirrors the
|
||||
/// `imsg typing` CLI surface (which is purely a wrapper over `TypingIndicator`)
|
||||
/// so callers that talk to `imsg rpc` over JSON-RPC have parity with the CLI.
|
||||
func handleTyping(params: [String: Any], id: Any?) async throws {
|
||||
let isTyping = boolParam(params["typing"]) ?? true
|
||||
let serviceRaw = stringParam(params["service"]) ?? "imessage"
|
||||
let input = ChatTargetInput(
|
||||
recipient: stringParam(params["to"]) ?? "",
|
||||
chatID: int64Param(params["chat_id"]),
|
||||
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
|
||||
chatGUID: stringParam(params["chat_guid"]) ?? ""
|
||||
)
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
|
||||
missingRecipientError: RPCError.invalidParams("to is required")
|
||||
)
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in try await cache.info(chatID: chatID) },
|
||||
unknownChatError: { chatID in
|
||||
RPCError.invalidParams("unknown chat_id \(chatID)")
|
||||
}
|
||||
)
|
||||
let identifier: String
|
||||
if let preferred = resolvedTarget.preferredIdentifier {
|
||||
identifier = preferred
|
||||
} else if input.hasChatTarget {
|
||||
throw RPCError.invalidParams("missing chat identifier or guid")
|
||||
} else {
|
||||
do {
|
||||
identifier = try ChatTargetResolver.directTypingIdentifier(
|
||||
recipient: input.recipient,
|
||||
serviceRaw: serviceRaw,
|
||||
invalidServiceError: { RPCError.invalidParams($0) }
|
||||
)
|
||||
} catch let err as RPCError {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
if isTyping {
|
||||
try TypingIndicator.startTyping(chatIdentifier: identifier)
|
||||
} else {
|
||||
try TypingIndicator.stopTyping(chatIdentifier: identifier)
|
||||
}
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
/// `read` — mark all messages in a chat as read on this device, which also
|
||||
/// fires a read-receipt to the sender if the chat has receipts enabled.
|
||||
func handleRead(params: [String: Any], id: Any?) async throws {
|
||||
let input = ChatTargetInput(
|
||||
recipient: stringParam(params["to"]) ?? "",
|
||||
chatID: int64Param(params["chat_id"]),
|
||||
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
|
||||
chatGUID: stringParam(params["chat_guid"]) ?? ""
|
||||
)
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
|
||||
missingRecipientError: RPCError.invalidParams("to is required")
|
||||
)
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in try await cache.info(chatID: chatID) },
|
||||
unknownChatError: { chatID in
|
||||
RPCError.invalidParams("unknown chat_id \(chatID)")
|
||||
}
|
||||
)
|
||||
let handle: String
|
||||
if let preferred = resolvedTarget.preferredIdentifier {
|
||||
handle = preferred
|
||||
} else if input.hasChatTarget {
|
||||
throw RPCError.invalidParams("missing chat identifier or guid")
|
||||
} else {
|
||||
handle = input.recipient
|
||||
}
|
||||
try await IMCoreBridge.shared.markAsRead(handle: handle)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
}
|
||||
|
||||
func buildMessagePayload(
|
||||
|
||||
@ -14,6 +14,30 @@ protocol RPCOutput: Sendable {
|
||||
func sendNotification(method: String, params: Any)
|
||||
}
|
||||
|
||||
/// Methods exposed by `imsg rpc` over JSON-RPC. Advertised to clients via
|
||||
/// `imsg status --json` (`rpc_methods` field) so capability-aware consumers
|
||||
/// (like the openclaw imessage channel plugin) can gate features off when
|
||||
/// running against an older imsg build that doesn't implement a given method.
|
||||
///
|
||||
/// Keep in sync with the dispatch switch in `RPCServer.handleLine`.
|
||||
let kSupportedRPCMethods: [String] = [
|
||||
"chats.list",
|
||||
"chats.create",
|
||||
"chats.delete",
|
||||
"chats.markUnread",
|
||||
"messages.history",
|
||||
"watch.subscribe",
|
||||
"watch.unsubscribe",
|
||||
"send",
|
||||
"typing",
|
||||
"read",
|
||||
"group.rename",
|
||||
"group.setIcon",
|
||||
"group.addParticipant",
|
||||
"group.removeParticipant",
|
||||
"group.leave",
|
||||
]
|
||||
|
||||
final class RPCServer {
|
||||
let store: MessageStore
|
||||
let watcher: MessageWatcher
|
||||
@ -101,6 +125,26 @@ final class RPCServer {
|
||||
try await handleWatchUnsubscribe(id: id, params: params)
|
||||
case "send":
|
||||
try await handleSend(params: params, id: id)
|
||||
case "typing":
|
||||
try await handleTyping(params: params, id: id)
|
||||
case "read":
|
||||
try await handleRead(params: params, id: id)
|
||||
case "chats.create":
|
||||
try await handleChatsCreate(id: id, params: params)
|
||||
case "chats.delete":
|
||||
try await handleChatsDelete(id: id, params: params)
|
||||
case "chats.markUnread":
|
||||
try await handleChatsMarkUnread(id: id, params: params)
|
||||
case "group.rename":
|
||||
try await handleGroupRename(id: id, params: params)
|
||||
case "group.setIcon":
|
||||
try await handleGroupSetIcon(id: id, params: params)
|
||||
case "group.addParticipant":
|
||||
try await handleGroupAddParticipant(id: id, params: params)
|
||||
case "group.removeParticipant":
|
||||
try await handleGroupRemoveParticipant(id: id, params: params)
|
||||
case "group.leave":
|
||||
try await handleGroupLeave(id: id, params: params)
|
||||
default:
|
||||
output.sendError(id: id, error: RPCError.methodNotFound(method))
|
||||
}
|
||||
|
||||
@ -54,6 +54,48 @@ func chatMarkRejectsConflictingFlags() async {
|
||||
#expect(output.contains("Invalid value for option: --read"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expressiveSendEffectExpandsShortNames() {
|
||||
// Bubble effects map to MobileSMS.expressivesend.<name>.
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("invisibleink")
|
||||
== "com.apple.MobileSMS.expressivesend.invisibleink")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("impact")
|
||||
== "com.apple.MobileSMS.expressivesend.impact")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("loud")
|
||||
== "com.apple.MobileSMS.expressivesend.loud")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("gentle")
|
||||
== "com.apple.MobileSMS.expressivesend.gentle")
|
||||
|
||||
// Screen effects map to messages.effect.CK<TitleCase>Effect.
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("confetti")
|
||||
== "com.apple.messages.effect.CKConfettiEffect")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("lasers")
|
||||
== "com.apple.messages.effect.CKLasersEffect")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("celebration")
|
||||
== "com.apple.messages.effect.CKCelebrationEffect")
|
||||
|
||||
// Case-insensitive on the short form.
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("InvisibleInk")
|
||||
== "com.apple.MobileSMS.expressivesend.invisibleink")
|
||||
|
||||
// Already-expanded ids pass through untouched.
|
||||
let expanded = "com.apple.MobileSMS.expressivesend.impact"
|
||||
#expect(ExpressiveSendEffect.expand(expanded) == expanded)
|
||||
let screenExpanded = "com.apple.messages.effect.CKHeartEffect"
|
||||
#expect(ExpressiveSendEffect.expand(screenExpanded) == screenExpanded)
|
||||
|
||||
// Unknown short names pass through so the dylib can return its own error.
|
||||
#expect(ExpressiveSendEffect.expand("totally-not-real") == "totally-not-real")
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatCreateRejectsUnsupportedServiceBeforeBridgeLaunch() async {
|
||||
let values = ParsedValues(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user