diff --git a/.agents/skills/imsg/SKILL.md b/.agents/skills/imsg/SKILL.md index 278d8251..7c069447 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`, `send-sticker`, `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`, `send-sticker`, `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. Stickers additionally require `send.sticker` in `rpc_methods` and `selectors.stickerSend`; attaching one requires `selectors.stickerAttach`. 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 49bbf4d5..1965f45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - 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). - fix: canonicalize securely staged attachment paths when Messages attachments are relocated through a symlink, and find nested threaded-reply items for edit, unsend, delete, and notify operations. ### Native Polls diff --git a/README.md b/README.md index 7f17fee7..e884ad40 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ Full docs: **[imsg.sh](https://imsg.sh)**. - **Attachment-aware.** Filenames, UTIs, byte counts, resolved paths, and optional CAF→M4A / GIF→PNG conversion for model consumers. - **Advanced IMCore (opt-in).** Edit, unsend, delete, rich-text formatting, - effects, reply threading, native stickers, group create/rename/photo, member add/remove, - read receipts, typing indicators, and live event streams via the bridge. + effects, reply threading, native stickers, group create/rename/photo, + member add/remove, 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 @@ -384,6 +386,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 5f257a93..adedaf26 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, .sendRichLink, .sendMultipart, .sendAttachment, .sendSticker, .sendPoll, - .sendPollVote, .sendPollUnvote, .sendReaction, .createChat: + .sendPollVote, .sendPollUnvote, .sendReaction, .createChat, .shareNickname: return defaultSendResponseTimeout default: return defaultResponseTimeout @@ -90,6 +90,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 21a65891..5728db9b 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -284,6 +284,7 @@ static BOOL ensureSecureDirectory(NSString *path, NSError **error) { static BOOL pollPayloadMessageInitializerAvailable(void); static BOOL pollVoteMessageInitializerAvailable(void); +static NSDictionary *nicknameSharingSelectorStatus(void); static BOOL urlPreviewMessageInitializerAvailable(void); static void probeSelectors(void) { @@ -311,6 +312,7 @@ - (NSString *)serviceName; @interface IMAccount : NSObject - (NSArray *)vettedAliases; - (id)loginIMHandle; +- (id)imHandleWithID:(NSString *)handleID; - (NSString *)serviceName; - (BOOL)isActive; @end @@ -346,6 +348,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; @@ -474,8 +477,15 @@ - (void)loadMessageWithGUID:(NSString *)guid @end @interface IMNicknameController : NSObject -+ (instancetype)sharedController; -- (id)nicknameForHandle:(NSString *)handle; ++ (instancetype)sharedInstance; +- (id)personalNickname; +- (BOOL)isInitialLoadComplete; +- (id)nicknameForHandle:(IMHandle *)handle; +- (BOOL)shouldOfferNicknameSharingForChat:(IMChat *)chat; +- (void)allowHandlesForNicknameSharing:(NSArray *)handles + forChat:(IMChat *)chat + fromHandle:(NSString *)fromHandleID + forceSend:(BOOL)forceSend; @end @interface IDSIDQueryController : NSObject @@ -937,6 +947,7 @@ static BOOL stickerTransferSelectorsAvailable(void) { } } + NSDictionary *nicknameSelectors = nicknameSharingSelectorStatus(); Class stickerTransferClass = NSClassFromString(@"IMFileTransfer"); Class transferCenterClass = NSClassFromString(@"IMFileTransferCenter"); BOOL stickerSetIsSticker = [stickerTransferClass instancesRespondToSelector: @@ -971,7 +982,6 @@ static BOOL stickerTransferSelectorsAvailable(void) { BOOL stickerAssociatedMessage = stickerAssociatedMessageInitializerAvailable(); BOOL stickerSend = stickerSetIsSticker && stickerSetUserInfo && stickerSetAttribution && stickerTransferCenter && stickerAttachmentMessage; - NSDictionary *selectors = @{ @"editMessageItem": @(gHasEditMessageItem), @"editMessage": @(gHasEditMessage), @@ -979,6 +989,9 @@ static BOOL stickerTransferSelectorsAvailable(void) { @"sendMessageReason": @(gHasSendMessageReason), @"pollPayloadMessage": @(pollPayloadMessageInitializerAvailable()), @"pollVoteMessage": @(pollVoteMessageInitializerAvailable()), + @"nicknameLookup": nicknameSelectors[@"nickname_lookup"], + @"namePhotoShouldOffer": nicknameSelectors[@"should_offer"], + @"namePhotoShare": nicknameSelectors[@"share"], @"stickerSetIsSticker": @(stickerSetIsSticker), @"stickerSetUserInfo": @(stickerSetUserInfo), @"stickerSetAttribution": @(stickerSetAttribution), @@ -6072,23 +6085,269 @@ static void cleanupPreparedStickerPaths(NSString *snapshotPath, 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 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 = + @"allowHandlesForNicknameSharing:forChat:fromHandle:forceSend:"; + return [nnClass instancesRespondToSelector:NSSelectorFromString(selectorName)] + ? selectorName + : 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"); + 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"); - 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 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 { + 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 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), + @"from_handle_source": senderSource ?: [NSNull null], + @"share_selector": shareSelector ?: [NSNull null], + @"force_supported": @(canShare) + }); +} + +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"); + 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)) + : 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"); } + 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; + 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, + @"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) { @@ -6218,6 +6477,10 @@ static void cleanupPreparedStickerPaths(NSString *snapshotPath, 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 0db49849..7332f5c6 100644 --- a/Sources/imsg/CommandRouter.swift +++ b/Sources/imsg/CommandRouter.swift @@ -48,6 +48,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..e83766e6 --- /dev/null +++ b/Sources/imsg/Commands/NamePhotoCommand.swift @@ -0,0 +1,75 @@ +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 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) " + + "personal_nickname_loaded=\(nicknameLoaded) " + + "has_personal_nickname=\(hasPersonalNickname) should_offer=\(shouldOffer)" + } + } +} diff --git a/Sources/imsg/Commands/StatusCommand.swift b/Sources/imsg/Commands/StatusCommand.swift index a868326b..4b923ab7 100644 --- a/Sources/imsg/Commands/StatusCommand.swift +++ b/Sources/imsg/Commands/StatusCommand.swift @@ -106,6 +106,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 d514732e..63dd82fa 100644 --- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift +++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift @@ -408,6 +408,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 b7774b4c..cb3ee6e6 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -61,6 +61,8 @@ let kSupportedRPCMethods: [String] = [ "group.addParticipant", "group.removeParticipant", "group.leave", + "contacts.shouldShareContact", + "contacts.shareContactCard", "handles.check", ] @@ -224,6 +226,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..6c60a861 --- /dev/null +++ b/Tests/imsgTests/NamePhotoCommandTests.swift @@ -0,0 +1,350 @@ +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, + "personal_nickname_loaded": true, + "has_personal_nickname": 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("personal_nickname_loaded=true")) + #expect(output.contains("has_personal_nickname=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:(NSArray")) + #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("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""#)) + #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"#)) + + let shareBody = try #require( + 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("@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")) + #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-rich --chat --url ` — sends an Apple URL @@ -103,6 +105,26 @@ 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 +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. + ## Launching Messages with a custom dylib ```bash diff --git a/docs/rpc.md b/docs/rpc.md index 19c39a71..54b5e68b 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: