diff --git a/.agents/skills/imsg/SKILL.md b/.agents/skills/imsg/SKILL.md index 3ae6de9b..c9b8860e 100644 --- a/.agents/skills/imsg/SKILL.md +++ b/.agents/skills/imsg/SKILL.md @@ -12,7 +12,7 @@ description: "iMessage/SMS: local history, contacts, live watch, and requested s - Every read command supports `--json` and emits **NDJSON** (one object per line). Pipe to `jq -s` to get an array. Stdout carries only JSON; progress and warnings go to stderr. - Two capability tiers: - **Standard** (normal permissions): `chats`, `group`, `history`, `watch`, `search`, `send`, `react`, `nickname --local`, `account --local`, `whois --local`. - - **Bridge** (SIP disabled + `imsg launch` dylib injection): `send-rich`, `send-multipart`, `send-attachment`, `tapback`, `poll`, `edit`, `unsend`, `delete-message`, `read`, `typing`, `notify-anyways`, `chat-*`, and default-mode `account`/`whois`/`nickname`. + - **Bridge** (SIP disabled + `imsg launch` dylib injection): `send-rich`, `send-multipart`, `send-attachment`, `tapback`, `poll`, `edit`, `unsend`, `delete-message`, `read`, `typing`, `notify-anyways`, `chat-*`, `contact-card`, and default-mode `account`/`whois`/`nickname`. - Check availability with `imsg status --json` before using bridge commands. If the bridge is down, use a standard command only when it preserves the requested semantics; otherwise stop and explain. Never turn a reply/effect/subject into a plain send or a GUID-targeted tapback into `react`, and never suggest disabling SIP unprompted. - Full command and flag reference: `imsg completions llm`. @@ -72,6 +72,7 @@ Only after `imsg status` confirms the bridge is loaded (`imsg launch` injects it ```bash imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi' --reply-to MSG_GUID # replies, effects, subjects +imsg contact-card status --chat GUID imsg poll send --chat GUID --question 'Dinner?' --option 'Pizza' --option 'Sushi' --comment 'Vote by 5pm' imsg edit --chat GUID --message MSG_GUID --new-text 'updated' # macOS 13+ imsg chat-create --addresses '+15551234567,+15559876543' --name 'Crew' diff --git a/Sources/IMsgCore/IMsgBridgeProtocol.swift b/Sources/IMsgCore/IMsgBridgeProtocol.swift index 849906b7..eb5603c3 100644 --- a/Sources/IMsgCore/IMsgBridgeProtocol.swift +++ b/Sources/IMsgCore/IMsgBridgeProtocol.swift @@ -87,6 +87,8 @@ public enum BridgeAction: String, Sendable, CaseIterable { case searchMessages = "search-messages" case getAccountInfo = "get-account-info" case getNicknameInfo = "get-nickname-info" + case shareContactCard = "share-contact-card" + case contactCardSharingStatus = "contact-card-sharing-status" case checkImessageAvailability = "check-imessage-availability" case downloadPurgedAttachment = "download-purged-attachment" } diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 80312309..6569e178 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -59,6 +59,7 @@ static NSTimer *fileWatchTimer = nil; static NSTimer *rpcInboxTimer = nil; +static dispatch_source_t rpcInboxDispatchTimer = nil; static NSMutableSet *processedRpcIds = nil; static os_unfair_lock eventsLock = OS_UNFAIR_LOCK_INIT; static int lockFd = -1; @@ -209,6 +210,7 @@ static BOOL ensureSecureDirectory(NSString *path, NSError **error) { static BOOL pollPayloadMessageInitializerAvailable(void); static BOOL pollVoteMessageInitializerAvailable(void); +static NSDictionary *contactCardSelectorStatus(void); static void probeSelectors(void) { Class chatClass = NSClassFromString(@"IMChat"); @@ -815,6 +817,7 @@ static id findChat(NSString *identifier) { @"sendMessageReason": @(gHasSendMessageReason), @"pollPayloadMessage": @(pollPayloadMessageInitializerAvailable()), @"pollVoteMessage": @(pollVoteMessageInitializerAvailable()), + @"contactCardSharing": @([contactCardSelectorStatus()[@"available"] boolValue]), @"deleteChat": @(hasRegistry && [registryClass instancesRespondToSelector:NSSelectorFromString(@"deleteChat:")]), @"removeChat": @(hasRegistry && @@ -4210,6 +4213,79 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, return successResponse(requestId, info); } +static NSArray *contactCardStatusSelectors(void) { + return @[ + @"shouldShareMyNameAndPhotoWithChat:", + @"shouldShareNicknameWithChat:", + @"shouldShareNameAndPhotoForChat:", + @"sharingStateForChat:", + @"sharingStateForChatGUID:" + ]; +} + +static NSArray *contactCardShareSelectors(void) { + return @[ + @"shareMyNameAndPhotoWithChat:", + @"shareNicknameWithChat:", + @"shareNameAndPhotoWithChat:", + @"setShouldShareMyNameAndPhoto:forChat:", + @"setShouldShareNickname:forChat:" + ]; +} + +static NSDictionary *contactCardSelectorStatus(void) { + Class nnClass = NSClassFromString(@"IMNicknameController"); + NSMutableDictionary *selectors = [NSMutableDictionary dictionary]; + BOOL available = NO; + for (NSString *name in [contactCardStatusSelectors() + arrayByAddingObjectsFromArray:contactCardShareSelectors()]) { + SEL sel = NSSelectorFromString(name); + BOOL responds = nnClass && class_getInstanceMethod(nnClass, sel) != NULL; + selectors[name] = @(responds); + if (responds) available = YES; + } + return @{ + @"controller_class_available": @(nnClass != nil), + @"controller_available": @NO, + @"available": @(available), + @"probe_reason": @"IMNicknameController.sharedController probing is disabled because it can block Messages.app", + @"selectors": selectors + }; +} + +static NSDictionary *handleContactCardSharingStatus(NSInteger requestId, NSDictionary *params) { + NSString *chatGuid = params[@"chatGuid"]; + if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid"); + debugLog(@"contact-card status enter chat=%@", chatGuid); + IMChat *chat = resolveChatByGuid(chatGuid); + if (!chat) { + return errorResponse(requestId, + [NSString stringWithFormat:@"Chat not found: %@", chatGuid]); + } + debugLog(@"contact-card status resolved chat"); + NSMutableDictionary *info = [contactCardSelectorStatus() mutableCopy]; + debugLog(@"contact-card status selector map ready"); + info[@"chatGuid"] = chatGuid; + info[@"should_share"] = [NSNull null]; + info[@"status_probe_invoked"] = @NO; + info[@"status_probe_reason"] = + @"selector availability only; private status selectors are not invoked"; + return successResponse(requestId, info); +} + +static NSDictionary *handleShareContactCard(NSInteger requestId, NSDictionary *params) { + NSString *chatGuid = params[@"chatGuid"]; + if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid"); + IMChat *chat = resolveChatByGuid(chatGuid); + if (!chat) { + return errorResponse(requestId, + [NSString stringWithFormat:@"Chat not found: %@", chatGuid]); + } + (void)chat; + return errorResponse(requestId, + @"Contact-card sharing unavailable: private controller probing is disabled because it can block Messages.app"); +} + static NSDictionary *handleCheckIMessageAvailability(NSInteger requestId, NSDictionary *params) { NSString *address = params[@"address"]; NSString *aliasType = params[@"aliasType"] ?: @"phone"; @@ -4324,6 +4400,9 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, if ([action isEqualToString:@"search-messages"]) return handleSearchMessages(legacyId, params); if ([action isEqualToString:@"get-account-info"]) return handleGetAccountInfo(legacyId, params); if ([action isEqualToString:@"get-nickname-info"]) return handleGetNicknameInfo(legacyId, params); + if ([action isEqualToString:@"share-contact-card"]) return handleShareContactCard(legacyId, params); + if ([action isEqualToString:@"contact-card-sharing-status"]) + return handleContactCardSharingStatus(legacyId, params); if ([action isEqualToString:@"check-imessage-availability"]) return handleCheckIMessageAvailability(legacyId, params); if ([action isEqualToString:@"download-purged-attachment"]) @@ -4354,6 +4433,7 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, } NSLog(@"[imsg-bridge v2] action=%@ id=%@", action, uuid); + debugLog(@"v2 action=%@ id=%@", action, uuid); NSDictionary *legacy = dispatchAction(0, action, params); if (![legacy isKindOfClass:[NSDictionary class]]) { @@ -4398,6 +4478,7 @@ static void processCommandFile(void) { return; } + debugLog(@"legacy action=%@", command[@"action"] ?: @"(missing)"); NSDictionary *result = processCommand(command); if (result != nil) { @@ -4585,6 +4666,7 @@ static void scanV2Inbox(void) { static void startV2InboxWatcher(void) { initFilePaths(); + debugLog(@"v2 watcher starting dir=%@", kRpcInDir); // Ensure the queue dirs exist (CLI also pre-creates them, but be defensive // in case a v2-only run happened). Mode 0700 keeps other UIDs / sandboxed @@ -4597,6 +4679,7 @@ static void startV2InboxWatcher(void) { !ensureSecureDirectory(kRpcOutDir, &secureDirError)) { NSLog(@"[imsg-bridge v2] Refusing insecure RPC queue path: %@", secureDirError.localizedDescription); + debugLog(@"v2 watcher refused: %@", secureDirError.localizedDescription); return; } @@ -4609,7 +4692,19 @@ static void startV2InboxWatcher(void) { [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; rpcInboxTimer = timer; + rpcInboxDispatchTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, + dispatch_get_main_queue()); + dispatch_source_set_timer(rpcInboxDispatchTimer, + dispatch_time(DISPATCH_TIME_NOW, 100 * NSEC_PER_MSEC), + 100 * NSEC_PER_MSEC, + 20 * NSEC_PER_MSEC); + dispatch_source_set_event_handler(rpcInboxDispatchTimer, ^{ + scanV2Inbox(); + }); + dispatch_resume(rpcInboxDispatchTimer); + NSLog(@"[imsg-bridge v2] Inbox watcher started"); + debugLog(@"v2 watcher started"); } #pragma mark - Dylib Entry Point @@ -4700,6 +4795,10 @@ static void injectedCleanup(void) { [rpcInboxTimer invalidate]; rpcInboxTimer = nil; } + if (rpcInboxDispatchTimer) { + dispatch_source_cancel(rpcInboxDispatchTimer); + rpcInboxDispatchTimer = nil; + } if (lockFd >= 0) { close(lockFd); diff --git a/Sources/imsg/CommandRouter.swift b/Sources/imsg/CommandRouter.swift index 20db4d19..f56a16ed 100644 --- a/Sources/imsg/CommandRouter.swift +++ b/Sources/imsg/CommandRouter.swift @@ -44,6 +44,7 @@ struct CommandRouter { AccountCommand.spec, WhoisCommand.spec, NicknameCommand.spec, + ContactCardCommand.spec, ] let descriptor = CommandDescriptor( name: rootName, diff --git a/Sources/imsg/Commands/ContactCardCommand.swift b/Sources/imsg/Commands/ContactCardCommand.swift new file mode 100644 index 00000000..4bf36dd9 --- /dev/null +++ b/Sources/imsg/Commands/ContactCardCommand.swift @@ -0,0 +1,68 @@ +import Commander +import Foundation +import IMsgCore + +enum ContactCardCommand { + static let spec = CommandSpec( + name: "contact-card", + abstract: "Share or inspect iMessage contact-card sharing for a chat", + discussion: """ + Requires `imsg launch` (SIP-disabled, dylib injected). The status action + reports whether the running Messages private API exposes a known contact + card sharing selector; share fails closed when it does not. + """, + signature: CommandSignatures.withRuntimeFlags( + CommandSignature( + arguments: [ + .make(label: "action", help: "share|status", isOptional: false) + ], + options: CommandSignatures.baseOptions() + [ + .make(label: "chat", names: [.long("chat")], help: "chat guid") + ] + ) + ), + usageExamples: [ + "imsg contact-card status --chat 'iMessage;-;+15551234567'", + "imsg contact-card share --chat 'iMessage;-;+15551234567'", + ] + ) { values, runtime in + try await run(values: values, runtime: runtime) + } + + static func run( + values: ParsedValues, + runtime: RuntimeOptions, + invokeBridge: @escaping (BridgeAction, [String: Any]) async throws -> [String: Any] = { + action, params in + try await IMsgBridgeClient.shared.invoke(action: action, params: params) + } + ) async throws { + guard let chat = values.option("chat"), !chat.isEmpty else { + throw ParsedValuesError.missingOption("chat") + } + let action = values.argument(0) + let bridgeAction: BridgeAction + switch action { + case "share": + bridgeAction = .shareContactCard + case "status": + bridgeAction = .contactCardSharingStatus + default: + throw ParsedValuesError.invalidOption("action") + } + + _ = try await BridgeOutput.invokeAndEmit( + action: bridgeAction, + params: ["chatGuid": chat], + runtime: runtime, + invokeBridge: invokeBridge + ) { data in + if action == "share" { + return "contact-card: share requested" + } + let available = (data["available"] as? Bool) ?? false + let sharing = (data["should_share"] as? Bool).map { $0 ? "yes" : "no" } ?? "unknown" + return "contact-card: available=\(available) should_share=\(sharing)" + } + } +} diff --git a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift index 5451a187..946509ae 100644 --- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift +++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift @@ -265,6 +265,19 @@ extension RPCServer { try await invokeMessageGUIDBridgeAction(action: .notifyAnyways, params: params, id: id) } + func handleContactCardShare(params: [String: Any], id: Any?) async throws { + let chatGUID = try await resolveChatGUIDParam(params) + let data = try await invokeBridge(action: .shareContactCard, params: ["chatGuid": chatGUID]) + respond(id: id, result: ["ok": true, "data": data]) + } + + func handleContactCardStatus(params: [String: Any], id: Any?) async throws { + let chatGUID = try await resolveChatGUIDParam(params) + let data = try await invokeBridge( + action: .contactCardSharingStatus, params: ["chatGuid": chatGUID]) + respond(id: id, result: data.merging(["ok": true]) { current, _ in current }) + } + private func invokeMessageGUIDBridgeAction( action: BridgeAction, params: [String: Any], diff --git a/Sources/imsg/RPCServer.swift b/Sources/imsg/RPCServer.swift index 09082dba..cebd5e9b 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -54,6 +54,8 @@ let kSupportedRPCMethods: [String] = [ "group.addParticipant", "group.removeParticipant", "group.leave", + "contacts.shareContactCard", + "contacts.shouldShareContact", "handles.check", ] @@ -190,6 +192,10 @@ final class RPCServer { try await handleGroupRemoveParticipant(id: id, params: params) case "group.leave": try await handleGroupLeave(id: id, params: params) + case "contacts.shareContactCard": + try await handleContactCardShare(params: params, id: id) + case "contacts.shouldShareContact": + try await handleContactCardStatus(params: params, id: id) case "handles.check": try await handleHandlesCheck(params: params, id: id) default: diff --git a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift index 3c8f064f..c84807e0 100644 --- a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift +++ b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift @@ -9,6 +9,8 @@ struct IMsgBridgeProtocolTests { func actionRawValuesMatchDylibVocabulary() { #expect(BridgeAction.sendMessage.rawValue == "send-message") #expect(BridgeAction.sendPoll.rawValue == "send-poll") + #expect(BridgeAction.shareContactCard.rawValue == "share-contact-card") + #expect(BridgeAction.contactCardSharingStatus.rawValue == "contact-card-sharing-status") #expect(BridgeAction.sendReaction.rawValue == "send-reaction") #expect(BridgeAction.editMessage.rawValue == "edit-message") #expect(BridgeAction.unsendMessage.rawValue == "unsend-message") diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index c4920e15..5a307a47 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -17,7 +17,7 @@ func commandRouterIncludesAllBridgeCommands() { "chat-create", "chat-name", "chat-photo", "chat-add-member", "chat-remove-member", "chat-leave", "chat-delete", "chat-mark", - "account", "whois", "nickname", + "account", "whois", "nickname", "contact-card", ] let registered = Set(router.specs.map { $0.name }) for name in expected { @@ -40,6 +40,7 @@ func bridgeMessagingCommandsExposeChatRequirement() async { ("unsend", ["--message", "message-guid"]), ("delete-message", ["--message", "message-guid"]), ("tapback", ["--message", "message-guid", "--kind", "love"]), + ("contact-card", ["status"]), ] for testCase in cases { let (output, status) = await StdoutCapture.capture { @@ -50,6 +51,67 @@ func bridgeMessagingCommandsExposeChatRequirement() async { } } +@Test +func contactCardCommandForwardsShareAndStatusActions() async throws { + let statusValues = ParsedValues( + positional: ["status"], + options: ["chat": ["iMessage;-;+15551234567"]], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: statusValues) + var calls: [(BridgeAction, [String: Any])] = [] + + _ = try await StdoutCapture.capture { + try await ContactCardCommand.run( + values: statusValues, + runtime: runtime, + invokeBridge: { action, params in + calls.append((action, params)) + return ["available": true, "should_share": false] + } + ) + } + + let shareValues = ParsedValues( + positional: ["share"], + options: ["chat": ["iMessage;-;+15551234567"]], + flags: [] + ) + _ = try await StdoutCapture.capture { + try await ContactCardCommand.run( + values: shareValues, + runtime: RuntimeOptions(parsedValues: shareValues), + invokeBridge: { action, params in + calls.append((action, params)) + return ["shared": true] + } + ) + } + + #expect(calls.map(\.0) == [.contactCardSharingStatus, .shareContactCard]) + #expect(calls.allSatisfy { $0.1["chatGuid"] as? String == "iMessage;-;+15551234567" }) +} + +@Test +func injectedHelperWiresContactCardSharingActions() throws { + let testFile = URL(fileURLWithPath: #filePath) + let repoRoot = + testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let helper = repoRoot.appendingPathComponent("Sources/IMsgHelper/IMsgInjected.m") + let source = stripObjectiveCComments(try String(contentsOf: helper, encoding: .utf8)) + + #expect(source.contains("share-contact-card")) + #expect(source.contains("contact-card-sharing-status")) + #expect(source.contains("IMNicknameController")) + #expect(source.contains("contactCardShareSelectors")) + #expect(source.contains("contactCardStatusSelectors")) + #expect(source.contains("class_getInstanceMethod(nnClass, sel)")) + #expect(source.contains(#""status_probe_invoked""#)) +} + @Test func bridgeAttachmentStagingUsesChatGuid() throws { let testFile = URL(fileURLWithPath: #filePath) diff --git a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift index 3517fe8b..34017454 100644 --- a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift +++ b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift @@ -305,3 +305,28 @@ func rpcBridgeMessageMethodsResolveDirectChatIdentifierToGUID() async throws { #expect(capturedParams["chatGuid"] as? String == "iMessage;-;+123") } + +@Test +func rpcContactCardMethodsResolveChatAndInvokeBridge() async throws { + let store = try CommandTestDatabase.makeStoreForRPC() + let output = TestRPCOutput() + var capturedActions: [BridgeAction] = [] + let server = RPCServer( + store: store, + verbose: false, + output: output, + invokeBridge: { action, _ in + capturedActions.append(action) + return ["available": true, "should_share": false] + } + ) + + await server.handleLineForTesting( + #"{"jsonrpc":"2.0","id":"status","method":"contacts.shouldShareContact","params":{"chat_id":1}}"# + ) + await server.handleLineForTesting( + #"{"jsonrpc":"2.0","id":"share","method":"contacts.shareContactCard","params":{"chat_id":1}}"# + ) + + #expect(capturedActions == [.contactCardSharingStatus, .shareContactCard]) +} diff --git a/docs/advanced-imcore.md b/docs/advanced-imcore.md index 2067132b..49e051a7 100644 --- a/docs/advanced-imcore.md +++ b/docs/advanced-imcore.md @@ -15,6 +15,9 @@ You almost certainly do not need any of this for normal use. - `imsg typing --to [--duration 5s] [--stop true]` — show or stop the typing indicator. - `imsg launch [--dylib ] [--kill-only]` — launch Messages.app with the helper dylib injected. - `imsg status` — read-only IMCore bridge status. +- `imsg contact-card status --chat ` and + `imsg contact-card share --chat ` — inspect or request iMessage + contact-card sharing for a chat when the running private API exposes it. - `imsg send-rich --chat --reply-to --file ` — sends a threaded reply with an attachment through the bridge. - `imsg send-attachment --chat --file [--reply-to ]` — diff --git a/docs/rpc.md b/docs/rpc.md index 6d6bdb2d..6486e924 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -169,6 +169,8 @@ These methods require the IMCore bridge and target an existing chat with `chat_i - `tapback` sends or removes a reaction. Params: `message_id` or `message_guid`, plus `reaction` / `kind` / `emoji`, optional `remove`. - `message.edit` edits `message_id` / `message_guid` with `text`. - `message.unsend`, `message.delete`, and `message.notifyAnyways` target `message_id` / `message_guid`. +- `contacts.shouldShareContact` reports contact-card sharing selector availability/status. +- `contacts.shareContactCard` requests contact-card sharing for the chat and fails closed when no known selector is available. Result: