Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/imsg/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ description: "iMessage/SMS: local history, contacts, live watch, and requested s
- Every read command supports `--json` and emits **NDJSON** (one object per line). Pipe to `jq -s` to get an array. Stdout carries only JSON; progress and warnings go to stderr.
- Two capability tiers:
- **Standard** (normal permissions): `chats`, `group`, `history`, `watch`, `search`, `send`, `react`, `nickname --local`, `account --local`, `whois --local`.
- **Bridge** (SIP disabled + `imsg launch` dylib injection): `send-rich`, `send-multipart`, `send-attachment`, `tapback`, `poll`, `edit`, `unsend`, `delete-message`, `read`, `typing`, `notify-anyways`, `chat-*`, and default-mode `account`/`whois`/`nickname`.
- **Bridge** (SIP disabled + `imsg launch` dylib injection): `send-rich`, `send-multipart`, `send-attachment`, `tapback`, `poll`, `edit`, `unsend`, `delete-message`, `read`, `typing`, `notify-anyways`, `chat-*`, `contact-card`, and default-mode `account`/`whois`/`nickname`.
- Check availability with `imsg status --json` before using bridge commands. If the bridge is down, use a standard command only when it preserves the requested semantics; otherwise stop and explain. Never turn a reply/effect/subject into a plain send or a GUID-targeted tapback into `react`, and never suggest disabling SIP unprompted.
- Full command and flag reference: `imsg completions llm`.

