From 39c82acfe54fc8caf8d616e268b4999873c6adff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 21:57:44 -0700 Subject: [PATCH 1/4] feat: add Messages Name & Photo sharing Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> --- .agents/skills/imsg/SKILL.md | 5 +- CHANGELOG.md | 3 + README.md | 17 +- Sources/IMsgCore/IMsgBridgeProtocol.swift | 4 +- Sources/IMsgHelper/IMsgInjected.m | 260 +++++++++++++- Sources/imsg/CommandRouter.swift | 1 + Sources/imsg/Commands/NamePhotoCommand.swift | 71 ++++ Sources/imsg/Commands/StatusCommand.swift | 1 + .../RPCServer+BridgeMessageHandlers.swift | 15 + Sources/imsg/RPCServer.swift | 12 + .../NamePhotoBridgeProtocolTests.swift | 26 ++ Tests/imsgTests/NamePhotoCommandTests.swift | 331 ++++++++++++++++++ docs/advanced-imcore.md | 20 ++ docs/rpc.md | 6 + 14 files changed, 756 insertions(+), 16 deletions(-) create mode 100644 Sources/imsg/Commands/NamePhotoCommand.swift create mode 100644 Tests/IMsgCoreTests/NamePhotoBridgeProtocolTests.swift create mode 100644 Tests/imsgTests/NamePhotoCommandTests.swift diff --git a/.agents/skills/imsg/SKILL.md b/.agents/skills/imsg/SKILL.md index 72d886af..fe643b5f 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-*`, `name-photo`, 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`. @@ -41,6 +41,7 @@ imsg history --chat-id ID --start 2025-01-01T00:00:00Z --end 2025-02-01T00:00:00 imsg stats --chat-id ID --time-zone UTC --media --json # logical message + media totals imsg scheduled list --json # future Send Later rows; read-only imsg chat-background status --chat-id ID --json # inspect local background state; read-only +imsg name-photo status --chat GUID --json # read-only Share Name & Photo eligibility ``` - Chat `id` is the `chat.db` rowid: stable on one machine, the preferred `--chat-id` handle. `identifier` and `guid` are portable across machines. @@ -67,10 +68,12 @@ Message `id` doubles as the watch cursor: persist the last-seen id and pass it b imsg send --to "+15551234567" --text "message" --service auto imsg send --chat-id ID --text "message" # prefer for groups: no address ambiguity imsg send --to "+15551234567" --file ~/Desktop/pic.jpg +imsg name-photo share --chat GUID # shares YOUR Name & Photo; explicit request only ``` - `--service auto` prefers iMessage and falls back to SMS for text-only phone sends; `--no-sms-fallback` disables that. - `imsg react --chat-id ID --reaction like` (AppleScript) only targets the **most recent incoming message** and needs Accessibility permission. To react to a specific message by GUID, use bridge `tapback`. +- `name-photo share` is not a vCard send. It discloses the local Messages Name & Photo to every participant in the selected chat; confirm the destination and explicit user intent before invoking it. ## Bridge extras diff --git a/CHANGELOG.md b/CHANGELOG.md index 39179bb7..ae1dc0d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ ### Packaging - fix: isolate universal builds per architecture and consume SwiftPM's reported product paths so stale slices cannot silently ship older CLI code. +### Advanced IMCore +- feat: inspect and explicitly share Apple Messages Name & Photo through `imsg name-photo` and compatible contact RPC methods, while fixing bridge nickname lookup to use the current controller and handle APIs (thanks @omarshahine). + ### Native Polls - fix: match native poll vote envelopes, participant handles, and summary metadata so votes render participant markers and correct notifications (#162, thanks @omarshahine). diff --git a/README.md b/README.md index 68dfba6a..c49fbece 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Full docs: **[imsg.sh](https://imsg.sh)**. optional CAF→M4A / GIF→PNG conversion for model consumers. - **Advanced IMCore (opt-in).** Edit, unsend, delete, rich-text formatting, effects, reply threading, group create/rename/photo, member add/remove, - read receipts, typing indicators, and live event streams via the bridge. + Name & Photo sharing, read receipts, typing indicators, and live event + streams via the bridge. - **Linux read-only preview.** Inspect a copied Messages database from a Linux host. No sending, no Messages.app integration. @@ -147,6 +148,7 @@ Advanced IMCore (require `imsg launch` with SIP off — see `imsg chat-add-member`, `imsg chat-remove-member`, `imsg chat-leave`, `imsg chat-delete`, `imsg chat-mark` - `imsg account`, `imsg whois`, `imsg nickname` +- `imsg name-photo status|share --chat ` `imsg status --json` reports native bridge selector capabilities. Poll creation requires `selectors.pollPayloadMessage`; poll voting requires @@ -374,6 +376,19 @@ imsg nickname --address +15551234567 imsg nickname --address +15551234567 --local ``` +Messages Name & Photo: + +```bash +imsg name-photo status --chat 'iMessage;-;+15551234567' # read-only offer eligibility +imsg name-photo share --chat 'iMessage;-;+15551234567' # explicitly share with participants +``` + +`status` reports whether Messages would currently offer its native Share Name +& Photo action; it is advisory, not a durable record of prior sharing. `share` +submits an explicit private-API send request and reports `requested: true`, not +a delivery receipt. Because it discloses your personal profile to every chat +participant, agents must only invoke it after an explicit user request. + Live events (typing indicators surfaced through the dylib): ```bash diff --git a/Sources/IMsgCore/IMsgBridgeProtocol.swift b/Sources/IMsgCore/IMsgBridgeProtocol.swift index 849906b7..8dc08cb2 100644 --- a/Sources/IMsgCore/IMsgBridgeProtocol.swift +++ b/Sources/IMsgCore/IMsgBridgeProtocol.swift @@ -32,7 +32,7 @@ public enum IMsgBridgeProtocol { public static func defaultResponseTimeout(for action: BridgeAction) -> TimeInterval { switch action { case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendPollVote, - .sendReaction, .createChat: + .sendReaction, .createChat, .shareNickname: return defaultSendResponseTimeout default: return defaultResponseTimeout @@ -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 shouldOfferNicknameSharing = "should-offer-nickname-sharing" + case shareNickname = "share-nickname" case checkImessageAvailability = "check-imessage-availability" case downloadPurgedAttachment = "download-purged-attachment" } diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 80312309..5eca9f10 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -209,6 +209,7 @@ static BOOL ensureSecureDirectory(NSString *path, NSError **error) { static BOOL pollPayloadMessageInitializerAvailable(void); static BOOL pollVoteMessageInitializerAvailable(void); +static NSDictionary *nicknameSharingSelectorStatus(void); static void probeSelectors(void) { Class chatClass = NSClassFromString(@"IMChat"); @@ -235,6 +236,7 @@ - (NSString *)serviceName; @interface IMAccount : NSObject - (NSArray *)vettedAliases; - (id)loginIMHandle; +- (id)imHandleWithID:(NSString *)handleID; - (NSString *)serviceName; - (BOOL)isActive; @end @@ -270,6 +272,7 @@ - (NSString *)displayName; - (id)lastMessage; - (id)lastSentMessage; - (id)account; +- (NSString *)lastAddressedHandleID; - (NSString *)displayNameForChat; - (void)sendMessage:(id)message; - (void)_sendMessage:(id)message adjustingSender:(BOOL)adjust shouldQueue:(BOOL)queue; @@ -375,8 +378,15 @@ - (void)loadMessageWithGUID:(NSString *)guid @end @interface IMNicknameController : NSObject -+ (instancetype)sharedController; -- (id)nicknameForHandle:(NSString *)handle; ++ (instancetype)sharedInstance; +- (id)nicknameForHandle:(IMHandle *)handle; +- (BOOL)shouldOfferNicknameSharingForChat:(IMChat *)chat; +- (void)allowHandlesForNicknameSharing:(NSArray *)handles + forChat:(IMChat *)chat + fromHandle:(NSString *)fromHandleID + forceSend:(BOOL)forceSend; +- (void)allowHandlesForNicknameSharing:(NSArray *)handles forChat:(IMChat *)chat; +- (void)whitelistHandlesForNicknameSharing:(NSArray *)handles forChat:(IMChat *)chat; @end @interface IDSIDQueryController : NSObject @@ -808,6 +818,7 @@ static id findChat(NSString *identifier) { } } + NSDictionary *nicknameSelectors = nicknameSharingSelectorStatus(); NSDictionary *selectors = @{ @"editMessageItem": @(gHasEditMessageItem), @"editMessage": @(gHasEditMessage), @@ -815,6 +826,9 @@ static id findChat(NSString *identifier) { @"sendMessageReason": @(gHasSendMessageReason), @"pollPayloadMessage": @(pollPayloadMessageInitializerAvailable()), @"pollVoteMessage": @(pollVoteMessageInitializerAvailable()), + @"nicknameLookup": nicknameSelectors[@"nickname_lookup"], + @"namePhotoShouldOffer": nicknameSelectors[@"should_offer"], + @"namePhotoShare": nicknameSelectors[@"share"], @"deleteChat": @(hasRegistry && [registryClass instancesRespondToSelector:NSSelectorFromString(@"deleteChat:")]), @"removeChat": @(hasRegistry && @@ -4191,23 +4205,239 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, return successResponse(requestId, info); } +static id sharedNicknameController(void) { + Class nnClass = NSClassFromString(@"IMNicknameController"); + SEL sharedSelector = @selector(sharedInstance); + if (!nnClass || ![(id)nnClass respondsToSelector:sharedSelector]) return nil; + return ((id (*)(id, SEL))objc_msgSend)(nnClass, sharedSelector); +} + +static NSString *nicknameSharingMutationSelectorName(Class nnClass) { + if (!nnClass) return nil; + NSArray *selectors = @[ + @"allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:", + @"allowHandlesForNicknameSharing:forChat:", + @"whitelistHandlesForNicknameSharing:forChat:" + ]; + for (NSString *name in selectors) { + if ([nnClass instancesRespondToSelector:NSSelectorFromString(name)]) return name; + } + return nil; +} + +static NSDictionary *nicknameSharingSelectorStatus(void) { + Class nnClass = NSClassFromString(@"IMNicknameController"); + BOOL hasSharedInstance = nnClass && [(id)nnClass respondsToSelector:@selector(sharedInstance)]; + BOOL hasLookup = nnClass && + [nnClass instancesRespondToSelector:@selector(nicknameForHandle:)]; + BOOL hasShouldOffer = nnClass && + [nnClass instancesRespondToSelector:@selector(shouldOfferNicknameSharingForChat:)]; + NSString *shareSelector = nicknameSharingMutationSelectorName(nnClass); + return @{ + @"controller": @(hasSharedInstance), + @"nickname_lookup": @(hasSharedInstance && hasLookup), + @"should_offer": @(hasSharedInstance && hasShouldOffer), + @"share": @(hasSharedInstance && shareSelector != nil), + @"share_selector": shareSelector ?: [NSNull null] + }; +} + +static IMAccount *activeIMessageAccount(void) { + Class accountControllerClass = NSClassFromString(@"IMAccountController"); + id accountController = accountControllerClass && + [(id)accountControllerClass respondsToSelector:@selector(sharedInstance)] + ? ((id (*)(id, SEL))objc_msgSend)(accountControllerClass, @selector(sharedInstance)) + : nil; + if ([accountController respondsToSelector:@selector(activeIMessageAccount)]) { + return ((id (*)(id, SEL))objc_msgSend)(accountController, + @selector(activeIMessageAccount)); + } + return nil; +} + +static NSString *nicknameLoginHandleID(IMAccount *account) { + if (![account respondsToSelector:@selector(loginIMHandle)]) return nil; + id loginHandle = ((id (*)(id, SEL))objc_msgSend)(account, @selector(loginIMHandle)); + if (![loginHandle respondsToSelector:@selector(ID)]) return nil; + id handleID = ((id (*)(id, SEL))objc_msgSend)(loginHandle, @selector(ID)); + return [handleID isKindOfClass:[NSString class]] && [handleID length] ? handleID : nil; +} + +static NSString *nicknameSenderHandleID(IMChat *chat, NSString **source) { + if ([chat respondsToSelector:@selector(lastAddressedHandleID)]) { + id handleID = ((id (*)(id, SEL))objc_msgSend)(chat, @selector(lastAddressedHandleID)); + if ([handleID isKindOfClass:[NSString class]] && [handleID length]) { + if (source) *source = @"chat.lastAddressedHandleID"; + return handleID; + } + } + + IMAccount *chatAccount = [chat respondsToSelector:@selector(account)] + ? ((id (*)(id, SEL))objc_msgSend)(chat, @selector(account)) + : nil; + NSString *chatLoginHandleID = nicknameLoginHandleID(chatAccount); + if (chatLoginHandleID.length) { + if (source) *source = @"chat.account.loginIMHandle"; + return chatLoginHandleID; + } + return nil; +} + static NSDictionary *handleGetNicknameInfo(NSInteger requestId, NSDictionary *params) { NSString *address = params[@"address"]; + if (![address isKindOfClass:[NSString class]] || address.length == 0) { + return errorResponse(requestId, @"Missing address"); + } + + @try { + id controller = sharedNicknameController(); + if (!controller) return errorResponse(requestId, @"IMNicknameController unavailable"); + if (![controller respondsToSelector:@selector(nicknameForHandle:)]) { + return errorResponse(requestId, @"nicknameForHandle: unavailable"); + } + + IMAccount *account = activeIMessageAccount(); + if (![account respondsToSelector:@selector(imHandleWithID:)]) { + return errorResponse(requestId, @"Active iMessage account unavailable"); + } + id handle = ((id (*)(id, SEL, id))objc_msgSend)(account, + @selector(imHandleWithID:), address); + if (!handle) return errorResponse(requestId, @"Could not resolve iMessage handle"); + + id nickname = ((id (*)(id, SEL, id))objc_msgSend)(controller, + @selector(nicknameForHandle:), handle); + NSMutableDictionary *info = [@{ + @"address": address, + @"has_nickname": @(nickname != nil) + } mutableCopy]; + if (nickname) info[@"description"] = [nickname description] ?: @""; + return successResponse(requestId, info); + } @catch (NSException *exception) { + return errorResponse(requestId, + [NSString stringWithFormat:@"Nickname lookup failed: %@", + exception.reason ?: @"unknown exception"]); + } +} + +static NSDictionary *handleShouldOfferNicknameSharing(NSInteger requestId, + NSDictionary *params) { + NSString *chatGuid = params[@"chatGuid"]; + if (![chatGuid isKindOfClass:[NSString class]] || chatGuid.length == 0) { + return errorResponse(requestId, @"Missing chatGuid"); + } + IMChat *chat = resolveChatByGuid(chatGuid); + if (!chat) return errorResponse(requestId, @"Chat not found"); + Class nnClass = NSClassFromString(@"IMNicknameController"); - if (!nnClass) return errorResponse(requestId, @"IMNicknameController unavailable"); - id ctrl = [nnClass performSelector:@selector(sharedController)]; - if (!ctrl) return errorResponse(requestId, @"controller nil"); + NSDictionary *capabilities = nicknameSharingSelectorStatus(); + id controller = nil; + @try { + controller = sharedNicknameController(); + } @catch (NSException *exception) { + return errorResponse(requestId, + [NSString stringWithFormat:@"Name & Photo controller failed: %@", + exception.reason ?: @"unknown exception"]); + } + + NSArray *participants = [chat respondsToSelector:@selector(participants)] + ? ((id (*)(id, SEL))objc_msgSend)(chat, @selector(participants)) + : @[]; + NSString *senderSource = nil; + NSString *senderHandleID = nicknameSenderHandleID(chat, &senderSource); + BOOL canInspectOffer = [capabilities[@"should_offer"] boolValue] && controller != nil; + BOOL canShare = [capabilities[@"share"] boolValue] && controller != nil; + id shouldOffer = [NSNull null]; + if (canInspectOffer) { + @try { + BOOL offer = ((BOOL (*)(id, SEL, id))objc_msgSend)( + controller, @selector(shouldOfferNicknameSharingForChat:), chat); + shouldOffer = @(offer); + } @catch (NSException *exception) { + return errorResponse(requestId, + [NSString stringWithFormat:@"Name & Photo status failed: %@", + exception.reason ?: @"unknown exception"]); + } + } - NSMutableDictionary *info = [NSMutableDictionary dictionary]; - if (address.length && [ctrl respondsToSelector:@selector(nicknameForHandle:)]) { - id nickname = [ctrl performSelector:@selector(nicknameForHandle:) withObject:address]; - info[@"address"] = address; - info[@"has_nickname"] = @(nickname != nil); - if (nickname) { - info[@"description"] = [nickname description] ?: @""; + NSString *shareSelector = nicknameSharingMutationSelectorName(nnClass); + BOOL forceSupported = [shareSelector isEqualToString: + @"allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:"]; + BOOL available = canShare && participants.count > 0 && + (!forceSupported || senderHandleID.length > 0); + return successResponse(requestId, @{ + @"chatGuid": chatGuid, + @"available": @(available), + @"can_inspect_offer": @(canInspectOffer), + @"can_share": @(canShare), + @"should_offer": shouldOffer, + @"participant_count": @(participants.count), + @"from_handle_available": @(senderHandleID.length > 0), + @"from_handle_source": senderSource ?: [NSNull null], + @"share_selector": shareSelector ?: [NSNull null], + @"force_supported": @(forceSupported) + }); +} + +static NSDictionary *handleShareNickname(NSInteger requestId, NSDictionary *params) { + NSString *chatGuid = params[@"chatGuid"]; + if (![chatGuid isKindOfClass:[NSString class]] || chatGuid.length == 0) { + return errorResponse(requestId, @"Missing chatGuid"); + } + IMChat *chat = resolveChatByGuid(chatGuid); + if (!chat) return errorResponse(requestId, @"Chat not found"); + + NSArray *participants = [chat respondsToSelector:@selector(participants)] + ? ((id (*)(id, SEL))objc_msgSend)(chat, @selector(participants)) + : nil; + if (![participants isKindOfClass:[NSArray class]] || participants.count == 0) { + return errorResponse(requestId, @"Chat has no participants to share with"); + } + + Class nnClass = NSClassFromString(@"IMNicknameController"); + NSString *selectorName = nicknameSharingMutationSelectorName(nnClass); + if (!selectorName) return errorResponse(requestId, @"Name & Photo sharing unavailable"); + + @try { + id controller = sharedNicknameController(); + if (!controller) return errorResponse(requestId, @"IMNicknameController unavailable"); + SEL selector = NSSelectorFromString(selectorName); + if (![controller respondsToSelector:selector]) { + return errorResponse(requestId, @"Name & Photo sharing selector unavailable"); } + + BOOL forceSend = NO; + NSString *senderSource = nil; + if ([selectorName isEqualToString: + @"allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:"]) { + NSString *senderHandleID = nicknameSenderHandleID(chat, &senderSource); + if (senderHandleID.length == 0) { + return errorResponse(requestId, @"Could not resolve the chat's local sending handle"); + } + // Explicit share requests must send even when the handles are already + // allow-listed; the private API otherwise only updates policy state. + // IMCore forwards fromHandle: unchanged as a local NSString handle ID; + // only the participants array contains IMHandle objects. + forceSend = YES; + ((void (*)(id, SEL, id, id, id, BOOL))objc_msgSend)( + controller, selector, participants, chat, senderHandleID, forceSend); + } else { + ((void (*)(id, SEL, id, id))objc_msgSend)( + controller, selector, participants, chat); + } + + return successResponse(requestId, @{ + @"chatGuid": chatGuid, + @"requested": @YES, + @"participant_count": @(participants.count), + @"share_selector": selectorName, + @"force_send": @(forceSend), + @"from_handle_source": senderSource ?: [NSNull null] + }); + } @catch (NSException *exception) { + return errorResponse(requestId, + [NSString stringWithFormat:@"Name & Photo sharing failed: %@", + exception.reason ?: @"unknown exception"]); } - return successResponse(requestId, info); } static NSDictionary *handleCheckIMessageAvailability(NSInteger requestId, NSDictionary *params) { @@ -4324,6 +4554,10 @@ 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:@"should-offer-nickname-sharing"]) + return handleShouldOfferNicknameSharing(legacyId, params); + if ([action isEqualToString:@"share-nickname"]) + return handleShareNickname(legacyId, params); if ([action isEqualToString:@"check-imessage-availability"]) return handleCheckIMessageAvailability(legacyId, params); if ([action isEqualToString:@"download-purged-attachment"]) diff --git a/Sources/imsg/CommandRouter.swift b/Sources/imsg/CommandRouter.swift index 8cf711fa..95b826be 100644 --- a/Sources/imsg/CommandRouter.swift +++ b/Sources/imsg/CommandRouter.swift @@ -47,6 +47,7 @@ struct CommandRouter { AccountCommand.spec, WhoisCommand.spec, NicknameCommand.spec, + NamePhotoCommand.spec, ] let descriptor = CommandDescriptor( name: rootName, diff --git a/Sources/imsg/Commands/NamePhotoCommand.swift b/Sources/imsg/Commands/NamePhotoCommand.swift new file mode 100644 index 00000000..ff672e7d --- /dev/null +++ b/Sources/imsg/Commands/NamePhotoCommand.swift @@ -0,0 +1,71 @@ +import Commander +import Foundation +import IMsgCore + +enum NamePhotoCommand { + static let spec = CommandSpec( + name: "name-photo", + abstract: "Inspect or share your Messages Name & Photo", + discussion: """ + Requires `imsg launch` (SIP disabled, dylib injected). `status` reads + whether Messages would offer Name & Photo sharing for the chat. `share` + explicitly sends your personal Name & Photo to every chat participant. + """, + signature: CommandSignatures.withRuntimeFlags( + CommandSignature( + arguments: [ + .make(label: "action", help: "status|share", isOptional: false) + ], + options: CommandSignatures.baseOptions() + [ + .make(label: "chat", names: [.long("chat")], help: "chat guid") + ] + ) + ), + usageExamples: [ + "imsg name-photo status --chat 'iMessage;-;+15551234567'", + "imsg name-photo 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: BridgeAction + switch values.argument(0) { + case "status": + action = .shouldOfferNicknameSharing + case "share": + action = .shareNickname + default: + throw ParsedValuesError.invalidOption("action") + } + + _ = try await BridgeOutput.invokeAndEmit( + action: action, + params: ["chatGuid": chat], + runtime: runtime, + invokeBridge: invokeBridge + ) { data in + if action == .shareNickname { + let requested = (data["requested"] as? Bool) ?? false + return "name-photo: share requested=\(requested)" + } + let canInspect = (data["can_inspect_offer"] as? Bool) ?? false + let canShare = (data["can_share"] as? Bool) ?? false + let shouldOffer = (data["should_offer"] as? Bool).map(String.init) ?? "unknown" + return + "name-photo: can_inspect_offer=\(canInspect) can_share=\(canShare) should_offer=\(shouldOffer)" + } + } +} diff --git a/Sources/imsg/Commands/StatusCommand.swift b/Sources/imsg/Commands/StatusCommand.swift index 4aa0b8c0..b23654cd 100644 --- a/Sources/imsg/Commands/StatusCommand.swift +++ b/Sources/imsg/Commands/StatusCommand.swift @@ -98,6 +98,7 @@ enum StatusCommand { " Chat: imsg chat-create, chat-name, chat-photo, chat-add/remove-member, chat-leave/delete, chat-mark" ) StdoutWriter.writeLine(" Introspect: imsg account, whois, nickname") + StdoutWriter.writeLine(" Name & Photo: imsg name-photo status|share") StdoutWriter.writeLine(" Local DB: imsg search") StdoutWriter.writeLine(" Watch with events: imsg watch --bb-events") } else { diff --git a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift index 5451a187..12b26fb4 100644 --- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift +++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift @@ -265,6 +265,21 @@ extension RPCServer { try await invokeMessageGUIDBridgeAction(action: .notifyAnyways, params: params, id: id) } + func handleNamePhotoStatus(params: [String: Any], id: Any?) async throws { + let chatGUID = try await resolveChatGUIDParam(params) + let data = try await invokeBridge( + action: .shouldOfferNicknameSharing, + params: ["chatGuid": chatGUID] + ) + respond(id: id, result: data.merging(["ok": true]) { current, _ in current }) + } + + func handleNamePhotoShare(params: [String: Any], id: Any?) async throws { + let chatGUID = try await resolveChatGUIDParam(params) + let data = try await invokeBridge(action: .shareNickname, 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 00730f29..d26028ad 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -56,6 +56,8 @@ let kSupportedRPCMethods: [String] = [ "group.addParticipant", "group.removeParticipant", "group.leave", + "contacts.shouldShareContact", + "contacts.shareContactCard", "handles.check", ] @@ -202,6 +204,16 @@ final class RPCServer { try await handleGroupRemoveParticipant(id: id, params: params) case "group.leave": try await handleGroupLeave(id: id, params: params) + case "contacts.shouldShareContact": + guard request.paramsAreNamed else { + throw RPCError.invalidParams("contacts.shouldShareContact params must be an object") + } + try await handleNamePhotoStatus(params: params, id: id) + case "contacts.shareContactCard": + guard request.paramsAreNamed else { + throw RPCError.invalidParams("contacts.shareContactCard params must be an object") + } + try await handleNamePhotoShare(params: params, id: id) case "handles.check": try await handleHandlesCheck(params: params, id: id) default: diff --git a/Tests/IMsgCoreTests/NamePhotoBridgeProtocolTests.swift b/Tests/IMsgCoreTests/NamePhotoBridgeProtocolTests.swift new file mode 100644 index 00000000..6edc9044 --- /dev/null +++ b/Tests/IMsgCoreTests/NamePhotoBridgeProtocolTests.swift @@ -0,0 +1,26 @@ +import Testing + +@testable import IMsgCore + +@Suite("Name & Photo bridge protocol") +struct NamePhotoBridgeProtocolTests { + @Test + func actionsMatchInjectedHelperVocabulary() { + #expect( + BridgeAction.shouldOfferNicknameSharing.rawValue == "should-offer-nickname-sharing" + ) + #expect(BridgeAction.shareNickname.rawValue == "share-nickname") + } + + @Test + func shareUsesMutationTimeoutWhileInspectionStaysShort() { + #expect( + IMsgBridgeProtocol.defaultResponseTimeout(for: .shareNickname) + == IMsgBridgeProtocol.defaultSendResponseTimeout + ) + #expect( + IMsgBridgeProtocol.defaultResponseTimeout(for: .shouldOfferNicknameSharing) + == IMsgBridgeProtocol.defaultResponseTimeout + ) + } +} diff --git a/Tests/imsgTests/NamePhotoCommandTests.swift b/Tests/imsgTests/NamePhotoCommandTests.swift new file mode 100644 index 00000000..5b0afb21 --- /dev/null +++ b/Tests/imsgTests/NamePhotoCommandTests.swift @@ -0,0 +1,331 @@ +import Commander +import Foundation +import Testing + +@testable import IMsgCore +@testable import imsg + +@Test +func namePhotoCommandIsRegisteredUnderCanonicalName() { + let router = CommandRouter() + + #expect(router.specs.contains { $0.name == "name-photo" }) +} + +@Test +func namePhotoStatusInvokesOfferInspectionForChat() async throws { + let values = ParsedValues( + positional: ["status"], + options: ["chat": ["iMessage;-;+15551234567"]], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: values) + var capturedAction: BridgeAction? + var capturedParams: [String: Any] = [:] + + let (output, _) = try await StdoutCapture.capture { + try await NamePhotoCommand.run( + values: values, + runtime: runtime, + invokeBridge: { action, params in + capturedAction = action + capturedParams = params + return [ + "can_inspect_offer": true, + "can_share": true, + "should_offer": false, + ] + } + ) + } + + #expect(capturedAction == .shouldOfferNicknameSharing) + #expect(capturedParams["chatGuid"] as? String == "iMessage;-;+15551234567") + #expect(output.contains("can_inspect_offer=true")) + #expect(output.contains("can_share=true")) + #expect(output.contains("should_offer=false")) +} + +@Test +func namePhotoStatusPreservesUnknownOfferState() async throws { + let values = ParsedValues( + positional: ["status"], + options: ["chat": ["iMessage;+;chat123"]], + flags: [] + ) + + let (output, _) = try await StdoutCapture.capture { + try await NamePhotoCommand.run( + values: values, + runtime: RuntimeOptions(parsedValues: values), + invokeBridge: { _, _ in + ["can_inspect_offer": false, "can_share": false, "should_offer": NSNull()] + } + ) + } + + #expect(output.contains("should_offer=unknown")) +} + +@Test +func namePhotoShareInvokesExplicitSharingAction() async throws { + let values = ParsedValues( + positional: ["share"], + options: ["chat": ["iMessage;+;chat123"]], + flags: ["jsonOutput"] + ) + let runtime = RuntimeOptions(parsedValues: values) + var capturedAction: BridgeAction? + var capturedParams: [String: Any] = [:] + + let (output, _) = try await StdoutCapture.capture { + try await NamePhotoCommand.run( + values: values, + runtime: runtime, + invokeBridge: { action, params in + capturedAction = action + capturedParams = params + return [ + "requested": true, + "share_selector": + "allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:", + ] + } + ) + } + + #expect(capturedAction == .shareNickname) + #expect(capturedParams["chatGuid"] as? String == "iMessage;+;chat123") + let payload = try #require( + JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any] + ) + #expect(payload["requested"] as? Bool == true) + #expect( + payload["share_selector"] as? String + == "allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:" + ) +} + +@Test +func namePhotoCommandRejectsMissingChatAndUnknownAction() async { + let missingChat = ParsedValues(positional: ["status"], options: [:], flags: []) + do { + try await NamePhotoCommand.run( + values: missingChat, + runtime: RuntimeOptions(parsedValues: missingChat) + ) + Issue.record("expected name-photo to require --chat") + } catch let error as ParsedValuesError { + #expect(error.description.contains("--chat")) + } catch { + Issue.record("unexpected error: \(error)") + } + + let unknownAction = ParsedValues( + positional: ["inspect"], + options: ["chat": ["iMessage;+;chat123"]], + flags: [] + ) + do { + try await NamePhotoCommand.run( + values: unknownAction, + runtime: RuntimeOptions(parsedValues: unknownAction) + ) + Issue.record("expected name-photo to reject an unknown action") + } catch let error as ParsedValuesError { + #expect(error.description.contains("action")) + } catch { + Issue.record("unexpected error: \(error)") + } +} + +@Test +func rpcAdvertisesNamePhotoCompatibilityMethods() { + let methods = Set(kSupportedRPCMethods) + + #expect(methods.contains("contacts.shouldShareContact")) + #expect(methods.contains("contacts.shareContactCard")) +} + +@Test +func rpcNamePhotoMethodsResolveChatAndInvokeMatchingBridgeActions() async throws { + let store = try CommandTestDatabase.makeStoreForRPC() + let output = TestRPCOutput() + var calls: [(BridgeAction, [String: Any])] = [] + let server = RPCServer( + store: store, + verbose: false, + output: output, + invokeBridge: { action, params in + calls.append((action, params)) + switch action { + case .shouldOfferNicknameSharing: + return ["can_share": true, "should_offer": true] + case .shareNickname: + return ["requested": true] + default: + return [:] + } + } + ) + + 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(calls.count == 2) + #expect(calls[0].0 == .shouldOfferNicknameSharing) + #expect(calls[0].1["chatGuid"] as? String == "iMessage;+;chat123") + #expect(calls[1].0 == .shareNickname) + #expect(calls[1].1["chatGuid"] as? String == "iMessage;+;chat123") + + let statusResult = output.responses.first?["result"] as? [String: Any] + #expect(statusResult?["ok"] as? Bool == true) + #expect(statusResult?["can_share"] as? Bool == true) + #expect(statusResult?["should_offer"] as? Bool == true) + let shareResult = output.responses.last?["result"] as? [String: Any] + #expect(shareResult?["ok"] as? Bool == true) + #expect(shareResult?["requested"] as? Bool == true) +} + +@Test +func rpcNamePhotoMethodsRequireNamedParamsAndChatTarget() async throws { + let store = try CommandTestDatabase.makeStoreForRPC() + let output = TestRPCOutput() + var bridgeCallCount = 0 + let server = RPCServer( + store: store, + verbose: false, + output: output, + invokeBridge: { _, _ in + bridgeCallCount += 1 + return [:] + } + ) + + await server.handleLineForTesting( + #"{"jsonrpc":"2.0","id":"positional","method":"contacts.shouldShareContact","params":[1]}"# + ) + await server.handleLineForTesting( + #"{"jsonrpc":"2.0","id":"missing","method":"contacts.shareContactCard","params":{}}"# + ) + + #expect(bridgeCallCount == 0) + #expect(output.errors.count == 2) + for response in output.errors { + let error = response["error"] as? [String: Any] + #expect(error?["code"] as? Int == -32602) + } +} + +@Test +func injectedHelperUsesGuardedNamePhotoSelectorFamilies() throws { + let testFile = URL(fileURLWithPath: #filePath) + let repoRoot = + testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let helper = repoRoot.appendingPathComponent("Sources/IMsgHelper/IMsgInjected.m") + let source = try String(contentsOf: helper, encoding: .utf8) + + #expect(source.contains("should-offer-nickname-sharing")) + #expect(source.contains("share-nickname")) + #expect(source.contains("@selector(sharedInstance)")) + #expect(source.contains("@selector(nicknameForHandle:)")) + #expect(source.contains("@selector(imHandleWithID:)")) + #expect(source.contains("@selector(shouldOfferNicknameSharingForChat:)")) + #expect( + source.contains("allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:") + ) + #expect(source.contains("fromHandle:(NSString *)fromHandleID")) + #expect(source.contains("allowHandlesForNicknameSharing:forChat:")) + #expect(source.contains("whitelistHandlesForNicknameSharing:forChat:")) + + let controllerBody = try #require( + objectiveCFunctionBody(named: "sharedNicknameController", in: source) + ) + #expect(controllerBody.contains("@selector(sharedInstance)")) + #expect(!controllerBody.contains("sharedController")) + + let senderBody = try #require( + objectiveCFunctionBody(named: "nicknameSenderHandleID", in: source) + ) + #expect(senderBody.contains("lastAddressedHandleID")) + #expect(senderBody.contains("chat.account.loginIMHandle")) + #expect(!senderBody.contains("activeIMessageAccount")) + + let statusBody = try #require( + objectiveCFunctionBody(named: "handleShouldOfferNicknameSharing", in: source) + ) + #expect(statusBody.contains("resolveChatByGuid(chatGuid)")) + #expect(statusBody.contains("nicknameSharingSelectorStatus()")) + #expect(statusBody.contains("shouldOfferNicknameSharingForChat:")) + #expect(statusBody.contains(#"@"should_offer""#)) + #expect(statusBody.contains(#"@"can_share""#)) + #expect(!statusBody.contains("forceSend = YES")) + #expect(!statusBody.contains(#"@"requested": @YES"#)) + + let shareBody = try #require( + objectiveCFunctionBody(named: "handleShareNickname", in: source) + ) + #expect(shareBody.contains("resolveChatByGuid(chatGuid)")) + #expect(shareBody.contains("participants")) + #expect(shareBody.contains("nicknameSharingMutationSelectorName")) + #expect(shareBody.contains("forceSend = YES")) + #expect(shareBody.contains("objc_msgSend")) + #expect(shareBody.contains(#"@"requested": @YES"#)) +} + +@Test +func injectedHelperMaterializesHandleForNicknameLookup() throws { + let testFile = URL(fileURLWithPath: #filePath) + let repoRoot = + testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let helper = repoRoot.appendingPathComponent("Sources/IMsgHelper/IMsgInjected.m") + let source = try String(contentsOf: helper, encoding: .utf8) + let lookupBody = try #require( + objectiveCFunctionBody(named: "handleGetNicknameInfo", in: source) + ) + + #expect(lookupBody.contains("sharedNicknameController()")) + #expect(lookupBody.contains("@selector(imHandleWithID:)")) + #expect(lookupBody.contains("@selector(nicknameForHandle:), handle")) + #expect(!lookupBody.contains("sharedController")) + #expect(!lookupBody.contains("withObject:address")) +} + +private func objectiveCFunctionBody(named name: String, in source: String) -> String? { + var searchStart = source.startIndex + while let nameRange = source.range(of: name, range: searchStart.. [--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 name-photo status|share --chat ` — inspect the native offer + eligibility or explicitly share your Messages Name & Photo with a chat. - `imsg send-rich --chat --reply-to --file ` — sends a threaded reply with an attachment through the bridge. - `imsg send-attachment --chat --file [--reply-to ]` — @@ -88,6 +90,24 @@ Reports whether Messages is running, whether the helper dylib is loaded, and whe When the bridge isn't loaded, `status` prints the reason rather than attempting to fix it. Use `imsg launch` if you want to bring it up. +## Messages Name & Photo + +```bash +imsg name-photo status --chat 'iMessage;-;+15551234567' +imsg name-photo share --chat 'iMessage;-;+15551234567' +``` + +This is Apple Messages' **Share Name & Photo** feature, not a vCard or Contacts +attachment. `status` is read-only and reports `should_offer`, the same advisory +eligibility Messages uses for its native prompt. A false value does not prove +that sharing previously happened. + +`share` is a privacy-sensitive mutation: it requests that Messages send your +personal nickname/photo to every participant in the selected chat. The bridge +returns `requested: true` only after invoking the version-gated private API; it +does not claim receiver delivery. Agents must not invoke it without an explicit +user request and a confirmed destination. + ## Launching Messages with a custom dylib ```bash diff --git a/docs/rpc.md b/docs/rpc.md index 5a22e2e1..60fb8f62 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -213,6 +213,12 @@ 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` reads Apple Messages' advisory Name & Photo offer eligibility. The result includes `can_inspect_offer`, `can_share`, and tri-state `should_offer`. +- `contacts.shareContactCard` explicitly requests Apple Messages Name & Photo sharing. Despite the compatibility name, this does not send a vCard. Success reports `requested: true`, not delivery. + +The two `contacts.*` compatibility methods accept `chat_id`, `chat_identifier`, +or `chat_guid`. Sharing discloses the local Messages profile to every chat +participant and must only be invoked after explicit user confirmation. Result: From c4d92cf933de5a2cf486d4df51195e44548ba1e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 19:43:49 +0100 Subject: [PATCH 2/4] fix: require explicit Name & Photo force send --- Sources/IMsgHelper/IMsgInjected.m | 52 +++++++-------------- Tests/imsgTests/NamePhotoCommandTests.swift | 5 +- 2 files changed, 21 insertions(+), 36 deletions(-) diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 9683f577..7e9bcfc7 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -484,8 +484,6 @@ - (void)allowHandlesForNicknameSharing:(NSArray *)handles forChat:(IMChat *)chat fromHandle:(NSString *)fromHandleID forceSend:(BOOL)forceSend; -- (void)allowHandlesForNicknameSharing:(NSArray *)handles forChat:(IMChat *)chat; -- (void)whitelistHandlesForNicknameSharing:(NSArray *)handles forChat:(IMChat *)chat; @end @interface IDSIDQueryController : NSObject @@ -6094,15 +6092,11 @@ static id sharedNicknameController(void) { static NSString *nicknameSharingMutationSelectorName(Class nnClass) { if (!nnClass) return nil; - NSArray *selectors = @[ - @"allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:", - @"allowHandlesForNicknameSharing:forChat:", - @"whitelistHandlesForNicknameSharing:forChat:" - ]; - for (NSString *name in selectors) { - if ([nnClass instancesRespondToSelector:NSSelectorFromString(name)]) return name; - } - return nil; + NSString *selectorName = + @"allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:"; + return [nnClass instancesRespondToSelector:NSSelectorFromString(selectorName)] + ? selectorName + : nil; } static NSDictionary *nicknameSharingSelectorStatus(void) { @@ -6240,10 +6234,7 @@ static id sharedNicknameController(void) { } NSString *shareSelector = nicknameSharingMutationSelectorName(nnClass); - BOOL forceSupported = [shareSelector isEqualToString: - @"allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:"]; - BOOL available = canShare && participants.count > 0 && - (!forceSupported || senderHandleID.length > 0); + BOOL available = canShare && participants.count > 0 && senderHandleID.length > 0; return successResponse(requestId, @{ @"chatGuid": chatGuid, @"available": @(available), @@ -6254,7 +6245,7 @@ static id sharedNicknameController(void) { @"from_handle_available": @(senderHandleID.length > 0), @"from_handle_source": senderSource ?: [NSNull null], @"share_selector": shareSelector ?: [NSNull null], - @"force_supported": @(forceSupported) + @"force_supported": @(canShare) }); } @@ -6285,25 +6276,18 @@ static id sharedNicknameController(void) { return errorResponse(requestId, @"Name & Photo sharing selector unavailable"); } - BOOL forceSend = NO; + BOOL forceSend = YES; NSString *senderSource = nil; - if ([selectorName isEqualToString: - @"allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:"]) { - NSString *senderHandleID = nicknameSenderHandleID(chat, &senderSource); - if (senderHandleID.length == 0) { - return errorResponse(requestId, @"Could not resolve the chat's local sending handle"); - } - // Explicit share requests must send even when the handles are already - // allow-listed; the private API otherwise only updates policy state. - // IMCore forwards fromHandle: unchanged as a local NSString handle ID; - // only the participants array contains IMHandle objects. - forceSend = YES; - ((void (*)(id, SEL, id, id, id, BOOL))objc_msgSend)( - controller, selector, participants, chat, senderHandleID, forceSend); - } else { - ((void (*)(id, SEL, id, id))objc_msgSend)( - controller, selector, participants, chat); - } + NSString *senderHandleID = nicknameSenderHandleID(chat, &senderSource); + if (senderHandleID.length == 0) { + return errorResponse(requestId, @"Could not resolve the chat's local sending handle"); + } + // Explicit share requests must send even when the handles are already + // allow-listed; the private API otherwise only updates policy state. + // IMCore forwards fromHandle: unchanged as a local NSString handle ID; + // only the participants array contains IMHandle objects. + ((void (*)(id, SEL, id, id, id, BOOL))objc_msgSend)( + controller, selector, participants, chat, senderHandleID, forceSend); return successResponse(requestId, @{ @"chatGuid": chatGuid, diff --git a/Tests/imsgTests/NamePhotoCommandTests.swift b/Tests/imsgTests/NamePhotoCommandTests.swift index 5b0afb21..8c471102 100644 --- a/Tests/imsgTests/NamePhotoCommandTests.swift +++ b/Tests/imsgTests/NamePhotoCommandTests.swift @@ -242,8 +242,8 @@ func injectedHelperUsesGuardedNamePhotoSelectorFamilies() throws { source.contains("allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:") ) #expect(source.contains("fromHandle:(NSString *)fromHandleID")) - #expect(source.contains("allowHandlesForNicknameSharing:forChat:")) - #expect(source.contains("whitelistHandlesForNicknameSharing:forChat:")) + #expect(!source.contains("allowHandlesForNicknameSharing:forChat:(NSArray")) + #expect(!source.contains("whitelistHandlesForNicknameSharing:forChat:")) let controllerBody = try #require( objectiveCFunctionBody(named: "sharedNicknameController", in: source) @@ -276,6 +276,7 @@ func injectedHelperUsesGuardedNamePhotoSelectorFamilies() throws { #expect(shareBody.contains("participants")) #expect(shareBody.contains("nicknameSharingMutationSelectorName")) #expect(shareBody.contains("forceSend = YES")) + #expect(!shareBody.contains("else {")) #expect(shareBody.contains("objc_msgSend")) #expect(shareBody.contains(#"@"requested": @YES"#)) } From 22ccf98cb317c02c22f34a4ca291c6254a01ec64 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 19:51:24 +0100 Subject: [PATCH 3/4] fix: reject profile sharing outside iMessage --- Sources/IMsgHelper/IMsgInjected.m | 10 ++++++++++ Tests/imsgTests/NamePhotoCommandTests.swift | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 7e9bcfc7..2d1ef95c 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -6201,6 +6201,11 @@ static id sharedNicknameController(void) { } IMChat *chat = resolveChatByGuid(chatGuid); if (!chat) return errorResponse(requestId, @"Chat not found"); + NSString *observedService = serviceNameForChat(chat, chatGuid); + if (![observedService isEqualToString:@"iMessage"] + && ![observedService isEqualToString:@"iMessageLite"]) { + return errorResponse(requestId, @"Name & Photo sharing requires an iMessage chat"); + } Class nnClass = NSClassFromString(@"IMNicknameController"); NSDictionary *capabilities = nicknameSharingSelectorStatus(); @@ -6256,6 +6261,11 @@ static id sharedNicknameController(void) { } IMChat *chat = resolveChatByGuid(chatGuid); if (!chat) return errorResponse(requestId, @"Chat not found"); + NSString *observedService = serviceNameForChat(chat, chatGuid); + if (![observedService isEqualToString:@"iMessage"] + && ![observedService isEqualToString:@"iMessageLite"]) { + return errorResponse(requestId, @"Name & Photo sharing requires an iMessage chat"); + } NSArray *participants = [chat respondsToSelector:@selector(participants)] ? ((id (*)(id, SEL))objc_msgSend)(chat, @selector(participants)) diff --git a/Tests/imsgTests/NamePhotoCommandTests.swift b/Tests/imsgTests/NamePhotoCommandTests.swift index 8c471102..4c1b8dbc 100644 --- a/Tests/imsgTests/NamePhotoCommandTests.swift +++ b/Tests/imsgTests/NamePhotoCommandTests.swift @@ -262,6 +262,9 @@ func injectedHelperUsesGuardedNamePhotoSelectorFamilies() throws { objectiveCFunctionBody(named: "handleShouldOfferNicknameSharing", in: source) ) #expect(statusBody.contains("resolveChatByGuid(chatGuid)")) + #expect(statusBody.contains("serviceNameForChat(chat, chatGuid)")) + #expect(statusBody.contains(#"isEqualToString:@"iMessage""#)) + #expect(statusBody.contains(#"isEqualToString:@"iMessageLite""#)) #expect(statusBody.contains("nicknameSharingSelectorStatus()")) #expect(statusBody.contains("shouldOfferNicknameSharingForChat:")) #expect(statusBody.contains(#"@"should_offer""#)) @@ -273,6 +276,9 @@ func injectedHelperUsesGuardedNamePhotoSelectorFamilies() throws { objectiveCFunctionBody(named: "handleShareNickname", in: source) ) #expect(shareBody.contains("resolveChatByGuid(chatGuid)")) + #expect(shareBody.contains("serviceNameForChat(chat, chatGuid)")) + #expect(shareBody.contains(#"isEqualToString:@"iMessage""#)) + #expect(shareBody.contains(#"isEqualToString:@"iMessageLite""#)) #expect(shareBody.contains("participants")) #expect(shareBody.contains("nicknameSharingMutationSelectorName")) #expect(shareBody.contains("forceSend = YES")) From 7bf716fb756c38948f751188baf03269a013199d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 21:09:36 +0100 Subject: [PATCH 4/4] fix: validate Name and Photo profile before sharing --- Sources/IMsgHelper/IMsgInjected.m | 40 +++++++++++++++++++- Sources/imsg/Commands/NamePhotoCommand.swift | 6 ++- Tests/imsgTests/NamePhotoCommandTests.swift | 12 ++++++ docs/advanced-imcore.md | 4 +- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 2d1ef95c..5728db9b 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -478,6 +478,8 @@ - (void)loadMessageWithGUID:(NSString *)guid @interface IMNicknameController : NSObject + (instancetype)sharedInstance; +- (id)personalNickname; +- (BOOL)isInitialLoadComplete; - (id)nicknameForHandle:(IMHandle *)handle; - (BOOL)shouldOfferNicknameSharingForChat:(IMChat *)chat; - (void)allowHandlesForNicknameSharing:(NSArray *)handles @@ -6090,6 +6092,23 @@ static id sharedNicknameController(void) { return ((id (*)(id, SEL))objc_msgSend)(nnClass, sharedSelector); } +static BOOL waitForNicknameControllerLoad(id controller, NSTimeInterval timeout) { + SEL loadedSelector = @selector(isInitialLoadComplete); + if (![controller respondsToSelector:loadedSelector]) return YES; + + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout]; + while (!((BOOL (*)(id, SEL))objc_msgSend)(controller, loadedSelector)) { + if ([deadline timeIntervalSinceNow] <= 0) return NO; + NSDate *nextCheck = [NSDate dateWithTimeIntervalSinceNow:0.05]; + if ([NSThread isMainThread]) { + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:nextCheck]; + } else { + [NSThread sleepForTimeInterval:0.05]; + } + } + return YES; +} + static NSString *nicknameSharingMutationSelectorName(Class nnClass) { if (!nnClass) return nil; NSString *selectorName = @@ -6223,8 +6242,12 @@ static id sharedNicknameController(void) { : @[]; NSString *senderSource = nil; NSString *senderHandleID = nicknameSenderHandleID(chat, &senderSource); - BOOL canInspectOffer = [capabilities[@"should_offer"] boolValue] && controller != nil; + BOOL controllerLoaded = controller != nil && waitForNicknameControllerLoad(controller, 1.0); + BOOL canInspectOffer = [capabilities[@"should_offer"] boolValue] && controllerLoaded; BOOL canShare = [capabilities[@"share"] boolValue] && controller != nil; + BOOL hasPersonalNickname = controllerLoaded + && [controller respondsToSelector:@selector(personalNickname)] + && ((id (*)(id, SEL))objc_msgSend)(controller, @selector(personalNickname)) != nil; id shouldOffer = [NSNull null]; if (canInspectOffer) { @try { @@ -6239,12 +6262,15 @@ static id sharedNicknameController(void) { } NSString *shareSelector = nicknameSharingMutationSelectorName(nnClass); - BOOL available = canShare && participants.count > 0 && senderHandleID.length > 0; + BOOL available = canShare && hasPersonalNickname && participants.count > 0 + && senderHandleID.length > 0; return successResponse(requestId, @{ @"chatGuid": chatGuid, @"available": @(available), @"can_inspect_offer": @(canInspectOffer), @"can_share": @(canShare), + @"personal_nickname_loaded": @(controllerLoaded), + @"has_personal_nickname": @(hasPersonalNickname), @"should_offer": shouldOffer, @"participant_count": @(participants.count), @"from_handle_available": @(senderHandleID.length > 0), @@ -6285,6 +6311,16 @@ static id sharedNicknameController(void) { if (![controller respondsToSelector:selector]) { return errorResponse(requestId, @"Name & Photo sharing selector unavailable"); } + if (!waitForNicknameControllerLoad(controller, 2.0)) { + return errorResponse(requestId, + @"Personal Name & Photo is still loading; retry the request"); + } + if (![controller respondsToSelector:@selector(personalNickname)] + || ((id (*)(id, SEL))objc_msgSend)(controller, + @selector(personalNickname)) == nil) { + return errorResponse(requestId, + @"No personal Name & Photo is configured in Messages"); + } BOOL forceSend = YES; NSString *senderSource = nil; diff --git a/Sources/imsg/Commands/NamePhotoCommand.swift b/Sources/imsg/Commands/NamePhotoCommand.swift index ff672e7d..e83766e6 100644 --- a/Sources/imsg/Commands/NamePhotoCommand.swift +++ b/Sources/imsg/Commands/NamePhotoCommand.swift @@ -63,9 +63,13 @@ enum NamePhotoCommand { } let canInspect = (data["can_inspect_offer"] as? Bool) ?? false let canShare = (data["can_share"] as? Bool) ?? false + let nicknameLoaded = (data["personal_nickname_loaded"] as? Bool) ?? false + let hasPersonalNickname = (data["has_personal_nickname"] as? Bool) ?? false let shouldOffer = (data["should_offer"] as? Bool).map(String.init) ?? "unknown" return - "name-photo: can_inspect_offer=\(canInspect) can_share=\(canShare) should_offer=\(shouldOffer)" + "name-photo: can_inspect_offer=\(canInspect) can_share=\(canShare) " + + "personal_nickname_loaded=\(nicknameLoaded) " + + "has_personal_nickname=\(hasPersonalNickname) should_offer=\(shouldOffer)" } } } diff --git a/Tests/imsgTests/NamePhotoCommandTests.swift b/Tests/imsgTests/NamePhotoCommandTests.swift index 4c1b8dbc..6c60a861 100644 --- a/Tests/imsgTests/NamePhotoCommandTests.swift +++ b/Tests/imsgTests/NamePhotoCommandTests.swift @@ -33,6 +33,8 @@ func namePhotoStatusInvokesOfferInspectionForChat() async throws { return [ "can_inspect_offer": true, "can_share": true, + "personal_nickname_loaded": true, + "has_personal_nickname": true, "should_offer": false, ] } @@ -43,6 +45,8 @@ func namePhotoStatusInvokesOfferInspectionForChat() async throws { #expect(capturedParams["chatGuid"] as? String == "iMessage;-;+15551234567") #expect(output.contains("can_inspect_offer=true")) #expect(output.contains("can_share=true")) + #expect(output.contains("personal_nickname_loaded=true")) + #expect(output.contains("has_personal_nickname=true")) #expect(output.contains("should_offer=false")) } @@ -269,6 +273,10 @@ func injectedHelperUsesGuardedNamePhotoSelectorFamilies() throws { #expect(statusBody.contains("shouldOfferNicknameSharingForChat:")) #expect(statusBody.contains(#"@"should_offer""#)) #expect(statusBody.contains(#"@"can_share""#)) + #expect(statusBody.contains(#"@"has_personal_nickname""#)) + #expect(statusBody.contains(#"@"personal_nickname_loaded""#)) + #expect(statusBody.contains("@selector(personalNickname)")) + #expect(statusBody.contains("waitForNicknameControllerLoad(controller, 1.0)")) #expect(!statusBody.contains("forceSend = YES")) #expect(!statusBody.contains(#"@"requested": @YES"#)) @@ -281,6 +289,10 @@ func injectedHelperUsesGuardedNamePhotoSelectorFamilies() throws { #expect(shareBody.contains(#"isEqualToString:@"iMessageLite""#)) #expect(shareBody.contains("participants")) #expect(shareBody.contains("nicknameSharingMutationSelectorName")) + #expect(shareBody.contains("@selector(personalNickname)")) + #expect(shareBody.contains("waitForNicknameControllerLoad(controller, 2.0)")) + #expect(shareBody.contains("Personal Name & Photo is still loading")) + #expect(shareBody.contains("No personal Name & Photo is configured in Messages")) #expect(shareBody.contains("forceSend = YES")) #expect(!shareBody.contains("else {")) #expect(shareBody.contains("objc_msgSend")) diff --git a/docs/advanced-imcore.md b/docs/advanced-imcore.md index c886834f..f04a4426 100644 --- a/docs/advanced-imcore.md +++ b/docs/advanced-imcore.md @@ -119,7 +119,9 @@ that sharing previously happened. `share` is a privacy-sensitive mutation: it requests that Messages send your personal nickname/photo to every participant in the selected chat. The bridge -returns `requested: true` only after invoking the version-gated private API; it +reports `has_personal_nickname: false` and refuses the share when Messages has +no personal Name & Photo configured, instead of claiming that it sent one. The +bridge returns `requested: true` only after invoking the version-gated private API; it does not claim receiver delivery. Agents must not invoke it without an explicit user request and a confirmed destination.