Expand Down Expand Up @@ -72,6 +72,7 @@ Only after `imsg status` confirms the bridge is loaded (`imsg launch` injects it

```bash
imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi' --reply-to MSG_GUID # replies, effects, subjects
imsg contact-card status --chat GUID
imsg poll send --chat GUID --question 'Dinner?' --option 'Pizza' --option 'Sushi' --comment 'Vote by 5pm'
imsg edit --chat GUID --message MSG_GUID --new-text 'updated' # macOS 13+
imsg chat-create --addresses '+15551234567,+15559876543' --name 'Crew'
Expand Down
2 changes: 2 additions & 0 deletions Sources/IMsgCore/IMsgBridgeProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ public enum BridgeAction: String, Sendable, CaseIterable {
case searchMessages = "search-messages"
case getAccountInfo = "get-account-info"
case getNicknameInfo = "get-nickname-info"
case shareContactCard = "share-contact-card"
case contactCardSharingStatus = "contact-card-sharing-status"
case checkImessageAvailability = "check-imessage-availability"
case downloadPurgedAttachment = "download-purged-attachment"
}
Expand Down
99 changes: 99 additions & 0 deletions Sources/IMsgHelper/IMsgInjected.m
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@

static NSTimer *fileWatchTimer = nil;
static NSTimer *rpcInboxTimer = nil;
static dispatch_source_t rpcInboxDispatchTimer = nil;
static NSMutableSet *processedRpcIds = nil;
static os_unfair_lock eventsLock = OS_UNFAIR_LOCK_INIT;
static int lockFd = -1;
Expand Down Expand Up @@ -209,6 +210,7 @@ static BOOL ensureSecureDirectory(NSString *path, NSError **error) {

static BOOL pollPayloadMessageInitializerAvailable(void);
static BOOL pollVoteMessageInitializerAvailable(void);
static NSDictionary *contactCardSelectorStatus(void);

static void probeSelectors(void) {
Class chatClass = NSClassFromString(@"IMChat");
Expand Down Expand Up @@ -815,6 +817,7 @@ static id findChat(NSString *identifier) {
@"sendMessageReason": @(gHasSendMessageReason),
@"pollPayloadMessage": @(pollPayloadMessageInitializerAvailable()),
@"pollVoteMessage": @(pollVoteMessageInitializerAvailable()),
@"contactCardSharing": @([contactCardSelectorStatus()[@"available"] boolValue]),
@"deleteChat": @(hasRegistry &&
[registryClass instancesRespondToSelector:NSSelectorFromString(@"deleteChat:")]),
@"removeChat": @(hasRegistry &&
Expand Down Expand Up @@ -4210,6 +4213,79 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer,
return successResponse(requestId, info);
}

static NSArray<NSString *> *contactCardStatusSelectors(void) {
return @[
@"shouldShareMyNameAndPhotoWithChat:",
@"shouldShareNicknameWithChat:",
@"shouldShareNameAndPhotoForChat:",
@"sharingStateForChat:",
@"sharingStateForChatGUID:"
];
}

static NSArray<NSString *> *contactCardShareSelectors(void) {
return @[
@"shareMyNameAndPhotoWithChat:",
@"shareNicknameWithChat:",
@"shareNameAndPhotoWithChat:",
@"setShouldShareMyNameAndPhoto:forChat:",
@"setShouldShareNickname:forChat:"
];
}

static NSDictionary *contactCardSelectorStatus(void) {
Class nnClass = NSClassFromString(@"IMNicknameController");
NSMutableDictionary *selectors = [NSMutableDictionary dictionary];
BOOL available = NO;
for (NSString *name in [contactCardStatusSelectors()
arrayByAddingObjectsFromArray:contactCardShareSelectors()]) {
SEL sel = NSSelectorFromString(name);
BOOL responds = nnClass && class_getInstanceMethod(nnClass, sel) != NULL;
selectors[name] = @(responds);
if (responds) available = YES;
}
return @{
@"controller_class_available": @(nnClass != nil),
@"controller_available": @NO,
@"available": @(available),
@"probe_reason": @"IMNicknameController.sharedController probing is disabled because it can block Messages.app",
@"selectors": selectors
};
}

static NSDictionary *handleContactCardSharingStatus(NSInteger requestId, NSDictionary *params) {
NSString *chatGuid = params[@"chatGuid"];
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
debugLog(@"contact-card status enter chat=%@", chatGuid);
IMChat *chat = resolveChatByGuid(chatGuid);
if (!chat) {
return errorResponse(requestId,
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
}
debugLog(@"contact-card status resolved chat");
NSMutableDictionary *info = [contactCardSelectorStatus() mutableCopy];
debugLog(@"contact-card status selector map ready");
info[@"chatGuid"] = chatGuid;
info[@"should_share"] = [NSNull null];
info[@"status_probe_invoked"] = @NO;
info[@"status_probe_reason"] =
@"selector availability only; private status selectors are not invoked";
return successResponse(requestId, info);
}

static NSDictionary *handleShareContactCard(NSInteger requestId, NSDictionary *params) {
NSString *chatGuid = params[@"chatGuid"];
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
IMChat *chat = resolveChatByGuid(chatGuid);
if (!chat) {
return errorResponse(requestId,
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
}
(void)chat;
return errorResponse(requestId,
@"Contact-card sharing unavailable: private controller probing is disabled because it can block Messages.app");
}

static NSDictionary *handleCheckIMessageAvailability(NSInteger requestId, NSDictionary *params) {
NSString *address = params[@"address"];
NSString *aliasType = params[@"aliasType"] ?: @"phone";
Expand Down Expand Up @@ -4324,6 +4400,9 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer,
if ([action isEqualToString:@"search-messages"]) return handleSearchMessages(legacyId, params);
if ([action isEqualToString:@"get-account-info"]) return handleGetAccountInfo(legacyId, params);
if ([action isEqualToString:@"get-nickname-info"]) return handleGetNicknameInfo(legacyId, params);
if ([action isEqualToString:@"share-contact-card"]) return handleShareContactCard(legacyId, params);
if ([action isEqualToString:@"contact-card-sharing-status"])
return handleContactCardSharingStatus(legacyId, params);
if ([action isEqualToString:@"check-imessage-availability"])
return handleCheckIMessageAvailability(legacyId, params);
if ([action isEqualToString:@"download-purged-attachment"])
Expand Down Expand Up @@ -4354,6 +4433,7 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer,
}

NSLog(@"[imsg-bridge v2] action=%@ id=%@", action, uuid);
debugLog(@"v2 action=%@ id=%@", action, uuid);

NSDictionary *legacy = dispatchAction(0, action, params);
if (![legacy isKindOfClass:[NSDictionary class]]) {
Expand Down Expand Up @@ -4398,6 +4478,7 @@ static void processCommandFile(void) {
return;
}

debugLog(@"legacy action=%@", command[@"action"] ?: @"(missing)");
NSDictionary *result = processCommand(command);

if (result != nil) {
Expand Down Expand Up @@ -4585,6 +4666,7 @@ static void scanV2Inbox(void) {

static void startV2InboxWatcher(void) {
initFilePaths();
debugLog(@"v2 watcher starting dir=%@", kRpcInDir);

// Ensure the queue dirs exist (CLI also pre-creates them, but be defensive
// in case a v2-only run happened). Mode 0700 keeps other UIDs / sandboxed
Expand All @@ -4597,6 +4679,7 @@ static void startV2InboxWatcher(void) {
!ensureSecureDirectory(kRpcOutDir, &secureDirError)) {
NSLog(@"[imsg-bridge v2] Refusing insecure RPC queue path: %@",
secureDirError.localizedDescription);
debugLog(@"v2 watcher refused: %@", secureDirError.localizedDescription);
return;
}

Expand All @@ -4609,7 +4692,19 @@ static void startV2InboxWatcher(void) {
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
rpcInboxTimer = timer;

rpcInboxDispatchTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0,
dispatch_get_main_queue());
dispatch_source_set_timer(rpcInboxDispatchTimer,
dispatch_time(DISPATCH_TIME_NOW, 100 * NSEC_PER_MSEC),
100 * NSEC_PER_MSEC,
20 * NSEC_PER_MSEC);
dispatch_source_set_event_handler(rpcInboxDispatchTimer, ^{
scanV2Inbox();
});
dispatch_resume(rpcInboxDispatchTimer);

NSLog(@"[imsg-bridge v2] Inbox watcher started");
debugLog(@"v2 watcher started");
}

#pragma mark - Dylib Entry Point
Expand Down Expand Up @@ -4700,6 +4795,10 @@ static void injectedCleanup(void) {
[rpcInboxTimer invalidate];
rpcInboxTimer = nil;
}
if (rpcInboxDispatchTimer) {
dispatch_source_cancel(rpcInboxDispatchTimer);
rpcInboxDispatchTimer = nil;
}

if (lockFd >= 0) {
close(lockFd);
Expand Down
1 change: 1 addition & 0 deletions Sources/imsg/CommandRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ struct CommandRouter {
AccountCommand.spec,
WhoisCommand.spec,
NicknameCommand.spec,
ContactCardCommand.spec,
]
let descriptor = CommandDescriptor(
name: rootName,
Expand Down
68 changes: 68 additions & 0 deletions Sources/imsg/Commands/ContactCardCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Commander
import Foundation
import IMsgCore

enum ContactCardCommand {
static let spec = CommandSpec(
name: "contact-card",
abstract: "Share or inspect iMessage contact-card sharing for a chat",
discussion: """
Requires `imsg launch` (SIP-disabled, dylib injected). The status action
reports whether the running Messages private API exposes a known contact
card sharing selector; share fails closed when it does not.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(label: "action", help: "share|status", isOptional: false)
],
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid")
]
)
),
usageExamples: [
"imsg contact-card status --chat 'iMessage;-;+15551234567'",
"imsg contact-card share --chat 'iMessage;-;+15551234567'",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}

static func run(
values: ParsedValues,
runtime: RuntimeOptions,
invokeBridge: @escaping (BridgeAction, [String: Any]) async throws -> [String: Any] = {
action, params in
try await IMsgBridgeClient.shared.invoke(action: action, params: params)
}
) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let action = values.argument(0)
let bridgeAction: BridgeAction
switch action {
case "share":
bridgeAction = .shareContactCard
case "status":
bridgeAction = .contactCardSharingStatus
default:
throw ParsedValuesError.invalidOption("action")
}

_ = try await BridgeOutput.invokeAndEmit(
action: bridgeAction,
params: ["chatGuid": chat],
runtime: runtime,
invokeBridge: invokeBridge
) { data in
if action == "share" {
return "contact-card: share requested"
}
let available = (data["available"] as? Bool) ?? false
let sharing = (data["should_share"] as? Bool).map { $0 ? "yes" : "no" } ?? "unknown"
return "contact-card: available=\(available) should_share=\(sharing)"
}
}
}
13 changes: 13 additions & 0 deletions Sources/imsg/RPCServer+BridgeMessageHandlers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,19 @@ extension RPCServer {
try await invokeMessageGUIDBridgeAction(action: .notifyAnyways, params: params, id: id)
}

func handleContactCardShare(params: [String: Any], id: Any?) async throws {
let chatGUID = try await resolveChatGUIDParam(params)
let data = try await invokeBridge(action: .shareContactCard, params: ["chatGuid": chatGUID])
respond(id: id, result: ["ok": true, "data": data])
}

func handleContactCardStatus(params: [String: Any], id: Any?) async throws {
let chatGUID = try await resolveChatGUIDParam(params)
let data = try await invokeBridge(
action: .contactCardSharingStatus, params: ["chatGuid": chatGUID])
respond(id: id, result: data.merging(["ok": true]) { current, _ in current })
}

private func invokeMessageGUIDBridgeAction(
action: BridgeAction,
params: [String: Any],
Expand Down
6 changes: 6 additions & 0 deletions Sources/imsg/RPCServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ let kSupportedRPCMethods: [String] = [
"group.addParticipant",
"group.removeParticipant",
"group.leave",
"contacts.shareContactCard",
"contacts.shouldShareContact",
"handles.check",
]

Expand Down Expand Up @@ -190,6 +192,10 @@ final class RPCServer {
try await handleGroupRemoveParticipant(id: id, params: params)
case "group.leave":
try await handleGroupLeave(id: id, params: params)
case "contacts.shareContactCard":
try await handleContactCardShare(params: params, id: id)
case "contacts.shouldShareContact":
try await handleContactCardStatus(params: params, id: id)
case "handles.check":
try await handleHandlesCheck(params: params, id: id)
default:
Expand Down
2 changes: 2 additions & 0 deletions Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ struct IMsgBridgeProtocolTests {
func actionRawValuesMatchDylibVocabulary() {
#expect(BridgeAction.sendMessage.rawValue == "send-message")
#expect(BridgeAction.sendPoll.rawValue == "send-poll")
#expect(BridgeAction.shareContactCard.rawValue == "share-contact-card")
#expect(BridgeAction.contactCardSharingStatus.rawValue == "contact-card-sharing-status")
#expect(BridgeAction.sendReaction.rawValue == "send-reaction")
#expect(BridgeAction.editMessage.rawValue == "edit-message")
#expect(BridgeAction.unsendMessage.rawValue == "unsend-message")
Expand Down
Loading