Compare commits

..

94 Commits
v0.4.0 ... main

Author SHA1 Message Date
Peter Steinberger
faa998e39f
docs: clarify imessage contact lookup skill
Some checks failed
CI / linux-read-core (push) Has been cancelled
CI / macos (push) Has been cancelled
2026-05-08 09:03:36 +01:00
Peter Steinberger
d5038414b2
docs: refresh readme 2026-05-08 05:56:20 +01:00
Peter Steinberger
23c5892688
chore: start 0.8.1 development 2026-05-08 05:04:04 +01:00
Peter Steinberger
03be1d9483
ci: point imsg tap updates at openclaw release repo 2026-05-08 05:00:28 +01:00
Peter Steinberger
53b4ada222
ci: allow release tag checkout in container 2026-05-08 04:57:52 +01:00
Peter Steinberger
9b0d341535
chore: release 0.8.0 2026-05-08 04:54:10 +01:00
Sagar Dagdu
98fd924a7f
fix: prefer structured typedstream prefix decoding
Fix typedstream attributedBody recovery for 32-126 byte messages whose length byte is printable ASCII, and keep the regression covered across the parser edge cases.\n\nCo-authored-by: Sagar Dagdu <shags032@gmail.com>
2026-05-08 02:49:55 +01:00
Peter Steinberger
0d1ca83815
docs: document linux read-only preview
Some checks failed
CI / linux-read-core (push) Waiting to run
CI / macos (push) Waiting to run
pages / Deploy docs (push) Has been cancelled
2026-05-07 14:40:10 +01:00
Peter Steinberger
f6de1c6fd5
ci: update homebrew tap on release 2026-05-07 03:56:52 +01:00
Peter Steinberger
e833e0c898
feat: add linux read-only build (#106)
Some checks are pending
CI / macos (push) Waiting to run
CI / linux-read-core (push) Waiting to run
pages / Deploy docs (push) Waiting to run
2026-05-07 01:29:26 +01:00
Peter Steinberger
788f9f2a4b
chore: start 0.7.4 development 2026-05-06 23:23:09 +01:00
Peter Steinberger
b2a4931016
chore: release 0.7.3 2026-05-06 23:20:05 +01:00
Peter Steinberger
c48ee7294b
docs: explain bridge attachment staging 2026-05-06 23:16:26 +01:00
Peter Steinberger
3695bfb96e
fix: stage bridge attachments with chat guid 2026-05-06 23:00:44 +01:00
Peter Steinberger
311cc41a7f
chore: start 0.7.3 development 2026-05-06 22:13:59 +01:00
Omar Shahine
243226951f
fix(security): clamp IPC dirs to 0700 and reject symlinked paths (#105)
* fix(security): clamp IPC dirs to 0700 and reject symlinked paths

Threat model: a same-UID attacker (another user process, a sandboxed peer
that can reach the home dir) can drop a file or symlink into the RPC
inbox, or supply an attachment path that points at a sensitive file via
a parent-directory symlink, and have Messages.app exfiltrate it as an
attachment to an attacker-controlled handle.

Mitigations applied at every IPC boundary:

- Mode 0700 on .imsg-rpc/, .imsg-rpc/in/, .imsg-rpc/out/ creation.
  Final chmod handles dirs that already existed 0755 from prior
  unsandboxed runs.
- Refuse any RPC queue path or attachment path where any component
  (final or parent) is a symbolic link. Walking each component with
  lstat() — done by SecurePath.hasSymlinkComponent in IMsgCore and the
  pathHasSymlinkComponent twin in the dylib — catches parent-directory
  links that realpath()-vs-lexical comparison misses (macOS rewrites
  /tmp -> /private/tmp, breaking that approach for legitimate paths).
- Strict throws for queue dir creation and cleanup in MessagesLauncher
  (was `try?` swallowing errors), and for ensureDirectory in
  IMsgBridgeClient. Bridge refuses to start instead of operating on an
  insecure path.

Tests cover both final-component and parent-component symlink detection
in SecurePath. Toolchain on this machine lacks swift-testing; tests
build clean and ship to be run by Xcode on the maintainer's box.

* fix: allow trusted system path aliases

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-06 22:13:31 +01:00
Omar Shahine
2d7b506d17
fix: macOS 26 bridge regressions — typing/read RPC, effect mapping, attachment registration (#101)
* feat: file-based debug logger for in-process diagnostics

NSLog output emitted from the injected dylib is redacted by macOS 26
unified logging when the host process is a system app, which makes
diagnosing handler behavior from outside the dylib painful (you can't
see anything in Console.app, log show, or the gateway logs).

Add an append-only file logger writing to .imsg-bridge.log in the
Messages.app sandbox container. Readable from outside, untouched by
unified logging.

Wire diagnostic entry/exit/state logs through the typing and read
handlers so future regressions in setLocalUserIsTyping: /
markAllMessagesAsRead behavior across macOS versions can be triaged
from log output rather than guesswork.

* fix(rpc): expose typing and read methods over JSON-RPC

The 'imsg rpc' server only routed chats.list, messages.history,
watch.subscribe, watch.unsubscribe, and send. Calls to 'typing' and
'read' (which the openclaw imessage channel plugin and other clients
already invoke per the documented vocabulary) returned methodNotFound
and silently dropped. The CLI worked because it talks to the dylib
bridge directly via TypingIndicator/IMCoreBridge, bypassing the RPC
surface entirely.

Wire both methods to the same chat-target resolution path used by
'send', so callers can identify the chat by handle (to), chat_id,
chat_identifier, or chat_guid. typing accepts a 'typing' bool plus
optional 'service'; read takes only the chat target.

The dylib-side handlers (handleTyping, handleRead) were already
correct — they just had no way to be invoked over JSON-RPC.

* fix: expand --effect short names to expressive-send bundle IDs

`imsg send-rich --effect invisibleink` was passing the literal short name
through to expressiveSendStyleID, so chat.db ended up with
expressive_send_style_id=invisibleink and Messages.app refused to render
the expressive effect. Messages expects the full bundle id, e.g.
com.apple.MobileSMS.expressivesend.invisibleink for bubble effects or
com.apple.messages.effect.CKConfettiEffect for screen effects.

Add ExpressiveSendEffect.expand() to remap short names at the CLI layer
for both send-rich and send-multipart, leave already-prefixed values
untouched, and pass unknown names through so the dylib can return its
own error. Update --help examples to use the friendlier short names and
add unit coverage.

* fix: register outgoing transfers properly in handleSendAttachment

`imsg send-attachment` was returning a transferGuid and messageGuid but
the receiver saw an empty OBJ-placeholder message and chat.db's
attachment / message_attachment_join tables had no matching rows. Two
gaps versus the BlueBubblesHelper reference:

1. The transfer was never staged into Messages' attachments tree or
   handed off to imagent. Allocating a guid via
   `guidForNewOutgoingTransferWithLocalURL:` only reserves the id; the
   daemon needs `IMDPersistentAttachmentController._persistentPath...`
   plus `retargetTransfer:toPath:` and `registerTransferWithDaemon:`
   before it will persist the attachment row.
2. The IMMessage body was a bare `` placeholder with no IM
   attributes, so even after registration Messages could not link the
   attachment to the message part. Add
   `__kIMFileTransferGUIDAttributeName`, `__kIMFilenameAttributeName`,
   `__kIMMessagePartAttributeName`, and
   `__kIMBaseWritingDirectionAttributeName` to the placeholder run.

Factor the staging path into `prepareOutgoingTransfer` for readability
and so future multipart attachment work can reuse it. Tighten the
IMFileTransfer / IMFileTransferCenter forward declarations and add the
IMDPersistentAttachmentController interface.

* fix(rpc): advertise rpc_methods capability in status --json

The openclaw imessage channel plugin reads `rpc_methods` from
`imsg status --json` to gate which JSON-RPC calls it issues. Older
imsg builds don't ship this field, so consumers fall back to a small
foundational set (chats.list/messages.history/watch.*/send) and refuse
to invoke typing/read/group.* until the user upgrades.

The previous commit added `typing` and `read` handlers to RPCServer
but didn't advertise them, so the openclaw plugin still gated them off
with the message:

  imessage: typing indicators / read receipts gated off (imsg build
  pre-dates the rpc_methods capability list). Upgrade imsg (current
  bridge needs typing+read in rpc_methods).

Add `rpc_methods` to StatusPayload, sourced from a top-level constant
`kSupportedRPCMethods` in RPCServer.swift so the dispatch switch and
the advertised list can't drift apart.

* fix: build IMMessageItem first to survive macOS 26 send pipeline

On macOS 26 the high-level `+initIMMessageWith…:expressiveSendStyleID:`
factories return an IMMessage whose underlying IMMessageItem has empty
`bodyData`. imagent reads bodyData (NSArchiver typedstream) when
shipping to chat.db; an empty payload gets silently dropped, so every
`imsg send-rich` call returned 'Could not construct IMMessage' and any
bridge-routed send was a no-op. Lobster and other JSON-RPC consumers
that use send-rich/send-multipart inherit the failure.

Port 10ce6ab's IMMessageItem-first approach into buildIMMessage:

1. Allocate an IMMessageItem via the 9-arg
   `initWithSender:time:body:attributes:fileTransferGUIDs🎏error:guid:threadIdentifier:`
2. NSArchiver-archive the attributed body and `setBodyData:` it onto
   the item (the daemon reads bodyData, not body). Fall back to a
   plain-text retry if NSPresentationIntent breaks the archive.
3. Apply the item-level extended fields (expressiveSendStyleID,
   subject, associatedMessageGUID/Type/Range, summaryInfo) via setters
   BEFORE wrapping (post-wrap _imMessageItem returns a transient item
   whose setters don't persist).
4. Wrap with `+[IMMessage messageFromIMMessageItem:sender:subject:]`.
5. Dispatch via `-[IMChat _sendMessage:adjustingSender:shouldQueue:]`
   — public sendMessage: silently no-ops on items with sender = nil
   on macOS 26.

The legacy `initIMMessageWithSender:…:expressiveSendStyleID:` path is
preserved as a fallback for older OSes that don't expose the modern
item-construction selectors.

Smoke-tested on macOS 26.4.1: send-rich now lands in chat.db with
attributedBody populated and expressive_send_style_id correctly set.

Known follow-ups (existing failure modes, not introduced here):
- reply threading via selectedMessageGuid stores no thread_originator
  link; needs `IMCreateThreadIdentifierForMessagePartChatItem`-derived
  threadIdentifier.
- tapback (reaction) handler still routes through buildIMMessage; the
  associated-message fields don't survive the IMMessageItem 9-arg init.
  A dedicated reaction constructor is the right fix.

* fix: derive thread identifier for replies + dedicated reaction constructor

Replies via `selectedMessageGuid` previously sent as standalone
messages on macOS 26 because the receiver also needs the
`threadIdentifier` string to render the in-line reply UI; the
associated_message_guid + type=100 combination alone isn't sufficient
on Tahoe. Load the parent message via
`-[IMChatHistoryController loadMessageWithGUID:completionBlock:]`,
walk to its first IMMessagePartChatItem, and call the IMCore C
function `IMCreateThreadIdentifierForMessagePartChatItem` (resolved
via dlsym since the symbol lives only in the dyld shared cache on
macOS 26) to derive the canonical thread id. Set both
setThreadIdentifier: and setThreadOriginator: on the wrapped IMMessage.

Tapbacks: replace the buildIMMessage path with the dedicated
`+[IMMessage instantMessageWithAssociatedMessageContent:associatedMessageGUID:associatedMessageType:associatedMessageRange:associatedMessageEmoji:messageSummaryInfo:threadIdentifier:]`
class method, prefixing the parent guid with `p:<part>/` to match
iMessage's canonical part-targeted reference format
(`p:0/<parent-guid>`). Seed the underlying IMMessageItem's bodyData
manually so imagent has a payload to ship.

Smoke-tested on macOS 26.4.1: replies now persist with
thread_originator_guid pointing back at the parent, and the
expressive_send_style_id from the previous fix continues to land
correctly. Tapback persistence in chat.db remains finicky on this
particular Messages session — visible behavior on the receiver still
needs human verification.

* fix(react): route reactions through legacy initIMMessageWithSender:…:associatedMessageGUID: path

The IMMessageItem-first path doesn't preserve associated-message
fields (the 9-arg item initializer doesn't accept them, and post-init
setters don't survive the IMMessage wrap on macOS 26 — verified by
tapbacks dispatching cleanly but not landing in chat.db).

Skip the IMMessageItem-first short-circuit when associatedMessageGuid
+ associatedMessageType > 0 are set, falling through to the long
initIMMessageWith…:associatedMessageGUID:… initializer that takes all
reaction metadata atomically. Mirror what upstream's reaction path was
doing at chat.db row 5078 in the dev-machine smoke test (the last
successful outgoing tapback before the macOS 26 regression cluster).

Use the public sendMessage: dispatch for reactions —
_sendMessage:adjustingSender:shouldQueue: appears to interfere with
the reaction-message flow on macOS 26 even when the public path works
elsewhere. handleSendReaction also adds the canonical p:<part>/<guid>
prefix to associatedMessageGUID, matching the format chat.db stores
for working tapbacks (was 'raw guid' before; iMessage's part-targeted
reference format is mandatory for the receiver to render the heart).

Smoke-tested on macOS 26.4.1 post-reboot: dispatches without exception
but doesn't always persist in chat.db on this Messages session. Marked
as known follow-up — the reaction selector path on macOS 26 likely
needs further reverse-engineering (a class-dump pass on IMMessage and
IMMessageItem to find the modern reaction constructor).

* fix: BlueBubblesHelper-verified macOS 26 selectors + reaction body

After a fine-tooth audit against BlueBubblesHelper's macOS-11+ tree,
several deltas in our IMCore use were causing macOS 26 regressions:

1. Reaction init signature was wrong on macOS 26.
   Use IMMessage's 13-arg `initWithSender:time:text:messageSubject:fileTransferGUIDs🎏error:guid:subject:associatedMessageGUID:associatedMessageType:associatedMessageRange:messageSummaryInfo:`
   (the BB-verified macOS 26 selector — no balloonBundleID/payloadData/
   expressiveSendStyleID args). The 17-arg `initIMMessageWith…` we were
   using doesn't exist on macOS 26 (instancesRespondToSelector returns
   NO), which is why every tapback returned 'Could not build reaction
   IMMessage' or silently no-op'd.

2. Reaction body was empty — imagent silently dropped reactions.
   Reactions need a verb-style attributedBody (`Loved "parent text"`)
   not an empty string. Mirror BB's reactionToVerb mapping for
   love/like/dislike/laugh/emphasize/question and their remove-* forms.
   Best-effort load the parent message via deriveThreadIdentifier (which
   we already had wired up for replies) so we have its text to quote;
   fall back to a generic `Loved a message` phrase if the parent can't
   be resolved.

3. Reaction associatedMessageGUID needs the `p:<part>/<guid>` prefix.
   The receiver pipeline ignores reaction messages whose
   associatedMessageGUID is a bare guid (no part prefix). chat.db rows
   for working tapbacks (e.g. row 5078 in the dev-machine smoke test)
   show `p:0/<parent-guid>`.

4. Send/attachment flags were wrong — 0x5 instead of 0x100005.
   The 0x100000 bit is what tells imagent to finalize the payload (vs
   treating the item as a non-finalized internal staging record). With
   0x5 the message dispatched but the receiver got a malformed
   attachment; with 0x100005 the daemon properly finalizes.
   BB-verified: isAudioMessage ? 0x300005 : (subject ? 0x10000d : 0x100005).

5. Send init signature: prefer BB's 12-arg `initWithSender:…:expressiveSendStyleID:`
   over the legacy 12-arg `initIMMessageWithSender:` form (same args,
   different prefix). Fall through to the legacy form if the macOS 26
   selector isn't available.

6. Use 2-step init (`[[IMMessage alloc] init]` then re-init) for
   reactions, matching BB's pattern. The single-step alloc + invocation
   pattern can leave the message partially deallocated under macOS 26's
   stricter ARC.

Smoke-tested on macOS 26.4.1: tapbacks now persist in chat.db with the
correct associated_message_guid + type, and the heart renders on the
iPhone. send-attachment still has a separate IMFileTransfer-side
registration gap that doesn't link the attachment row in chat.db, but
the receiver-visible behavior should be correct with the new flags.

* fix(attachment): tighten ARC retention + skip IMMessageItem-first

Per the BB-helper audit:

1. `_persistentPathForTransfer:…` returns its NSString via
   NSInvocation.getReturnValue, which puts the result into an
   __unsafe_unretained slot. Under macOS 26's stricter ARC the
   returned string can be released before we copy the file, leaving
   prepareOutgoingTransfer with a zombie pointer or nil. Take a
   strong reference immediately after getReturnValue.

2. Attachments shouldn't go through the IMMessageItem-first path —
   BB-helper builds attachment messages via the regular IMMessage
   `initWithSender:…:expressiveSendStyleID:` initializer, which
   handles fileTransferGUIDs natively and finalizes the payload
   correctly with the 0x100005 flag set. Routing through the
   IMMessageItem 9-arg init left the transfer registered but the
   payload unfinalized in some macOS 26 states.

Smoke-tested on macOS 26.4.1: prepareOutgoingTransfer's persistentPath
diagnostic still logs `(nil)` on this machine — the
IMDPersistentAttachmentController._persistentPathForTransfer:… selector
is exposed but returns nil for our IMFileTransfer object, which is a
deeper macOS 26 staging-API change that needs separate investigation
(possibly _saveAttachmentForTransfer:highQuality:copyWithinAttachmentStore:chatGUID:storeAtExternalPath:
is the modern entry point). Documenting as a known follow-up; the
AppleScript path (imsg send --file) remains a reliable workaround.

* fix: BlueBubblesHelper-aligned selectors for group ops, mark-unread, notify-anyways, reaction summary

Six independent functional bugs the BB-helper audit surfaced; all
single-selector fixes that bring our IMCore handling back in line with
the canonical BB MacOS-11+ tree.

1. Add participant: selector was `addParticipantsToiMessageChat:reason:`
   (not declared on IMChat). Use BB-verified
   `inviteParticipantsToiMessageChat:reason:`. Group join was
   error-failing.

2. Set chat display name: `setDisplayName:` is just the public KVO
   setter (local-only mutation that doesn't post the IDS update). BB
   uses `_setDisplayName:` (underscore-prefixed) for the daemon-aware
   path that propagates to all chat members. Renames were sender-only
   before. Also fixed the second call site in handleCreateChat.

3. Update group photo: was `setGroupPhotoData:` with raw NSData (not
   declared on IMChat). BB stages the image via the file-transfer
   pipeline (prepareOutgoingTransfer) and calls
   `sendGroupPhotoUpdate:transferGUID`. Group photo was no-op'ing.

4. Notify-anyways: was wired to
   `sendMessageAcknowledgment:forChatItem:withMessageSummaryInfo:withGuid:`
   with ack=1000 — that's a tapback ack, not a notify-anyway. BB-verified
   selector is `markChatItemAsNotifyRecipient:` (single arg).
   notify-anyways wasn't actually bypassing focus mode before.

5. Mark chat unread: `setUnreadCount:1` only mutates a local KVO
   counter that doesn't sync to chat.db or propagate. BB uses
   `markLastMessageAsUnread` for the daemon-aware path.

6. Edit / unsend / delete-message lookup: `findMessageItem` walked
   `chat.chatItems` synchronously, which only covers the live IMChat
   window. Edit-after-scroll-back failed with 'Message not found'. Try
   `IMChatHistoryController.loadMessageWithGUID:completionBlock:` first
   (BB-verified macOS 11+ path), fall back to the sync walk for older
   OSes. Reuses the loadParentFirstChatItem helper.

7. Reaction `messageSummaryInfo` shape: `amc` was the parent guid as
   a string. BB sets `amc: @1` (NSNumber count). Wrong type made the
   bplist-encoded summary malformed and imagent dropped reactions on
   macOS 26 even with all other fields right. `ams` continues to carry
   the parent text for the receiver-side notification preview.

8. Reaction `associatedMessageRange`: was hardcoded `{0, 1}`. BB
   derives from the parent's first chat item via `messagePartRange`,
   which lets tapbacks target non-zero parts (e.g. the second image of
   a multipart photo grid). Loaded via the new loadParentFirstChatItem
   helper.

9. Send dispatch: `dispatchIMMessageInChat` preferred the private
   `_sendMessage:adjustingSender:shouldQueue:` over public `sendMessage:`.
   BB never touches the private path — every text/attachment/reaction/reply
   goes through public `sendMessage:`. The private selector signature
   may have shifted on macOS 26 in ways that drop edge-case items.
   Simplified dispatchIMMessageInChat to just call `[chat sendMessage:]`.

Smoke-tested on macOS 26.4.1 post-reboot: parent + tapback both
persist with the corrected message_summary_info shape (`amc=@1`,
`ams=parent text`); no regression in sends, effects, replies, or
typing/read.

* fix(rpc): restore chat/group lifecycle handlers cut during PR #100 merge

PR #100 was squash-merged with 8 RPC handler functions silently dropped
from the dispatch surface plus the corresponding `supportedMethods`
capability list. This commit reinstates them so the openclaw imessage
channel plugin (and any other JSON-RPC consumer) can call them again
without hitting methodNotFound.

Restored RPC methods:
- chats.create   — create a 1:1 or group chat
- chats.delete   — delete a chat from Messages.app
- chats.markUnread — mark last message in a chat as unread
- group.rename   — set a group chat's display name
- group.setIcon  — set or clear a group chat photo
- group.addParticipant
- group.removeParticipant
- group.leave    — leave a group chat

The handler functions all dispatch into v2 bridge actions the dylib
already implements (createChat, deleteChat, markChatUnread, leaveChat,
etc.), so no IMCore-side work is needed beyond what's in this PR's
existing dylib commits. Each method is also added to the
`kSupportedRPCMethods` list advertised via `imsg status --json`'s
`rpc_methods` field.

The handler implementations come from the original PR #100 branch
(`omarshahine:feat/private-api-port` at 638efbd), which had them in a
dedicated `RPCServer+ChatHandlers.swift` extension. Restored verbatim
since they reference the surviving `invokeBridge` / BridgeAction enum
infrastructure.

Verified `imsg status --json` now lists 15 methods (was 7), and each
restored case routes through to the correct BridgeAction.

* docs: note macOS 26 bridge fixes

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-06 22:07:28 +01:00
Peter Steinberger
020e3de20e
chore: prepare 0.7.2 release 2026-05-06 08:38:04 +01:00
Peter Steinberger
0ac10bbb85
chore: start 0.7.2 development 2026-05-06 08:35:49 +01:00
Peter Steinberger
e18a6431be
chore: prepare 0.7.1 release 2026-05-06 07:30:46 +01:00
Peter Steinberger
34ff986c78
chore: prepare 0.7.0 release 2026-05-06 07:15:52 +01:00
Sagar Dagdu
e0a2e972b8
fix: decode long attributedBody typedstream payloads
Decode typedstream attributedBody segments that use 0x81/0x82 length prefixes so long fallback message text is preserved in history/watch output.

Also records the fix in the 0.7.0 changelog.

Co-authored-by: Sagar Dagdu <shags032@gmail.com>
2026-05-06 06:54:45 +01:00
Peter Steinberger
672c0b7eb7
docs: prepare 0.7.0 changelog 2026-05-06 06:40:35 +01:00
Peter Steinberger
f017773382
docs: add docs syntax highlighting 2026-05-06 06:34:55 +01:00
Omar Shahine
c56c24d488
feat: port BlueBubbles private-API bridge
Port the BlueBubbles-inspired IMCore bridge surface into imsg with rich sends, message mutation, chat management, account/nickname introspection, live bridge events, and v2 UUID-keyed IPC.

Fixes #60.

Co-authored-by: Omar Shahine <omarshahine@users.noreply.github.com>
2026-05-06 06:28:00 +01:00
Peter Steinberger
bbd6b93a1e
docs: add per-feature docs site and deploy to imsg.sh
Some checks failed
CI / build (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
Per-feature pages (install, quickstart, permissions, chats, history,
watch, send, groups, attachments, json, rpc, completions, advanced-imcore,
troubleshooting) plus an Apple-styled static-site builder rendering them
to dist/docs-site. GitHub Pages workflow deploys on every docs/ change to
imsg.sh.
2026-05-05 19:09:09 +01:00
Peter Steinberger
c16daed4b4
ci: quote workflow architecture lookup 2026-05-05 06:14:38 +01:00
Peter Steinberger
a4ca952ea2
ci: update actions for node 24 runtime 2026-05-05 06:11:35 +01:00
Peter Steinberger
6c30d94e62
chore: prepare 0.6.0 release 2026-05-05 06:05:18 +01:00
Peter Steinberger
6a05484ae6
refactor: split message store query layers 2026-05-05 05:31:04 +01:00
Peter Steinberger
63b55b04d6
refactor: centralize message store schema 2026-05-05 03:38:10 +01:00
Peter Steinberger
e982084640
refactor: share message row selection 2026-05-05 03:28:22 +01:00
Peter Steinberger
edad072a41
refactor: decode sqlite rows by name 2026-05-05 02:24:59 +01:00
Peter Steinberger
327829a819
feat: expose chat routing hints 2026-05-05 02:00:56 +01:00
Peter Steinberger
df2d928ff0
fix: reject unsupported custom emoji reactions 2026-05-05 01:22:07 +01:00
Peter Steinberger
715a75fb4e
fix: clarify Tahoe typing failures 2026-05-05 00:34:45 +01:00
Peter Steinberger
9ec34e69fb
feat: add completion generation
Co-authored-by: Brian Morin <bdmorin@gmail.com>
2026-05-04 10:09:38 +01:00
Peter Steinberger
8dcb9d087b
feat: convert unsupported attachment metadata
Co-authored-by: Mark Zeidan <6324940+mfzeidan@users.noreply.github.com>
2026-05-04 09:41:04 +01:00
Peter Steinberger
5203f7461c
feat: resolve contact names
Co-authored-by: Joshua Sindy <josh@root.bz>
Co-authored-by: Dan Wager <danielwager@gmail.com>
2026-05-04 09:16:44 +01:00
Dinakar Sarbada
7725473729
docs: integrate Homebrew tap update into local release flow
Adds a local helper to dispatch the centralized Homebrew tap formula update during releases.

Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
2026-05-04 08:58:49 +01:00
Peter Steinberger
f8f0c5d712
fix: normalize typing chat lookup 2026-05-04 08:51:46 +01:00
Peter Steinberger
6253bdd135
docs: clarify watch fallback polling 2026-05-04 08:31:10 +01:00
Peter Steinberger
f40487bd67
docs: clarify audio attachment sends 2026-05-04 08:28:14 +01:00
Peter Steinberger
55203c5960
test: serialize command stdout captures 2026-05-04 08:20:26 +01:00
Peter Steinberger
810d62b529
docs: document tapback reactions 2026-05-04 08:18:14 +01:00
Peter Steinberger
abddb40268
fix: detect Tahoe group send ghost rows 2026-05-04 07:48:10 +01:00
Peter Steinberger
19c4cd3083
fix: keep watch streams alive after missed events 2026-05-04 07:37:20 +01:00
Peter Steinberger
af6fd822c1
fix: return RPC send message identifiers 2026-05-04 07:25:33 +01:00
Peter Steinberger
60ed8a9f02
fix: expose RPC watch debounce 2026-05-04 07:00:45 +01:00
Peter Steinberger
f0cd725c70
test: cover newline JSON output 2026-05-04 06:57:38 +01:00
Peter Steinberger
9d92837726
fix: batch JSON history metadata 2026-05-04 06:51:31 +01:00
Peter Steinberger
90b7d84cb9
docs: improve Full Disk Access troubleshooting 2026-05-04 06:46:17 +01:00
Peter Steinberger
79224803ac
fix: speed up chat listing 2026-05-04 06:43:31 +01:00
Peter Steinberger
518144ee5b
feat: add chat group lookup command 2026-05-04 06:37:27 +01:00
Peter Steinberger
d825174537
test: cover CLI metadata and release packaging 2026-05-04 06:33:01 +01:00
Peter Steinberger
13b4bff08a
ci: publish universal macOS release binary 2026-05-04 06:20:04 +01:00
Peter Steinberger
6b08ee1d4f
fix: include group metadata in CLI JSON output 2026-05-04 06:17:48 +01:00
Peter Steinberger
d8c9f70f63
docs: document Homebrew install path 2026-05-04 06:15:04 +01:00
Peter Steinberger
fb7b847531
fix: decode UTF-16LE attributed bodies 2026-05-04 05:55:51 +01:00
Peter Steinberger
f2fff0bdcf
fix: confirm standard tapback reactions 2026-05-04 05:54:49 +01:00
Peter Steinberger
0a4388dd68
fix: decouple RPC watch reactions from attachments 2026-05-04 05:53:15 +01:00
Peter Steinberger
4ee8f6cd32
chore: fix copyright header 2026-04-27 11:26:47 +01:00
Peter Steinberger
be6c0d3110
chore: remove slides ignore 2026-04-27 02:13:30 +01:00
Peter Steinberger
bf2d02e5e7
feat: add advanced message controls 2026-04-27 02:11:23 +01:00
Peter Steinberger
c9fa1c2003 fix: harden URL balloon dedupe in watch stream (#64) (thanks @lesaai) 2026-03-02 03:06:50 +00:00
Lēsa AI
a2c16b3cac fix: deduplicate URL balloon messages in watch stream
iMessage writes multiple rows to chat.db for the same URL when the rich
link preview resolves. Both rows have balloon_bundle_id set to
'com.apple.messages.URLBalloonProvider' with identical text and sender
but different ROWIDs and timestamps.

This causes downstream consumers (like OpenClaw) to process the same
URL message twice, burning tokens and producing duplicate responses.

Fix: in messagesAfter(), track seen (sender, text) pairs for URL balloon
messages and skip duplicates within the same batch.
2026-03-02 03:06:34 +00:00
Peter Steinberger
5591b2e99e fix: remove broken typing surface 2026-03-02 01:40:42 +00:00
Peter Steinberger
9c328a247d fix: set explicit release tag in workflow_dispatch 2026-02-16 07:19:10 +01:00
Peter Steinberger
b43fc32f98 chore: prepare 0.5.0 release 2026-02-16 07:15:53 +01:00
Peter Steinberger
38fa96a3ee feat: ship typing/rpc updates and prep 0.5.0 2026-02-16 07:14:15 +01:00
Peter Steinberger
a2c0865a54 fix: update changelog for typing indicators (#41) (thanks @kohoj) 2026-02-16 06:30:58 +01:00
Peter Steinberger
7fa01f182b fix: harden typing command and rpc validation 2026-02-16 06:30:58 +01:00
Koho Zheng
69499d891f feat: add typing indicator support via IMCore private framework
Implements typing indicators for outgoing messages using runtime
dynamic loading of Apple's IMCore private framework. This is the
only way to programmatically send typing indicators — AppleScript
has no equivalent capability.

Closes #22

Changes:
- IMsgCore: Add TypingIndicator struct with start/stop/duration APIs
- CLI: Add 'imsg typing' command with --to, --duration, --stop flags
- RPC: Add typing.start and typing.stop methods
- Errors: Add typingIndicatorFailed error case

Usage:
  imsg typing --to +14155551212
  imsg typing --to +14155551212 --duration 5s
  imsg typing --to +14155551212 --stop true
  imsg typing --chat-identifier "iMessage;-;+14155551212"
2026-02-16 06:30:58 +01:00
Peter Steinberger
c608db6db7 test: split command tests by domain 2026-02-16 05:54:22 +01:00
Peter Steinberger
5a9f5441b9 refactor: unify message decode and RPC payload mapping 2026-02-16 05:54:16 +01:00
Alex
ba9a1ff079 feat: expose destination_caller_id in message output
This field helps distinguish between messages actually sent by the local
user vs messages received on a secondary phone number registered with
the same Apple ID.

When is_from_me is true but destination_caller_id differs from the
user's own numbers, the message was actually received from another
device/person messaging that secondary number.

This enables tools like Clawdbot to properly detect inbound messages
on secondary iMessage phone numbers.
2026-02-16 05:33:29 +01:00
Peter Steinberger
3406429299 fix: align group-handle test expectation (#42) (thanks @shivshil) 2026-02-16 04:50:23 +01:00
safetynotgauranteed
f849fcab14 fix: isGroupHandle now correctly detects group chats
The previous implementation preferred `identifier` over `guid` when
checking for group chat prefixes. For group chats, `identifier` contains
the bare GUID (e.g. `70012e07fc54...`) which never has the `;+;` prefix,
while `guid` contains the prefixed form (e.g. `any;+;70012e07fc54...`).

Since `identifier` is never empty (ChatInfo always populates it), the
`guid` field was never checked, causing `is_group` to always return
`false` for group chats in RPC output.

Also removed the `;-;` check since that prefix indicates DMs, not groups.

Fix: always check both `guid` and `identifier` for the `;+;` group prefix.
2026-02-16 04:50:23 +01:00
Peter Steinberger
deb01a0ef7
Merge pull request #31 from pangu25/feat/reaction-events-and-send
feat: reaction events in watch + react command
2026-02-16 04:04:52 +01:00
Peter Steinberger
b78027b251 chore: merge origin/main into pr-31 2026-02-16 04:04:01 +01:00
Peter Steinberger
9321a77efa fix: harden react command AppleScript execution 2026-02-16 03:59:50 +01:00
Peter Steinberger
1b27e3b12c
refactor: centralize stdout writing (#49) 2026-02-16 03:49:39 +01:00
Carl Caum
ee3c085070
Fix watch command stdout buffering (#43)
* Fix watch command stdout buffering

The watch command was not producing any output because stdout was not being flushed after printing JSON lines. This caused the watch functionality to appear broken even though message detection was working correctly.

Added fflush(stdout) call after Swift.print() to ensure immediate output delivery, fixing both CLI watch mode and RPC watch notifications.

Fixes: Messages detected but not displayed
Testing: Verified with 'imsg watch --chat-id 1 --json' - messages now appear immediately

* fix: flush watch stdout buffering (#43) (thanks @ccaum)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-16 03:17:42 +01:00
Peter Steinberger
5b5c8bcc50 refactor: consolidate schema detection 2026-02-15 14:31:14 +01:00
Peter Steinberger
f9258472c8 fix: detect thread_originator_guid column (#39) (thanks @ruthmade) 2026-02-15 14:15:01 +01:00
Ru
057b7c5a91 feat: add thread_originator_guid to message output
Adds thread_originator_guid field to JSON output for history, watch, and RPC.
This field contains the GUID of the message being replied to when users
use iMessage's inline reply feature.

This is the correct field for reply detection - it matches the UI's reply
target, unlike reply_to_guid which can point to different messages.

Closes #30

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-15 14:15:01 +01:00
pangu25
7d51f301e0 feat: add reaction metadata to RPC message payload 2026-01-31 02:28:25 -06:00
pangu25
1c7352f706 feat: add include_reactions param to watch.subscribe RPC 2026-01-30 23:46:09 -06:00
pangu25
a03e81c8b4 feat: reaction events in watch + react command
- Add --reactions flag to watch command to include tapback events (#26)
- Reaction events include metadata: is_reaction, reaction_type, reaction_emoji,
  is_reaction_add, reacted_to_guid
- Add 'imsg react' command to send tapback reactions via UI automation (#24)
- Supports standard tapbacks (love, like, dislike, laugh, emphasis, question)
  and custom emoji reactions (iOS 17+/macOS 14+)

Closes #24, Closes #26
2026-01-30 23:37:54 -06:00
Peter Steinberger
40e2084ef3
Merge pull request #20 from tommybananas/fix/history-filters-before-limit
fix: apply history filters before limit
2026-01-16 21:21:08 +00:00
Peter Steinberger
0fb6e5966e fix: apply history filters before limit (#20) (thanks @tommybananas) 2026-01-16 21:20:49 +00:00
Tom Juszczyk
72c42d5b34 fix: apply history filters before limit
Apply --start/--end/--participants in SQL so LIMIT applies after filtering (CLI history + RPC messages.history). Add regressions proving filtered windows work with small limits.
2026-01-16 21:18:57 +00:00
Peter Steinberger
d7ec962076 chore: bump version to 0.4.1 2026-01-16 21:18:19 +00:00
Peter Steinberger
f93bfe1b8f fix: prefer handle send for direct chats 2026-01-15 07:58:56 +00:00
130 changed files with 18347 additions and 1804 deletions

View File

@ -0,0 +1,64 @@
---
name: imsg
description: Use for local iMessage/SMS archive reads, iMessage contact lookup, visible Messages.app contact lookup, chat history, watch, and explicitly requested sends.
---
# imsg
Use this for Messages.app history, chat lookup, streaming, visible UI contact lookup, and sends. Reading is local DB access; sending uses Messages automation and must be explicitly requested.
## Sources
- DB: `~/Library/Messages/chat.db`
- Repo: `~/Projects/imsg`
- CLI: `imsg`
- JSON output is NDJSON; pipe to `jq -s` for arrays.
## Read Workflow
Check DB access:
```bash
sqlite3 ~/Library/Messages/chat.db 'pragma quick_check;'
```
For a visible Messages.app person/name, start with chats. The UI-resolved name usually appears as `contact_name`; it may not appear in `imsg search`, raw `message.text`, or the `handle` table.
```bash
imsg chats --limit 200 --json | jq -s '.[] | select((.contact_name // .display_name // .name // .identifier // "" | ascii_downcase) | contains("beatrix"))'
```
Then read the chat by id:
```bash
imsg history --chat-id ID --json | jq -s
```
Use `imsg search --query ... --json` for message-body search only; do not treat no search hits as proof that a visible UI contact does not exist. Use `--attachments` when attachment metadata matters. Use `--start`/`--end` with absolute timestamps for date-scoped questions.
Direct DB checks are only a fallback. The `handle` table is keyed by phone/email and often lacks the contact display name that `imsg chats` resolves.
## Sends
Only send, react, mark read, or show typing when the user explicitly asks. Prefer dry wording in the final confirmation: recipient, service, and what was sent.
Common send command:
```bash
imsg send --to "+15551234567" --text "message" --service auto
```
## Verification
For repo edits:
```bash
make test
make build
```
For live read proof:
```bash
imsg chats --limit 3 --json | jq -s
```

View File

@ -6,10 +6,10 @@ on:
pull_request:
jobs:
build:
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Swift version
run: swift --version
- name: Install SwiftLint
@ -19,4 +19,26 @@ jobs:
- name: Test
run: make test
- name: Build
run: make build ARCHES=$(uname -m)
run: make build ARCHES="$(uname -m)"
linux-read-core:
runs-on: ubuntu-latest
container: swift:6.2.4-noble
steps:
- uses: actions/checkout@v6
- name: Swift version
run: swift --version
- name: Install Python
run: |
apt-get update
apt-get install -y --no-install-recommends python3
- name: Generate version
run: scripts/generate-version.sh
- name: Resolve dependencies
run: swift package resolve
- name: Patch dependencies
run: scripts/patch-deps.sh
- name: Test Linux read core
run: swift test
- name: Build CLI
run: swift build --product imsg

54
.github/workflows/pages.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: pages
on:
push:
branches:
- main
paths:
- "docs/**"
- "scripts/build-docs-site.mjs"
- "scripts/docs-site-assets.mjs"
- "Makefile"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy docs
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Build docs site
run: make docs-site
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: dist/docs-site
- name: Deploy
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

View File

@ -7,16 +7,22 @@ on:
description: "Tag to (re)release (e.g. v0.1.0)"
required: true
type: string
include_macos:
description: "Also rebuild and upload the macOS archive"
required: false
default: false
type: boolean
permissions:
contents: write
jobs:
release:
macos-release:
if: ${{ inputs.include_macos }}
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@ -32,7 +38,9 @@ jobs:
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: git checkout ${{ inputs.tag }}
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git checkout ${{ steps.tag.outputs.tag }}
- name: Resolve packages
run: swift package resolve
@ -44,20 +52,12 @@ jobs:
run: scripts/generate-version.sh
- name: Build
run: swift build -c release --product imsg
- name: Codesign
run: codesign --force --sign - --entitlements Resources/imsg.entitlements --identifier com.steipete.imsg .build/release/imsg
run: |
rm -rf dist
OUTPUT_DIR=dist scripts/build-universal.sh
- name: Package artifact
run: |
mkdir -p dist
cp .build/release/imsg dist/imsg
for bundle in .build/release/*.bundle; do
if [ -e "$bundle" ]; then
cp -R "$bundle" dist/
fi
done
(
cd dist
shopt -s nullglob
@ -70,9 +70,12 @@ jobs:
)
- name: Publish release assets
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: ${{ steps.tag.outputs.tag }}
files: dist/imsg-macos.zip
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -97,3 +100,112 @@ jobs:
fi
gh release edit "$TAG" --notes-file "$notes_file"
linux-release:
runs-on: ubuntu-latest
container: swift:6.2.4-noble
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Determine tag
id: tag
shell: bash
run: |
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git checkout ${{ steps.tag.outputs.tag }}
- name: Install Python
run: |
apt-get update
apt-get install -y --no-install-recommends python3
- name: Resolve packages
run: swift package resolve
- name: Patch dependencies
run: scripts/patch-deps.sh
- name: Sync version
run: scripts/generate-version.sh
- name: Build Linux archive
run: |
rm -rf dist
OUTPUT_DIR=dist scripts/build-linux.sh
- name: Publish Linux release asset
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: ${{ steps.tag.outputs.tag }}
files: dist/imsg-linux-x86_64.tar.gz
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-homebrew-tap:
if: ${{ inputs.include_macos }}
runs-on: ubuntu-latest
needs: macos-release
steps:
- name: Resolve release tag
run: echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
- name: Dispatch tap formula update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
exit 1
fi
request_id="imsg-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update imsg for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
--ref main \
-f formula=imsg \
-f tag="$RELEASE_TAG" \
-f repository=openclaw/imsg \
-f macos_artifact=imsg-macos.zip \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo steipete/homebrew-tap \
--exit-status \
--interval 10

1
.gitignore vendored
View File

@ -39,6 +39,7 @@ Package.resolved
# Build artifacts
bin/
dist/
# Node.js / pnpm
pnpm-lock.yaml

View File

@ -1,6 +1,128 @@
# Changelog
## Unreleased
## 0.8.1 - Unreleased
## 0.8.0 - 2026-05-08
### Linux Read-Only Preview
- feat: add a Linux read-only core build with fixture-backed tests and GitHub
CI coverage for copied Messages databases.
- build: add Linux release archive packaging for `imsg-linux-x86_64.tar.gz`.
- docs: document Linux as read-only support for existing copied Messages
databases.
### Message Decoding
- fix: strip printable typedstream length bytes from recovered `attributedBody`
text for 32-126 byte messages (#107, thanks @SagarSDagdu).
## 0.7.3 - 2026-05-06
### Private API Bridge
- fix: restore macOS 26 bridge sends, replies, tapbacks, typing/read RPC, and
chat/group lifecycle RPC methods after the BlueBubbles-inspired bridge port
regressed on Tahoe (#101, thanks @omarshahine).
- fix: stage bridge attachments with the target chat GUID and fall back to the
modern IMDPersistence save API when the legacy persistent-path API returns
nil (#102, #103, thanks @omarshahine).
### Security
- fix: harden bridge IPC queue directories and attachment paths against
symlink traversal while preserving trusted macOS system aliases like `/tmp`
(#105, thanks @omarshahine).
## 0.7.2 - 2026-05-06
### Release Packaging
- fix: publish a fresh signed and notarized macOS patch archive with matching
Homebrew metadata.
## 0.7.1 - 2026-05-06
### Release Packaging
- fix: ship a signed and notarized macOS release archive and refresh the
Homebrew checksum for the patch release.
## 0.7.0 - 2026-05-06
### Private API Bridge
- feat: port the BlueBubbles-inspired private-API bridge surface for rich sends,
message mutation, chat management, account/nickname introspection, and live
bridge events; add local DB search and v2 concurrent bridge IPC (#100, thanks
@omarshahine).
- fix: route default bridge calls over v2 IPC when available and reject
unsupported `chat-create --service SMS` requests instead of reporting a
service that was not applied.
- fix: decode typedstream attributed bodies with `0x81`/`0x82` length prefixes
so long fallback message text is preserved in history and watch output (#99,
thanks @SagarSDagdu).
### Docs And CI
- docs: publish the per-feature docs site at `imsg.sh` and add
syntax-highlighted code examples.
- ci: update GitHub Actions for the Node 24 runtime and quote workflow
architecture lookup.
## 0.6.0 - 2026-05-05
### More Reliable Live Streams And History
- fix: keep `imsg watch` streams alive with a lightweight polling fallback when macOS misses filesystem events (#78).
- fix: dedupe URL preview balloon messages in `watch` without dropping similar messages from other chats or older database schemas (#64, thanks @lesaai).
- fix: decode UTF-16LE BOM attributed bodies so plain-text history output recovers messages whose `text` column is empty (#91, thanks @clawbunny).
- fix: speed up JSON history output by batching attachment and reaction metadata lookups (#81, thanks @kacy).
- fix: speed up chat listing by using `chat_message_join.message_date` when Messages provides it (#76, thanks @tmad4000).
- docs: clarify stale Full Disk Access grants, Terminal.app permissions, and watch fallback polling requirements (#28, #32, #33, #46, #83, thanks @wangran870414).
### Better Chat, Group, And Account Diagnostics
- feat: add `imsg group --chat-id <id>` to inspect a chat's identifier, GUID, service, participants, account metadata, and group/direct status (#88, thanks @mryanb).
- feat: resolve Contacts names in `chats`, `history`, `watch`, and direct sends while preserving raw handles for automation (#75, #77, thanks @regaw-leinad and @jsindy).
- feat: expose read-only account routing hints (`account_id`, `account_login`, `last_addressed_handle`) for multi-number diagnostics (#18).
- fix: include group metadata in CLI JSON history/watch output, not just RPC payloads (#57, thanks @clawbunny).
### Sending, RPC, And Automation Fixes
- fix: return best-effort sent message `id` and `guid` from RPC `send` responses when the row can be observed after Messages accepts the send (#85).
- fix: expose RPC watch debounce and default it to 500ms to reduce outbound echo races (#72, #80).
- fix: gate RPC watch reaction metadata on `include_reactions`, not `attachments` (#82).
- fix: confirm standard tapback reaction selection in Messages automation before reporting success (#53, thanks @PeterRosdahl).
- fix: reject unsupported custom emoji reaction sends instead of taking a no-op AppleScript path (#55).
- fix: detect Tahoe group-send ghost rows and fail instead of reporting false success (#90, thanks @loop).
- docs: document standard tapback sending and watch reaction events (#66, thanks @safaaleigh).
### Attachments, Completions, And Install Polish
- feat: optionally report model-compatible converted receive-side attachment files for CAF audio and GIF images (#73, thanks @mfzeidan).
- feat: add shell completions and an LLM-oriented command reference generator (`imsg completions bash|zsh|fish|llm`) (#21, thanks @bdmorin).
- fix: publish universal macOS release binaries for Homebrew installs (#68, #79).
- docs: document the Homebrew install path in the README (#61, thanks @joshuayoes).
- docs: clarify that `send --file` supports regular file and audio attachments through Messages.app (#35, thanks @rock19).
- docs: add a local release helper for dispatching Homebrew tap updates (#97, thanks @dinakars777).
### Advanced IMCore / Tahoe Notes
- feat: add advanced IMCore controls for `status`, `launch`, `read`, and typing diagnostics.
- fix: normalize IMCore typing chat lookup across `iMessage`, `SMS`, and `any` prefixes (#51, #54, #56, #58).
- fix: report macOS 26/Tahoe IMCore typing entitlement failures as advanced-feature setup errors instead of misleading chat lookup failures (#60).
- docs: document macOS 26 advanced IMCore injection, library-validation, and private-entitlement limits (#60).
### Internal Safety
- refactor: centralize Messages schema detection, row decoding, query assembly, typed row IDs, and attachment/reaction query paths behind smaller `MessageStore` extensions.
- test: expand release packaging, CLI metadata, schema-compatibility, JSON newline, stdout capture, and live-read coverage.
## 0.5.0 - 2026-02-16
- feat: add typing indicator command + RPC methods with stricter validation (#41, thanks @kohoj)
- feat: `--reactions` flag for `watch` command to include tapback events in stream (#26)
- feat: `imsg react` command to send tapback reactions via UI automation (#24)
- feat: reaction events include `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, `reacted_to_guid` fields
- feat: add `include_reactions` toggle to `watch.subscribe` RPC and extend RPC reaction metadata fields
- feat: include `thread_originator_guid` in message output (#39, thanks @ruthmade)
- feat: expose `destination_caller_id` in message output (#29, thanks @commander-alexander)
- fix: apply history filters before limit (#20, thanks @tommybananas)
- fix: flush watch output immediately when stdout is buffered (#43, thanks @ccaum)
- fix: prefer handle sends when chat identifier is a direct handle
- fix: detect groups from `;+;` prefix in guid/identifier for RPC payloads (#42, thanks @shivshil)
- fix: harden `react` AppleScript execution and tighten group-handle detection paths
- refactor: consolidate schema detection, stdout writing, and message/RPC payload mapping paths
- test: split command test suites by domain and align group-handle expectations
- docs: update changelog entries as typing/reaction work landed
- chore: bump version marker to `0.5.0`
## 0.4.0 - 2026-01-07
- feat: surface audio message transcriptions (thanks @antons)
@ -11,8 +133,13 @@
- ci: switch to make-based lint/test/build
- docs: update build/test/release instructions
- chore: replace pnpm scripts with make targets
- refactor: split message-store query paths for clearer message retrieval internals
- test: keep attachment tests isolated from user attachment directories
- fix: address attachment upload error handling regressions
- docs: refine changelog ordering/notes for patch-deps and 0.4.0 prep
- chore: version housekeeping for the 0.3.1 -> 0.4.0 release transition
## 0.3.0 - 2026-01-02
## 0.3.0 - 2026-01-03
- feat: JSON-RPC server over stdin/stdout (`imsg rpc`) with chats, history, watch, and send
- feat: group chat metadata in JSON/RPC output (participants, chat identifiers, is_group)
- feat: tapback + emoji reaction support in JSON output (#8) — thanks @tylerwince
@ -24,10 +151,19 @@
- docs: add RPC + group chat notes
- test: expand RPC/command coverage, add reaction fixtures, drop unused stdout helper
- test: add coverage for sender fallback
- feat: add IMCore send mode and IMCore-based reaction send path
- fix: stabilize IMCore send and sender fallback behavior
- change: remove private API send mode in favor of IMCore path
- build: add/harden notarized release script checks
- chore: update copyright year to 2026
- test: split message-store fixtures for more isolated reaction/sender coverage
- docs: maintain unreleased/release changelog staging for 0.2.2/0.3.0
- chore: release/prepare metadata updates for 0.3.0 and 0.3.1
## 0.2.1 - 2025-12-30
- fix: avoid crash parsing long attributed bodies (>256 bytes) (thanks @tommybananas)
- docs: prepare/backfill changelog notes for 0.2.1
- chore: bump release version metadata to 0.2.1
## 0.2.0 - 2025-12-28
- feat: Swift 6 rewrite with reusable IMsgCore library target
@ -35,17 +171,25 @@
- feat: event-driven watch using filesystem events (no polling)
- feat: SQLite.swift + PhoneNumberKit + NSAppleScript integration
- fix: ship PhoneNumberKit resource bundle for CLI installs
- fix: patch/avoid PhoneNumberKit bundle lookup crashes across install layouts
- fix: embed Info.plist + AppleEvents entitlement for automation prompts
- fix: fall back to osascript when AppleEvents permission is missing
- fix: retry osascript on transient unknown AppleScript errors
- fix: decode length-prefixed attributed bodies for sent messages
- fix: resolve CLI version detection for symlinked/bundle installs
- chore: SwiftLint + swift-format linting
- change: JSON attachment keys now snake_case
- deprecation note: `--interval` replaced by `--debounce` (no compatibility)
- docs: add release process documentation
- ci: publish release notes from changelog and harden extraction
- chore: reset release versioning during Swift rewrite stabilization
- chore: version.env + generated version source for `--version`
## 0.1.1 - 2025-12-27
- feat: `imsg chats --json`
- fix: drop sqlite `immutable` flag so new messages/replies show up (thanks @zleman1593)
- test: add/stabilize live update regression coverage
- docs: add unreleased entry and backfill/prepare changelog history
- chore: update go dependencies
## 0.1.0 - 2025-12-20
@ -55,3 +199,8 @@
- feat: `imsg send` text and/or one attachment (`--service imessage|sms|auto`, `--region`)
- feat: attachment metadata output (`--attachments`) incl. resolved path + missing flag
- fix: clearer Full Disk Access error for `~/Library/Messages/chat.db`
- fix: coerce attachment aliasing in message parsing
- build: add GoReleaser workflow and tag backfill support
- ci: harden Go/lint environment setup and align toolchain/linter installation
- docs: add repository guidelines/package docs and initial README polish
- chore: bootstrap initial project/release scaffolding and dependency baseline

View File

@ -1,6 +1,6 @@
MIT License
\g<1>2026 Peter Steinberger
Copyright (c) 2026 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,21 +1,23 @@
SHELL := /bin/bash
.PHONY: help format lint test build imsg clean
.PHONY: help format lint test build imsg clean build-dylib docs-site
help:
@printf "%s\n" \
"make format - swift format in-place" \
"make lint - swift format lint + swiftlint" \
"make test - sync version, patch deps, run swift test" \
"make build - universal release build into bin/" \
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
"make clean - swift package clean"
"make format - swift format in-place" \
"make lint - swift format lint + swiftlint" \
"make test - sync version, patch deps, run swift test" \
"make build - universal release build into bin/" \
"make build-dylib - build injectable dylib for Messages.app" \
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
"make docs-site - build the imsg.sh docs site into dist/docs-site" \
"make clean - swift package clean"
format:
swift format --in-place --recursive Sources Tests
swift format --in-place --recursive Sources Tests TestsLinux
lint:
swift format lint --recursive Sources Tests
swift format lint --recursive Sources Tests TestsLinux
swiftlint
test:
@ -30,6 +32,19 @@ build:
scripts/patch-deps.sh
scripts/build-universal.sh
# Build injectable dylib for Messages.app (DYLD_INSERT_LIBRARIES).
# Uses arm64e architecture to match Messages.app on Apple Silicon.
# Requires SIP disabled on the target machine to inject into system apps.
build-dylib:
@echo "Building imsg-bridge-helper.dylib (injectable)..."
@mkdir -p .build/release
@clang -dynamiclib -arch arm64e -fobjc-arc \
-Wno-arc-performSelector-leaks \
-framework Foundation \
-o .build/release/imsg-bridge-helper.dylib \
Sources/IMsgHelper/IMsgInjected.m
@echo "Built .build/release/imsg-bridge-helper.dylib"
imsg:
scripts/generate-version.sh
swift package resolve
@ -38,5 +53,10 @@ imsg:
swift build -c debug --product imsg
./.build/debug/imsg $(ARGS)
docs-site:
node scripts/build-docs-site.mjs
clean:
swift package clean
@rm -f .build/release/imsg-bridge-helper.dylib
@rm -rf dist/docs-site

View File

@ -2,58 +2,85 @@
import PackageDescription
let package = Package(
name: "imsg",
platforms: [.macOS(.v14)],
products: [
.library(name: "IMsgCore", targets: ["IMsgCore"]),
.executable(name: "imsg", targets: ["imsg"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.4"),
.package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.2"),
],
targets: [
.target(
name: "IMsgCore",
dependencies: [
.product(name: "SQLite", package: "SQLite.swift"),
.product(name: "PhoneNumberKit", package: "PhoneNumberKit"),
],
linkerSettings: [
.linkedFramework("ScriptingBridge"),
]
),
.executableTarget(
name: "imsg",
name: "imsg",
platforms: [.macOS(.v14)],
products: [
.library(name: "IMsgCore", targets: ["IMsgCore"]),
.executable(name: "imsg", targets: ["imsg"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.1"),
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.5"),
.package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.5"),
],
targets: {
var targets: [Target] = [
.target(
name: "IMsgCore",
dependencies: [
"IMsgCore",
.product(name: "Commander", package: "Commander"),
],
exclude: [
"Resources/Info.plist",
.product(name: "SQLite", package: "SQLite.swift"),
.product(name: "PhoneNumberKit", package: "PhoneNumberKit"),
],
linkerSettings: [
.unsafeFlags([
"-Xlinker", "-sectcreate",
"-Xlinker", "__TEXT",
"-Xlinker", "__info_plist",
"-Xlinker", "Sources/imsg/Resources/Info.plist",
])
.linkedFramework("ScriptingBridge", .when(platforms: [.macOS])),
.linkedFramework("Contacts", .when(platforms: [.macOS])),
]
),
.testTarget(
name: "IMsgCoreTests",
dependencies: [
"IMsgCore",
]
),
.testTarget(
name: "imsgTests",
dependencies: [
"imsg",
"IMsgCore",
]
),
),
.executableTarget(
name: "imsg",
dependencies: [
"IMsgCore",
.product(name: "Commander", package: "Commander"),
],
exclude: [
"Resources/Info.plist"
],
linkerSettings: [
.unsafeFlags(
[
"-Xlinker", "-sectcreate",
"-Xlinker", "__TEXT",
"-Xlinker", "__info_plist",
"-Xlinker", "Sources/imsg/Resources/Info.plist",
],
.when(platforms: [.macOS])
)
]
),
]
#if os(macOS)
targets.append(contentsOf: [
.testTarget(
name: "IMsgCoreTests",
dependencies: [
"IMsgCore"
]
),
.testTarget(
name: "imsgTests",
dependencies: [
"imsg",
"IMsgCore",
],
exclude: [
"README-live.md"
]
),
])
#else
targets.append(
.testTarget(
name: "IMsgLinuxTests",
dependencies: [
"imsg",
"IMsgCore",
.product(name: "SQLite", package: "SQLite.swift"),
],
path: "TestsLinux"
))
#endif
return targets
}()
)

395
README.md
View File

@ -1,82 +1,373 @@
# 💬 imsg — Send, read, stream iMessage & SMS
# imsg
A macOS Messages.app CLI to send, read, and stream iMessage/SMS (with attachment metadata). Read-only for receives; send uses AppleScript (no private APIs).
Read, watch, and send iMessage / SMS from the macOS terminal — with stable JSON
and JSON-RPC surfaces designed for agents, scripts, and long-running
integrations.
## Features
- List chats, view history, or stream new messages (`watch`).
- Send text and attachments via iMessage or SMS (AppleScript, no private APIs).
- Phone normalization to E.164 for reliable buddy lookup (`--region`, default US).
- Optional attachment metadata output (mime, name, path, missing flag).
- Filters: participants, start/end time, JSON output for tooling.
- Read-only DB access (`mode=ro`), no DB writes.
- Event-driven watch via filesystem events.
`imsg` reads `~/Library/Messages/chat.db` directly, streams new rows over
filesystem events (with a polling fallback), and drives Messages.app through
its public AppleScript automation surface. Advanced IMCore controls (read
receipts, typing indicators, edit/unsend, group management, rich sends) are
opt-in behind a SIP-disabled dylib injection. Linux builds are a read-only
preview against a `chat.db` copied from macOS.
Full docs: **[imsg.sh](https://imsg.sh)**.
[Quickstart](https://imsg.sh/quickstart) ·
[JSON schema](https://imsg.sh/json) ·
[JSON-RPC](https://imsg.sh/rpc) ·
[Changelog](CHANGELOG.md)
## Highlights
- **Local-first reads.** Chats, history, attachments, and search query
`chat.db` directly — no daemon, no network round-trip.
- **Live streams.** `imsg watch` follows filesystem events on `chat.db` and
falls back to a lightweight poll when macOS drops the event.
- **Send through Messages.app.** Text, files, and standard tapbacks ride the
public AppleScript surface — no private send APIs required.
- **Group-aware.** Direct chats, group threads, participants, GUIDs, and
per-chat account routing hints all show up in JSON.
- **Built for agents.** Stable JSON-RPC over stdio, deterministic JSON
schemas, and `imsg completions llm` for in-context CLI help.
- **Contacts integration.** Resolves names from Address Book when permission
is granted, while keeping raw handles in the output.
- **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, group create/rename/photo, member add/remove,
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.
## Requirements
- macOS 14+ with Messages.app signed in.
- Full Disk Access for your terminal to read `~/Library/Messages/chat.db`.
- Automation permission for your terminal to control Messages.app (for sending).
- For SMS relay, enable “Text Message Forwarding” on your iPhone to this Mac.
- macOS 14 or newer (macOS 26 / Tahoe supported, with caveats noted below).
- Messages.app signed in to iMessage and/or SMS relay.
- Full Disk Access for the terminal or parent app that launches `imsg`.
- Automation permission for Messages.app when using `send` or `react`.
- Optional Contacts permission for name resolution.
- Optional `ffmpeg` on `PATH` for receive-side attachment conversion.
For SMS, enable Text Message Forwarding on your iPhone for this Mac.
Linux support is read-only and requires an existing Messages database copied
from macOS. It does not send, react, mark read, show typing, launch
Messages.app, or access iMessage/SMS accounts on Linux.
## Install
```bash
brew install steipete/tap/imsg
imsg --version
```
Build from source:
```bash
make build
# binary at ./bin/imsg
./bin/imsg --help
```
## Quickstart
```bash
# List recent chats.
imsg chats --limit 10 --json | jq -s
# Inspect one chat before automating against it.
imsg group --chat-id 42 --json
# Read history with attachment metadata.
imsg history --chat-id 42 --limit 20 --attachments --json
# Stream new messages, including tapbacks.
imsg watch --chat-id 42 --reactions --json
# Send a message — auto-pick iMessage or SMS.
imsg send --to "+14155551212" --text "on my way"
# Send a file (image, audio, document).
imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
# Send a standard tapback.
imsg react --chat-id 42 --reaction like
# Search local history.
imsg search --query "pizza" --match contains
```
`--json` emits one JSON object per line. Pipe to `jq -s` to materialize an
array, or stream it to whatever consumer you're wiring up. Human progress and
warnings always go to stderr so pipes stay parseable.
## Commands
- `imsg chats [--limit 20] [--json]` — list recent conversations.
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]`
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--debounce 250ms] [--attachments] [--participants …] [--start …] [--end …] [--json]`
- `imsg send --to <handle> [--text "hi"] [--file /path/img.jpg] [--service imessage|sms|auto] [--region US]`
### Quick samples
```
# list 5 chats
imsg chats --limit 5
Read, watch, and send (no special permissions beyond Full Disk Access and
Automation):
# list chats as JSON
imsg chats --limit 5 --json
- `imsg chats [--limit 20] [--json]`
- `imsg group --chat-id <id> [--json]`
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--convert-attachments] [--participants <handles>] [--start <iso>] [--end <iso>] [--json]`
- `imsg watch [--chat-id <id>] [--since-rowid <id>] [--debounce <duration>] [--attachments] [--convert-attachments] [--reactions] [--participants <handles>] [--start <iso>] [--end <iso>] [--json]`
- `imsg search --query <text> [--match contains|exact] [--limit 50] [--json]`
- `imsg send (--to <handle-or-contact-name> | --chat-id <id> | --chat-identifier <id> | --chat-guid <guid>) [--text <text>] [--file <path>] [--service imessage|sms|auto] [--region US] [--json]`
- `imsg react --chat-id <id> --reaction love|like|dislike|laugh|emphasis|question`
- `imsg rpc`
- `imsg completions bash|zsh|fish|llm`
# last 10 messages in chat 1 with attachments
imsg history --chat-id 1 --limit 10 --attachments
Advanced IMCore (require `imsg launch` with SIP off — see
[Advanced IMCore](#advanced-imcore-features)):
# filter by date and emit JSON
imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json
- `imsg read --to <handle> [--chat-id <id>]`
- `imsg typing --to <handle> [--duration 5s] [--stop true]`
- `imsg launch [--dylib <path>] [--kill-only] [--json]`
- `imsg status [--json]`
- `imsg send-rich`, `imsg send-multipart`, `imsg send-attachment`,
`imsg tapback`
- `imsg edit`, `imsg unsend`, `imsg delete-message`, `imsg notify-anyways`
- `imsg chat-create`, `imsg chat-name`, `imsg chat-photo`,
`imsg chat-add-member`, `imsg chat-remove-member`, `imsg chat-leave`,
`imsg chat-delete`, `imsg chat-mark`
- `imsg account`, `imsg whois`, `imsg nickname`
# live stream a chat
imsg watch --chat-id 1 --attachments --debounce 250ms
`react` intentionally sends only the standard tapbacks Messages.app exposes
reliably through automation. Custom emoji tapbacks can be read from
history/watch output, but are sent through the bridge `tapback` command.
# send a picture
imsg send --to "+14155551212" --text "hi" --file ~/Desktop/pic.jpg --service imessage
```
## JSON Output
## Attachment notes
`--attachments` prints per-attachment lines with name, MIME, missing flag, and resolved path (tilde expanded). Only metadata is shown; files arent copied.
`--json` emits one JSON object per line, so consumers can stream it directly
or collect it with `jq -s`.
## JSON output
`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`.
`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `guid`, `reply_to_guid`, `sender`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata with `filename`, `transfer_name`, `uti`, `mime_type`, `total_bytes`, `is_sticker`, `original_path`, `missing`), `reactions`.
Chat objects include:
Note: `reply_to_guid` and `reactions` are read-only metadata.
- `id`, `name`, `identifier`, `guid`, `service`, `last_message_at`
- `display_name`, `contact_name`
- `is_group`, `participants`
- `account_id`, `account_login`, `last_addressed_handle`
## Permissions troubleshooting
If you see “unable to open database file” or empty output:
1) Grant Full Disk Access: System Settings → Privacy & Security → Full Disk Access → add your terminal.
2) Ensure Messages.app is signed in and `~/Library/Messages/chat.db` exists.
3) For send, allow the terminal under System Settings → Privacy & Security → Automation → Messages.
Message objects include:
- `id`, `chat_id`, `chat_identifier`, `chat_guid`, `chat_name`
- `participants`, `is_group`
- `guid`, `reply_to_guid`, `thread_originator_guid`, `destination_caller_id`
- `sender`, `sender_name`, `is_from_me`, `text`, `created_at`
- `attachments`, `reactions`
When `watch --reactions --json` sees a tapback event, the message object also
includes `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`,
and `reacted_to_guid`.
Routing fields such as `destination_caller_id`, `account_id`,
`account_login`, and `last_addressed_handle` are read-only diagnostics from
Messages. AppleScript does not expose a way for `imsg send` to force a
specific outgoing Apple ID phone number or inline reply target.
## JSON-RPC
`imsg rpc` speaks JSON-RPC 2.0 over stdin/stdout, one JSON object per line.
It is intended for agents and long-running integrations that want a single
process for chats, history, send, and watch.
Read methods: `chats.list`, `messages.history`, `watch.subscribe`,
`watch.unsubscribe`. Mutating: `send`. See [docs/rpc.md](docs/rpc.md) for
request and response shapes.
## Attachments
`--attachments` reports metadata only. It does not copy or upload files.
Attachment metadata includes filename, transfer name, UTI, MIME type, byte
count, sticker flag, missing flag, and resolved original path.
`--convert-attachments` exposes cached, model-compatible receive-side
variants:
- CAF audio → M4A
- GIF image → first-frame PNG
Conversion requires `ffmpeg` on `PATH`. Original Messages attachments are
left unchanged. Converted metadata is reported with `converted_path` and
`converted_mime_type`.
`send --file` sends regular files, including audio, through Messages.app.
Before handing the file to Messages, `imsg` stages it under
`~/Library/Messages/Attachments/imsg/` so Messages can read it reliably.
## Watch Behavior
`imsg watch` starts at the newest message by default and streams messages
written after it starts. Use `--since-rowid <id>` to resume from a stored
cursor.
The watcher listens for filesystem events on `chat.db`, `chat.db-wal`, and
`chat.db-shm`, then backs that up with a lightweight poll. The poll keeps
streams alive when macOS drops file events or rotates SQLite sidecar files.
RPC watch defaults to a 500ms debounce to reduce outbound echo races. CLI
watch can be tuned with `--debounce`.
## Permissions Troubleshooting
If reads fail with `unable to open database file`, empty output, or
`authorization denied`:
1. Open System Settings → Privacy & Security → Full Disk Access.
2. Add the terminal or parent app that launches `imsg`.
3. If launched from an editor, Node process, gateway, or shell wrapper, grant
Full Disk Access to that parent app too.
4. Also add the built-in Terminal.app at
`/System/Applications/Utilities/Terminal.app`; macOS can still consult the
default terminal grant.
5. Toggle stale Full Disk Access entries off and on after terminal, Homebrew,
Node, or app updates.
6. Confirm Messages.app is signed in and `~/Library/Messages/chat.db` exists.
For sends and tapbacks, allow the terminal or parent app under Privacy &
Security → Automation → Messages.
`imsg` opens `chat.db` read-only. It does not use SQLite `immutable=1` by
default because immutable reads can miss WAL-backed Messages updates.
## Advanced IMCore Features
Default `send`, `chats`, `history`, `watch`, `search`, and read-only `rpc`
workflows do not require IMCore injection.
Advanced features such as `read`, `typing`, `launch`, bridge-backed rich
send, message mutation, and chat management are opt-in. They require SIP to
be disabled and a helper dylib to be injected into Messages.app:
## Testing
```bash
make test
make build-dylib
imsg launch
imsg status
```
Note: `make test` applies a small patch to SQLite.swift to silence a SwiftPM warning about `PrivacyInfo.xcprivacy`.
Important limits:
- `imsg launch` refuses to inject when SIP is enabled.
- `imsg status` is read-only and does not auto-launch or auto-inject.
- macOS 26 / Tahoe can block injection through library validation.
- macOS 26 / Tahoe can also reject direct IMCore clients through `imagent`
private-entitlement checks.
- These limits affect advanced IMCore features such as typing indicators,
not normal send/history/watch usage.
To revert after testing, re-enable SIP from Recovery mode with
`csrutil enable`.
### Bridge command surface
The bridge implements a manual port of the BlueBubbles private-API surface
(inspired by their Apache-2.0 helper) into our own dylib — no third-party
binary. Most commands take a `--chat` argument that is the chat GUID
(e.g. `iMessage;-;+15551234567` for direct, `iMessage;+;chat0000` for
groups). Get a chat GUID via `imsg chats --json`.
Messaging:
```bash
# Rich send with effect + reply
imsg send-rich --chat 'iMessage;-;+15551234567' --text "boom" \
--effect com.apple.MobileSMS.expressivesend.impact \
--reply-to <messageGuid>
# Text formatting (macOS 15+ Sequoia): bold/italic/underline/strikethrough
# applied to specific ranges of the message body.
imsg send-rich --chat ... --text 'hello world' \
--format '[{"start":0,"length":5,"styles":["bold"]},
{"start":6,"length":5,"styles":["italic","underline"]}]'
# Multipart send (text-only in v1; per-part textFormatting also supported)
imsg send-multipart --chat 'iMessage;+;chat0000' \
--parts '[{"text":"hi"},
{"text":"there","textFormatting":[{"start":0,"length":5,"styles":["bold"]}]}]'
# Attachment (file or audio)
imsg send-attachment --chat ... --file ~/Pictures/img.jpg
imsg send-attachment --chat ... --file ~/audio.caf --audio
# Bridge tapback (custom emoji + remove supported here, unlike `imsg react`)
imsg tapback --chat ... --message <guid> --kind love
imsg tapback --chat ... --message <guid> --kind love --remove
```
Mutate (macOS 13+ — selector availability surfaced in `imsg status`):
```bash
imsg edit --chat ... --message <guid> --new-text "actually..."
imsg unsend --chat ... --message <guid>
imsg delete-message --chat ... --message <guid>
imsg notify-anyways --chat ... --message <guid>
```
Chat management:
```bash
imsg chat-create --addresses '+15551111111,+15552222222' --name 'Crew' --text 'gm'
imsg chat-name --chat ... --name 'Renamed'
imsg chat-photo --chat ... --file ~/Downloads/g.jpg # set
imsg chat-photo --chat ... # clear
imsg chat-add-member --chat ... --address +15553333333
imsg chat-remove-member --chat ... --address +15553333333
imsg chat-leave --chat ...
imsg chat-delete --chat ...
imsg chat-mark --chat ... --read # or --unread
```
`chat-create` currently creates iMessage chats only. SMS sending remains
available through `imsg send --service sms`.
Introspection:
```bash
imsg account # active iMessage account + aliases
imsg whois --address +15551234567 --type phone
imsg whois --address foo@bar.com --type email
imsg nickname --address +15551234567
```
Live events (typing indicators surfaced through the dylib):
```bash
imsg watch --bb-events # merge dylib events into stdout
imsg watch --bb-events --json # one JSON object per event
```
### v2 IPC under the hood
The dylib v1 used a single overwriting `.imsg-command.json` polled at 100ms,
which races when multiple CLI invocations run concurrently. v2 uses a
per-request UUID-keyed queue:
```
~/Library/Containers/com.apple.MobileSMS/Data/
.imsg-bridge-ready PID lock — set when injection is live
.imsg-rpc/in/<uuid>.json requests dropped here by the CLI (atomic rename)
.imsg-rpc/out/<uuid>.json responses written by the dylib (atomic rename)
.imsg-events.jsonl inbound async events (typing, alias-removed)
```
Set `IMSG_BRIDGE_LEGACY_IPC=1` to force the legacy single-file path for
debugging (existing v1 callers and un-rebuilt dylibs continue to work
without this).
## Development
## Linting & formatting
```bash
make lint
make format
make test
make build
```
## Core library
The reusable Swift core lives in `Sources/IMsgCore` and is consumed by the CLI target. Apps can depend on the `IMsgCore` library target directly.
`make test` applies the repository's SQLite.swift patch before running Swift
tests.
The reusable Swift core lives in `Sources/IMsgCore`; the CLI target lives in
`Sources/imsg`; the injected helper lives in `Sources/IMsgHelper`.
## License
MIT. Not affiliated with Apple. iMessage and SMS are trademarks of their
respective owners.

View File

@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.personal-information.addressbook</key>
<true/>
</dict>
</plist>

View File

@ -1,6 +1,16 @@
import Foundation
#if canImport(CryptoKit)
import CryptoKit
#endif
enum AttachmentResolver {
private struct ConversionPlan {
let targetExtension: String
let mimeType: String
let arguments: (_ input: String, _ output: String) -> [String]
}
static func resolve(_ path: String) -> (resolved: String, missing: Bool) {
guard !path.isEmpty else { return ("", true) }
let expanded = (path as NSString).expandingTildeInPath
@ -9,9 +19,174 @@ enum AttachmentResolver {
return (expanded, !(exists && !isDir.boolValue))
}
static func metadata(
filename: String,
transferName: String,
uti: String,
mimeType: String,
totalBytes: Int64,
isSticker: Bool,
options: AttachmentQueryOptions = .default
) -> AttachmentMeta {
let resolved = resolve(filename)
let converted =
options.convertUnsupported && !resolved.missing
? convertUnsupportedAttachment(path: resolved.resolved, uti: uti, mimeType: mimeType)
: nil
return AttachmentMeta(
filename: filename,
transferName: transferName,
uti: uti,
mimeType: mimeType,
totalBytes: totalBytes,
isSticker: isSticker,
originalPath: resolved.resolved,
convertedPath: converted?.path,
convertedMimeType: converted?.mimeType,
missing: resolved.missing
)
}
static func displayName(filename: String, transferName: String) -> String {
if !transferName.isEmpty { return transferName }
if !filename.isEmpty { return filename }
return "(unknown)"
}
static func convertedURL(for sourcePath: String, targetExtension: String) -> URL {
let sourceURL = URL(fileURLWithPath: sourcePath)
let values = try? sourceURL.resourceValues(forKeys: [
.contentModificationDateKey, .fileSizeKey,
])
let modification = values?.contentModificationDate?.timeIntervalSince1970 ?? 0
let size = values?.fileSize ?? 0
let token = "\(sourceURL.path)|\(size)|\(modification)"
let digest = cacheDigest(for: token)
let base = sourceURL.deletingPathExtension().lastPathComponent
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
.joined(separator: "-")
let prefix = base.isEmpty ? "attachment" : String(base.prefix(48))
return conversionCacheDirectory()
.appendingPathComponent("\(prefix)-\(digest.prefix(16)).\(targetExtension)")
}
private static func convertUnsupportedAttachment(
path: String,
uti: String,
mimeType: String
) -> (path: String, mimeType: String)? {
guard let plan = conversionPlan(path: path, uti: uti, mimeType: mimeType) else {
return nil
}
let outputURL = convertedURL(for: path, targetExtension: plan.targetExtension)
if FileManager.default.fileExists(atPath: outputURL.path) {
return (outputURL.path, plan.mimeType)
}
guard let ffmpegURL = executableURL(named: "ffmpeg") else {
return nil
}
do {
try FileManager.default.createDirectory(
at: outputURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
} catch {
return nil
}
let temporaryURL = outputURL.deletingLastPathComponent()
.appendingPathComponent(".\(UUID().uuidString).\(plan.targetExtension)")
let process = Process()
process.executableURL = ffmpegURL
process.arguments = plan.arguments(path, temporaryURL.path)
process.standardOutput = Pipe()
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0,
FileManager.default.fileExists(atPath: temporaryURL.path)
else {
try? FileManager.default.removeItem(at: temporaryURL)
return nil
}
try? FileManager.default.removeItem(at: outputURL)
try FileManager.default.moveItem(at: temporaryURL, to: outputURL)
return (outputURL.path, plan.mimeType)
} catch {
try? FileManager.default.removeItem(at: temporaryURL)
return nil
}
}
private static func conversionPlan(
path: String,
uti: String,
mimeType: String
) -> ConversionPlan? {
let lowerPath = path.lowercased()
let lowerUTI = uti.lowercased()
let lowerMime = mimeType.lowercased()
if lowerUTI == "com.apple.coreaudio-format"
|| lowerPath.hasSuffix(".caf")
|| lowerMime == "audio/x-caf"
{
return ConversionPlan(targetExtension: "m4a", mimeType: "audio/mp4") { input, output in
["-nostdin", "-y", "-i", input, "-c:a", "aac", "-b:a", "128k", output]
}
}
if lowerUTI == "com.compuserve.gif"
|| lowerPath.hasSuffix(".gif")
|| lowerMime == "image/gif"
{
return ConversionPlan(targetExtension: "png", mimeType: "image/png") { input, output in
["-nostdin", "-y", "-i", input, "-vframes", "1", output]
}
}
return nil
}
private static func conversionCacheDirectory() -> URL {
if let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
return caches.appendingPathComponent("imsg/converted-attachments", isDirectory: true)
}
return FileManager.default.temporaryDirectory.appendingPathComponent(
"imsg/converted-attachments",
isDirectory: true
)
}
private static func cacheDigest(for token: String) -> String {
#if canImport(CryptoKit)
return SHA256.hash(data: Data(token.utf8))
.map { String(format: "%02x", $0) }
.joined()
#else
// Linux Swift does not ship CryptoKit. This digest only names cache files;
// it is not used as a security boundary, so stable FNV-1a is enough.
var hash: UInt64 = 14_695_981_039_346_656_037
for byte in token.utf8 {
hash ^= UInt64(byte)
hash &*= 1_099_511_628_211
}
return String(format: "%016llx", hash)
#endif
}
private static func executableURL(named name: String) -> URL? {
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
let candidates =
path.split(separator: ":").map(String.init)
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
for directory in candidates {
let url = URL(fileURLWithPath: directory).appendingPathComponent(name)
if FileManager.default.isExecutableFile(atPath: url.path) {
return url
}
}
return nil
}
}

View File

@ -0,0 +1,215 @@
import Foundation
#if os(macOS)
@preconcurrency import Contacts
#endif
public struct ContactMatch: Equatable, Sendable {
public let name: String
public let handle: String
public init(name: String, handle: String) {
self.name = name
self.handle = handle
}
}
public protocol ContactResolving: Sendable {
var contactsUnavailable: Bool { get }
func displayName(for handle: String) -> String?
func displayNames(for handles: [String]) -> [String: String]
func searchByName(_ query: String) -> [ContactMatch]
}
public final class NoOpContactResolver: ContactResolving, Sendable {
public let contactsUnavailable: Bool
public init(contactsUnavailable: Bool = false) {
self.contactsUnavailable = contactsUnavailable
}
public func displayName(for handle: String) -> String? { nil }
public func displayNames(for handles: [String]) -> [String: String] { [:] }
public func searchByName(_ query: String) -> [ContactMatch] { [] }
}
public final class ContactResolver: ContactResolving, @unchecked Sendable {
#if os(macOS)
private let phoneToName: [String: String]
private let emailToName: [String: String]
private let contacts: [ContactRecord]
private let normalizer = PhoneNumberNormalizer()
private let region: String
public let contactsUnavailable: Bool
private init(
phoneToName: [String: String],
emailToName: [String: String],
contacts: [ContactRecord],
region: String
) {
self.phoneToName = phoneToName
self.emailToName = emailToName
self.contacts = contacts
self.region = region
self.contactsUnavailable = false
}
#else
public let contactsUnavailable = true
#endif
public static func create(region: String = "US") async -> any ContactResolving {
#if os(macOS)
let store = CNContactStore()
switch CNContactStore.authorizationStatus(for: .contacts) {
case .authorized:
return load(store: store, region: region)
case .notDetermined:
let granted = await requestAccess(store: store)
return granted
? load(store: store, region: region) : NoOpContactResolver(contactsUnavailable: true)
case .denied, .restricted:
return NoOpContactResolver(contactsUnavailable: true)
@unknown default:
return NoOpContactResolver(contactsUnavailable: true)
}
#else
_ = region
return NoOpContactResolver(contactsUnavailable: true)
#endif
}
public func displayName(for handle: String) -> String? {
#if os(macOS)
let lookup = normalizedLookupHandle(handle)
if lookup.contains("@") {
return emailToName[lookup.lowercased()]
}
return phoneToName[normalizer.normalize(lookup, region: region)]
#else
_ = handle
return nil
#endif
}
public func displayNames(for handles: [String]) -> [String: String] {
#if os(macOS)
var resolved: [String: String] = [:]
for handle in handles {
if let name = displayName(for: handle) {
resolved[handle] = name
}
}
return resolved
#else
_ = handles
return [:]
#endif
}
public func searchByName(_ query: String) -> [ContactMatch] {
#if os(macOS)
let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalizedQuery.isEmpty else { return [] }
var matches: [ContactMatch] = []
for contact in contacts where contact.name.lowercased().contains(normalizedQuery) {
if let phone = contact.phones.first {
matches.append(ContactMatch(name: contact.name, handle: phone))
} else if let email = contact.emails.first {
matches.append(ContactMatch(name: contact.name, handle: email))
}
}
return matches
#else
_ = query
return []
#endif
}
#if os(macOS)
private static func requestAccess(store: CNContactStore) async -> Bool {
await withCheckedContinuation { continuation in
store.requestAccess(for: .contacts) { granted, _ in
continuation.resume(returning: granted)
}
}
}
private static func load(store: CNContactStore, region: String) -> any ContactResolving {
let keysToFetch: [CNKeyDescriptor] = [
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactNicknameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
]
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
let normalizer = PhoneNumberNormalizer()
var phoneToName: [String: String] = [:]
var emailToName: [String: String] = [:]
var contacts: [ContactRecord] = []
do {
try store.enumerateContacts(with: request) { contact, _ in
guard let name = displayName(for: contact) else { return }
var phones: [String] = []
var emails: [String] = []
for number in contact.phoneNumbers {
let normalized = normalizer.normalize(number.value.stringValue, region: region)
phones.append(normalized)
phoneToName[normalized] = phoneToName[normalized] ?? name
}
for email in contact.emailAddresses {
let normalized = String(email.value).lowercased()
emails.append(normalized)
emailToName[normalized] = emailToName[normalized] ?? name
}
if !phones.isEmpty || !emails.isEmpty {
contacts.append(ContactRecord(name: name, phones: phones, emails: emails))
}
}
} catch {
return NoOpContactResolver(contactsUnavailable: true)
}
return ContactResolver(
phoneToName: phoneToName,
emailToName: emailToName,
contacts: contacts,
region: region
)
}
private static func displayName(for contact: CNContact) -> String? {
if !contact.nickname.isEmpty {
return contact.nickname
}
let name = [contact.givenName, contact.familyName]
.filter { !$0.isEmpty }
.joined(separator: " ")
return name.isEmpty ? nil : name
}
private func normalizedLookupHandle(_ handle: String) -> String {
let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines)
for prefix in ["iMessage;-;", "iMessage;+;", "SMS;-;", "SMS;+;", "any;-;", "any;+;"]
where trimmed.hasPrefix(prefix) {
return String(trimmed.dropFirst(prefix.count))
}
return trimmed
}
#endif
}
#if os(macOS)
private struct ContactRecord: Sendable {
let name: String
let phones: [String]
let emails: [String]
}
#endif

View File

@ -0,0 +1,11 @@
struct MessageID: RawRepresentable, Hashable, Sendable {
let rawValue: Int64
}
struct ChatID: RawRepresentable, Hashable, Sendable {
let rawValue: Int64
}
struct HandleID: RawRepresentable, Hashable, Sendable {
let rawValue: Int64
}

View File

@ -1,11 +1,16 @@
import Foundation
public enum IMsgError: LocalizedError, Sendable {
public enum IMsgError: LocalizedError, CustomStringConvertible, Sendable {
case permissionDenied(path: String, underlying: Error)
case invalidISODate(String)
case invalidService(String)
case unsupportedService(String)
case invalidChatTarget(String)
case appleScriptFailure(String)
case typingIndicatorFailed(String)
case invalidReaction(String)
case unsupportedReaction(String)
case chatNotFound(chatID: Int64)
public var errorDescription: String? {
switch self {
@ -19,9 +24,10 @@ public enum IMsgError: LocalizedError, Sendable {
To fix:
1. Open System Settings Privacy & Security Full Disk Access
2. Add your terminal application (Terminal.app, iTerm, etc.)
3. Restart your terminal
4. Try again
2. Add your terminal application and any parent launcher (VS Code, Node, gateway, etc.)
3. Also add the built-in Terminal.app if you normally use another terminal
4. Toggle stale entries off and on after terminal/Homebrew/app updates
5. Restart the terminal or parent app, then try again
Note: This is required because macOS protects the Messages database.
For more details, see: https://github.com/steipete/imsg#permissions-troubleshooting
@ -30,10 +36,28 @@ public enum IMsgError: LocalizedError, Sendable {
return "Invalid ISO8601 date: \(value)"
case .invalidService(let value):
return "Invalid service: \(value)"
case .unsupportedService(let value):
return "Unsupported service: \(value)"
case .invalidChatTarget(let value):
return "Invalid chat target: \(value)"
case .appleScriptFailure(let message):
return "AppleScript failed: \(message)"
case .typingIndicatorFailed(let message):
return "Typing indicator failed: \(message)"
case .invalidReaction(let value):
return """
Invalid reaction: \(value)
Valid reactions: love, like, dislike, laugh, emphasis, question
"""
case .unsupportedReaction(let message):
return "Unsupported reaction: \(message)"
case .chatNotFound(let chatID):
return "Chat not found: \(chatID)"
}
}
public var description: String {
errorDescription ?? "Unknown imsg error"
}
}

View File

@ -0,0 +1,173 @@
import Foundation
public enum IMCoreBridgeError: Error, CustomStringConvertible {
case dylibNotFound
case connectionFailed(String)
case chatNotFound(String)
case operationFailed(String)
public var description: String {
switch self {
case .dylibNotFound:
return "imsg-bridge-helper.dylib not found. Build with: make build-dylib"
case .connectionFailed(let error):
return "Connection to Messages.app failed: \(error)"
case .chatNotFound(let id):
return "Chat not found: \(id)"
case .operationFailed(let reason):
return "Operation failed: \(reason)"
}
}
}
/// Bridge to IMCore via DYLD injection into Messages.app.
///
/// Communicates with an injected dylib inside Messages.app via file-based IPC.
/// The dylib has access to IMCore when Messages.app accepts the injection.
/// macOS 26/Tahoe can still block this path with library validation/private
/// entitlement checks.
///
/// Requires:
/// - SIP disabled (for `DYLD_INSERT_LIBRARIES` on system apps)
/// - The `imsg-bridge-helper.dylib` built via `make build-dylib`
public final class IMCoreBridge: @unchecked Sendable {
public static let shared = IMCoreBridge()
private let launcher = MessagesLauncher.shared
/// Whether the dylib exists on disk (does not check if Messages.app is running).
public var isAvailable: Bool {
let possiblePaths = [
"/usr/local/lib/imsg-bridge-helper.dylib",
".build/release/imsg-bridge-helper.dylib",
".build/debug/imsg-bridge-helper.dylib",
]
return possiblePaths.contains { FileManager.default.fileExists(atPath: $0) }
}
private init() {}
// MARK: - Commands
/// Set typing indicator for a conversation.
public func setTyping(for handle: String, typing: Bool) async throws {
let params: [String: Any] = [
"handle": handle,
"typing": typing,
]
_ = try await invokeBridge(action: .typing, params: params)
}
/// Mark all messages as read in a conversation.
public func markAsRead(handle: String) async throws {
_ = try await invokeBridge(action: .read, params: ["handle": handle])
}
/// List all available chats (for debugging).
public func listChats() async throws -> [[String: Any]] {
let response = try await invokeBridge(action: .listChats, params: [:])
return response["chats"] as? [[String: Any]] ?? []
}
/// Get detailed status from the injected helper.
public func getStatus() async throws -> [String: Any] {
return try await invokeBridge(action: .status, params: [:])
}
/// Check availability and return a diagnostic message.
public func checkAvailability() -> (available: Bool, message: String) {
let possiblePaths = [
"/usr/local/lib/imsg-bridge-helper.dylib",
".build/release/imsg-bridge-helper.dylib",
".build/debug/imsg-bridge-helper.dylib",
]
var dylibPath: String?
for path in possiblePaths {
if FileManager.default.fileExists(atPath: path) {
dylibPath = path
break
}
}
guard dylibPath != nil else {
return (
false,
"""
imsg-bridge-helper.dylib not found. To build:
1. make build-dylib
2. Restart imsg
Note: Advanced features require:
- SIP disabled (for DYLD injection)
- Full Disk Access granted to Terminal
"""
)
}
switch MessagesLauncher.currentSIPStatus() {
case .enabled:
return (
false,
"""
System Integrity Protection (SIP) is enabled.
Advanced IMCore features are intentionally disabled.
To enable advanced features:
1. Disable SIP in Recovery mode (`csrutil disable`)
2. Run `make build-dylib`
3. Run `imsg launch`
"""
)
case .unknown(let details):
return (
false,
"""
Unable to determine SIP status. Refusing to auto-inject Messages.app.
Details: \(details)
"""
)
case .disabled:
break
}
if launcher.hasReadyLockFile() {
return (true, "Connected to Messages.app. IMCore features available.")
}
return (
false,
"""
SIP is disabled and the helper dylib is present, but Messages.app is not currently injected.
Run `imsg launch` to enable advanced IMCore features.
Note: macOS 26/Tahoe can still block advanced IMCore features through
library validation or imagent private entitlement checks. Basic send,
history, and watch commands do not use this path.
"""
)
}
// MARK: - Private
private func invokeBridge(
action: BridgeAction, params: [String: Any]
) async throws -> [String: Any] {
do {
return try await IMsgBridgeClient.shared.invoke(action: action, params: params)
} catch let error as IMsgBridgeError {
switch error {
case .dylibReturnedError(let message):
if message.contains("Chat not found") {
let handle = params["handle"] as? String ?? "unknown"
throw IMCoreBridgeError.chatNotFound(handle)
}
throw IMCoreBridgeError.operationFailed(message)
default:
throw IMCoreBridgeError.connectionFailed(error.description)
}
} catch let error as MessagesLauncherError {
throw IMCoreBridgeError.connectionFailed(error.description)
}
}
}

View File

@ -0,0 +1,151 @@
import Foundation
/// One-shot RPC client for the v2 bridge protocol.
///
/// Each call atomically drops a `<uuid>.json` request file into
/// `~/Library/Containers/com.apple.MobileSMS/Data/.imsg-rpc/in/`, then polls
/// `out/<uuid>.json` until the dylib responds (or `timeout` elapses).
///
/// The dylib is shared across CLI invocations: many concurrent `imsg`
/// processes can drop requests at once and each gets routed back to the
/// correct caller via the UUID. There is no global lock on the CLI side.
public final class IMsgBridgeClient: @unchecked Sendable {
public static let shared = IMsgBridgeClient(launcher: MessagesLauncher.shared)
private let launcher: MessagesLauncher
private let useLegacyIPC: Bool
/// Polling cadence while waiting for a response file to appear.
private let pollInterval: TimeInterval = 0.05
public init(launcher: MessagesLauncher, useLegacyIPC: Bool? = nil) {
self.launcher = launcher
if let override = useLegacyIPC {
self.useLegacyIPC = override
} else {
let env = ProcessInfo.processInfo.environment["IMSG_BRIDGE_LEGACY_IPC"]
self.useLegacyIPC = (env == "1" || env == "true")
}
}
/// Whether the dylib is currently injected and has published its ready lock.
public func isReady() -> Bool {
launcher.hasReadyLockFile()
}
// MARK: - High-level API
/// Invoke a v2 bridge action and return its `data` payload on success.
/// Legacy single-file IPC is only used when explicitly requested through
/// `IMSG_BRIDGE_LEGACY_IPC=1`.
public func invoke(
action: BridgeAction,
params: [String: Any] = [:],
timeout: TimeInterval = IMsgBridgeProtocol.defaultResponseTimeout
) async throws -> [String: Any] {
if useLegacyIPC {
try launcher.ensureRunning()
return try await invokeLegacy(action: action, params: params)
}
try launcher.ensureLaunched()
return try await invokeV2(action: action, params: params, timeout: timeout)
}
// MARK: - v2 path
private func invokeV2(
action: BridgeAction,
params: [String: Any],
timeout: TimeInterval
) async throws -> [String: Any] {
let id = UUID().uuidString
let envelope: [String: Any] = [
"v": IMsgBridgeProtocol.version,
"id": id,
"action": action.rawValue,
"params": params,
]
let inboxDir = launcher.bridgeInboxDirectory
let outboxDir = launcher.bridgeOutboxDirectory
try ensureDirectory(inboxDir)
try ensureDirectory(outboxDir)
let tmp = (inboxDir as NSString).appendingPathComponent("\(id).tmp")
let final = (inboxDir as NSString).appendingPathComponent("\(id).json")
let outPath = (outboxDir as NSString).appendingPathComponent("\(id).json")
let payload = try JSONSerialization.data(withJSONObject: envelope, options: [])
try payload.write(to: URL(fileURLWithPath: tmp))
try FileManager.default.moveItem(atPath: tmp, toPath: final)
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000))
guard
let data = try? Data(contentsOf: URL(fileURLWithPath: outPath)),
data.count > 1
else { continue }
// Best-effort cleanup; ignore failures (dylib may also unlink).
try? FileManager.default.removeItem(atPath: outPath)
guard
let raw = try? JSONSerialization.jsonObject(with: data, options: [])
as? [String: Any]
else {
throw IMsgBridgeError.malformedResponse("non-object body")
}
let response = try BridgeResponse.parse(raw)
if response.success {
return response.data
}
throw IMsgBridgeError.dylibReturnedError(response.error ?? "unknown")
}
try? FileManager.default.removeItem(atPath: final)
throw IMsgBridgeError.timeout(action: action.rawValue)
}
// MARK: - Legacy path
private func invokeLegacy(
action: BridgeAction,
params: [String: Any]
) async throws -> [String: Any] {
do {
let raw = try await launcher.sendCommand(action: action.rawValue, params: params)
let response = try BridgeResponse.parse(raw)
if response.success {
return response.data
}
throw IMsgBridgeError.dylibReturnedError(response.error ?? "unknown")
} catch let error as MessagesLauncherError {
throw IMsgBridgeError.bridgeNotReady(error.description)
}
}
private func ensureDirectory(_ path: String) throws {
if SecurePath.hasSymlinkComponent(path) {
throw IMsgBridgeError.ioError("\(path) traverses a symlink")
}
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: path, isDirectory: &isDir) {
if isDir.boolValue { return }
throw IMsgBridgeError.ioError("\(path) exists and is not a directory")
}
do {
try FileManager.default.createDirectory(
atPath: path,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
if SecurePath.hasSymlinkComponent(path) {
throw IMsgBridgeError.ioError("\(path) traverses a symlink (post-mkdir)")
}
} catch let error as IMsgBridgeError {
throw error
} catch {
throw IMsgBridgeError.ioError("mkdir \(path): \(error.localizedDescription)")
}
}
}

View File

@ -0,0 +1,181 @@
import Foundation
/// Wire-level constants and helpers for the v2 imsg dylib bridge protocol.
///
/// v1 (legacy) used a single overwriting `.imsg-command.json` file with a 100ms
/// polling loop in the dylib. That model races when two CLI invocations write
/// concurrently. v2 uses a per-request queue directory: callers atomically
/// rename `<uuid>.tmp` `<uuid>.json` into `.imsg-rpc/in/`, the dylib
/// processes each file once and writes the matching response into
/// `.imsg-rpc/out/<uuid>.json`.
public enum IMsgBridgeProtocol {
/// Current envelope version. Bump when the on-wire shape changes.
public static let version: Int = 2
/// Subdirectory under the Messages.app sandbox container holding RPC files.
public static let rpcDirectoryName: String = ".imsg-rpc"
public static let inboxDirectoryName: String = "in"
public static let outboxDirectoryName: String = "out"
/// Inbound async event log written by the dylib (typing, alias-changes, ).
public static let eventsFileName: String = ".imsg-events.jsonl"
public static let rotatedEventsFileName: String = ".imsg-events.jsonl.1"
public static let eventsRotationBytes: Int = 1 * 1024 * 1024
/// Default per-request timeout for synchronous RPC waits.
public static let defaultResponseTimeout: TimeInterval = 10.0
}
/// All action verbs exposed by the v2 bridge. Names match the BlueBubbles
/// reference vocabulary so traffic shape stays familiar, but each handler is a
/// local rewrite inside `Sources/IMsgHelper/IMsgInjected.m`.
public enum BridgeAction: String, Sendable, CaseIterable {
// Liveness
case ping
case status
case listChats = "list_chats"
// Typing
case typing // legacy compound: { handle, typing: bool }
case startTyping = "start-typing"
case stopTyping = "stop-typing"
case checkTypingStatus = "check-typing-status"
// Read
case read // legacy
case markChatRead = "mark-chat-read"
case markChatUnread = "mark-chat-unread"
// Send
case sendMessage = "send-message"
case sendMultipart = "send-multipart"
case sendAttachment = "send-attachment"
case sendReaction = "send-reaction"
case notifyAnyways = "notify-anyways"
// Mutate
case editMessage = "edit-message"
case unsendMessage = "unsend-message"
case deleteMessage = "delete-message"
// Chat management
case addParticipant = "add-participant"
case removeParticipant = "remove-participant"
case setDisplayName = "set-display-name"
case updateGroupPhoto = "update-group-photo"
case leaveChat = "leave-chat"
case deleteChat = "delete-chat"
case createChat = "create-chat"
// Introspection
case searchMessages = "search-messages"
case getAccountInfo = "get-account-info"
case getNicknameInfo = "get-nickname-info"
case checkImessageAvailability = "check-imessage-availability"
case downloadPurgedAttachment = "download-purged-attachment"
}
/// Reaction kinds (BlueBubbles vocabulary) IMAssociatedMessageType integers.
///
/// Constants are stable across macOS 1115. Add 1000 to the kind id to send a
/// removal (e.g. `love` 2000, `remove-love` 3000).
public enum BridgeReactionKind: String, Sendable, CaseIterable {
case love
case like
case dislike
case laugh
case emphasize
case question
case removeLove = "remove-love"
case removeLike = "remove-like"
case removeDislike = "remove-dislike"
case removeLaugh = "remove-laugh"
case removeEmphasize = "remove-emphasize"
case removeQuestion = "remove-question"
public var associatedMessageType: Int {
switch self {
case .love: return 2000
case .like: return 2001
case .dislike: return 2002
case .laugh: return 2003
case .emphasize: return 2004
case .question: return 2005
case .removeLove: return 3000
case .removeLike: return 3001
case .removeDislike: return 3002
case .removeLaugh: return 3003
case .removeEmphasize: return 3004
case .removeQuestion: return 3005
}
}
}
/// Errors surfaced by `IMsgBridgeClient` and adjacent helpers.
public enum IMsgBridgeError: Error, CustomStringConvertible, Equatable {
case bridgeNotReady(String)
case timeout(action: String)
case malformedResponse(String)
case dylibReturnedError(String)
case ioError(String)
public var description: String {
switch self {
case .bridgeNotReady(let detail): return "imsg bridge not ready: \(detail)"
case .timeout(let action): return "Timed out waiting for response to '\(action)'"
case .malformedResponse(let detail): return "Malformed bridge response: \(detail)"
case .dylibReturnedError(let msg): return "Dylib error: \(msg)"
case .ioError(let detail): return "Bridge IO error: \(detail)"
}
}
}
/// Decoded shape of a v2 bridge response.
///
/// The dylib always writes `{"v":2,"id":"<uuid>","success":<bool>,...}`. On
/// success, action-specific fields land under `data` (or directly at the top
/// level for handlers that haven't been migrated yet). On failure, `error`
/// holds a human-readable string.
public struct BridgeResponse {
public let id: String
public let success: Bool
public let data: [String: Any]
public let error: String?
public init(id: String, success: Bool, data: [String: Any], error: String?) {
self.id = id
self.success = success
self.data = data
self.error = error
}
/// Parse a JSON response object into a `BridgeResponse`. Tolerates v1 shape
/// (no `v` field, integer `id`) so the legacy single-file IPC keeps working.
public static func parse(_ raw: [String: Any]) throws -> BridgeResponse {
let id: String
if let s = raw["id"] as? String {
id = s
} else if let i = raw["id"] as? Int {
id = String(i)
} else if let d = raw["id"] as? Double {
id = String(Int(d))
} else {
id = ""
}
let success = (raw["success"] as? Bool) ?? false
let error = raw["error"] as? String
var data: [String: Any]
if let d = raw["data"] as? [String: Any] {
data = d
} else {
data = raw
for stripped in ["v", "id", "success", "error", "timestamp"] {
data.removeValue(forKey: stripped)
}
}
return BridgeResponse(id: id, success: success, data: data, error: error)
}
}

View File

@ -0,0 +1,175 @@
import Foundation
#if os(macOS)
import Darwin
#endif
/// Live tailer for `.imsg-events.jsonl` written by the injected dylib.
///
/// Uses `DispatchSource.makeFileSystemObjectSource` watching `.write`,
/// `.extend`, and `.rename`. On rename (file rotation by the dylib at 1 MiB)
/// the source closes and reopens. Each newly-written full line is decoded as
/// a JSON object and surfaced via the `events` AsyncStream.
///
/// Designed to be co-resident with `MessageWatcher` inside `imsg watch`.
public final class IMsgEventTailer: @unchecked Sendable {
/// One decoded event line. `payloadJSON` is the raw JSON-encoded `data`
/// object (UTF-8 bytes); decode lazily on the consumer side via
/// `JSONSerialization` if you need typed access. Holding raw Data keeps the
/// type Sendable across actor boundaries under Swift 6 strict concurrency.
public struct Event: Sendable {
public let timestamp: String?
public let name: String
public let payloadJSON: Data
public init(timestamp: String?, name: String, payloadJSON: Data) {
self.timestamp = timestamp
self.name = name
self.payloadJSON = payloadJSON
}
/// Decode `payloadJSON` to a dictionary. Returns `[:]` on any error.
public func decodedPayload() -> [String: Any] {
guard
let obj = try? JSONSerialization.jsonObject(with: payloadJSON, options: [])
as? [String: Any]
else { return [:] }
return obj
}
}
private let path: String
private let replayExisting: Bool
#if os(macOS)
private var source: DispatchSourceFileSystemObject?
private var fd: Int32 = -1
private var pending = Data()
#endif
private var continuation: AsyncStream<Event>.Continuation?
private let queue = DispatchQueue(label: "imsg.event.tailer")
public init(path: String, replayExisting: Bool = false) {
self.path = path
self.replayExisting = replayExisting
}
/// Start tailing and return an AsyncStream of decoded events. Starts at EOF
/// by default so `watch --bb-events` only emits live events.
public func events() -> AsyncStream<Event> {
return AsyncStream { continuation in
self.continuation = continuation
continuation.onTermination = { @Sendable _ in
self.stop()
}
#if os(macOS)
self.queue.async {
self.openAndStart()
}
#endif
}
}
public func stop() {
#if os(macOS)
queue.async { [weak self] in
guard let self else { return }
self.source?.cancel()
self.source = nil
if self.fd >= 0 {
close(self.fd)
self.fd = -1
}
}
#endif
}
// MARK: - Private
#if os(macOS)
private func openAndStart() {
if !FileManager.default.fileExists(atPath: path) {
// Create empty file so we can watch it. The dylib appends; missing
// file means injection isn't active yet caller can retry later.
FileManager.default.createFile(atPath: path, contents: Data(), attributes: nil)
}
let fd = open(path, O_RDONLY)
if fd < 0 { return }
self.fd = fd
if replayExisting {
drainAvailable()
} else {
lseek(fd, 0, SEEK_END)
}
let src = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.extend, .write, .rename, .delete],
queue: queue
)
src.setEventHandler { [weak self] in
guard let self else { return }
let mask = src.data
if mask.contains(.rename) || mask.contains(.delete) {
// File rotated by the dylib close and reopen the new file.
self.reopen()
return
}
self.drainAvailable()
}
src.setCancelHandler { [weak self] in
guard let self else { return }
if self.fd >= 0 {
close(self.fd)
self.fd = -1
}
}
src.resume()
self.source = src
}
private func reopen() {
source?.cancel()
source = nil
if fd >= 0 {
close(fd)
fd = -1
}
pending.removeAll(keepingCapacity: true)
// Small delay lets the dylib finish the rename; then start fresh.
queue.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.openAndStart()
}
}
private func drainAvailable() {
guard fd >= 0 else { return }
var buffer = Data(count: 8192)
while true {
let n = buffer.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) -> Int in
guard let base = raw.baseAddress else { return -1 }
return read(fd, base, raw.count)
}
if n <= 0 { break }
pending.append(buffer.prefix(n))
processPending()
}
}
private func processPending() {
while let nl = pending.firstIndex(of: 0x0A) {
let line = pending[..<nl]
pending.removeSubrange(...nl)
guard !line.isEmpty else { continue }
guard
let obj = try? JSONSerialization.jsonObject(with: line, options: [])
as? [String: Any]
else { continue }
let name = (obj["event"] as? String) ?? "unknown"
let ts = obj["ts"] as? String
let data = (obj["data"] as? [String: Any]) ?? [:]
let payloadData = (try? JSONSerialization.data(withJSONObject: data, options: [])) ?? Data()
continuation?.yield(Event(timestamp: ts, name: name, payloadJSON: payloadData))
}
}
#endif
}

View File

@ -1,6 +1,9 @@
import Carbon
import Foundation
#if os(macOS)
import Carbon
#endif
public enum MessageService: String, Sendable, CaseIterable {
case auto
case imessage
@ -62,20 +65,26 @@ public struct MessageSender {
}
public func send(_ options: MessageSendOptions) throws {
var resolved = options
let chatTarget = resolveChatTarget(&resolved)
let useChat = !chatTarget.isEmpty
if useChat == false {
if resolved.region.isEmpty { resolved.region = "US" }
resolved.recipient = normalizer.normalize(resolved.recipient, region: resolved.region)
if resolved.service == .auto { resolved.service = .imessage }
}
#if !os(macOS)
_ = options
throw IMsgError.appleScriptFailure(
"Sending requires Messages.app automation and is only supported on macOS.")
#else
var resolved = options
let chatTarget = resolveChatTarget(&resolved)
let useChat = !chatTarget.isEmpty
if useChat == false {
if resolved.region.isEmpty { resolved.region = "US" }
resolved.recipient = normalizer.normalize(resolved.recipient, region: resolved.region)
if resolved.service == .auto { resolved.service = .imessage }
}
if resolved.attachmentPath.isEmpty == false {
resolved.attachmentPath = try stageAttachment(at: resolved.attachmentPath)
}
if resolved.attachmentPath.isEmpty == false {
resolved.attachmentPath = try stageAttachment(at: resolved.attachmentPath)
}
try sendViaAppleScript(resolved, chatTarget: chatTarget, useChat: useChat)
try sendViaAppleScript(resolved, chatTarget: chatTarget, useChat: useChat)
#endif
}
private func stageAttachment(at path: String) throws -> String {
@ -173,19 +182,19 @@ public struct MessageSender {
private func resolveChatTarget(_ options: inout MessageSendOptions) -> String {
let guid = options.chatGUID.trimmingCharacters(in: .whitespacesAndNewlines)
if !guid.isEmpty {
return guid
}
let identifier = options.chatIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
if identifier.isEmpty {
return ""
}
if looksLikeHandle(identifier) {
if !identifier.isEmpty && looksLikeHandle(identifier) {
if options.recipient.isEmpty {
options.recipient = identifier
}
return ""
}
if !guid.isEmpty {
return guid
}
if identifier.isEmpty {
return ""
}
return identifier
}
@ -202,48 +211,60 @@ public struct MessageSender {
}
private static func runAppleScript(source: String, arguments: [String]) throws {
guard let script = NSAppleScript(source: source) else {
throw IMsgError.appleScriptFailure("Unable to compile AppleScript")
}
var errorInfo: NSDictionary?
let event = NSAppleEventDescriptor(
eventClass: AEEventClass(kASAppleScriptSuite),
eventID: AEEventID(kASSubroutineEvent),
targetDescriptor: nil,
returnID: AEReturnID(kAutoGenerateReturnID),
transactionID: AETransactionID(kAnyTransactionID)
)
event.setParam(
NSAppleEventDescriptor(string: "run"), forKeyword: AEKeyword(keyASSubroutineName))
let list = NSAppleEventDescriptor.list()
for (index, value) in arguments.enumerated() {
list.insert(NSAppleEventDescriptor(string: value), at: index + 1)
}
event.setParam(list, forKeyword: keyDirectObject)
script.executeAppleEvent(event, error: &errorInfo)
if let errorInfo {
if shouldFallbackToOsascript(errorInfo: errorInfo) {
try runOsascript(source: source, arguments: arguments)
return
#if os(macOS)
guard let script = NSAppleScript(source: source) else {
throw IMsgError.appleScriptFailure("Unable to compile AppleScript")
}
let message =
(errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown AppleScript error"
throw IMsgError.appleScriptFailure(message)
}
var errorInfo: NSDictionary?
let event = NSAppleEventDescriptor(
eventClass: AEEventClass(kASAppleScriptSuite),
eventID: AEEventID(kASSubroutineEvent),
targetDescriptor: nil,
returnID: AEReturnID(kAutoGenerateReturnID),
transactionID: AETransactionID(kAnyTransactionID)
)
event.setParam(
NSAppleEventDescriptor(string: "run"), forKeyword: AEKeyword(keyASSubroutineName))
let list = NSAppleEventDescriptor.list()
for (index, value) in arguments.enumerated() {
list.insert(NSAppleEventDescriptor(string: value), at: index + 1)
}
event.setParam(list, forKeyword: keyDirectObject)
script.executeAppleEvent(event, error: &errorInfo)
if let errorInfo {
if shouldFallbackToOsascript(errorInfo: errorInfo) {
try runOsascript(source: source, arguments: arguments)
return
}
let message =
(errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown AppleScript error"
throw IMsgError.appleScriptFailure(message)
}
#else
_ = source
_ = arguments
throw IMsgError.appleScriptFailure(
"Sending requires Messages.app automation and is only supported on macOS.")
#endif
}
private static func shouldFallbackToOsascript(errorInfo: NSDictionary) -> Bool {
if let errorNumber = errorInfo[NSAppleScript.errorNumber] as? Int, errorNumber == -1743 {
return true
}
if errorInfo[NSAppleScript.errorMessage] == nil {
return true
}
if let message = errorInfo[NSAppleScript.errorMessage] as? String {
let lower = message.lowercased()
return lower.contains("not authorized") || lower.contains("not authorised")
}
return false
#if os(macOS)
if let errorNumber = errorInfo[NSAppleScript.errorNumber] as? Int, errorNumber == -1743 {
return true
}
if errorInfo[NSAppleScript.errorMessage] == nil {
return true
}
if let message = errorInfo[NSAppleScript.errorMessage] as? String {
let lower = message.lowercased()
return lower.contains("not authorized") || lower.contains("not authorised")
}
return false
#else
_ = errorInfo
return false
#endif
}
private static func runOsascript(source: String, arguments: [String]) throws {

View File

@ -0,0 +1,102 @@
import Foundation
import SQLite
private struct AttachmentQuery {
let sql: String
let bindings: [Binding?]
init(messageID: MessageID) {
self.sql = """
SELECT a.filename AS filename, a.transfer_name AS transfer_name, a.uti AS uti,
a.mime_type AS mime_type, a.total_bytes AS total_bytes, a.is_sticker AS is_sticker
FROM message_attachment_join maj
JOIN attachment a ON a.ROWID = maj.attachment_id
WHERE maj.message_id = ?
"""
self.bindings = [messageID.rawValue]
}
}
private struct AudioTranscriptionQuery {
let sql: String
let bindings: [Binding?]
init(messageID: MessageID) {
self.sql = """
SELECT a.user_info
FROM message_attachment_join maj
JOIN attachment a ON a.ROWID = maj.attachment_id
WHERE maj.message_id = ?
LIMIT 1
"""
self.bindings = [messageID.rawValue]
}
}
extension MessageStore {
public func attachments(
for messageID: Int64,
options: AttachmentQueryOptions = .default
) throws -> [AttachmentMeta] {
let query = AttachmentQuery(messageID: MessageID(rawValue: messageID))
return try withConnection { db in
var metas: [AttachmentMeta] = []
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let filename = try stringValue(row, "filename")
let transferName = try stringValue(row, "transfer_name")
let uti = try stringValue(row, "uti")
let mimeType = try stringValue(row, "mime_type")
let totalBytes = try int64Value(row, "total_bytes") ?? 0
let isSticker = try boolValue(row, "is_sticker")
metas.append(
AttachmentResolver.metadata(
filename: filename,
transferName: transferName,
uti: uti,
mimeType: mimeType,
totalBytes: totalBytes,
isSticker: isSticker,
options: options
))
}
return metas
}
}
func audioTranscription(for messageID: Int64) throws -> String? {
guard schema.hasAttachmentUserInfo else { return nil }
let query = AudioTranscriptionQuery(messageID: MessageID(rawValue: messageID))
return try withConnection { db in
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let info = try dataValue(row, "user_info")
guard !info.isEmpty else { continue }
if let transcription = parseAudioTranscription(from: info) {
return transcription
}
}
return nil
}
}
private func parseAudioTranscription(from data: Data) -> String? {
do {
let plist = try PropertyListSerialization.propertyList(
from: data,
options: [],
format: nil
)
guard
let dict = plist as? [String: Any],
let transcription = dict["audio-transcription"] as? String,
!transcription.isEmpty
else {
return nil
}
return transcription
} catch {
return nil
}
}
}

View File

@ -0,0 +1,152 @@
import Foundation
import SQLite
private struct ChatRoutingSelection {
let accountIDColumn: String
let accountLoginColumn: String
let lastAddressedHandleColumn: String
init(schema: MessageStoreSchema) {
self.accountIDColumn = schema.hasChatAccountIDColumn ? "IFNULL(c.account_id, '')" : "''"
self.accountLoginColumn =
schema.hasChatAccountLoginColumn ? "IFNULL(c.account_login, '')" : "''"
self.lastAddressedHandleColumn =
schema.hasChatLastAddressedHandleColumn ? "IFNULL(c.last_addressed_handle, '')" : "''"
}
}
private struct ListChatsQuery {
let sql: String
let bindings: [Binding?]
init(limit: Int, schema: MessageStoreSchema) {
let routing = ChatRoutingSelection(schema: schema)
if schema.hasChatMessageJoinMessageDateColumn {
self.sql = """
SELECT c.ROWID AS chat_rowid, IFNULL(c.display_name, c.chat_identifier) AS name,
c.chat_identifier AS chat_identifier, c.service_name AS service_name,
MAX(cmj.message_date) AS last_date,
\(routing.accountIDColumn) AS account_id,
\(routing.accountLoginColumn) AS account_login,
\(routing.lastAddressedHandleColumn) AS last_addressed_handle
FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
GROUP BY c.ROWID
ORDER BY last_date DESC
LIMIT ?
"""
} else {
self.sql = """
SELECT c.ROWID AS chat_rowid, IFNULL(c.display_name, c.chat_identifier) AS name,
c.chat_identifier AS chat_identifier, c.service_name AS service_name,
MAX(m.date) AS last_date,
\(routing.accountIDColumn) AS account_id,
\(routing.accountLoginColumn) AS account_login,
\(routing.lastAddressedHandleColumn) AS last_addressed_handle
FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
JOIN message m ON m.ROWID = cmj.message_id
GROUP BY c.ROWID
ORDER BY last_date DESC
LIMIT ?
"""
}
self.bindings = [limit]
}
}
private struct ChatInfoQuery {
let sql: String
let bindings: [Binding?]
init(chatID: ChatID, schema: MessageStoreSchema) {
let routing = ChatRoutingSelection(schema: schema)
self.sql = """
SELECT c.ROWID AS chat_rowid, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid,
IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service,
\(routing.accountIDColumn) AS account_id,
\(routing.accountLoginColumn) AS account_login,
\(routing.lastAddressedHandleColumn) AS last_addressed_handle
FROM chat c
WHERE c.ROWID = ?
LIMIT 1
"""
self.bindings = [chatID.rawValue]
}
}
private struct ParticipantsQuery {
let sql = """
SELECT h.id
FROM chat_handle_join chj
JOIN handle h ON h.ROWID = chj.handle_id
WHERE chj.chat_id = ?
ORDER BY h.id ASC
"""
let bindings: [Binding?]
init(chatID: ChatID) {
self.bindings = [chatID.rawValue]
}
}
extension MessageStore {
public func listChats(limit: Int) throws -> [Chat] {
let query = ListChatsQuery(limit: limit, schema: schema)
return try withConnection { db in
var chats: [Chat] = []
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
chats.append(
Chat(
id: try int64Value(row, "chat_rowid") ?? 0,
identifier: try stringValue(row, "chat_identifier"),
name: try stringValue(row, "name"),
service: try stringValue(row, "service_name"),
lastMessageAt: try appleDate(from: int64Value(row, "last_date")),
accountID: try stringValue(row, "account_id").nilIfEmpty,
accountLogin: try stringValue(row, "account_login").nilIfEmpty,
lastAddressedHandle: try stringValue(row, "last_addressed_handle").nilIfEmpty
))
}
return chats
}
}
public func chatInfo(chatID: Int64) throws -> ChatInfo? {
let query = ChatInfoQuery(chatID: ChatID(rawValue: chatID), schema: schema)
return try withConnection { db in
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
return ChatInfo(
id: try int64Value(row, "chat_rowid") ?? 0,
identifier: try stringValue(row, "identifier"),
guid: try stringValue(row, "guid"),
name: try stringValue(row, "name"),
service: try stringValue(row, "service"),
accountID: try stringValue(row, "account_id").nilIfEmpty,
accountLogin: try stringValue(row, "account_login").nilIfEmpty,
lastAddressedHandle: try stringValue(row, "last_addressed_handle").nilIfEmpty
)
}
return nil
}
}
public func participants(chatID: Int64) throws -> [String] {
let query = ParticipantsQuery(chatID: ChatID(rawValue: chatID))
return try withConnection { db in
var results: [String] = []
var seen = Set<String>()
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let handle = try stringValue(row, "id")
if handle.isEmpty { continue }
if seen.insert(handle).inserted {
results.append(handle)
}
}
return results
}
}
}

View File

@ -2,68 +2,57 @@ import Foundation
import SQLite
extension MessageStore {
static func detectAttributedBody(connection: Connection) -> Bool {
struct DecodedReaction: Sendable {
let isReaction: Bool
let reactionType: ReactionType?
let isReactionAdd: Bool?
let reactedToGUID: String?
}
static func tableColumns(connection: Connection, table: String) -> Set<String> {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("attributedBody") == .orderedSame
{
return true
let rows = try connection.prepareRowIterator("PRAGMA table_info(\(table))")
var columns = Set<String>()
while let row = try rows.failableNext() {
if let name = try row.get(Expression<String?>("name")) {
columns.insert(name.lowercased())
}
}
return columns
} catch {
return false
return []
}
return false
}
static func reactionColumnsPresent(in columns: Set<String>) -> Bool {
return columns.contains("guid")
&& columns.contains("associated_message_guid")
&& columns.contains("associated_message_type")
}
static func detectReactionColumns(connection: Connection) -> Bool {
let columns = tableColumns(connection: connection, table: "message")
return reactionColumnsPresent(in: columns)
}
static func detectThreadOriginatorGUIDColumn(connection: Connection) -> Bool {
return tableColumns(connection: connection, table: "message").contains("thread_originator_guid")
}
static func detectAttributedBody(connection: Connection) -> Bool {
return tableColumns(connection: connection, table: "message").contains("attributedbody")
}
static func detectDestinationCallerID(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("destination_caller_id") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "message").contains("destination_caller_id")
}
static func detectAudioMessageColumn(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("is_audio_message") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "message").contains("is_audio_message")
}
static func detectAttachmentUserInfo(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(attachment)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("user_info") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "attachment").contains("user_info")
}
static func enhance(error: Error, path: String) -> Error {
@ -76,6 +65,11 @@ extension MessageStore {
return error
}
static func appleEpoch(_ date: Date) -> Int64 {
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
return Int64(seconds * 1_000_000_000)
}
func appleDate(from value: Int64?) -> Date {
guard let value else { return Date(timeIntervalSince1970: MessageStore.appleEpochOffset) }
return Date(
@ -129,4 +123,38 @@ extension MessageStore {
}
return normalized
}
func decodeReaction(
associatedType: Int?,
associatedGUID: String,
text: String
) -> DecodedReaction {
guard let typeValue = associatedType, ReactionType.isReaction(typeValue) else {
return DecodedReaction(
isReaction: false,
reactionType: nil,
isReactionAdd: nil,
reactedToGUID: nil
)
}
let isAdd = ReactionType.isReactionAdd(typeValue)
let rawType = isAdd ? typeValue : typeValue - 1000
let customEmoji = (rawType == 2006) ? extractCustomEmoji(from: text) : nil
guard let reactionType = ReactionType(rawValue: rawType, customEmoji: customEmoji) else {
return DecodedReaction(
isReaction: true,
reactionType: nil,
isReactionAdd: isAdd,
reactedToGUID: normalizeAssociatedGUID(associatedGUID)
)
}
return DecodedReaction(
isReaction: true,
reactionType: reactionType,
isReactionAdd: isAdd,
reactedToGUID: normalizeAssociatedGUID(associatedGUID)
)
}
}

View File

@ -0,0 +1,233 @@
import Foundation
import SQLite
extension MessageStore {
private static let bulkAttachmentBatchSize = 500
private static let bulkReactionBatchSize = 200
public func attachments(
for messageIDs: [Int64],
options: AttachmentQueryOptions = .default
) throws -> [Int64: [AttachmentMeta]] {
let uniqueIDs = Array(Set(messageIDs)).sorted()
guard !uniqueIDs.isEmpty else { return [:] }
var metasByMessageID: [Int64: [AttachmentMeta]] = [:]
for start in stride(from: 0, to: uniqueIDs.count, by: Self.bulkAttachmentBatchSize) {
let end = min(start + Self.bulkAttachmentBatchSize, uniqueIDs.count)
let batch = Array(uniqueIDs[start..<end])
let placeholders = Array(repeating: "?", count: batch.count).joined(separator: ",")
let sql = """
SELECT maj.message_id AS message_id, a.filename AS filename,
a.transfer_name AS transfer_name, a.uti AS uti, a.mime_type AS mime_type,
a.total_bytes AS total_bytes, a.is_sticker AS is_sticker
FROM message_attachment_join maj
JOIN attachment a ON a.ROWID = maj.attachment_id
WHERE maj.message_id IN (\(placeholders))
ORDER BY maj.message_id ASC
"""
let bindings: [Binding?] = batch.map { $0 }
try withConnection { db in
let rows = try db.prepareRowIterator(sql, bindings: bindings)
while let row = try rows.failableNext() {
let messageID = try int64Value(row, "message_id") ?? 0
let filename = try stringValue(row, "filename")
let transferName = try stringValue(row, "transfer_name")
let uti = try stringValue(row, "uti")
let mimeType = try stringValue(row, "mime_type")
let totalBytes = try int64Value(row, "total_bytes") ?? 0
let isSticker = try boolValue(row, "is_sticker")
metasByMessageID[messageID, default: []].append(
AttachmentResolver.metadata(
filename: filename,
transferName: transferName,
uti: uti,
mimeType: mimeType,
totalBytes: totalBytes,
isSticker: isSticker,
options: options
))
}
}
}
return metasByMessageID
}
public func reactions(for messages: [Message]) throws -> [Int64: [Reaction]] {
guard schema.hasReactionColumns else { return [:] }
var messageIDByGUID: [String: Int64] = [:]
for message in messages where !message.guid.isEmpty {
messageIDByGUID[message.guid] = message.rowID
}
let guids = Array(messageIDByGUID.keys).sorted()
guard !guids.isEmpty else { return [:] }
var reactionsByMessageID: [Int64: [Reaction]] = [:]
var reactionIndexByMessageID: [Int64: [BulkReactionKey: Int]] = [:]
for start in stride(from: 0, to: guids.count, by: Self.bulkReactionBatchSize) {
let end = min(start + Self.bulkReactionBatchSize, guids.count)
let batch = Array(guids[start..<end])
try appendReactions(
matching: batch,
messageIDByGUID: messageIDByGUID,
reactionsByMessageID: &reactionsByMessageID,
reactionIndexByMessageID: &reactionIndexByMessageID
)
}
return reactionsByMessageID
}
private func appendReactions(
matching guids: [String],
messageIDByGUID: [String: Int64],
reactionsByMessageID: inout [Int64: [Reaction]],
reactionIndexByMessageID: inout [Int64: [BulkReactionKey: Int]]
) throws {
let exactPlaceholders = Array(repeating: "?", count: guids.count).joined(separator: ",")
let suffixConditions = Array(
repeating: "r.associated_message_guid LIKE ?",
count: guids.count
).joined(separator: " OR ")
let bodyColumn = schema.hasAttributedBody ? "r.attributedBody" : "NULL"
let sql = """
SELECT r.ROWID AS reaction_rowid, r.associated_message_guid AS associated_message_guid,
r.associated_message_type AS associated_message_type, h.id AS sender,
r.is_from_me AS is_from_me, r.date AS date, IFNULL(r.text, '') AS text,
\(bodyColumn) AS body
FROM message r
LEFT JOIN handle h ON r.handle_id = h.ROWID
WHERE r.associated_message_guid IS NOT NULL
AND r.associated_message_guid != ''
AND r.associated_message_type >= 2000
AND r.associated_message_type <= 3006
AND (
r.associated_message_guid IN (\(exactPlaceholders))
OR \(suffixConditions)
)
ORDER BY r.date ASC
"""
let bindings: [Binding?] = guids.map { $0 } + guids.map { "%/\($0)" }
try withConnection { db in
let rows = try db.prepareRowIterator(sql, bindings: bindings)
while let row = try rows.failableNext() {
let associatedGUID = try stringValue(row, "associated_message_guid")
let baseGUID = baseAssociatedMessageGUID(from: associatedGUID)
guard let messageID = messageIDByGUID[baseGUID] else { continue }
let rowID = try int64Value(row, "reaction_rowid") ?? 0
let typeValue = try intValue(row, "associated_message_type") ?? 0
let sender = try stringValue(row, "sender")
let isFromMe = try boolValue(row, "is_from_me")
let date = try appleDate(from: int64Value(row, "date"))
let text = try stringValue(row, "text")
let body = try dataValue(row, "body")
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
var reactions = reactionsByMessageID[messageID, default: []]
var reactionIndex = reactionIndexByMessageID[messageID] ?? [:]
applyBulkReactionRow(
rowID: rowID,
typeValue: typeValue,
sender: sender,
isFromMe: isFromMe,
date: date,
resolvedText: resolvedText,
messageID: messageID,
reactions: &reactions,
reactionIndex: &reactionIndex
)
reactionsByMessageID[messageID] = reactions
reactionIndexByMessageID[messageID] = reactionIndex
}
}
}
private func baseAssociatedMessageGUID(from associatedGUID: String) -> String {
guard let slashIndex = associatedGUID.lastIndex(of: "/") else { return associatedGUID }
let guidStart = associatedGUID.index(after: slashIndex)
return String(associatedGUID[guidStart...])
}
private func applyBulkReactionRow(
rowID: Int64,
typeValue: Int,
sender: String,
isFromMe: Bool,
date: Date,
resolvedText: String,
messageID: Int64,
reactions: inout [Reaction],
reactionIndex: inout [BulkReactionKey: Int]
) {
if ReactionType.isReactionRemove(typeValue) {
let customEmoji = typeValue == 3006 ? extractCustomEmoji(from: resolvedText) : nil
let reactionType = ReactionType.fromRemoval(typeValue, customEmoji: customEmoji)
if let reactionType {
let key = BulkReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
if let index = reactionIndex.removeValue(forKey: key) {
reactions.remove(at: index)
reactionIndex = BulkReactionKey.reindex(reactions: reactions)
}
return
}
if typeValue == 3006 {
if let index = reactions.firstIndex(where: {
$0.sender == sender && $0.isFromMe == isFromMe && $0.reactionType.isCustom
}) {
reactions.remove(at: index)
reactionIndex = BulkReactionKey.reindex(reactions: reactions)
}
}
return
}
let customEmoji = typeValue == 2006 ? extractCustomEmoji(from: resolvedText) : nil
guard let reactionType = ReactionType(rawValue: typeValue, customEmoji: customEmoji) else {
return
}
let key = BulkReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
if let index = reactionIndex[key] {
reactions[index] = Reaction(
rowID: rowID,
reactionType: reactionType,
sender: sender,
isFromMe: isFromMe,
date: date,
associatedMessageID: messageID
)
} else {
reactionIndex[key] = reactions.count
reactions.append(
Reaction(
rowID: rowID,
reactionType: reactionType,
sender: sender,
isFromMe: isFromMe,
date: date,
associatedMessageID: messageID
))
}
}
private struct BulkReactionKey: Hashable {
let sender: String
let isFromMe: Bool
let reactionType: ReactionType
static func reindex(reactions: [Reaction]) -> [BulkReactionKey: Int] {
var index: [BulkReactionKey: Int] = [:]
for (offset, reaction) in reactions.enumerated() {
let key = BulkReactionKey(
sender: reaction.sender,
isFromMe: reaction.isFromMe,
reactionType: reaction.reactionType
)
index[key] = offset
}
return index
}
}
}

View File

@ -1,72 +1,286 @@
import Foundation
import SQLite
extension MessageStore {
public func messages(chatID: Int64, limit: Int) throws -> [Message] {
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
let guidColumn = hasReactionColumns ? "m.guid" : "NULL"
let associatedGuidColumn = hasReactionColumns ? "m.associated_message_guid" : "NULL"
let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL"
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0"
struct MessageRowColumns {
static let balloonBundleID = "balloon_bundle_id"
let rowID: String
let chatID: String?
let handleID: String
let sender: String
let text: String
let date: String
let isFromMe: String
let service: String
let isAudioMessage: String
let destinationCallerID: String
let guid: String
let associatedGUID: String
let associatedType: String
let attachments: String
let body: String
let threadOriginatorGUID: String
static func message(chatID: String?) -> MessageRowColumns {
MessageRowColumns(
rowID: "message_rowid",
chatID: chatID,
handleID: "handle_id",
sender: "sender",
text: "text",
date: "date",
isFromMe: "is_from_me",
service: "service",
isAudioMessage: "is_audio_message",
destinationCallerID: "destination_caller_id",
guid: "guid",
associatedGUID: "associated_guid",
associatedType: "associated_type",
attachments: "attachments",
body: "body",
threadOriginatorGUID: "thread_originator_guid"
)
}
}
struct DecodedMessageRow {
let rowID: Int64
let chatID: Int64
let handleID: Int64?
let sender: String
let text: String
let date: Date
let isFromMe: Bool
let service: String
let destinationCallerID: String
let guid: String
let associatedGUID: String
let associatedType: Int?
let attachments: Int
let threadOriginatorGUID: String
}
struct MessageRowSelection {
let selectList: String
let columns: MessageRowColumns
init(store: MessageStore, includeChatID: Bool, includeBalloonBundleID: Bool = false) {
let columns = MessageRowColumns.message(chatID: includeChatID ? "chat_id" : nil)
let schema = store.schema
let bodyColumn = schema.hasAttributedBody ? "m.attributedBody" : "NULL"
let guidColumn = schema.hasReactionColumns ? "m.guid" : "NULL"
let associatedGuidColumn = schema.hasReactionColumns ? "m.associated_message_guid" : "NULL"
let associatedTypeColumn = schema.hasReactionColumns ? "m.associated_message_type" : "NULL"
let destinationCallerColumn =
schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
let audioMessageColumn = schema.hasAudioMessageColumn ? "m.is_audio_message" : "0"
let threadOriginatorColumn =
schema.hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL"
let chatColumn = includeChatID ? ", cmj.chat_id AS \(columns.chatID!)" : ""
var selectList = """
m.ROWID AS \(columns.rowID)\(chatColumn), m.handle_id AS \(columns.handleID),
h.id AS \(columns.sender), IFNULL(m.text, '') AS \(columns.text),
m.date AS \(columns.date), m.is_from_me AS \(columns.isFromMe),
m.service AS \(columns.service),
\(audioMessageColumn) AS \(columns.isAudioMessage),
\(destinationCallerColumn) AS \(columns.destinationCallerID),
\(guidColumn) AS \(columns.guid), \(associatedGuidColumn) AS \(columns.associatedGUID),
\(associatedTypeColumn) AS \(columns.associatedType),
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS \(columns.attachments),
\(bodyColumn) AS \(columns.body),
\(threadOriginatorColumn) AS \(columns.threadOriginatorGUID)
"""
if includeBalloonBundleID {
let balloonColumn = schema.hasBalloonBundleIDColumn ? "m.balloon_bundle_id" : "NULL"
selectList += ",\n \(balloonColumn) AS \(MessageRowColumns.balloonBundleID)"
}
self.selectList = selectList
self.columns = columns
}
}
private struct ChatMessagesQuery {
let sql: String
let bindings: [Binding?]
let selection: MessageRowSelection
let fallbackChatID: Int64
init(store: MessageStore, chatID: ChatID, limit: Int, filter: MessageFilter?) {
self.selection = MessageRowSelection(store: store, includeChatID: false)
let destinationCallerColumn =
store.schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
let reactionFilter =
hasReactionColumns
store.schema.hasReactionColumns
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
: ""
let sql = """
SELECT m.ROWID, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
\(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id,
\(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
\(bodyColumn) AS body
var sql = """
SELECT \(selection.selectList)
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE cmj.chat_id = ?\(reactionFilter)
ORDER BY m.date DESC
LIMIT ?
"""
var bindings: [Binding?] = [chatID.rawValue]
if let filter {
if let startDate = filter.startDate {
sql += " AND m.date >= ?"
bindings.append(MessageStore.appleEpoch(startDate))
}
if let endDate = filter.endDate {
sql += " AND m.date < ?"
bindings.append(MessageStore.appleEpoch(endDate))
}
if !filter.participants.isEmpty {
let placeholders = Array(repeating: "?", count: filter.participants.count).joined(
separator: ",")
sql +=
" AND COALESCE(NULLIF(h.id,''), \(destinationCallerColumn)) COLLATE NOCASE IN (\(placeholders))"
for participant in filter.participants {
bindings.append(participant)
}
}
}
sql += " ORDER BY m.date DESC LIMIT ?"
bindings.append(limit)
self.sql = sql
self.bindings = bindings
self.fallbackChatID = chatID.rawValue
}
}
private struct MessagesAfterQuery {
let sql: String
let bindings: [Binding?]
let selection: MessageRowSelection
let fallbackChatID: Int64?
init(
store: MessageStore,
afterRowID: MessageID,
chatID: ChatID?,
limit: Int,
includeReactions: Bool
) {
self.selection = MessageRowSelection(
store: store,
includeChatID: true,
includeBalloonBundleID: true
)
let reactionFilter: String
if includeReactions || !store.schema.hasReactionColumns {
reactionFilter = ""
} else {
reactionFilter =
" AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
}
var sql = """
SELECT \(selection.selectList)
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.ROWID > ?\(reactionFilter)
"""
var bindings: [Binding?] = [afterRowID.rawValue]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID.rawValue)
}
sql += " ORDER BY m.ROWID ASC LIMIT ?"
bindings.append(limit)
self.sql = sql
self.bindings = bindings
self.fallbackChatID = chatID?.rawValue
}
}
private struct LatestSentMessageQuery {
let sql: String
let bindings: [Binding?]
let selection: MessageRowSelection
let fallbackChatID: Int64?
init(store: MessageStore, text: String, chatID: ChatID?, since date: Date) {
self.selection = MessageRowSelection(store: store, includeChatID: true)
var sql = """
SELECT \(selection.selectList)
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.is_from_me = 1
AND IFNULL(m.text, '') = ?
AND m.date >= ?
"""
var bindings: [Binding?] = [text, MessageStore.appleEpoch(date)]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID.rawValue)
}
sql += " ORDER BY m.date DESC, m.ROWID DESC LIMIT 1"
self.sql = sql
self.bindings = bindings
self.fallbackChatID = chatID?.rawValue
}
}
extension MessageStore {
public func maxRowID() throws -> Int64 {
return try withConnection { db in
let value = try db.scalar("SELECT MAX(ROWID) FROM message")
return int64Value(value) ?? 0
}
}
public func messages(chatID: Int64, limit: Int) throws -> [Message] {
return try messages(chatID: chatID, limit: limit, filter: nil)
}
public func messages(chatID: Int64, limit: Int, filter: MessageFilter?) throws -> [Message] {
let query = ChatMessagesQuery(
store: self,
chatID: ChatID(rawValue: chatID),
limit: limit,
filter: filter
)
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, chatID, limit) {
let rowID = int64Value(row[0]) ?? 0
let handleID = int64Value(row[1])
var sender = stringValue(row[2])
let text = stringValue(row[3])
let date = appleDate(from: int64Value(row[4]))
let isFromMe = boolValue(row[5])
let service = stringValue(row[6])
let isAudioMessage = boolValue(row[7])
let destinationCallerID = stringValue(row[8])
if sender.isEmpty && !destinationCallerID.isEmpty {
sender = destinationCallerID
}
let guid = stringValue(row[9])
let associatedGuid = stringValue(row[10])
let associatedType = intValue(row[11])
let attachments = intValue(row[12]) ?? 0
let body = dataValue(row[13])
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
resolvedText = transcription
}
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let decoded = try decodeMessageRow(
row,
columns: query.selection.columns,
fallbackChatID: query.fallbackChatID
)
let replyToGUID = replyToGUID(
associatedGuid: associatedGuid,
associatedType: associatedType
associatedGuid: decoded.associatedGUID,
associatedType: decoded.associatedType
)
messages.append(
Message(
rowID: rowID,
chatID: chatID,
sender: sender,
text: resolvedText,
date: date,
isFromMe: isFromMe,
service: service,
handleID: handleID,
attachmentsCount: attachments,
guid: guid,
replyToGUID: replyToGUID
rowID: decoded.rowID,
chatID: decoded.chatID,
sender: decoded.sender,
text: decoded.text,
date: decoded.date,
isFromMe: decoded.isFromMe,
service: decoded.service,
handleID: decoded.handleID,
attachmentsCount: decoded.attachments,
guid: decoded.guid,
routing: Message.RoutingMetadata(
replyToGUID: replyToGUID,
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID
)
))
}
return messages
@ -74,80 +288,188 @@ extension MessageStore {
}
public func messagesAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [Message] {
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
let guidColumn = hasReactionColumns ? "m.guid" : "NULL"
let associatedGuidColumn = hasReactionColumns ? "m.associated_message_guid" : "NULL"
let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL"
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0"
let reactionFilter =
hasReactionColumns
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
: ""
var sql = """
SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
\(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id,
\(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
\(bodyColumn) AS body
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.ROWID > ?\(reactionFilter)
"""
var bindings: [Binding?] = [afterRowID]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID)
}
sql += " ORDER BY m.ROWID ASC LIMIT ?"
bindings.append(limit)
return try messagesAfter(
afterRowID: afterRowID,
chatID: chatID,
limit: limit,
includeReactions: false
)
}
public func messagesAfter(
afterRowID: Int64,
chatID: Int64?,
limit: Int,
includeReactions: Bool
) throws -> [Message] {
let query = MessagesAfterQuery(
store: self,
afterRowID: MessageID(rawValue: afterRowID),
chatID: chatID.map { ChatID(rawValue: $0) },
limit: limit,
includeReactions: includeReactions
)
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, bindings) {
let rowID = int64Value(row[0]) ?? 0
let resolvedChatID = int64Value(row[1]) ?? chatID ?? 0
let handleID = int64Value(row[2])
var sender = stringValue(row[3])
let text = stringValue(row[4])
let date = appleDate(from: int64Value(row[5]))
let isFromMe = boolValue(row[6])
let service = stringValue(row[7])
let isAudioMessage = boolValue(row[8])
let destinationCallerID = stringValue(row[9])
if sender.isEmpty && !destinationCallerID.isEmpty {
sender = destinationCallerID
}
let guid = stringValue(row[10])
let associatedGuid = stringValue(row[11])
let associatedType = intValue(row[12])
let attachments = intValue(row[13]) ?? 0
let body = dataValue(row[14])
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
resolvedText = transcription
}
let replyToGUID = replyToGUID(
associatedGuid: associatedGuid,
associatedType: associatedType
let urlBalloonProvider = "com.apple.messages.URLBalloonProvider"
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let decoded = try decodeMessageRow(
row,
columns: query.selection.columns,
fallbackChatID: query.fallbackChatID
)
let balloonBundleID = try stringValue(row, MessageRowColumns.balloonBundleID)
if balloonBundleID == urlBalloonProvider,
shouldSkipURLBalloonDuplicate(
chatID: decoded.chatID,
sender: decoded.sender,
text: decoded.text,
isFromMe: decoded.isFromMe,
date: decoded.date,
rowID: decoded.rowID
)
{
continue
}
let replyToGUID = replyToGUID(
associatedGuid: decoded.associatedGUID,
associatedType: decoded.associatedType
)
let reaction = decodeReaction(
associatedType: decoded.associatedType,
associatedGUID: decoded.associatedGUID,
text: decoded.text
)
messages.append(
Message(
rowID: rowID,
chatID: resolvedChatID,
sender: sender,
text: resolvedText,
date: date,
isFromMe: isFromMe,
service: service,
handleID: handleID,
attachmentsCount: attachments,
guid: guid,
replyToGUID: replyToGUID
rowID: decoded.rowID,
chatID: decoded.chatID,
sender: decoded.sender,
text: decoded.text,
date: decoded.date,
isFromMe: decoded.isFromMe,
service: decoded.service,
handleID: decoded.handleID,
attachmentsCount: decoded.attachments,
guid: decoded.guid,
routing: Message.RoutingMetadata(
replyToGUID: replyToGUID,
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID
),
reaction: Message.ReactionMetadata(
isReaction: reaction.isReaction,
reactionType: reaction.reactionType,
isReactionAdd: reaction.isReactionAdd,
reactedToGUID: reaction.reactedToGUID
)
))
}
return messages
}
}
public func latestSentMessage(matchingText text: String, chatID: Int64?, since date: Date)
throws -> Message?
{
guard !text.isEmpty else { return nil }
let query = LatestSentMessageQuery(
store: self,
text: text,
chatID: chatID.map { ChatID(rawValue: $0) },
since: date
)
return try withConnection { db in
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
guard let row = try rows.failableNext() else { return nil }
let decoded = try decodeMessageRow(
row,
columns: query.selection.columns,
fallbackChatID: query.fallbackChatID
)
let replyToGUID = replyToGUID(
associatedGuid: decoded.associatedGUID,
associatedType: decoded.associatedType
)
return Message(
rowID: decoded.rowID,
chatID: decoded.chatID,
sender: decoded.sender,
text: decoded.text,
date: decoded.date,
isFromMe: decoded.isFromMe,
service: decoded.service,
handleID: decoded.handleID,
attachmentsCount: decoded.attachments,
guid: decoded.guid,
routing: Message.RoutingMetadata(
replyToGUID: replyToGUID,
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID
)
)
}
}
func decodeMessageRow(
_ row: Row,
columns: MessageRowColumns,
fallbackChatID: Int64?
) throws -> DecodedMessageRow {
let rowID = try int64Value(row, columns.rowID) ?? 0
let resolvedChatID =
try columns.chatID.flatMap { try int64Value(row, $0) } ?? fallbackChatID ?? 0
let handleID = try int64Value(row, columns.handleID)
let sender = try stringValue(row, columns.sender)
let text = try stringValue(row, columns.text)
let date = try appleDate(from: int64Value(row, columns.date))
let isFromMe = try boolValue(row, columns.isFromMe)
let service = try stringValue(row, columns.service)
let isAudioMessage = try boolValue(row, columns.isAudioMessage)
let destinationCallerID = try stringValue(row, columns.destinationCallerID)
let guid = try stringValue(row, columns.guid)
let associatedGUID = try stringValue(row, columns.associatedGUID)
let associatedType = try intValue(row, columns.associatedType)
let attachments = try intValue(row, columns.attachments) ?? 0
let body = try dataValue(row, columns.body)
let threadOriginatorGUID = try stringValue(row, columns.threadOriginatorGUID)
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
resolvedText = transcription
}
var resolvedSender = sender
if resolvedSender.isEmpty && !destinationCallerID.isEmpty {
resolvedSender = destinationCallerID
}
return DecodedMessageRow(
rowID: rowID,
chatID: resolvedChatID,
handleID: handleID,
sender: resolvedSender,
text: resolvedText,
date: date,
isFromMe: isFromMe,
service: service,
destinationCallerID: destinationCallerID,
guid: guid,
associatedGUID: associatedGUID,
associatedType: associatedType,
attachments: attachments,
threadOriginatorGUID: threadOriginatorGUID
)
}
}

View File

@ -0,0 +1,140 @@
import Foundation
import SQLite
/// A reaction event represents when someone adds or removes a reaction to a message.
/// Unlike `Reaction` which represents the current state, this captures the event itself.
public struct ReactionEvent: Sendable, Equatable {
/// The ROWID of the reaction message in the database
public let rowID: Int64
/// The chat ID where the reaction occurred
public let chatID: Int64
/// The type of reaction
public let reactionType: ReactionType
/// Whether this is adding (true) or removing (false) a reaction
public let isAdd: Bool
/// The sender of the reaction (phone number or email)
public let sender: String
/// Whether the reaction was sent by the current user
public let isFromMe: Bool
/// When the reaction event occurred
public let date: Date
/// The GUID of the message being reacted to
public let reactedToGUID: String
/// The ROWID of the message being reacted to (if available)
public let reactedToID: Int64?
/// The original text of the reaction message (e.g., "Liked \"hello\"")
public let text: String
public init(
rowID: Int64,
chatID: Int64,
reactionType: ReactionType,
isAdd: Bool,
sender: String,
isFromMe: Bool,
date: Date,
reactedToGUID: String,
reactedToID: Int64?,
text: String
) {
self.rowID = rowID
self.chatID = chatID
self.reactionType = reactionType
self.isAdd = isAdd
self.sender = sender
self.isFromMe = isFromMe
self.date = date
self.reactedToGUID = reactedToGUID
self.reactedToID = reactedToID
self.text = text
}
}
extension MessageStore {
/// Fetch reaction events (add/remove) after a given rowID.
/// These are the reaction messages themselves, useful for streaming reaction events in watch mode.
public func reactionEventsAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws
-> [ReactionEvent]
{
guard schema.hasReactionColumns else { return [] }
let bodyColumn = schema.hasAttributedBody ? "m.attributedBody" : "NULL"
let destinationCallerColumn =
schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
var sql = """
SELECT m.ROWID AS reaction_rowid, cmj.chat_id AS chat_id,
m.associated_message_type AS associated_message_type,
m.associated_message_guid AS associated_message_guid,
m.handle_id AS handle_id, h.id AS sender, m.is_from_me AS is_from_me,
m.date AS date, IFNULL(m.text, '') AS text,
\(destinationCallerColumn) AS destination_caller_id,
\(bodyColumn) AS body,
orig.ROWID AS orig_rowid
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
LEFT JOIN message orig ON (orig.guid = m.associated_message_guid
OR m.associated_message_guid LIKE '%/' || orig.guid)
WHERE m.ROWID > ?
AND m.associated_message_type >= 2000
AND m.associated_message_type <= 3006
"""
var bindings: [Binding?] = [afterRowID]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID)
}
sql += " ORDER BY m.ROWID ASC LIMIT ?"
bindings.append(limit)
return try withConnection { db in
var events: [ReactionEvent] = []
let rows = try db.prepareRowIterator(sql, bindings: bindings)
while let row = try rows.failableNext() {
let rowID = try int64Value(row, "reaction_rowid") ?? 0
let resolvedChatID = try int64Value(row, "chat_id") ?? chatID ?? 0
let typeValue = try intValue(row, "associated_message_type") ?? 0
let associatedGUID = try stringValue(row, "associated_message_guid")
var sender = try stringValue(row, "sender")
let isFromMe = try boolValue(row, "is_from_me")
let date = try appleDate(from: int64Value(row, "date"))
let text = try stringValue(row, "text")
let destinationCallerID = try stringValue(row, "destination_caller_id")
let body = try dataValue(row, "body")
let origRowID = try int64Value(row, "orig_rowid")
if sender.isEmpty && !destinationCallerID.isEmpty {
sender = destinationCallerID
}
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
let decoded = decodeReaction(
associatedType: typeValue,
associatedGUID: associatedGUID,
text: resolvedText
)
guard let reactionType = decoded.reactionType, let isAdd = decoded.isReactionAdd else {
continue
}
events.append(
ReactionEvent(
rowID: rowID,
chatID: resolvedChatID,
reactionType: reactionType,
isAdd: isAdd,
sender: sender,
isFromMe: isFromMe,
date: date,
reactedToGUID: decoded.reactedToGUID ?? "",
reactedToID: origRowID,
text: resolvedText
))
}
return events
}
}
}

View File

@ -0,0 +1,145 @@
import Foundation
import SQLite
private struct CurrentReactionsQuery {
let sql: String
let bindings: [Binding?]
init(messageID: MessageID, schema: MessageStoreSchema) {
let bodyColumn = schema.hasAttributedBody ? "r.attributedBody" : "NULL"
self.sql = """
SELECT r.ROWID AS reaction_rowid, r.associated_message_type AS associated_message_type,
h.id AS sender, r.is_from_me AS is_from_me, r.date AS date, IFNULL(r.text, '') AS text,
\(bodyColumn) AS body
FROM message m
JOIN message r ON r.associated_message_guid = m.guid
OR r.associated_message_guid LIKE '%/' || m.guid
LEFT JOIN handle h ON r.handle_id = h.ROWID
WHERE m.ROWID = ?
AND m.guid IS NOT NULL
AND m.guid != ''
AND r.associated_message_type >= 2000
AND r.associated_message_type <= 3006
ORDER BY r.date ASC
"""
self.bindings = [messageID.rawValue]
}
}
extension MessageStore {
public func reactions(for messageID: Int64) throws -> [Reaction] {
guard schema.hasReactionColumns else { return [] }
let query = CurrentReactionsQuery(
messageID: MessageID(rawValue: messageID),
schema: schema
)
return try withConnection { db in
var reactions: [Reaction] = []
var reactionIndex: [ReactionKey: Int] = [:]
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let rowID = try int64Value(row, "reaction_rowid") ?? 0
let typeValue = try intValue(row, "associated_message_type") ?? 0
let sender = try stringValue(row, "sender")
let isFromMe = try boolValue(row, "is_from_me")
let date = try appleDate(from: int64Value(row, "date"))
let text = try stringValue(row, "text")
let body = try dataValue(row, "body")
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if ReactionType.isReactionRemove(typeValue) {
let customEmoji = typeValue == 3006 ? extractCustomEmoji(from: resolvedText) : nil
let reactionType = ReactionType.fromRemoval(typeValue, customEmoji: customEmoji)
if let reactionType {
let key = ReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
if let index = reactionIndex.removeValue(forKey: key) {
reactions.remove(at: index)
reactionIndex = ReactionKey.reindex(reactions: reactions)
}
continue
}
if typeValue == 3006 {
if let index = reactions.firstIndex(where: {
$0.sender == sender && $0.isFromMe == isFromMe && $0.reactionType.isCustom
}) {
reactions.remove(at: index)
reactionIndex = ReactionKey.reindex(reactions: reactions)
}
}
continue
}
let customEmoji: String? = typeValue == 2006 ? extractCustomEmoji(from: resolvedText) : nil
guard let reactionType = ReactionType(rawValue: typeValue, customEmoji: customEmoji) else {
continue
}
let key = ReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
if let index = reactionIndex[key] {
reactions[index] = Reaction(
rowID: rowID,
reactionType: reactionType,
sender: sender,
isFromMe: isFromMe,
date: date,
associatedMessageID: messageID
)
} else {
reactionIndex[key] = reactions.count
reactions.append(
Reaction(
rowID: rowID,
reactionType: reactionType,
sender: sender,
isFromMe: isFromMe,
date: date,
associatedMessageID: messageID
))
}
}
return reactions
}
}
/// Extract custom emoji from reaction message text like "Reacted 🎉 to "original message""
func extractCustomEmoji(from text: String) -> String? {
guard
let reactedRange = text.range(of: "Reacted "),
let toRange = text.range(of: " to ", range: reactedRange.upperBound..<text.endIndex)
else {
return extractFirstEmoji(from: text)
}
let emoji = String(text[reactedRange.upperBound..<toRange.lowerBound])
return emoji.isEmpty ? extractFirstEmoji(from: text) : emoji
}
private func extractFirstEmoji(from text: String) -> String? {
for character in text {
if character.unicodeScalars.contains(where: {
$0.properties.isEmojiPresentation || $0.properties.isEmoji
}) {
return String(character)
}
}
return nil
}
private struct ReactionKey: Hashable {
let sender: String
let isFromMe: Bool
let reactionType: ReactionType
static func reindex(reactions: [Reaction]) -> [ReactionKey: Int] {
var index: [ReactionKey: Int] = [:]
for (offset, reaction) in reactions.enumerated() {
let key = ReactionKey(
sender: reaction.sender,
isFromMe: reaction.isFromMe,
reactionType: reaction.reactionType
)
index[key] = offset
}
return index
}
}
}

View File

@ -0,0 +1,26 @@
import Foundation
import SQLite
extension MessageStore {
func stringValue(_ row: Row, _ column: String) throws -> String {
try row.get(Expression<String?>(column)) ?? ""
}
func int64Value(_ row: Row, _ column: String) throws -> Int64? {
try row.get(Expression<Int64?>(column))
}
func intValue(_ row: Row, _ column: String) throws -> Int? {
guard let value = try int64Value(row, column) else { return nil }
return Int(value)
}
func boolValue(_ row: Row, _ column: String) throws -> Bool {
try row.get(Expression<Bool?>(column)) ?? false
}
func dataValue(_ row: Row, _ column: String) throws -> Data {
guard let blob = try row.get(Expression<Blob?>(column)) else { return Data() }
return Data(blob.bytes)
}
}

View File

@ -0,0 +1,94 @@
import Foundation
import SQLite
private struct SearchMessagesQuery {
let sql: String
let bindings: [Binding?]
let selection: MessageRowSelection
let fallbackChatID: Int64? = nil
init(store: MessageStore, text: String, exact: Bool, limit: Int) {
self.selection = MessageRowSelection(store: store, includeChatID: true)
let reactionFilter =
store.schema.hasReactionColumns
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
: ""
let predicate =
exact
? "IFNULL(m.text, '') = ? COLLATE NOCASE"
: "IFNULL(m.text, '') LIKE ? ESCAPE '\\' COLLATE NOCASE"
let textBinding = exact ? text : SearchMessagesQuery.likePattern(for: text)
self.sql = """
SELECT \(selection.selectList)
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE \(predicate)\(reactionFilter)
ORDER BY m.date DESC, m.ROWID DESC
LIMIT ?
"""
self.bindings = [textBinding, limit]
}
private static func likePattern(for text: String) -> String {
var escaped = ""
for char in text {
if char == "\\" || char == "%" || char == "_" {
escaped.append("\\")
}
escaped.append(char)
}
return "%\(escaped)%"
}
}
extension MessageStore {
public func searchMessages(query text: String, match: String, limit: Int) throws -> [Message] {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
let exact = match.lowercased() == "exact"
let query = SearchMessagesQuery(
store: self,
text: trimmed,
exact: exact,
limit: limit
)
return try withConnection { db in
var messages: [Message] = []
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let decoded = try decodeMessageRow(
row,
columns: query.selection.columns,
fallbackChatID: query.fallbackChatID
)
let replyToGUID = replyToGUID(
associatedGuid: decoded.associatedGUID,
associatedType: decoded.associatedType
)
messages.append(
Message(
rowID: decoded.rowID,
chatID: decoded.chatID,
sender: decoded.sender,
text: decoded.text,
date: decoded.date,
isFromMe: decoded.isFromMe,
service: decoded.service,
handleID: decoded.handleID,
attachmentsCount: decoded.attachments,
guid: decoded.guid,
routing: Message.RoutingMetadata(
replyToGUID: replyToGUID,
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID
)
))
}
return messages
}
}
}

View File

@ -0,0 +1,123 @@
import Foundation
import SQLite
extension MessageStore {
public func chatInfo(matchingTarget target: String) throws -> ChatInfo? {
let candidates = Self.chatTargetCandidates(target)
guard !candidates.isEmpty else { return nil }
let placeholders = Array(repeating: "?", count: candidates.count).joined(separator: ",")
let accountIDColumn = schema.hasChatAccountIDColumn ? "IFNULL(c.account_id, '')" : "''"
let accountLoginColumn = schema.hasChatAccountLoginColumn ? "IFNULL(c.account_login, '')" : "''"
let lastAddressedHandleColumn =
schema.hasChatLastAddressedHandleColumn ? "IFNULL(c.last_addressed_handle, '')" : "''"
let sql = """
SELECT c.ROWID AS chat_rowid, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid,
IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service,
\(accountIDColumn) AS account_id,
\(accountLoginColumn) AS account_login,
\(lastAddressedHandleColumn) AS last_addressed_handle
FROM chat c
WHERE c.chat_identifier IN (\(placeholders))
OR c.guid IN (\(placeholders))
LIMIT 1
"""
let bindings: [Binding?] = candidates + candidates
return try withConnection { db in
let rows = try db.prepareRowIterator(sql, bindings: bindings)
guard let row = try rows.failableNext() else { return nil }
return ChatInfo(
id: try int64Value(row, "chat_rowid") ?? 0,
identifier: try stringValue(row, "identifier"),
guid: try stringValue(row, "guid"),
name: try stringValue(row, "name"),
service: try stringValue(row, "service"),
accountID: try stringValue(row, "account_id").nilIfEmpty,
accountLogin: try stringValue(row, "account_login").nilIfEmpty,
lastAddressedHandle: try stringValue(row, "last_addressed_handle").nilIfEmpty
)
}
}
public func latestUnjoinedSentMessageRowID(
matchingTargetHandles handles: [String],
since date: Date
) throws -> Int64? {
let candidates = Self.chatTargetHandleCandidates(handles)
guard !candidates.isEmpty else { return nil }
let placeholders = Array(repeating: "?", count: candidates.count).joined(separator: ",")
let sql = """
SELECT m.ROWID AS message_rowid
FROM message m
LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
LEFT JOIN handle h ON h.ROWID = m.handle_id
WHERE m.is_from_me = 1
AND m.date >= ?
AND IFNULL(m.text, '') = ''
AND cmj.message_id IS NULL
AND IFNULL(h.id, '') IN (\(placeholders))
ORDER BY m.date DESC, m.ROWID DESC
LIMIT 1
"""
let bindings: [Binding?] = [MessageStore.appleEpoch(date)] + candidates
return try withConnection { db in
let rows = try db.prepareRowIterator(sql, bindings: bindings)
guard let row = try rows.failableNext() else { return nil }
return try int64Value(row, "message_rowid")
}
}
private static func chatTargetHandleCandidates(_ handles: [String]) -> [String] {
var candidates: [String] = []
for handle in handles {
candidates.append(contentsOf: chatTargetCandidates(handle))
}
return dedupe(candidates)
}
private static func chatTargetCandidates(_ target: String) -> [String] {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
var candidates = [trimmed]
if let toggled = toggledAnyGroupPolarity(trimmed) {
candidates.append(toggled)
}
if let bare = bareAnyGroupIdentifier(trimmed) {
candidates.append(bare)
}
return dedupe(candidates)
}
private static func toggledAnyGroupPolarity(_ value: String) -> String? {
if value.hasPrefix("any;+;") {
return "any;-;" + value.dropFirst("any;+;".count)
}
if value.hasPrefix("any;-;") {
return "any;+;" + value.dropFirst("any;-;".count)
}
return nil
}
private static func bareAnyGroupIdentifier(_ value: String) -> String? {
if value.hasPrefix("any;+;") {
return String(value.dropFirst("any;+;".count))
}
if value.hasPrefix("any;-;") {
return String(value.dropFirst("any;-;".count))
}
return nil
}
private static func dedupe(_ values: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for value in values where !value.isEmpty {
if seen.insert(value).inserted {
result.append(value)
}
}
return result
}
}

View File

@ -14,11 +14,17 @@ public final class MessageStore: @unchecked Sendable {
private let connection: Connection
private let queue: DispatchQueue
private let queueKey = DispatchSpecificKey<Void>()
let hasAttributedBody: Bool
let hasReactionColumns: Bool
let hasDestinationCallerID: Bool
let hasAudioMessageColumn: Bool
let hasAttachmentUserInfo: Bool
let schema: MessageStoreSchema
private struct URLBalloonDedupeEntry: Sendable {
let rowID: Int64
let date: Date
}
private static let urlBalloonDedupeWindow: TimeInterval = 90
private static let urlBalloonDedupeRetention: TimeInterval = 10 * 60
private var urlBalloonDedupe: [String: URLBalloonDedupeEntry] = [:]
public init(path: String = MessageStore.defaultPath) throws {
let normalized = NSString(string: path).expandingTildeInPath
@ -30,17 +36,7 @@ public final class MessageStore: @unchecked Sendable {
let location = Connection.Location.uri(uri, parameters: [.mode(.readOnly)])
self.connection = try Connection(location, readonly: true)
self.connection.busyTimeout = 5
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: self.connection)
self.hasReactionColumns = MessageStore.detectReactionColumns(connection: self.connection)
self.hasDestinationCallerID = MessageStore.detectDestinationCallerID(
connection: self.connection
)
self.hasAudioMessageColumn = MessageStore.detectAudioMessageColumn(
connection: self.connection
)
self.hasAttachmentUserInfo = MessageStore.detectAttachmentUserInfo(
connection: self.connection
)
self.schema = MessageStoreSchema(connection: self.connection)
} catch {
throw MessageStore.enhance(error: error, path: normalized)
}
@ -51,116 +47,35 @@ public final class MessageStore: @unchecked Sendable {
path: String,
hasAttributedBody: Bool? = nil,
hasReactionColumns: Bool? = nil,
hasThreadOriginatorGUIDColumn: Bool? = nil,
hasDestinationCallerID: Bool? = nil,
hasAudioMessageColumn: Bool? = nil,
hasAttachmentUserInfo: Bool? = nil
hasAttachmentUserInfo: Bool? = nil,
hasBalloonBundleIDColumn: Bool? = nil,
hasChatMessageJoinMessageDateColumn: Bool? = nil,
hasChatAccountIDColumn: Bool? = nil,
hasChatAccountLoginColumn: Bool? = nil,
hasChatLastAddressedHandleColumn: Bool? = nil
) throws {
self.path = path
self.queue = DispatchQueue(label: "imsg.db.test", qos: .userInitiated)
self.queue.setSpecific(key: queueKey, value: ())
self.connection = connection
self.connection.busyTimeout = 5
if let hasAttributedBody {
self.hasAttributedBody = hasAttributedBody
} else {
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: connection)
}
if let hasReactionColumns {
self.hasReactionColumns = hasReactionColumns
} else {
self.hasReactionColumns = MessageStore.detectReactionColumns(connection: connection)
}
if let hasDestinationCallerID {
self.hasDestinationCallerID = hasDestinationCallerID
} else {
self.hasDestinationCallerID = MessageStore.detectDestinationCallerID(connection: connection)
}
if let hasAudioMessageColumn {
self.hasAudioMessageColumn = hasAudioMessageColumn
} else {
self.hasAudioMessageColumn = MessageStore.detectAudioMessageColumn(connection: connection)
}
if let hasAttachmentUserInfo {
self.hasAttachmentUserInfo = hasAttachmentUserInfo
} else {
self.hasAttachmentUserInfo = MessageStore.detectAttachmentUserInfo(connection: connection)
}
}
public func listChats(limit: Int) throws -> [Chat] {
let sql = """
SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, c.service_name,
MAX(m.date) AS last_date
FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
JOIN message m ON m.ROWID = cmj.message_id
GROUP BY c.ROWID
ORDER BY last_date DESC
LIMIT ?
"""
return try withConnection { db in
var chats: [Chat] = []
for row in try db.prepare(sql, limit) {
let id = int64Value(row[0]) ?? 0
let name = stringValue(row[1])
let identifier = stringValue(row[2])
let service = stringValue(row[3])
let lastDate = appleDate(from: int64Value(row[4]))
chats.append(
Chat(
id: id, identifier: identifier, name: name, service: service, lastMessageAt: lastDate))
}
return chats
}
}
public func chatInfo(chatID: Int64) throws -> ChatInfo? {
let sql = """
SELECT c.ROWID, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid,
IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service
FROM chat c
WHERE c.ROWID = ?
LIMIT 1
"""
return try withConnection { db in
for row in try db.prepare(sql, chatID) {
let id = int64Value(row[0]) ?? 0
let identifier = stringValue(row[1])
let guid = stringValue(row[2])
let name = stringValue(row[3])
let service = stringValue(row[4])
return ChatInfo(
id: id,
identifier: identifier,
guid: guid,
name: name,
service: service
)
}
return nil
}
}
public func participants(chatID: Int64) throws -> [String] {
let sql = """
SELECT h.id
FROM chat_handle_join chj
JOIN handle h ON h.ROWID = chj.handle_id
WHERE chj.chat_id = ?
ORDER BY h.id ASC
"""
return try withConnection { db in
var results: [String] = []
var seen = Set<String>()
for row in try db.prepare(sql, chatID) {
let handle = stringValue(row[0])
if handle.isEmpty { continue }
if seen.insert(handle).inserted {
results.append(handle)
}
}
return results
}
self.schema = MessageStoreSchema(
base: MessageStoreSchema(connection: connection),
hasAttributedBody: hasAttributedBody,
hasReactionColumns: hasReactionColumns,
hasThreadOriginatorGUIDColumn: hasThreadOriginatorGUIDColumn,
hasDestinationCallerID: hasDestinationCallerID,
hasAudioMessageColumn: hasAudioMessageColumn,
hasAttachmentUserInfo: hasAttachmentUserInfo,
hasBalloonBundleIDColumn: hasBalloonBundleIDColumn,
hasChatMessageJoinMessageDateColumn: hasChatMessageJoinMessageDateColumn,
hasChatAccountIDColumn: hasChatAccountIDColumn,
hasChatAccountLoginColumn: hasChatAccountLoginColumn,
hasChatLastAddressedHandleColumn: hasChatLastAddressedHandleColumn
)
}
func withConnection<T>(_ block: (Connection) throws -> T) throws -> T {
@ -171,235 +86,42 @@ public final class MessageStore: @unchecked Sendable {
try block(connection)
}
}
}
extension MessageStore {
public func attachments(for messageID: Int64) throws -> [AttachmentMeta] {
let sql = """
SELECT a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker
FROM message_attachment_join maj
JOIN attachment a ON a.ROWID = maj.attachment_id
WHERE maj.message_id = ?
"""
return try withConnection { db in
var metas: [AttachmentMeta] = []
for row in try db.prepare(sql, messageID) {
let filename = stringValue(row[0])
let transferName = stringValue(row[1])
let uti = stringValue(row[2])
let mimeType = stringValue(row[3])
let totalBytes = int64Value(row[4]) ?? 0
let isSticker = boolValue(row[5])
let resolved = AttachmentResolver.resolve(filename)
metas.append(
AttachmentMeta(
filename: filename,
transferName: transferName,
uti: uti,
mimeType: mimeType,
totalBytes: totalBytes,
isSticker: isSticker,
originalPath: resolved.resolved,
missing: resolved.missing
))
}
return metas
}
}
func shouldSkipURLBalloonDuplicate(
chatID: Int64,
sender: String,
text: String,
isFromMe: Bool,
date: Date,
rowID: Int64
) -> Bool {
guard !text.isEmpty else { return false }
func audioTranscription(for messageID: Int64) throws -> String? {
guard hasAttachmentUserInfo else { return nil }
let sql = """
SELECT a.user_info
FROM message_attachment_join maj
JOIN attachment a ON a.ROWID = maj.attachment_id
WHERE maj.message_id = ?
LIMIT 1
"""
return try withConnection { db in
for row in try db.prepare(sql, messageID) {
let info = dataValue(row[0])
guard !info.isEmpty else { continue }
if let transcription = parseAudioTranscription(from: info) {
return transcription
}
}
return nil
}
}
pruneURLBalloonDedupe(referenceDate: date)
private func parseAudioTranscription(from data: Data) -> String? {
do {
let plist = try PropertyListSerialization.propertyList(
from: data,
options: [],
format: nil
)
guard
let dict = plist as? [String: Any],
let transcription = dict["audio-transcription"] as? String,
!transcription.isEmpty
else {
return nil
}
return transcription
} catch {
return nil
}
}
public func maxRowID() throws -> Int64 {
return try withConnection { db in
let value = try db.scalar("SELECT MAX(ROWID) FROM message")
return int64Value(value) ?? 0
}
}
public func reactions(for messageID: Int64) throws -> [Reaction] {
guard hasReactionColumns else { return [] }
// Reactions are stored as messages with associated_message_type in range 2000-2006
// 2000-2005 are standard tapbacks, 2006 is custom emoji reactions
// They reference the original message via associated_message_guid which has format "p:X/GUID"
// where X is the part index (0 for single-part messages) and GUID matches the original message's guid
let bodyColumn = hasAttributedBody ? "r.attributedBody" : "NULL"
let sql = """
SELECT r.ROWID, r.associated_message_type, h.id, r.is_from_me, r.date, IFNULL(r.text, '') as text,
\(bodyColumn) AS body
FROM message m
JOIN message r ON r.associated_message_guid = m.guid
OR r.associated_message_guid LIKE '%/' || m.guid
LEFT JOIN handle h ON r.handle_id = h.ROWID
WHERE m.ROWID = ?
AND m.guid IS NOT NULL
AND m.guid != ''
AND r.associated_message_type >= 2000
AND r.associated_message_type <= 3006
ORDER BY r.date ASC
"""
return try withConnection { db in
var reactions: [Reaction] = []
var reactionIndex: [ReactionKey: Int] = [:]
for row in try db.prepare(sql, messageID) {
let rowID = int64Value(row[0]) ?? 0
let typeValue = intValue(row[1]) ?? 0
let sender = stringValue(row[2])
let isFromMe = boolValue(row[3])
let date = appleDate(from: int64Value(row[4]))
let text = stringValue(row[5])
let body = dataValue(row[6])
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if ReactionType.isReactionRemove(typeValue) {
let customEmoji = typeValue == 3006 ? extractCustomEmoji(from: resolvedText) : nil
let reactionType = ReactionType.fromRemoval(typeValue, customEmoji: customEmoji)
if let reactionType {
let key = ReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
if let index = reactionIndex.removeValue(forKey: key) {
reactions.remove(at: index)
reactionIndex = ReactionKey.reindex(reactions: reactions)
}
continue
}
if typeValue == 3006 {
if let index = reactions.firstIndex(where: {
$0.sender == sender && $0.isFromMe == isFromMe && $0.reactionType.isCustom
}) {
reactions.remove(at: index)
reactionIndex = ReactionKey.reindex(reactions: reactions)
}
}
continue
}
let customEmoji: String? = typeValue == 2006 ? extractCustomEmoji(from: resolvedText) : nil
guard let reactionType = ReactionType(rawValue: typeValue, customEmoji: customEmoji) else {
continue
}
let key = ReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
if let index = reactionIndex[key] {
reactions[index] = Reaction(
rowID: rowID,
reactionType: reactionType,
sender: sender,
isFromMe: isFromMe,
date: date,
associatedMessageID: messageID
)
} else {
reactionIndex[key] = reactions.count
reactions.append(
Reaction(
rowID: rowID,
reactionType: reactionType,
sender: sender,
isFromMe: isFromMe,
date: date,
associatedMessageID: messageID
))
}
}
return reactions
}
}
/// Extract custom emoji from reaction message text like "Reacted 🎉 to "original message""
private func extractCustomEmoji(from text: String) -> String? {
// Format: "Reacted X to "..." where X is the emoji. Fallback to first emoji in text.
guard
let reactedRange = text.range(of: "Reacted "),
let toRange = text.range(of: " to ", range: reactedRange.upperBound..<text.endIndex)
else {
return extractFirstEmoji(from: text)
}
let emoji = String(text[reactedRange.upperBound..<toRange.lowerBound])
return emoji.isEmpty ? extractFirstEmoji(from: text) : emoji
}
private func extractFirstEmoji(from text: String) -> String? {
for character in text {
if character.unicodeScalars.contains(where: {
$0.properties.isEmojiPresentation || $0.properties.isEmoji
}) {
return String(character)
}
}
return nil
}
private static func detectReactionColumns(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
var columns = Set<String>()
for row in rows {
if let name = row[1] as? String {
columns.insert(name.lowercased())
}
}
return columns.contains("guid")
&& columns.contains("associated_message_guid")
&& columns.contains("associated_message_type")
} catch {
let key = "\(chatID)|\(isFromMe ? 1 : 0)|\(sender)|\(text)"
let current = URLBalloonDedupeEntry(rowID: rowID, date: date)
guard let previous = urlBalloonDedupe[key] else {
urlBalloonDedupe[key] = current
return false
}
urlBalloonDedupe[key] = current
if rowID <= previous.rowID {
return true
}
return date.timeIntervalSince(previous.date) <= MessageStore.urlBalloonDedupeWindow
}
private struct ReactionKey: Hashable {
let sender: String
let isFromMe: Bool
let reactionType: ReactionType
static func reindex(reactions: [Reaction]) -> [ReactionKey: Int] {
var index: [ReactionKey: Int] = [:]
for (offset, reaction) in reactions.enumerated() {
let key = ReactionKey(
sender: reaction.sender,
isFromMe: reaction.isFromMe,
reactionType: reaction.reactionType
)
index[key] = offset
}
return index
}
private func pruneURLBalloonDedupe(referenceDate: Date) {
guard !urlBalloonDedupe.isEmpty else { return }
let cutoff = referenceDate.addingTimeInterval(-MessageStore.urlBalloonDedupeRetention)
urlBalloonDedupe = urlBalloonDedupe.filter { $0.value.date >= cutoff }
}
}
extension String {
var nilIfEmpty: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,67 @@
import SQLite
struct MessageStoreSchema: Sendable {
let hasAttributedBody: Bool
let hasReactionColumns: Bool
let hasThreadOriginatorGUIDColumn: Bool
let hasDestinationCallerID: Bool
let hasAudioMessageColumn: Bool
let hasAttachmentUserInfo: Bool
let hasBalloonBundleIDColumn: Bool
let hasChatMessageJoinMessageDateColumn: Bool
let hasChatAccountIDColumn: Bool
let hasChatAccountLoginColumn: Bool
let hasChatLastAddressedHandleColumn: Bool
init(connection: Connection) {
let messageColumns = MessageStore.tableColumns(connection: connection, table: "message")
let attachmentColumns = MessageStore.tableColumns(connection: connection, table: "attachment")
let chatMessageJoinColumns = MessageStore.tableColumns(
connection: connection,
table: "chat_message_join"
)
let chatColumns = MessageStore.tableColumns(connection: connection, table: "chat")
self.hasAttributedBody = messageColumns.contains("attributedbody")
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
self.hasBalloonBundleIDColumn = messageColumns.contains("balloon_bundle_id")
self.hasChatMessageJoinMessageDateColumn = chatMessageJoinColumns.contains("message_date")
self.hasChatAccountIDColumn = chatColumns.contains("account_id")
self.hasChatAccountLoginColumn = chatColumns.contains("account_login")
self.hasChatLastAddressedHandleColumn = chatColumns.contains("last_addressed_handle")
}
init(
base: MessageStoreSchema,
hasAttributedBody: Bool? = nil,
hasReactionColumns: Bool? = nil,
hasThreadOriginatorGUIDColumn: Bool? = nil,
hasDestinationCallerID: Bool? = nil,
hasAudioMessageColumn: Bool? = nil,
hasAttachmentUserInfo: Bool? = nil,
hasBalloonBundleIDColumn: Bool? = nil,
hasChatMessageJoinMessageDateColumn: Bool? = nil,
hasChatAccountIDColumn: Bool? = nil,
hasChatAccountLoginColumn: Bool? = nil,
hasChatLastAddressedHandleColumn: Bool? = nil
) {
self.hasAttributedBody = hasAttributedBody ?? base.hasAttributedBody
self.hasReactionColumns = hasReactionColumns ?? base.hasReactionColumns
self.hasThreadOriginatorGUIDColumn =
hasThreadOriginatorGUIDColumn ?? base.hasThreadOriginatorGUIDColumn
self.hasDestinationCallerID = hasDestinationCallerID ?? base.hasDestinationCallerID
self.hasAudioMessageColumn = hasAudioMessageColumn ?? base.hasAudioMessageColumn
self.hasAttachmentUserInfo = hasAttachmentUserInfo ?? base.hasAttachmentUserInfo
self.hasBalloonBundleIDColumn = hasBalloonBundleIDColumn ?? base.hasBalloonBundleIDColumn
self.hasChatMessageJoinMessageDateColumn =
hasChatMessageJoinMessageDateColumn ?? base.hasChatMessageJoinMessageDateColumn
self.hasChatAccountIDColumn = hasChatAccountIDColumn ?? base.hasChatAccountIDColumn
self.hasChatAccountLoginColumn = hasChatAccountLoginColumn ?? base.hasChatAccountLoginColumn
self.hasChatLastAddressedHandleColumn =
hasChatLastAddressedHandleColumn ?? base.hasChatLastAddressedHandleColumn
}
}

View File

@ -1,13 +1,26 @@
import Darwin
import Foundation
#if os(macOS)
import Darwin
#endif
public struct MessageWatcherConfiguration: Sendable, Equatable {
public var debounceInterval: TimeInterval
public var fallbackPollInterval: TimeInterval?
public var batchLimit: Int
/// When true, reaction events (tapback add/remove) are included in the stream
public var includeReactions: Bool
public init(debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100) {
public init(
debounceInterval: TimeInterval = 0.25,
fallbackPollInterval: TimeInterval? = 5,
batchLimit: Int = 100,
includeReactions: Bool = false
) {
self.debounceInterval = debounceInterval
self.fallbackPollInterval = fallbackPollInterval
self.batchLimit = batchLimit
self.includeReactions = includeReactions
}
}
@ -47,8 +60,11 @@ private final class WatchState: @unchecked Sendable {
private let queue = DispatchQueue(label: "imsg.watch", qos: .userInitiated)
private var cursor: Int64
private var sources: [DispatchSourceFileSystemObject] = []
#if os(macOS)
private var sources: [DispatchSourceFileSystemObject] = []
#endif
private var pending = false
private var stopped = false
init(
store: MessageStore,
@ -76,59 +92,82 @@ private final class WatchState: @unchecked Sendable {
}
}
let paths = [store.path, store.path + "-wal", store.path + "-shm"]
for path in paths {
if let source = makeSource(path: path) {
sources.append(source)
#if os(macOS)
let paths = [store.path, store.path + "-wal", store.path + "-shm"]
for path in paths {
if let source = makeSource(path: path) {
sources.append(source)
}
}
}
#endif
queue.async {
self.scheduleFallbackPoll()
}
}
func stop() {
queue.async {
for source in self.sources {
source.cancel()
}
self.sources.removeAll()
self.stopped = true
#if os(macOS)
for source in self.sources {
source.cancel()
}
self.sources.removeAll()
#endif
}
}
private func makeSource(path: String) -> DispatchSourceFileSystemObject? {
let fd = open(path, O_EVTONLY)
guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .extend, .rename, .delete],
queue: queue
)
source.setEventHandler { [weak self] in
self?.schedulePoll()
#if os(macOS)
private func makeSource(path: String) -> DispatchSourceFileSystemObject? {
let fd = open(path, O_EVTONLY)
guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .extend, .rename, .delete],
queue: queue
)
source.setEventHandler { [weak self] in
self?.schedulePoll()
}
source.setCancelHandler {
close(fd)
}
source.resume()
return source
}
source.setCancelHandler {
close(fd)
}
source.resume()
return source
}
#endif
private func schedulePoll() {
if stopped { return }
if pending { return }
pending = true
let delay = configuration.debounceInterval
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
if self.stopped { return }
self.pending = false
self.poll()
}
}
private func scheduleFallbackPoll() {
guard let interval = configuration.fallbackPollInterval, interval > 0 else { return }
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
guard let self, !self.stopped else { return }
self.poll()
self.scheduleFallbackPoll()
}
}
private func poll() {
if stopped { return }
do {
let messages = try store.messagesAfter(
afterRowID: cursor,
chatID: chatID,
limit: configuration.batchLimit
limit: configuration.batchLimit,
includeReactions: configuration.includeReactions
)
for message in messages {
continuation.yield(message)

View File

@ -0,0 +1,405 @@
import Foundation
#if os(macOS)
/// Manages Messages.app lifecycle for DYLD injection.
///
/// Kills any running Messages.app, relaunches with `DYLD_INSERT_LIBRARIES`
/// pointing to the imsg-bridge dylib, then waits for the lock file that
/// confirms the dylib is ready for commands.
public final class MessagesLauncher: @unchecked Sendable {
public static let shared = MessagesLauncher()
// File-based IPC paths must match the paths in IMsgInjected.m.
// The dylib uses NSHomeDirectory() which resolves to the container path;
// from outside we construct the full container path ourselves.
private var commandFile: String {
containerPath + "/.imsg-command.json"
}
private var responseFile: String {
containerPath + "/.imsg-response.json"
}
private var lockFile: String {
containerPath + "/.imsg-bridge-ready"
}
private var containerPath: String {
NSHomeDirectory() + "/Library/Containers/com.apple.MobileSMS/Data"
}
/// Inbox directory for v2 RPC requests (`<uuid>.json` files dropped here by
/// the CLI; consumed by the dylib).
public var bridgeInboxDirectory: String {
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
+ IMsgBridgeProtocol.inboxDirectoryName
}
/// Outbox directory for v2 RPC responses (`<uuid>.json` files written by
/// the dylib; consumed by the CLI).
public var bridgeOutboxDirectory: String {
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
+ IMsgBridgeProtocol.outboxDirectoryName
}
/// Path to the dylib's append-only event log.
public var bridgeEventsFile: String {
containerPath + "/" + IMsgBridgeProtocol.eventsFileName
}
private let messagesAppPath =
"/System/Applications/Messages.app/Contents/MacOS/Messages"
private let queue = DispatchQueue(label: "imsg.messages.launcher")
private let lock = NSLock()
/// Path to the dylib to inject.
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
private init() {
let possiblePaths = [
"/usr/local/lib/imsg-bridge-helper.dylib",
".build/release/imsg-bridge-helper.dylib",
".build/debug/imsg-bridge-helper.dylib",
]
for path in possiblePaths {
if FileManager.default.fileExists(atPath: path) {
self.dylibPath = path
break
}
}
}
/// Check if Messages.app has published the bridge-ready lock file.
public func hasReadyLockFile() -> Bool {
FileManager.default.fileExists(atPath: lockFile)
}
/// Check if Messages.app is running with our dylib (lock file exists and responds to ping).
public func isInjectedAndReady() -> Bool {
guard hasReadyLockFile() else {
return false
}
do {
let response = try sendCommandSync(action: "ping", params: [:])
return response["success"] as? Bool == true
} catch {
return false
}
}
/// Ensure Messages.app is running with our dylib injected.
public func ensureRunning() throws {
if isInjectedAndReady() { return }
try launchInjectedMessages()
}
/// Ensure Messages.app is launched with the helper without touching legacy IPC.
public func ensureLaunched() throws {
if hasReadyLockFile() { return }
try launchInjectedMessages()
}
private func launchInjectedMessages() throws {
switch Self.currentSIPStatus() {
case .disabled:
break
case .enabled:
throw MessagesLauncherError.sipEnabled
case .unknown(let details):
throw MessagesLauncherError.sipStatusUnknown(details)
}
guard FileManager.default.fileExists(atPath: dylibPath) else {
throw MessagesLauncherError.dylibNotFound(dylibPath)
}
killMessages()
Thread.sleep(forTimeInterval: 1.0)
// Clean up stale IPC files
try? FileManager.default.removeItem(atPath: commandFile)
try? FileManager.default.removeItem(atPath: responseFile)
try? FileManager.default.removeItem(atPath: lockFile)
// Pre-create v2 RPC queue directories so the dylib can FSEvent-watch them
// immediately on startup (FSEventStream registration on a missing path
// silently fails to deliver events).
try ensureSecureQueueDirectory(bridgeInboxDirectory)
try ensureSecureQueueDirectory(bridgeOutboxDirectory)
try cleanQueueDirectory(bridgeInboxDirectory)
try cleanQueueDirectory(bridgeOutboxDirectory)
try launchWithInjection()
try waitForReady(timeout: 15.0)
}
private func ensureSecureQueueDirectory(_ path: String) throws {
if SecurePath.hasSymlinkComponent(path) {
throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)")
}
do {
try FileManager.default.createDirectory(
atPath: path,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
if SecurePath.hasSymlinkComponent(path) {
throw MessagesLauncherError.socketError(
"RPC queue path traverses a symlink (post-mkdir): \(path)")
}
try FileManager.default.setAttributes(
[.posixPermissions: 0o700], ofItemAtPath: path)
} catch let error as MessagesLauncherError {
throw error
} catch {
throw MessagesLauncherError.socketError("mkdir \(path): \(error.localizedDescription)")
}
}
private func cleanQueueDirectory(_ path: String) throws {
if SecurePath.hasSymlinkComponent(path) {
throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)")
}
let entries = try FileManager.default.contentsOfDirectory(atPath: path)
for entry in entries {
try FileManager.default.removeItem(atPath: (path as NSString).appendingPathComponent(entry))
}
}
/// Kill Messages.app if running.
public func killMessages() {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
task.arguments = ["Messages"]
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
try? task.run()
task.waitUntilExit()
}
/// Send a command asynchronously.
public func sendCommand(
action: String, params: [String: Any]
) async throws -> [String: Any] {
try ensureRunning()
// Serialize params to JSON data to cross the Sendable boundary safely
let paramsData = try JSONSerialization.data(withJSONObject: params, options: [])
return try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<[String: Any], Error>) in
queue.async {
do {
let deserializedParams =
(try? JSONSerialization.jsonObject(with: paramsData, options: []))
as? [String: Any] ?? [:]
let response = try self.sendCommandSync(action: action, params: deserializedParams)
continuation.resume(returning: response)
} catch {
continuation.resume(throwing: error)
}
}
}
}
// MARK: - Private
private static func csrutilStatusOutput() -> String? {
let task = Process()
let output = Pipe()
task.executableURL = URL(fileURLWithPath: "/usr/bin/csrutil")
task.arguments = ["status"]
task.standardOutput = output
task.standardError = output
do {
try task.run()
} catch {
return nil
}
task.waitUntilExit()
let data = output.fileHandleForReading.readDataToEndOfFile()
guard let text = String(data: data, encoding: .utf8) else { return nil }
return text.trimmingCharacters(in: .whitespacesAndNewlines)
}
public enum SIPStatus: Equatable, Sendable {
case enabled
case disabled
case unknown(String)
}
public static func currentSIPStatus() -> SIPStatus {
guard let output = csrutilStatusOutput(), !output.isEmpty else {
return .unknown("Unable to run `csrutil status`.")
}
let lowered = output.lowercased()
if lowered.contains("disabled") {
return .disabled
}
if lowered.contains("enabled") {
return .enabled
}
return .unknown(output)
}
private func launchWithInjection() throws {
let absoluteDylibPath =
dylibPath.hasPrefix("/")
? dylibPath
: FileManager.default.currentDirectoryPath + "/" + dylibPath
guard FileManager.default.fileExists(atPath: absoluteDylibPath) else {
throw MessagesLauncherError.dylibNotFound(absoluteDylibPath)
}
let task = Process()
task.executableURL = URL(fileURLWithPath: messagesAppPath)
var environment = ProcessInfo.processInfo.environment
environment["DYLD_INSERT_LIBRARIES"] = absoluteDylibPath
task.environment = environment
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
do {
try task.run()
} catch {
throw MessagesLauncherError.launchFailed(error.localizedDescription)
}
}
private func waitForReady(timeout: TimeInterval) throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: lockFile) {
Thread.sleep(forTimeInterval: 0.5)
return
}
Thread.sleep(forTimeInterval: 0.5)
}
throw MessagesLauncherError.socketTimeout
}
private func sendCommandSync(
action: String, params: [String: Any]
) throws -> [String: Any] {
lock.lock()
defer { lock.unlock() }
let command: [String: Any] = [
"id": Int(Date().timeIntervalSince1970 * 1000),
"action": action,
"params": params,
]
let jsonData = try JSONSerialization.data(withJSONObject: command, options: [])
try jsonData.write(to: URL(fileURLWithPath: commandFile))
let deadline = Date().addingTimeInterval(10.0)
while Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
guard
let responseData = try? Data(contentsOf: URL(fileURLWithPath: responseFile)),
responseData.count > 2
else { continue }
// Check if command file was cleared (indicates processing completed)
if let cmdData = try? Data(contentsOf: URL(fileURLWithPath: commandFile)),
cmdData.count <= 2
{
guard
let response = try? JSONSerialization.jsonObject(with: responseData, options: [])
as? [String: Any]
else {
throw MessagesLauncherError.invalidResponse
}
// Clear response file
try? "".write(toFile: responseFile, atomically: true, encoding: .utf8)
return response
}
}
throw MessagesLauncherError.socketError("Timeout waiting for response")
}
}
#else
/// Non-macOS stub. Linux can read copied Messages databases, but there is no
/// Messages.app process, SIP state, or DYLD injection bridge to launch.
public final class MessagesLauncher: @unchecked Sendable {
public static let shared = MessagesLauncher()
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
public var bridgeInboxDirectory: String { "/nonexistent/.imsg-rpc/in" }
public var bridgeOutboxDirectory: String { "/nonexistent/.imsg-rpc/out" }
public var bridgeEventsFile: String { "/nonexistent/.imsg-events.jsonl" }
private init() {}
public func hasReadyLockFile() -> Bool { false }
public func isInjectedAndReady() -> Bool { false }
public func ensureRunning() throws {
throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.")
}
public func ensureLaunched() throws {
throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.")
}
public func killMessages() {}
public func sendCommand(action: String, params: [String: Any]) async throws -> [String: Any] {
_ = action
_ = params
throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.")
}
public enum SIPStatus: Equatable, Sendable {
case enabled
case disabled
case unknown(String)
}
public static func currentSIPStatus() -> SIPStatus {
.unknown("System Integrity Protection is a macOS-only concept.")
}
}
#endif
public enum MessagesLauncherError: Error, CustomStringConvertible {
case dylibNotFound(String)
case launchFailed(String)
case sipEnabled
case sipStatusUnknown(String)
case socketTimeout
case socketError(String)
case invalidResponse
public var description: String {
switch self {
case .dylibNotFound(let path):
return "imsg-bridge-helper.dylib not found at \(path). Build with: make build-dylib"
case .launchFailed(let reason):
return "Failed to launch Messages.app: \(reason)"
case .sipEnabled:
return
"System Integrity Protection (SIP) is enabled. "
+ "Refusing to inject into Messages.app. "
+ "Disable SIP in Recovery mode before using `imsg launch`."
case .sipStatusUnknown(let details):
return
"Unable to determine SIP status. "
+ "Refusing to inject into Messages.app. "
+ "Details: \(details)"
case .socketTimeout:
return
"Timeout waiting for Messages.app to initialize. "
+ "Ensure SIP is disabled and Messages.app has necessary permissions."
case .socketError(let reason):
return "IPC error: \(reason)"
case .invalidResponse:
return "Invalid response from Messages.app helper"
}
}
}

View File

@ -190,13 +190,28 @@ public struct Chat: Sendable, Equatable {
public let name: String
public let service: String
public let lastMessageAt: Date
public let accountID: String?
public let accountLogin: String?
public let lastAddressedHandle: String?
public init(id: Int64, identifier: String, name: String, service: String, lastMessageAt: Date) {
public init(
id: Int64,
identifier: String,
name: String,
service: String,
lastMessageAt: Date,
accountID: String? = nil,
accountLogin: String? = nil,
lastAddressedHandle: String? = nil
) {
self.id = id
self.identifier = identifier
self.name = name
self.service = service
self.lastMessageAt = lastMessageAt
self.accountID = accountID
self.accountLogin = accountLogin
self.lastAddressedHandle = lastAddressedHandle
}
}
@ -206,21 +221,72 @@ public struct ChatInfo: Sendable, Equatable {
public let guid: String
public let name: String
public let service: String
public let accountID: String?
public let accountLogin: String?
public let lastAddressedHandle: String?
public init(id: Int64, identifier: String, guid: String, name: String, service: String) {
public init(
id: Int64,
identifier: String,
guid: String,
name: String,
service: String,
accountID: String? = nil,
accountLogin: String? = nil,
lastAddressedHandle: String? = nil
) {
self.id = id
self.identifier = identifier
self.guid = guid
self.name = name
self.service = service
self.accountID = accountID
self.accountLogin = accountLogin
self.lastAddressedHandle = lastAddressedHandle
}
}
public struct Message: Sendable, Equatable {
public struct RoutingMetadata: Sendable, Equatable {
public let replyToGUID: String?
public let threadOriginatorGUID: String?
public let destinationCallerID: String?
public init(
replyToGUID: String? = nil,
threadOriginatorGUID: String? = nil,
destinationCallerID: String? = nil
) {
self.replyToGUID = replyToGUID
self.threadOriginatorGUID = threadOriginatorGUID
self.destinationCallerID = destinationCallerID
}
}
public struct ReactionMetadata: Sendable, Equatable {
public let isReaction: Bool
public let reactionType: ReactionType?
public let isReactionAdd: Bool?
public let reactedToGUID: String?
public init(
isReaction: Bool = false,
reactionType: ReactionType? = nil,
isReactionAdd: Bool? = nil,
reactedToGUID: String? = nil
) {
self.isReaction = isReaction
self.reactionType = reactionType
self.isReactionAdd = isReactionAdd
self.reactedToGUID = reactedToGUID
}
}
public let rowID: Int64
public let chatID: Int64
public let guid: String
public let replyToGUID: String?
public let threadOriginatorGUID: String?
public let sender: String
public let text: String
public let date: Date
@ -228,6 +294,20 @@ public struct Message: Sendable, Equatable {
public let service: String
public let handleID: Int64?
public let attachmentsCount: Int
/// The destination_caller_id from the database. For messages where is_from_me is true,
/// this can help distinguish between messages actually sent by the local user vs
/// messages received on a secondary phone number registered with the same Apple ID.
public let destinationCallerID: String?
// Reaction metadata (populated when message is a reaction event)
/// Whether this message is a reaction event (tapback add/remove)
public let isReaction: Bool
/// The type of reaction (only set when isReaction is true)
public let reactionType: ReactionType?
/// Whether this is adding (true) or removing (false) a reaction (only set when isReaction is true)
public let isReactionAdd: Bool?
/// The GUID of the message being reacted to (only set when isReaction is true)
public let reactedToGUID: String?
public init(
rowID: Int64,
@ -240,12 +320,14 @@ public struct Message: Sendable, Equatable {
handleID: Int64?,
attachmentsCount: Int,
guid: String = "",
replyToGUID: String? = nil
routing: RoutingMetadata = RoutingMetadata(),
reaction: ReactionMetadata = ReactionMetadata()
) {
self.rowID = rowID
self.chatID = chatID
self.guid = guid
self.replyToGUID = replyToGUID
self.replyToGUID = routing.replyToGUID
self.threadOriginatorGUID = routing.threadOriginatorGUID
self.sender = sender
self.text = text
self.date = date
@ -253,6 +335,55 @@ public struct Message: Sendable, Equatable {
self.service = service
self.handleID = handleID
self.attachmentsCount = attachmentsCount
self.destinationCallerID = routing.destinationCallerID
self.isReaction = reaction.isReaction
self.reactionType = reaction.reactionType
self.isReactionAdd = reaction.isReactionAdd
self.reactedToGUID = reaction.reactedToGUID
}
public init(
rowID: Int64,
chatID: Int64,
sender: String,
text: String,
date: Date,
isFromMe: Bool,
service: String,
handleID: Int64?,
attachmentsCount: Int,
guid: String = "",
replyToGUID: String? = nil,
threadOriginatorGUID: String? = nil,
destinationCallerID: String? = nil,
isReaction: Bool = false,
reactionType: ReactionType? = nil,
isReactionAdd: Bool? = nil,
reactedToGUID: String? = nil
) {
self.init(
rowID: rowID,
chatID: chatID,
sender: sender,
text: text,
date: date,
isFromMe: isFromMe,
service: service,
handleID: handleID,
attachmentsCount: attachmentsCount,
guid: guid,
routing: RoutingMetadata(
replyToGUID: replyToGUID,
threadOriginatorGUID: threadOriginatorGUID,
destinationCallerID: destinationCallerID
),
reaction: ReactionMetadata(
isReaction: isReaction,
reactionType: reactionType,
isReactionAdd: isReactionAdd,
reactedToGUID: reactedToGUID
)
)
}
}
@ -264,6 +395,8 @@ public struct AttachmentMeta: Sendable, Equatable {
public let totalBytes: Int64
public let isSticker: Bool
public let originalPath: String
public let convertedPath: String?
public let convertedMimeType: String?
public let missing: Bool
public init(
@ -274,6 +407,8 @@ public struct AttachmentMeta: Sendable, Equatable {
totalBytes: Int64,
isSticker: Bool,
originalPath: String,
convertedPath: String? = nil,
convertedMimeType: String? = nil,
missing: Bool
) {
self.filename = filename
@ -283,6 +418,18 @@ public struct AttachmentMeta: Sendable, Equatable {
self.totalBytes = totalBytes
self.isSticker = isSticker
self.originalPath = originalPath
self.convertedPath = convertedPath
self.convertedMimeType = convertedMimeType
self.missing = missing
}
}
public struct AttachmentQueryOptions: Sendable, Equatable {
public static let `default` = AttachmentQueryOptions()
public let convertUnsupported: Bool
public init(convertUnsupported: Bool = false) {
self.convertUnsupported = convertUnsupported
}
}

View File

@ -0,0 +1,67 @@
import Foundation
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
/// Lexical-walk symlink detector. Used wherever we accept a filesystem path
/// from outside the dylib (RPC inbox dir, attachment paths) and want to refuse
/// any path that traverses a symbolic link, including parent components.
///
/// `realpath()` alone isn't sufficient: a same-UID attacker who can write to
/// our RPC inbox could otherwise symlink an arbitrary file (a credential file,
/// a password manager DB) into a location they control and have Messages.app
/// exfiltrate it as an attachment. Comparing the resolved path against the
/// lexical input is fragile too macOS rewrites `/tmp` to `/private/tmp`,
/// breaking that check for legitimate paths. Walking each component with
/// `lstat()` and refusing the path on any `S_IFLNK` is the robust answer.
public enum SecurePath {
private static func normalizingTrustedSystemAliasPrefix(_ path: String) -> String {
let aliases = [
"/tmp": "/private/tmp",
"/var": "/private/var",
"/etc": "/private/etc",
]
for (alias, canonical) in aliases {
if path == alias {
return canonical
}
if path.hasPrefix(alias + "/") {
return canonical + path.dropFirst(alias.count)
}
}
return path
}
/// Returns true if any component of `path` (after tilde expansion and CWD
/// resolution for relative paths) is a symbolic link. Final component
/// included.
public static func hasSymlinkComponent(_ path: String) -> Bool {
var lexicalPath = (path as NSString).expandingTildeInPath
if !lexicalPath.hasPrefix("/") {
lexicalPath =
(FileManager.default.currentDirectoryPath as NSString)
.appendingPathComponent(lexicalPath)
}
lexicalPath = normalizingTrustedSystemAliasPrefix(lexicalPath)
let components = (lexicalPath as NSString).pathComponents
guard !components.isEmpty else { return false }
var cursor = components.first == "/" ? "/" : ""
for component in components where component != "/" && !component.isEmpty {
cursor = (cursor as NSString).appendingPathComponent(component)
var info = stat()
if lstat(cursor, &info) != 0 {
continue
}
if (info.st_mode & S_IFMT) == S_IFLNK {
return true
}
}
return false
}
}

View File

@ -4,6 +4,12 @@ enum TypedStreamParser {
static func parseAttributedBody(_ data: Data) -> String {
guard !data.isEmpty else { return "" }
let bytes = [UInt8](data)
if bytes.count >= 2, bytes[0] == 0xff, bytes[1] == 0xfe {
let payload = data.dropFirst(2)
if let text = String(data: payload, encoding: .utf16LittleEndian) {
return text.trimmingLeadingControlCharacters()
}
}
let start = [UInt8(0x01), UInt8(0x2b)]
let end = [UInt8(0x86), UInt8(0x84)]
var best = ""
@ -13,13 +19,8 @@ enum TypedStreamParser {
if bytes[index] == start[0], bytes[index + 1] == start[1] {
let sliceStart = index + 2
if let sliceEnd = findSequence(end, in: bytes, from: sliceStart) {
var segment = Array(bytes[sliceStart..<sliceEnd])
// Check if first byte equals length prefix (convert byte to Int for comparison)
if segment.count > 1, Int(segment[0]) == segment.count - 1 {
segment.removeFirst()
}
let candidate = String(decoding: segment, as: UTF8.self)
.trimmingLeadingControlCharacters()
let segment = Array(bytes[sliceStart..<sliceEnd])
let candidate = decodeSegment(segment)
if candidate.count > best.count {
best = candidate
}
@ -36,6 +37,46 @@ enum TypedStreamParser {
return text.trimmingLeadingControlCharacters()
}
/// Strips a typedstream length prefix from `segment` and returns the longest valid UTF-8 decoding.
/// Length prefix forms (BER-style): single byte (< 0x80), `0x81 NN`, or `0x82 NN NN`.
/// Structured prefixes always win over the raw `prefixLen = 0` decode: otherwise, when the
/// length byte is itself a printable-ASCII character (body length 32126), the unstripped decode
/// produces an N+1 character string that beats the correct N-character body.
private static func decodeSegment(_ segment: [UInt8]) -> String {
guard let first = segment.first else { return "" }
var structuredPrefixes: [Int] = []
if first < 0x80, Int(first) == segment.count - 1 {
structuredPrefixes.append(1)
}
if first == 0x81, segment.count >= 2 {
structuredPrefixes.append(2)
}
if first == 0x82, segment.count >= 3 {
structuredPrefixes.append(3)
}
var bestStructured = ""
var anyStructuredValid = false
for prefixLen in structuredPrefixes {
let body = Array(segment[prefixLen...])
guard
let candidate = String(bytes: body, encoding: .utf8)?
.trimmingLeadingControlCharacters()
else { continue }
anyStructuredValid = true
if candidate.count > bestStructured.count {
bestStructured = candidate
}
}
if anyStructuredValid {
return bestStructured
}
return String(bytes: segment, encoding: .utf8)?
.trimmingLeadingControlCharacters() ?? ""
}
private static func findSequence(_ needle: [UInt8], in haystack: [UInt8], from start: Int)
-> Int?
{

View File

@ -0,0 +1,345 @@
import Foundation
#if os(macOS)
/// Sends typing indicators for iMessage chats.
///
/// Prefers the IMCore bridge (via DYLD injection into Messages.app) which
/// is reliable on stock macOS with SIP disabled. Falls back to direct
/// IMCore access via `dlopen` when the bridge is unavailable.
public struct TypingIndicator: Sendable {
private static let daemonConnectionTracker = DaemonConnectionTracker()
/// Start showing the typing indicator for a chat.
/// - Parameter chatIdentifier: e.g. `"iMessage;-;+14155551212"` or a chat GUID.
/// - Throws: `IMsgError.typingIndicatorFailed` if both bridge and direct IMCore fail.
public static func startTyping(chatIdentifier: String) throws {
try setTyping(chatIdentifier: chatIdentifier, isTyping: true)
}
/// Stop showing the typing indicator for a chat.
/// - Parameter chatIdentifier: The chat identifier string.
/// - Throws: `IMsgError.typingIndicatorFailed` if both bridge and direct IMCore fail.
public static func stopTyping(chatIdentifier: String) throws {
try setTyping(chatIdentifier: chatIdentifier, isTyping: false)
}
/// Show typing indicator for a duration, then automatically stop.
/// - Parameters:
/// - chatIdentifier: The chat identifier string.
/// - duration: Seconds to show the typing indicator.
public static func typeForDuration(chatIdentifier: String, duration: TimeInterval) async throws
{
try await typeForDuration(
chatIdentifier: chatIdentifier,
duration: duration,
startTyping: { try startTyping(chatIdentifier: $0) },
stopTyping: { try stopTyping(chatIdentifier: $0) },
sleep: { try await Task.sleep(nanoseconds: $0) }
)
}
// MARK: - Private
private static func setTyping(chatIdentifier: String, isTyping: Bool) throws {
// Prefer the bridge (dylib injected into Messages.app)
let bridge = IMCoreBridge.shared
if bridge.isAvailable {
do {
try setTypingViaBridge(bridge: bridge, chatIdentifier: chatIdentifier, isTyping: isTyping)
return
} catch {
// Bridge failed fall through to direct IMCore access
}
}
// Fallback: direct IMCore access (requires AMFI disabled + XPC plist)
try setTypingDirect(chatIdentifier: chatIdentifier, isTyping: isTyping)
}
/// Synchronous wrapper for the async bridge call using a Sendable result box.
private static func setTypingViaBridge(
bridge: IMCoreBridge, chatIdentifier: String, isTyping: Bool
) throws {
let semaphore = DispatchSemaphore(value: 0)
let box = BridgeResultBox()
Task { @Sendable in
do {
try await bridge.setTyping(for: chatIdentifier, typing: isTyping)
} catch {
box.setError(error)
}
semaphore.signal()
}
semaphore.wait()
if let error = box.error {
throw error
}
}
private static func setTypingDirect(chatIdentifier: String, isTyping: Bool) throws {
let frameworkPath = "/System/Library/PrivateFrameworks/IMCore.framework/IMCore"
guard let handle = dlopen(frameworkPath, RTLD_LAZY) else {
let error = String(cString: dlerror())
throw IMsgError.typingIndicatorFailed(
"Failed to load IMCore framework: \(error)")
}
defer { dlclose(handle) }
try ensureDaemonConnection()
let chat = try lookupChat(identifier: chatIdentifier)
let selector = sel_registerName("setLocalUserIsTyping:")
guard let method = class_getInstanceMethod(object_getClass(chat), selector) else {
throw IMsgError.typingIndicatorFailed(
"setLocalUserIsTyping: method not found on IMChat")
}
let implementation = method_getImplementation(method)
typealias SetTypingFunc = @convention(c) (AnyObject, Selector, Bool) -> Void
let setTypingFunc = unsafeBitCast(implementation, to: SetTypingFunc.self)
setTypingFunc(chat, selector, isTyping)
}
static func typeForDuration(
chatIdentifier: String,
duration: TimeInterval,
startTyping: (String) throws -> Void,
stopTyping: (String) throws -> Void,
sleep: (UInt64) async throws -> Void
) async throws {
try startTyping(chatIdentifier)
var stopped = false
defer {
if !stopped {
try? stopTyping(chatIdentifier)
}
}
try await sleep(UInt64(duration * 1_000_000_000))
try stopTyping(chatIdentifier)
stopped = true
}
private static func ensureDaemonConnection() throws {
guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else {
throw IMsgError.typingIndicatorFailed("IMDaemonController class not found")
}
let sharedSel = sel_registerName("sharedInstance")
guard controllerClass.responds(to: sharedSel) else {
throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available")
}
guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else {
throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance")
}
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.hasAttemptedConnection = true
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
daemonConnectionTracker.lock.lock()
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
if shouldAttemptConnection {
daemonConnectionTracker.hasAttemptedConnection = true
}
daemonConnectionTracker.lock.unlock()
if !shouldAttemptConnection { return }
let connectSel = sel_registerName("connectToDaemon")
if controller.responds(to: connectSel) {
_ = controller.perform(connectSel)
}
let maxAttempts = 50
for _ in 0..<maxAttempts {
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
Thread.sleep(forTimeInterval: 0.1)
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
}
if !hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = true
daemonConnectionTracker.lock.unlock()
throw IMsgError.typingIndicatorFailed(
daemonUnavailableMessage()
)
}
}
private static func hasLiveDaemonConnection(_ controller: AnyObject) -> Bool {
let isConnectedSel = sel_registerName("isConnected")
guard controller.responds(to: isConnectedSel) else { return false }
guard let value = controller.perform(isConnectedSel)?.takeUnretainedValue() else {
return false
}
if let number = value as? NSNumber {
return number.boolValue
}
return false
}
private static func lookupChat(identifier: String) throws -> NSObject {
guard let registryClass = objc_getClass("IMChatRegistry") as? NSObject.Type else {
throw IMsgError.typingIndicatorFailed("IMChatRegistry class not found")
}
let sharedSel = sel_registerName("sharedInstance")
guard registryClass.responds(to: sharedSel) else {
throw IMsgError.typingIndicatorFailed("IMChatRegistry.sharedInstance not available")
}
guard let registry = registryClass.perform(sharedSel)?.takeUnretainedValue() as? NSObject
else {
throw IMsgError.typingIndicatorFailed("Failed to get IMChatRegistry shared instance")
}
let candidates = chatLookupCandidates(for: identifier)
let guidSel = sel_registerName("existingChatWithGUID:")
if registry.responds(to: guidSel) {
for candidate in candidates {
if let chat = registry.perform(guidSel, with: candidate)?.takeUnretainedValue()
as? NSObject
{
return chat
}
}
}
let identSel = sel_registerName("existingChatWithChatIdentifier:")
if registry.responds(to: identSel) {
for candidate in candidates {
if let chat = registry.perform(identSel, with: candidate)?.takeUnretainedValue()
as? NSObject
{
return chat
}
}
}
daemonConnectionTracker.lock.lock()
let connectionKnownUnavailable = daemonConnectionTracker.connectionKnownUnavailable
daemonConnectionTracker.lock.unlock()
if connectionKnownUnavailable {
throw IMsgError.typingIndicatorFailed(daemonUnavailableMessage())
}
throw IMsgError.typingIndicatorFailed(
"Chat not found for identifier: \(identifier). "
+ "Make sure Messages.app has an active conversation with this contact.")
}
static func daemonUnavailableMessage() -> String {
"Failed to connect to imagent (Messages daemon) for IMCore typing indicators. "
+ "On macOS 26/Tahoe, imagent can reject third-party clients without "
+ "Apple-private entitlements, and Messages.app may also block the injected "
+ "bridge via library validation. Run 'imsg status' and 'imsg launch' to "
+ "verify advanced feature setup. Normal 'send', 'history', and 'watch' "
+ "commands do not use this IMCore path."
}
static func chatLookupCandidates(for identifier: String) -> [String] {
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
let bareIdentifier = stripKnownChatPrefix(trimmed) ?? trimmed
var candidates = [trimmed]
if bareIdentifier != trimmed {
candidates.append(bareIdentifier)
}
for prefix in chatIdentifierPrefixes {
candidates.append(prefix + bareIdentifier)
}
return dedupe(candidates)
}
private static let chatIdentifierPrefixes = [
"iMessage;-;",
"iMessage;+;",
"SMS;-;",
"SMS;+;",
"any;-;",
"any;+;",
]
private static func stripKnownChatPrefix(_ value: String) -> String? {
for prefix in chatIdentifierPrefixes where value.hasPrefix(prefix) {
return String(value.dropFirst(prefix.count))
}
return nil
}
private static func dedupe(_ values: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for value in values where !value.isEmpty {
if seen.insert(value).inserted {
result.append(value)
}
}
return result
}
}
#else
/// Non-macOS stub. Linux can read copied databases, but typing indicators
/// require private IMCore APIs inside Messages.app.
public struct TypingIndicator: Sendable {
public static func startTyping(chatIdentifier: String) throws {
_ = chatIdentifier
throw unsupported()
}
public static func stopTyping(chatIdentifier: String) throws {
_ = chatIdentifier
throw unsupported()
}
public static func typeForDuration(chatIdentifier: String, duration: TimeInterval) async throws
{
_ = chatIdentifier
_ = duration
throw unsupported()
}
private static func unsupported() -> IMsgError {
IMsgError.typingIndicatorFailed(
"Typing indicators require Messages.app/IMCore and are only supported on macOS.")
}
}
#endif
#if os(macOS)
private final class DaemonConnectionTracker: @unchecked Sendable {
let lock = NSLock()
var hasAttemptedConnection = false
var connectionKnownUnavailable = false
}
/// Thread-safe box for passing an error out of a Task back to the calling thread.
private final class BridgeResultBox: @unchecked Sendable {
private let lock = NSLock()
private var _error: Error?
var error: Error? {
lock.lock()
defer { lock.unlock() }
return _error
}
func setError(_ error: Error) {
lock.lock()
_error = error
lock.unlock()
}
}
#endif

File diff suppressed because it is too large Load Diff

View File

@ -9,3 +9,14 @@ func displayName(for meta: AttachmentMeta) -> String {
if !meta.filename.isEmpty { return meta.filename }
return "(unknown)"
}
func attachmentMetadataLine(for meta: AttachmentMeta) -> String {
let name = displayName(for: meta)
var line =
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
if let convertedPath = meta.convertedPath {
let convertedMime = meta.convertedMimeType ?? ""
line += " converted_mime=\(convertedMime) converted_path=\(convertedPath)"
}
return line
}

View File

@ -0,0 +1,107 @@
import Foundation
import IMsgCore
struct ChatTargetInput: Sendable {
let recipient: String
let chatID: Int64?
let chatIdentifier: String
let chatGUID: String
var hasChatTarget: Bool {
chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
}
}
struct ResolvedChatTarget: Sendable {
let chatIdentifier: String
let chatGUID: String
var preferredIdentifier: String? {
if !chatGUID.isEmpty { return chatGUID }
if !chatIdentifier.isEmpty { return chatIdentifier }
return nil
}
}
enum ChatTargetResolver {
static func validateRecipientRequirements(
input: ChatTargetInput,
mixedTargetError: Error,
missingRecipientError: Error
) throws {
if input.hasChatTarget && !input.recipient.isEmpty {
throw mixedTargetError
}
if !input.hasChatTarget && input.recipient.isEmpty {
throw missingRecipientError
}
}
static func resolveChatTarget(
input: ChatTargetInput,
lookupChat: (Int64) async throws -> ChatInfo?,
unknownChatError: (Int64) -> Error
) async throws -> ResolvedChatTarget {
var resolvedIdentifier = input.chatIdentifier
var resolvedGUID = input.chatGUID
if let chatID = input.chatID {
guard let info = try await lookupChat(chatID) else {
throw unknownChatError(chatID)
}
resolvedIdentifier = info.identifier
resolvedGUID = info.guid
}
return ResolvedChatTarget(
chatIdentifier: resolvedIdentifier,
chatGUID: resolvedGUID
)
}
static func directTypingIdentifier(
recipient: String,
serviceRaw: String,
invalidServiceError: (String) -> Error
) throws -> String {
guard let service = MessageService(rawValue: serviceRaw.lowercased()) else {
throw invalidServiceError(serviceRaw)
}
let prefix = service == .sms ? "SMS" : "iMessage"
return "\(prefix);-;\(recipient)"
}
static func looksLikeContactName(_ recipient: String) -> Bool {
let trimmed = recipient.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed.contains("@") { return false }
if trimmed.hasPrefix("+") { return false }
let phoneCharacters = CharacterSet(charactersIn: "0123456789-(). ")
if trimmed.unicodeScalars.allSatisfy({ phoneCharacters.contains($0) }) {
return false
}
return true
}
static func resolveRecipientName(
_ recipient: String,
contacts: any ContactResolving
) throws -> String {
guard looksLikeContactName(recipient) else { return recipient }
let matches = contacts.searchByName(recipient)
switch matches.count {
case 0:
return recipient
case 1:
return matches[0].handle
default:
let details =
matches
.map { " \($0.name): \($0.handle)" }
.joined(separator: "\n")
throw IMsgError.invalidChatTarget(
"Multiple contacts match \"\(recipient)\":\n\(details)\nSpecify a phone number or email instead."
)
}
}
}

View File

@ -11,10 +11,38 @@ struct CommandRouter {
self.version = CommandRouter.resolveVersion()
self.specs = [
ChatsCommand.spec,
GroupCommand.spec,
HistoryCommand.spec,
WatchCommand.spec,
SendCommand.spec,
ReactCommand.spec,
ReadCommand.spec,
TypingCommand.spec,
LaunchCommand.spec,
StatusCommand.spec,
RpcCommand.spec,
CompletionsCommand.spec,
// Bridge-backed (require `imsg launch` + SIP off)
SendRichCommand.spec,
SendMultipartCommand.spec,
SendAttachmentCommand.spec,
BridgeReactCommand.spec,
EditCommand.spec,
UnsendCommand.spec,
DeleteMessageCommand.spec,
NotifyAnywaysCommand.spec,
ChatCreateCommand.spec,
ChatNameCommand.spec,
ChatPhotoCommand.spec,
ChatAddMemberCommand.spec,
ChatRemoveMemberCommand.spec,
ChatLeaveCommand.spec,
ChatDeleteCommand.spec,
ChatMarkCommand.spec,
SearchCommand.spec,
AccountCommand.spec,
WhoisCommand.spec,
NicknameCommand.spec,
]
let descriptor = CommandDescriptor(
name: rootName,
@ -33,7 +61,7 @@ struct CommandRouter {
func run(argv: [String]) async -> Int32 {
let argv = normalizeArguments(argv)
if argv.contains("--version") || argv.contains("-V") {
Swift.print(version)
StdoutWriter.writeLine(version)
return 0
}
if argv.count <= 1 || argv.contains("--help") || argv.contains("-h") {
@ -46,7 +74,7 @@ struct CommandRouter {
guard let commandName = invocation.path.last,
let spec = specs.first(where: { $0.name == commandName })
else {
Swift.print("Unknown command")
StdoutWriter.writeLine("Unknown command")
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
return 1
}
@ -54,18 +82,20 @@ struct CommandRouter {
do {
try await spec.run(invocation.parsedValues, runtime)
return 0
} catch is BridgeOutput.EmittedError {
return 1
} catch {
Swift.print(error)
StdoutWriter.writeLine(String(describing: error))
return 1
}
} catch let error as CommanderProgramError {
Swift.print(error.description)
StdoutWriter.writeLine(error.description)
if case .missingSubcommand = error {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
}
return 1
} catch {
Swift.print(error)
StdoutWriter.writeLine(String(describing: error))
return 1
}
}

View File

@ -0,0 +1,302 @@
import Commander
import Foundation
import IMsgCore
// MARK: - chat-create
enum ChatCreateCommand {
static let spec = CommandSpec(
name: "chat-create",
abstract: "Create a new chat (1:1 or group)",
discussion: """
Requires `imsg launch` (SIP-disabled, dylib injected). Vends handles for
each address through Messages' private IMCore API and asks IMChatRegistry
to materialize a chat. Optionally sets a display name and sends an
initial message. Chat creation is currently iMessage-only; use
`imsg send --service sms` for SMS sends.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(
label: "addresses", names: [.long("addresses")],
help: "comma-separated handles (phone or email)"),
.make(label: "name", names: [.long("name")], help: "group display name"),
.make(label: "text", names: [.long("text")], help: "initial message body"),
.make(
label: "service", names: [.long("service")], help: "iMessage (default)"),
]
)
),
usageExamples: [
"imsg chat-create --addresses '+15551234567,+15559876543' --name 'Crew' --text 'gm'"
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let raw = values.option("addresses"), !raw.isEmpty else {
throw ParsedValuesError.missingOption("addresses")
}
let addresses = raw.split(separator: ",").map {
String($0).trimmingCharacters(in: .whitespaces)
}
.filter { !$0.isEmpty }
guard !addresses.isEmpty else { throw ParsedValuesError.invalidOption("addresses") }
let service = values.option("service") ?? "iMessage"
guard service.caseInsensitiveCompare("iMessage") == .orderedSame else {
throw IMsgError.unsupportedService(service)
}
var params: [String: Any] = [
"addresses": addresses,
"service": "iMessage",
]
if let text = values.option("text"), !text.isEmpty { params["message"] = text }
if let name = values.option("name"), !name.isEmpty { params["displayName"] = name }
_ = try await BridgeOutput.invokeAndEmit(
action: .createChat, params: params, runtime: runtime
) { data in
let guid = (data["chatGuid"] as? String) ?? ""
return "chat-create: created (guid=\(guid))"
}
}
}
// MARK: - chat-name
enum ChatNameCommand {
static let spec = CommandSpec(
name: "chat-name",
abstract: "Set a chat's display name",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "name", names: [.long("name")], help: "new display name"),
]
)
),
usageExamples: ["imsg chat-name --chat 'iMessage;+;chat0000' --name 'New Name'"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let name = values.option("name") else {
throw ParsedValuesError.missingOption("name")
}
let params: [String: Any] = ["chatGuid": chat, "newName": name]
_ = try await BridgeOutput.invokeAndEmit(
action: .setDisplayName, params: params, runtime: runtime
) { _ in "chat-name: set" }
}
}
// MARK: - chat-photo
enum ChatPhotoCommand {
static let spec = CommandSpec(
name: "chat-photo",
abstract: "Set or clear a group chat photo",
discussion: "Omit --file to clear the existing photo.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "file", names: [.long("file")], help: "path to image (omit to clear)"),
]
)
),
usageExamples: ["imsg chat-photo --chat 'iMessage;+;chat0000' --file ~/Downloads/g.jpg"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
var params: [String: Any] = ["chatGuid": chat]
if let file = values.option("file"), !file.isEmpty {
params["filePath"] = (file as NSString).expandingTildeInPath
}
_ = try await BridgeOutput.invokeAndEmit(
action: .updateGroupPhoto, params: params, runtime: runtime
) { _ in "chat-photo: updated" }
}
}
// MARK: - chat-add-member / chat-remove-member
enum ChatAddMemberCommand {
static let spec = CommandSpec(
name: "chat-add-member",
abstract: "Add a participant to a group chat",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "address", names: [.long("address")], help: "phone or email to add"),
]
)
),
usageExamples: ["imsg chat-add-member --chat 'iMessage;+;chat0000' --address +15551234567"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let addr = values.option("address"), !addr.isEmpty else {
throw ParsedValuesError.missingOption("address")
}
let params: [String: Any] = ["chatGuid": chat, "address": addr]
_ = try await BridgeOutput.invokeAndEmit(
action: .addParticipant, params: params, runtime: runtime
) { _ in "chat-add-member: added" }
}
}
enum ChatRemoveMemberCommand {
static let spec = CommandSpec(
name: "chat-remove-member",
abstract: "Remove a participant from a group chat",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "address", names: [.long("address")], help: "phone or email to remove"),
]
)
),
usageExamples: ["imsg chat-remove-member --chat 'iMessage;+;chat0000' --address +15551234567"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let addr = values.option("address"), !addr.isEmpty else {
throw ParsedValuesError.missingOption("address")
}
let params: [String: Any] = ["chatGuid": chat, "address": addr]
_ = try await BridgeOutput.invokeAndEmit(
action: .removeParticipant, params: params, runtime: runtime
) { _ in "chat-remove-member: removed" }
}
}
// MARK: - chat-leave / chat-delete
enum ChatLeaveCommand {
static let spec = CommandSpec(
name: "chat-leave",
abstract: "Leave a group chat",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid")
]
)
),
usageExamples: ["imsg chat-leave --chat 'iMessage;+;chat0000'"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let params: [String: Any] = ["chatGuid": chat]
_ = try await BridgeOutput.invokeAndEmit(
action: .leaveChat, params: params, runtime: runtime
) { _ in "chat-leave: left" }
}
}
enum ChatDeleteCommand {
static let spec = CommandSpec(
name: "chat-delete",
abstract: "Delete a chat from Messages.app",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid")
]
)
),
usageExamples: ["imsg chat-delete --chat 'iMessage;-;+15551234567'"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let params: [String: Any] = ["chatGuid": chat]
_ = try await BridgeOutput.invokeAndEmit(
action: .deleteChat, params: params, runtime: runtime
) { _ in "chat-delete: deleted" }
}
}
// MARK: - chat-mark
enum ChatMarkCommand {
static let spec = CommandSpec(
name: "chat-mark",
abstract: "Mark a chat as read or unread",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid")
],
flags: [
.make(label: "read", names: [.long("read")], help: "mark as read"),
.make(label: "unread", names: [.long("unread")], help: "mark as unread"),
]
)
),
usageExamples: [
"imsg chat-mark --chat ... --read",
"imsg chat-mark --chat ... --unread",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let read = values.flag("read")
let unread = values.flag("unread")
if read && unread {
throw ParsedValuesError.invalidOption("read")
}
let action: BridgeAction = unread ? .markChatUnread : .markChatRead
let params: [String: Any] = ["chatGuid": chat]
_ = try await BridgeOutput.invokeAndEmit(
action: action, params: params, runtime: runtime
) { _ in "chat-mark: \(unread ? "unread" : "read")" }
}
}

View File

@ -0,0 +1,177 @@
import Commander
import Foundation
import IMsgCore
// MARK: - search
enum SearchCommand {
static let spec = CommandSpec(
name: "search",
abstract: "Search local Messages history",
discussion: """
Searches the local chat.db, not the injected bridge. Use --match exact
for case-insensitive exact text matches; the default is contains.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "query", names: [.long("query")], help: "search query (required)"),
.make(label: "match", names: [.long("match")], help: "exact|contains (default contains)"),
.make(label: "limit", names: [.long("limit")], help: "maximum results (default 50)"),
]
)
),
usageExamples: ["imsg search --query 'pizza tonight' --match contains"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(
values: ParsedValues,
runtime: RuntimeOptions,
contactResolverFactory: @escaping () async -> any ContactResolving = {
await ContactResolver.create()
}
) async throws {
guard let q = values.option("query"), !q.isEmpty else {
throw ParsedValuesError.missingOption("query")
}
let match = values.option("match") ?? "contains"
guard match == "contains" || match == "exact" else {
throw ParsedValuesError.invalidOption("match")
}
let dbPath = values.option("db") ?? MessageStore.defaultPath
let limit = values.optionInt("limit") ?? 50
let store = try MessageStore(path: dbPath)
let messages = try store.searchMessages(query: q, match: match, limit: limit)
let contacts = await contactResolverFactory()
if runtime.jsonOutput {
let cache = ChatCache(store: store)
for message in messages {
let payload = try await buildMessagePayload(
store: store,
cache: cache,
message: message,
includeAttachments: false,
includeReactions: false,
contactResolver: contacts
)
try JSONLines.printObject(payload)
}
return
}
for message in messages {
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
let sender =
message.isFromMe
? message.sender : (contacts.displayName(for: message.sender) ?? message.sender)
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(sender): \(message.text)")
}
}
}
// MARK: - account
enum AccountCommand {
static let spec = CommandSpec(
name: "account",
abstract: "Show the active iMessage account, login, and aliases",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions()
)),
usageExamples: ["imsg account"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
_ = try await BridgeOutput.invokeAndEmit(
action: .getAccountInfo, params: [:], runtime: runtime
) { data in
let login = (data["login"] as? String) ?? ""
let aliases = (data["vetted_aliases"] as? [String]) ?? []
return "account: \(login)\n aliases: \(aliases.joined(separator: ", "))"
}
}
}
// MARK: - whois
enum WhoisCommand {
static let spec = CommandSpec(
name: "whois",
abstract: "Check whether a handle is reachable on iMessage",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "address", names: [.long("address")], help: "phone or email to check"),
.make(label: "type", names: [.long("type")], help: "phone|email"),
]
)
),
usageExamples: [
"imsg whois --address +15551234567 --type phone",
"imsg whois --address foo@bar.com --type email",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let addr = values.option("address"), !addr.isEmpty else {
throw ParsedValuesError.missingOption("address")
}
let aliasType = values.option("type") ?? (addr.contains("@") ? "email" : "phone")
let params: [String: Any] = [
"address": addr,
"aliasType": aliasType,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .checkImessageAvailability, params: params, runtime: runtime
) { data in
let avail = (data["available"] as? Bool) ?? false
let status = (data["id_status"] as? Int) ?? 0
return "whois \(addr): \(avail ? "available" : "unavailable") (id_status=\(status))"
}
}
}
// MARK: - nickname
enum NicknameCommand {
static let spec = CommandSpec(
name: "nickname",
abstract: "Show contact-card / nickname info for a handle",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "address", names: [.long("address")], help: "phone or email")
]
)
),
usageExamples: ["imsg nickname --address +15551234567"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let addr = values.option("address"), !addr.isEmpty else {
throw ParsedValuesError.missingOption("address")
}
let params: [String: Any] = ["address": addr]
_ = try await BridgeOutput.invokeAndEmit(
action: .getNicknameInfo, params: params, runtime: runtime
) { data in
let has = (data["has_nickname"] as? Bool) ?? false
let desc = (data["description"] as? String) ?? ""
return "nickname: \(has ? desc : "(none)")"
}
}
}

View File

@ -0,0 +1,505 @@
import Commander
import Foundation
import IMsgCore
/// Expand short expressive-send names (e.g. `invisibleink`, `confetti`) to the
/// full bundle identifiers Messages.app expects on `expressiveSendStyleID`.
/// Already-prefixed strings (anything starting with `com.apple.`) and unknown
/// names pass through untouched so the dylib can return its own error.
enum ExpressiveSendEffect {
/// Bubble effects render on the message bubble itself.
static let bubbleNames: Set<String> = ["impact", "loud", "gentle", "invisibleink"]
/// Screen effects play a full-screen animation. Map the short name to the
/// `CK<TitleCase>Effect` token used in the bundle id.
static let screenNames: [String: String] = [
"confetti": "Confetti",
"lasers": "Lasers",
"fireworks": "Fireworks",
"balloons": "Balloons",
"sparkles": "Sparkles",
"spotlight": "Spotlight",
"echo": "Echo",
"love": "Love",
"celebration": "Celebration",
]
static func expand(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return raw }
if trimmed.hasPrefix("com.apple.") { return trimmed }
let key = trimmed.lowercased()
if bubbleNames.contains(key) {
return "com.apple.MobileSMS.expressivesend.\(key)"
}
if let token = screenNames[key] {
return "com.apple.messages.effect.CK\(token)Effect"
}
return trimmed
}
}
/// Helpers shared by all bridge-backed commands.
enum BridgeOutput {
struct EmittedError: Error {}
static func emit(_ data: [String: Any], runtime: RuntimeOptions, summary: String) {
if runtime.jsonOutput {
try? JSONLines.printObject(data)
} else {
StdoutWriter.writeLine(summary)
}
}
static func emitError(_ message: String, runtime: RuntimeOptions) {
if runtime.jsonOutput {
try? JSONLines.printObject(["success": false, "error": message])
} else {
StdoutWriter.writeLine("error: \(message)")
}
}
/// Invoke a bridge action and emit the result. Returns the data dict on
/// success or nil on failure (after emitting an error message).
static func invokeAndEmit(
action: BridgeAction,
params: [String: Any],
runtime: RuntimeOptions,
summary: (([String: Any]) -> String)
) async throws -> [String: Any] {
do {
let data = try await IMsgBridgeClient.shared.invoke(action: action, params: params)
emit(data, runtime: runtime, summary: summary(data))
return data
} catch {
emitError(String(describing: error), runtime: runtime)
throw EmittedError()
}
}
}
// MARK: - send-rich
enum SendRichCommand {
static let spec = CommandSpec(
name: "send-rich",
abstract: "Send a message via the IMCore bridge (effects, replies, subjects)",
discussion: """
Requires `imsg launch` (SIP-disabled, dylib injected). Unlike `imsg send`
which uses AppleScript, this routes through Messages' private API for
richer features: expressive-send effects, reply targets, subject lines.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(
label: "chat", names: [.long("chat")], help: "chat guid (e.g. iMessage;-;+15551234567)"),
.make(label: "text", names: [.long("text")], help: "message body"),
.make(
label: "effect", names: [.long("effect")],
help: "expressive send id (impact, loud, gentle, invisibleink, confetti, …)"),
.make(label: "subject", names: [.long("subject")], help: "subject line"),
.make(label: "replyTo", names: [.long("reply-to")], help: "guid of message to reply to"),
.make(label: "part", names: [.long("part")], help: "part index (default 0)"),
.make(
label: "format",
names: [.long("format")],
help: "JSON array of {start,length,styles:[...]} ranges (macOS 15+)"),
.make(
label: "formatFile", names: [.long("format-file")],
help: "path to JSON file containing the format ranges array"),
],
flags: [
.make(
label: "noDDScan", names: [.long("no-dd-scan")],
help: "disable data-detector scan deferral")
]
)
),
usageExamples: [
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi'",
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'BOOM' --effect impact",
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'pew pew' --effect lasers",
"imsg send-rich --chat ... --text 'hello world' --format '[{\"start\":0,\"length\":5,\"styles\":[\"bold\"]}]'",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let text = values.option("text") ?? ""
var params: [String: Any] = [
"chatGuid": chat,
"message": text,
"partIndex": Int(values.option("part") ?? "0") ?? 0,
"ddScan": !values.flag("noDDScan"),
]
if let effect = values.option("effect"), !effect.isEmpty {
params["effectId"] = ExpressiveSendEffect.expand(effect)
}
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
if let reply = values.option("replyTo"), !reply.isEmpty {
params["selectedMessageGuid"] = reply
}
// Optional text formatting (macOS 15+ Sequoia and later). Pass either
// inline JSON via --format or a file path via --format-file. Format:
// [{"start":0,"length":5,"styles":["bold","italic"]}, ...]
let formatRaw: String?
if let inline = values.option("format"), !inline.isEmpty {
formatRaw = inline
} else if let path = values.option("formatFile"), !path.isEmpty {
formatRaw = try String(contentsOfFile: path, encoding: .utf8)
} else {
formatRaw = nil
}
if let raw = formatRaw {
guard
let bytes = raw.data(using: .utf8),
let ranges = try JSONSerialization.jsonObject(with: bytes) as? [[String: Any]]
else {
throw ParsedValuesError.invalidOption("format")
}
params["textFormatting"] = ranges
}
_ = try await BridgeOutput.invokeAndEmit(
action: .sendMessage, params: params, runtime: runtime
) { data in
let guid = (data["messageGuid"] as? String) ?? ""
return guid.isEmpty ? "send-rich: queued" : "send-rich: sent (guid=\(guid))"
}
}
}
// MARK: - send-multipart
enum SendMultipartCommand {
static let spec = CommandSpec(
name: "send-multipart",
abstract: "Send a multi-part message",
discussion: """
Pass --parts as a JSON array (e.g., '[{"text":"hi"},{"text":"there"}]')
or via --parts-file pointing at a .json file. v1 supports text-only
parts; mention/file parts are a future enhancement.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "parts", names: [.long("parts")], help: "JSON array of parts"),
.make(
label: "partsFile", names: [.long("parts-file")],
help: "path to JSON file containing parts array"),
.make(label: "effect", names: [.long("effect")], help: "expressive send id"),
.make(label: "subject", names: [.long("subject")], help: "subject line"),
]
)
),
usageExamples: [
"imsg send-multipart --chat 'iMessage;+;chat0000' --parts '[{\"text\":\"hi\"},{\"text\":\"world\"}]'"
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let partsRaw: String
if let inline = values.option("parts"), !inline.isEmpty {
partsRaw = inline
} else if let path = values.option("partsFile"), !path.isEmpty {
partsRaw = try String(contentsOfFile: path, encoding: .utf8)
} else {
throw ParsedValuesError.missingOption("parts")
}
guard
let data = partsRaw.data(using: .utf8),
let parts = try JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
throw ParsedValuesError.invalidOption("parts")
}
var params: [String: Any] = ["chatGuid": chat, "parts": parts]
if let effect = values.option("effect"), !effect.isEmpty {
params["effectId"] = ExpressiveSendEffect.expand(effect)
}
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
_ = try await BridgeOutput.invokeAndEmit(
action: .sendMultipart, params: params, runtime: runtime
) { data in
let guid = (data["messageGuid"] as? String) ?? ""
let count = (data["parts_count"] as? Int) ?? 0
return "send-multipart: \(count) parts queued (guid=\(guid))"
}
}
}
// MARK: - send-attachment
enum SendAttachmentCommand {
static let spec = CommandSpec(
name: "send-attachment",
abstract: "Send a file attachment via the IMCore bridge",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "file", names: [.long("file")], help: "absolute path to file"),
],
flags: [
.make(label: "audio", names: [.long("audio")], help: "send as audio message")
]
)
),
usageExamples: [
"imsg send-attachment --chat 'iMessage;-;+15551234567' --file ~/Pictures/me.jpg"
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let file = values.option("file"), !file.isEmpty else {
throw ParsedValuesError.missingOption("file")
}
let expanded = (file as NSString).expandingTildeInPath
let params: [String: Any] = [
"chatGuid": chat,
"filePath": expanded,
"isAudioMessage": values.flag("audio"),
]
_ = try await BridgeOutput.invokeAndEmit(
action: .sendAttachment, params: params, runtime: runtime
) { data in
let guid = (data["messageGuid"] as? String) ?? ""
return "send-attachment: queued (guid=\(guid))"
}
}
}
// MARK: - react (BB-style; complements existing AS-backed `react`)
enum BridgeReactCommand {
static let spec = CommandSpec(
name: "tapback",
abstract: "Send a tapback reaction via the IMCore bridge",
discussion: """
`imsg tapback` uses the bridge for reliability across macOS versions.
`imsg react` (AppleScript) remains for SIP-on machines.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
.make(
label: "kind", names: [.long("kind")],
help: "love|like|dislike|laugh|emphasize|question"),
.make(label: "part", names: [.long("part")], help: "part index"),
],
flags: [
.make(
label: "remove", names: [.long("remove")],
help: "remove this reaction instead of adding")
]
)
),
usageExamples: [
"imsg tapback --chat 'iMessage;-;+15551234567' --message ABCD-EFGH --kind love"
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
guard let kind = values.option("kind"), !kind.isEmpty else {
throw ParsedValuesError.missingOption("kind")
}
let normalized = kind.lowercased()
let prefixed = values.flag("remove") ? "remove-\(normalized)" : normalized
let params: [String: Any] = [
"chatGuid": chat,
"selectedMessageGuid": message,
"reactionType": prefixed,
"partIndex": Int(values.option("part") ?? "0") ?? 0,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .sendReaction, params: params, runtime: runtime
) { _ in "tapback: \(prefixed) sent" }
}
}
// MARK: - edit
enum EditCommand {
static let spec = CommandSpec(
name: "edit",
abstract: "Edit a sent message",
discussion: "Requires macOS 13+ (selector-probed at startup).",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
.make(label: "newText", names: [.long("new-text")], help: "replacement text"),
.make(
label: "bcText",
names: [.long("bc-text")],
help: "backwards-compat text shown to older clients (default: same as new-text)"),
.make(label: "part", names: [.long("part")], help: "part index"),
]
)
),
usageExamples: ["imsg edit --chat ... --message <guid> --new-text 'updated'"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
guard let newText = values.option("newText"), !newText.isEmpty else {
throw ParsedValuesError.missingOption("new-text")
}
let params: [String: Any] = [
"chatGuid": chat,
"messageGuid": message,
"editedMessage": newText,
"backwardsCompatibilityMessage": values.option("bcText") ?? newText,
"partIndex": Int(values.option("part") ?? "0") ?? 0,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .editMessage, params: params, runtime: runtime
) { _ in "edit: queued" }
}
}
// MARK: - unsend
enum UnsendCommand {
static let spec = CommandSpec(
name: "unsend",
abstract: "Retract a sent message",
discussion: "Requires macOS 13+ (selector-probed at startup).",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
.make(label: "part", names: [.long("part")], help: "part index"),
]
)
),
usageExamples: ["imsg unsend --chat ... --message <guid>"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
let params: [String: Any] = [
"chatGuid": chat,
"messageGuid": message,
"partIndex": Int(values.option("part") ?? "0") ?? 0,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .unsendMessage, params: params, runtime: runtime
) { _ in "unsend: queued" }
}
}
// MARK: - delete-message
enum DeleteMessageCommand {
static let spec = CommandSpec(
name: "delete-message",
abstract: "Delete a single message from a chat",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
]
)
),
usageExamples: ["imsg delete-message --chat ... --message <guid>"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
let params: [String: Any] = [
"chatGuid": chat,
"messageGuid": message,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .deleteMessage, params: params, runtime: runtime
) { _ in "delete-message: queued" }
}
}
// MARK: - notify-anyways
enum NotifyAnywaysCommand {
static let spec = CommandSpec(
name: "notify-anyways",
abstract: "Force a notification for a message that was filtered/suppressed",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
]
)
),
usageExamples: ["imsg notify-anyways --chat ... --message <guid>"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
let params: [String: Any] = ["chatGuid": chat, "messageGuid": message]
_ = try await BridgeOutput.invokeAndEmit(
action: .notifyAnyways, params: params, runtime: runtime
) { _ in "notify-anyways: queued" }
}
}

View File

@ -19,21 +19,72 @@ enum ChatsCommand {
"imsg chats --limit 5 --json",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(
values: ParsedValues,
runtime: RuntimeOptions,
contactResolverFactory: @escaping () async -> any ContactResolving = {
await ContactResolver.create()
}
) async throws {
let dbPath = values.option("db") ?? MessageStore.defaultPath
let limit = values.optionInt("limit") ?? 20
let store = try MessageStore(path: dbPath)
let chats = try store.listChats(limit: limit)
let contacts = await contactResolverFactory()
if runtime.jsonOutput {
for chat in chats {
try JSONLines.print(ChatPayload(chat: chat))
let chatInfo = try store.chatInfo(chatID: chat.id)
let participants = try store.participants(chatID: chat.id)
let contactName = contactNameForChat(
chat: chat,
chatInfo: chatInfo,
participants: participants,
contacts: contacts
)
try StdoutWriter.writeJSONLine(
ChatPayload(
chat: chat,
chatInfo: chatInfo,
participants: participants,
contactName: contactName
))
}
return
}
for chat in chats {
let last = CLIISO8601.format(chat.lastMessageAt)
Swift.print("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)")
let participants = try store.participants(chatID: chat.id)
let contactName = contactNameForChat(
chat: chat,
chatInfo: nil,
participants: participants,
contacts: contacts
)
let displayName = contactName ?? chat.name
StdoutWriter.writeLine("[\(chat.id)] \(displayName) (\(chat.identifier)) last=\(last)")
}
}
private static func contactNameForChat(
chat: Chat,
chatInfo: ChatInfo?,
participants: [String],
contacts: any ContactResolving
) -> String? {
let identifier = chatInfo?.identifier ?? chat.identifier
let guid = chatInfo?.guid ?? ""
guard !isGroupHandle(identifier: identifier, guid: guid) else { return nil }
if let name = contacts.displayName(for: identifier) {
return name
}
if participants.count == 1 {
return contacts.displayName(for: participants[0])
}
return nil
}
}

View File

@ -0,0 +1,461 @@
import Commander
import Foundation
import IMsgCore
enum CompletionsCommand {
static let spec = CommandSpec(
name: "completions",
abstract: "Generate shell completions or LLM context",
discussion: "Outputs completion scripts for bash, zsh, fish, or a Markdown CLI reference.",
signature: CommandSignature(
arguments: [
.make(label: "shell", help: "bash, zsh, fish, or llm", isOptional: true)
]
),
usageExamples: [
"imsg completions bash > ~/.bash_completion.d/imsg",
"imsg completions zsh > ~/.zsh/completions/_imsg",
"imsg completions fish > ~/.config/fish/completions/imsg.fish",
"imsg completions llm",
]
) { values, _ in
try await run(shell: values.argument(0), specs: CommandRouter().specs)
}
static func run(shell: String?, specs: [CommandSpec]) async throws {
let output = try CompletionGenerator.generate(shell: shell, rootName: "imsg", specs: specs)
StdoutWriter.writeLine(output)
}
}
enum CompletionError: Error, CustomStringConvertible, Sendable {
case missingShell
case unknownShell(String)
var description: String {
switch self {
case .missingShell:
return "Missing shell argument. Use: bash, zsh, fish, or llm"
case .unknownShell(let shell):
return "Unknown shell '\(shell)'. Use: bash, zsh, fish, or llm"
}
}
}
enum CompletionGenerator {
static func generate(shell: String?, rootName: String, specs: [CommandSpec]) throws -> String {
guard let shell, !shell.isEmpty else {
throw CompletionError.missingShell
}
switch shell.lowercased() {
case "bash":
return BashCompletionGenerator.generate(rootName: rootName, specs: specs)
case "zsh":
return ZshCompletionGenerator.generate(rootName: rootName, specs: specs)
case "fish":
return FishCompletionGenerator.generate(rootName: rootName, specs: specs)
case "llm":
return LLMCompletionGenerator.generate(rootName: rootName, specs: specs)
default:
throw CompletionError.unknownShell(shell)
}
}
static let serviceChoices = MessageService.allCases.map(\.rawValue).joined(separator: " ")
static let reactionChoices = "love like dislike laugh emphasis question"
static let logLevelChoices = "trace verbose debug info warning error critical"
static func optionNames(for spec: CommandSpec) -> [String] {
let signature = spec.signature.flattened()
return
(signature.options.flatMap { names($0.names) } + signature.flags.flatMap { names($0.names) })
.sorted()
}
static func zshOptions(for spec: CommandSpec) -> [String] {
let signature = spec.signature.flattened()
var result = signature.options.map { option in
let names = zshNameGroup(option.names)
let help = escapeZsh(option.help ?? "")
let longName = primaryLongName(option.names) ?? option.label
let choices = choicesForOption(longName)
let value =
choices.map { ":value:(\($0))" }
?? ":value:"
return "'\(names)[\(help)]\(value)'"
}
result += signature.flags.map { flag in
"'\(zshNameGroup(flag.names))[\(escapeZsh(flag.help ?? ""))]'"
}
if spec.name == "completions" {
result.append("'1:shell:(bash zsh fish llm)'")
}
return result
}
static func fishOption(
rootName: String,
command: String,
option: OptionDefinition
) -> String {
var line = "complete -c \(rootName) -n '__\(rootName)_using_command \(command)'"
for name in option.names where !name.isAlias {
line += fishName(name)
}
line += " -d \(shellQuote(option.help ?? ""))"
if let choices = choicesForOption(primaryLongName(option.names) ?? option.label) {
line += " -xa \(shellQuote(choices))"
} else if optionWantsFiles(option) {
line += " -r -F"
} else {
line += " -x"
}
return line
}
static func fishFlag(rootName: String, command: String, flag: FlagDefinition) -> String {
var line = "complete -c \(rootName) -n '__\(rootName)_using_command \(command)'"
for name in flag.names where !name.isAlias {
line += fishName(name)
}
line += " -d \(shellQuote(flag.help ?? ""))"
return line
}
static func usageFragment(for signature: CommandSignature) -> String {
var parts: [String] = []
for argument in signature.arguments {
parts.append(argument.isOptional ? "[\(argument.label)]" : "<\(argument.label)>")
}
if !signature.options.isEmpty || !signature.flags.isEmpty {
parts.append("[options]")
}
return parts.joined(separator: " ")
}
static func names(_ names: [CommanderName]) -> [String] {
names.map { name in
switch name {
case .short(let value), .aliasShort(let value):
return "-\(value)"
case .long(let value), .aliasLong(let value):
return "--\(value)"
}
}
}
static func formatNames(_ commandNames: [CommanderName], expectsValue: Bool) -> String {
names(commandNames).joined(separator: ", ") + (expectsValue ? " <value>" : "")
}
static func primaryLongName(_ names: [CommanderName]) -> String? {
for name in names {
if case .long(let value) = name {
return value
}
}
return nil
}
static func choicesForOption(_ name: String) -> String? {
switch name {
case "service":
return serviceChoices
case "reaction":
return reactionChoices
case "log-level", "logLevel":
return logLevelChoices
default:
return nil
}
}
static func optionWantsFiles(_ option: OptionDefinition) -> Bool {
let longName = primaryLongName(option.names) ?? option.label
return longName == "db" || longName == "file"
|| option.help?.localizedCaseInsensitiveContains("path") == true
}
static func zshNameGroup(_ names: [CommanderName]) -> String {
let visible = names.filter { !$0.isAlias }
return visible.map { name in
switch name {
case .short(let value):
return "-\(value)"
case .long(let value):
return "--\(value)"
case .aliasShort(let value):
return "-\(value)"
case .aliasLong(let value):
return "--\(value)"
}
}.joined(separator: ",")
}
static func fishName(_ name: CommanderName) -> String {
switch name {
case .short(let value), .aliasShort(let value):
return " -s \(value)"
case .long(let value), .aliasLong(let value):
return " -l \(value)"
}
}
static func shellQuote(_ value: String) -> String {
"'\(value.replacingOccurrences(of: "'", with: "\\'"))'"
}
static func escapeZsh(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "]", with: "\\]")
.replacingOccurrences(of: "'", with: "'\\''")
}
}
private enum BashCompletionGenerator {
static func generate(rootName: String, specs: [CommandSpec]) -> String {
let commands = specs.map(\.name).joined(separator: " ")
let commandCases = specs.map { spec in
let options = CompletionGenerator.optionNames(for: spec).joined(separator: " ")
return """
\(spec.name))
COMPREPLY=($(compgen -W "\(options)" -- "$cur"))
;;
"""
}.joined(separator: "\n")
return """
# Bash completion for \(rootName)
# Generated by: \(rootName) completions bash
_\(rootName)() {
local cur prev words cword
if type _init_completion >/dev/null 2>&1; then
_init_completion || return
else
COMPREPLY=()
words=("${COMP_WORDS[@]}")
cword=$COMP_CWORD
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
fi
local commands="\(commands)"
case "$prev" in
--db|--file)
COMPREPLY=($(compgen -f -- "$cur"))
return
;;
--service)
COMPREPLY=($(compgen -W "\(CompletionGenerator.serviceChoices)" -- "$cur"))
return
;;
--reaction|-r)
COMPREPLY=($(compgen -W "\(CompletionGenerator.reactionChoices)" -- "$cur"))
return
;;
--log-level|--logLevel)
COMPREPLY=($(compgen -W "\(CompletionGenerator.logLevelChoices)" -- "$cur"))
return
;;
completions)
COMPREPLY=($(compgen -W "bash zsh fish llm" -- "$cur"))
return
;;
esac
local cmd=""
local word
for word in "${words[@]:1:cword-1}"; do
case "$word" in
-*) ;;
*)
if [[ " $commands " == *" $word "* ]]; then
cmd="$word"
break
fi
;;
esac
done
if [[ -z "$cmd" ]]; then
COMPREPLY=($(compgen -W "$commands --help -h --version -V" -- "$cur"))
return
fi
case "$cmd" in
\(commandCases)
esac
}
complete -F _\(rootName) \(rootName)
"""
}
}
private enum ZshCompletionGenerator {
static func generate(rootName: String, specs: [CommandSpec]) -> String {
let commandDescriptions =
specs
.map { " '\($0.name):\(CompletionGenerator.escapeZsh($0.abstract))'" }
.joined(separator: "\n")
let commandCases = specs.map { spec in
let optionSpecs = CompletionGenerator.zshOptions(for: spec).map { " \($0) \\" }
.joined(separator: "\n")
return """
\(spec.name))
_arguments \\
\(optionSpecs)
&& return 0
;;
"""
}.joined(separator: "\n")
return """
#compdef \(rootName)
# Zsh completion for \(rootName)
# Generated by: \(rootName) completions zsh
_\(rootName)() {
local context state line
typeset -A opt_args
local -a commands
commands=(
\(commandDescriptions)
)
_arguments -C \\
'(- *)'{-h,--help}'[Show help]' \\
'(- *)'{-V,--version}'[Show version]' \\
'1:command:->command' \\
'*::arg:->args' \\
&& return 0
case $state in
command)
_describe -t commands '\(rootName) commands' commands
;;
args)
case $words[2] in
\(commandCases)
esac
;;
esac
}
_\(rootName) "$@"
"""
}
}
private enum FishCompletionGenerator {
static func generate(rootName: String, specs: [CommandSpec]) -> String {
var lines: [String] = [
"# Fish completion for \(rootName)",
"# Generated by: \(rootName) completions fish",
"",
"complete -c \(rootName) -f",
"",
"function __\(rootName)_needs_command",
" set -l cmd (commandline -opc)",
" test (count $cmd) -eq 1",
"end",
"",
"function __\(rootName)_using_command",
" set -l cmd (commandline -opc)",
" test (count $cmd) -gt 1; and contains -- $cmd[2] $argv",
"end",
"",
]
for spec in specs {
let commandName = CompletionGenerator.shellQuote(spec.name)
let abstract = CompletionGenerator.shellQuote(spec.abstract)
lines.append(
"complete -c \(rootName) -n __\(rootName)_needs_command -a \(commandName) -d \(abstract)"
)
}
lines.append("")
for spec in specs {
for option in spec.signature.flattened().options {
lines.append(
CompletionGenerator.fishOption(rootName: rootName, command: spec.name, option: option))
}
for flag in spec.signature.flattened().flags {
lines.append(
CompletionGenerator.fishFlag(rootName: rootName, command: spec.name, flag: flag))
}
if spec.name == "completions" {
lines.append(
"complete -c \(rootName) -n '__\(rootName)_using_command completions' -a 'bash zsh fish llm'"
)
}
}
return lines.joined(separator: "\n")
}
}
private enum LLMCompletionGenerator {
static func generate(rootName: String, specs: [CommandSpec]) -> String {
var lines: [String] = [
"# \(rootName) CLI Reference",
"",
"macOS Messages.app CLI to send, read, and stream iMessage/SMS.",
"",
"## Commands",
"",
]
for spec in specs {
lines.append("### \(spec.name)")
lines.append("")
lines.append(spec.abstract)
if let discussion = spec.discussion, !discussion.isEmpty {
lines.append("")
lines.append(discussion)
}
lines.append("")
lines.append(
"Usage: `\(rootName) \(spec.name) \(CompletionGenerator.usageFragment(for: spec.signature))`"
)
lines.append("")
let signature = spec.signature.flattened()
if !signature.arguments.isEmpty {
lines.append("Arguments:")
for argument in signature.arguments {
let optional = argument.isOptional ? " optional" : ""
lines.append("- `\(argument.label)`\(optional): \(argument.help ?? "")")
}
lines.append("")
}
if !signature.options.isEmpty || !signature.flags.isEmpty {
lines.append("Options:")
for option in signature.options {
lines.append(
"- `\(CompletionGenerator.formatNames(option.names, expectsValue: true))`: \(option.help ?? "")"
)
}
for flag in signature.flags {
lines.append(
"- `\(CompletionGenerator.formatNames(flag.names, expectsValue: false))`: \(flag.help ?? "")"
)
}
lines.append("")
}
if !spec.usageExamples.isEmpty {
lines.append("Examples:")
for example in spec.usageExamples {
lines.append("- `\(example)`")
}
lines.append("")
}
}
return lines.joined(separator: "\n")
}
}

View File

@ -0,0 +1,63 @@
import Commander
import Foundation
import IMsgCore
enum GroupCommand {
static let spec = CommandSpec(
name: "group",
abstract: "Show chat identity and participants for a chat id",
discussion: "Prints chat identifier, guid, display name, service, group flag, "
+ "and participants for a given chat rowid. Works for direct chats too.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid from 'imsg chats'")
]
)
),
usageExamples: [
"imsg group --chat-id 1",
"imsg group --chat-id 1 --json",
]
) { values, runtime in
guard let chatID = values.optionInt64("chatID") else {
throw ParsedValuesError.missingOption("chat-id")
}
let dbPath = values.option("db") ?? MessageStore.defaultPath
let store = try MessageStore(path: dbPath)
guard let info = try store.chatInfo(chatID: chatID) else {
throw IMsgError.chatNotFound(chatID: chatID)
}
let participants = try store.participants(chatID: chatID)
if runtime.jsonOutput {
try StdoutWriter.writeJSONLine(GroupPayload(chatInfo: info, participants: participants))
return
}
StdoutWriter.writeLine("id: \(info.id)")
StdoutWriter.writeLine("identifier: \(info.identifier)")
StdoutWriter.writeLine("guid: \(info.guid)")
StdoutWriter.writeLine("name: \(info.name)")
StdoutWriter.writeLine("service: \(info.service)")
if let accountID = info.accountID {
StdoutWriter.writeLine("account_id: \(accountID)")
}
if let accountLogin = info.accountLogin {
StdoutWriter.writeLine("account_login: \(accountLogin)")
}
if let lastAddressedHandle = info.lastAddressedHandle {
StdoutWriter.writeLine("last_addressed_handle: \(lastAddressedHandle)")
}
let isGroup = isGroupHandle(identifier: info.identifier, guid: info.guid)
StdoutWriter.writeLine("is_group: \(isGroup)")
if participants.isEmpty {
StdoutWriter.writeLine("participants: (none)")
} else {
StdoutWriter.writeLine("participants:")
for handle in participants {
StdoutWriter.writeLine(" - \(handle)")
}
}
}
}

View File

@ -21,7 +21,11 @@ enum HistoryCommand {
flags: [
.make(
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
)
),
.make(
label: "convertAttachments", names: [.long("convert-attachments")],
help: "convert CAF/GIF attachments to model-compatible cached files"
),
]
)
),
@ -30,12 +34,24 @@ enum HistoryCommand {
"imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(
values: ParsedValues,
runtime: RuntimeOptions,
contactResolverFactory: @escaping () async -> any ContactResolving = {
await ContactResolver.create()
}
) async throws {
guard let chatID = values.optionInt64("chatID") else {
throw ParsedValuesError.missingOption("chat-id")
}
let dbPath = values.option("db") ?? MessageStore.defaultPath
let limit = values.optionInt("limit") ?? 50
let showAttachments = values.flag("attachments")
let attachmentOptions = AttachmentQueryOptions(
convertUnsupported: values.flag("convertAttachments"))
let participants = values.optionValues("participants")
.flatMap { $0.split(separator: ",").map { String($0) } }
.filter { !$0.isEmpty }
@ -46,19 +62,29 @@ enum HistoryCommand {
)
let store = try MessageStore(path: dbPath)
let messages = try store.messages(chatID: chatID, limit: limit)
let filtered = messages.filter { filter.allows($0) }
let filtered = try store.messages(chatID: chatID, limit: limit, filter: filter)
let contacts = await contactResolverFactory()
if runtime.jsonOutput {
let cache = ChatCache(store: store)
let attachmentsByMessageID = try store.attachments(
for: filtered.map(\.rowID),
options: attachmentOptions
)
let reactionsByMessageID = try store.reactions(for: filtered)
for message in filtered {
let attachments = try store.attachments(for: message.rowID)
let reactions = try store.reactions(for: message.rowID)
let payload = MessagePayload(
let payload = try await buildMessagePayload(
store: store,
cache: cache,
message: message,
attachments: attachments,
reactions: reactions
includeAttachments: true,
includeReactions: true,
prefetchedAttachments: attachmentsByMessageID[message.rowID] ?? [],
prefetchedReactions: reactionsByMessageID[message.rowID] ?? [],
attachmentOptions: attachmentOptions,
contactResolver: contacts
)
try JSONLines.print(payload)
try JSONLines.printObject(payload)
}
return
}
@ -66,18 +92,18 @@ enum HistoryCommand {
for message in filtered {
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
let sender =
message.isFromMe
? message.sender : (contacts.displayName(for: message.sender) ?? message.sender)
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(sender): \(message.text)")
if message.attachmentsCount > 0 {
if showAttachments {
let metas = try store.attachments(for: message.rowID)
let metas = try store.attachments(for: message.rowID, options: attachmentOptions)
for meta in metas {
let name = displayName(for: meta)
Swift.print(
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
)
StdoutWriter.writeLine(attachmentMetadataLine(for: meta))
}
} else {
Swift.print(
StdoutWriter.writeLine(
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
)
}

View File

@ -0,0 +1,155 @@
import Commander
import Foundation
import IMsgCore
enum LaunchCommand {
static let spec = CommandSpec(
name: "launch",
abstract: "Launch Messages.app with dylib injection",
discussion: """
Kills any running Messages.app instance, then relaunches it with
DYLD_INSERT_LIBRARIES set to inject the imsg bridge helper dylib.
This enables advanced features like typing indicators and read receipts
that require IMCore framework access.
Requires SIP (System Integrity Protection) to be disabled.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: [
.make(
label: "dylib", names: [.long("dylib")],
help: "Custom path to imsg-bridge-helper.dylib")
],
flags: [
.make(
label: "killOnly", names: [.long("kill-only")],
help: "Only kill Messages.app, don't relaunch")
]
)
),
usageExamples: [
"imsg launch",
"imsg launch --kill-only",
"imsg launch --dylib /path/to/dylib",
"imsg launch --json",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
let killOnly = values.flags.contains("killOnly")
let customDylib = values.option("dylib")
let launcher = MessagesLauncher.shared
if killOnly {
if !runtime.jsonOutput {
StdoutWriter.writeLine("Killing Messages.app...")
}
launcher.killMessages()
try await Task.sleep(nanoseconds: 1_000_000_000)
if runtime.jsonOutput {
try JSONLines.print(["status": "killed", "message": "Messages.app terminated"])
} else {
StdoutWriter.writeLine("Messages.app terminated")
}
return
}
switch MessagesLauncher.currentSIPStatus() {
case .enabled:
let message =
"SIP is enabled. Refusing to inject into Messages.app. "
+ "Disable SIP in Recovery mode (`csrutil disable`) before running `imsg launch`."
if runtime.jsonOutput {
try JSONLines.print(["status": "error", "error": "sip_enabled", "message": message])
} else {
StdoutWriter.writeLine(message)
}
throw IMsgError.typingIndicatorFailed(message)
case .unknown(let details):
let message =
"Unable to determine SIP status. Refusing to inject into Messages.app. Details: \(details)"
if runtime.jsonOutput {
try JSONLines.print(["status": "error", "error": "sip_unknown", "message": message])
} else {
StdoutWriter.writeLine(message)
}
throw IMsgError.typingIndicatorFailed(message)
case .disabled:
break
}
let dylibPath = resolveDylibPath(custom: customDylib)
guard let resolvedPath = dylibPath else {
let error =
"imsg-bridge-helper.dylib not found. Searched:\n"
+ " - /usr/local/lib/imsg-bridge-helper.dylib\n"
+ " - .build/release/imsg-bridge-helper.dylib\n"
+ "Run 'make build-dylib' or specify --dylib <path>"
if runtime.jsonOutput {
try JSONLines.print(["status": "error", "error": "dylib_not_found", "message": error])
} else {
StdoutWriter.writeLine(error)
}
throw IMsgError.typingIndicatorFailed("dylib not found")
}
launcher.dylibPath = resolvedPath
if !runtime.jsonOutput {
StdoutWriter.writeLine("Using dylib: \(resolvedPath)")
StdoutWriter.writeLine("Launching Messages.app with injection...")
}
do {
try launcher.ensureRunning()
if runtime.jsonOutput {
try JSONLines.print([
"status": "launched",
"dylib": resolvedPath,
"message": "Messages.app launched with dylib injection",
])
} else {
StdoutWriter.writeLine("Messages.app launched with dylib injection")
}
} catch {
if runtime.jsonOutput {
try JSONLines.print([
"status": "error",
"dylib": resolvedPath,
"error": "\(error)",
])
} else {
StdoutWriter.writeLine("Failed to launch: \(error)")
}
throw error
}
}
private static func resolveDylibPath(custom: String?) -> String? {
if let custom = custom {
if FileManager.default.fileExists(atPath: custom) {
return custom
}
return nil
}
let searchPaths = [
"/usr/local/lib/imsg-bridge-helper.dylib",
".build/release/imsg-bridge-helper.dylib",
]
for path in searchPaths {
if FileManager.default.fileExists(atPath: path) {
return path
}
}
return nil
}
}

View File

@ -0,0 +1,210 @@
import Commander
import Foundation
import IMsgCore
enum ReactCommand {
static let spec = CommandSpec(
name: "react",
abstract: "Send a tapback reaction to the most recent message",
discussion: """
Sends a tapback reaction to the most recent incoming message in the specified chat.
IMPORTANT LIMITATIONS:
- Only reacts to the MOST RECENT incoming message in the conversation
- Requires Messages.app to be running
- Uses UI automation (System Events) which requires accessibility permissions
Reaction types:
love (), like (👍), dislike (👎), laugh (😂), emphasis (), question ()
Custom emoji tapbacks can be read from history/watch output, but cannot be
sent reliably through Messages.app AppleScript automation.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid to react in"),
.make(
label: "reaction", names: [.long("reaction"), .short("r")],
help: "reaction type: love, like, dislike, laugh, emphasis, question"),
],
flags: []
)
),
usageExamples: [
"imsg react --chat-id 1 --reaction like",
"imsg react --chat-id 1 -r love",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(
values: ParsedValues,
runtime: RuntimeOptions,
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
appleScriptRunner: @escaping (String, [String]) throws -> Void = { source, arguments in
try runAppleScript(source, arguments: arguments)
}
) async throws {
guard let chatID = values.optionInt64("chatID") else {
throw ParsedValuesError.missingOption("chat-id")
}
guard let reactionString = values.option("reaction") else {
throw ParsedValuesError.missingOption("reaction")
}
guard let reactionType = ReactionType.parse(reactionString) else {
throw IMsgError.invalidReaction(reactionString)
}
if case .custom(let emoji) = reactionType, !isSingleEmoji(emoji) {
throw IMsgError.invalidReaction(reactionString)
}
if case .custom(let emoji) = reactionType {
throw IMsgError.unsupportedReaction(
"custom emoji tapback '\(emoji)' cannot be sent by Messages.app "
+ "AppleScript automation; use love, like, dislike, laugh, emphasis, or question."
)
}
// Get chat info for the GUID
let dbPath = values.option("db") ?? MessageStore.defaultPath
let store = try storeFactory(dbPath)
guard let chatInfo = try store.chatInfo(chatID: chatID) else {
throw IMsgError.chatNotFound(chatID: chatID)
}
let chatLookup = preferredChatLookup(chatInfo: chatInfo)
// Send the reaction via AppleScript + System Events
try sendReaction(
reactionType: reactionType,
chatGUID: chatInfo.guid,
chatLookup: chatLookup,
appleScriptRunner: appleScriptRunner
)
if runtime.jsonOutput {
let result = ReactResult(
success: true,
chatID: chatID,
reactionType: reactionType.name,
reactionEmoji: reactionType.emoji
)
try JSONLines.print(result)
} else {
print("Sent \(reactionType.emoji) reaction to chat \(chatID)")
}
}
private static func sendReaction(
reactionType: ReactionType,
chatGUID: String,
chatLookup: String,
appleScriptRunner: @escaping (String, [String]) throws -> Void
) throws {
let keyNumber: Int
switch reactionType {
case .love: keyNumber = 1
case .like: keyNumber = 2
case .dislike: keyNumber = 3
case .laugh: keyNumber = 4
case .emphasis: keyNumber = 5
case .question: keyNumber = 6
case .custom(let emoji):
throw IMsgError.unsupportedReaction(
"custom emoji tapback '\(emoji)' cannot be sent by Messages.app "
+ "AppleScript automation; use love, like, dislike, laugh, emphasis, or question."
)
}
let script = """
on run argv
set chatGUID to item 1 of argv
set chatLookup to item 2 of argv
set reactionKey to item 3 of argv
tell application "Messages"
activate
set targetChat to chat id chatGUID
end tell
delay 0.3
tell application "System Events"
tell process "Messages"
keystroke "f" using command down
delay 0.15
keystroke "a" using command down
keystroke chatLookup
delay 0.25
key code 36
delay 0.35
keystroke "t" using command down
delay 0.2
keystroke reactionKey
delay 0.1
key code 36
end tell
end tell
end run
"""
try appleScriptRunner(script, [chatGUID, chatLookup, "\(keyNumber)"])
}
private static func preferredChatLookup(chatInfo: ChatInfo) -> String {
let preferred = chatInfo.name.trimmingCharacters(in: .whitespacesAndNewlines)
if !preferred.isEmpty {
return preferred
}
let identifier = chatInfo.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
if !identifier.isEmpty {
return identifier
}
return chatInfo.guid
}
private static func isSingleEmoji(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count == 1 else { return false }
guard let scalar = trimmed.unicodeScalars.first else { return false }
return scalar.properties.isEmoji || scalar.properties.isEmojiPresentation
}
private static func runAppleScript(_ source: String, arguments: [String]) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-l", "AppleScript", "-"] + arguments
let stdinPipe = Pipe()
let stderrPipe = Pipe()
process.standardInput = stdinPipe
process.standardError = stderrPipe
try process.run()
if let data = source.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
}
stdinPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
if process.terminationStatus != 0 {
let data = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let message = String(data: data, encoding: .utf8) ?? "Unknown AppleScript error"
throw IMsgError.appleScriptFailure(message.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
}
struct ReactResult: Codable {
let success: Bool
let chatID: Int64
let reactionType: String
let reactionEmoji: String
enum CodingKeys: String, CodingKey {
case success
case chatID = "chat_id"
case reactionType = "reaction_type"
case reactionEmoji = "reaction_emoji"
}
}

View File

@ -0,0 +1,99 @@
import Commander
import Foundation
import IMsgCore
enum ReadCommand {
static let spec = CommandSpec(
name: "read",
abstract: "Mark messages as read for a chat",
discussion: """
Marks messages as read via IMCore advanced features.
Requires SIP disabled and Messages launched with `imsg launch`.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(
label: "to",
names: [.long("to"), .aliasLong("handle")],
help: "phone number or email"),
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid"),
.make(
label: "chatIdentifier", names: [.long("chat-identifier")],
help: "chat identifier (e.g. iMessage;-;+14155551212)"),
.make(label: "chatGUID", names: [.long("chat-guid")], help: "chat guid"),
]
)
),
usageExamples: [
"imsg read --to +14155551212",
"imsg read --handle steipete@gmail.com",
"imsg read --chat-id 1",
"imsg read --chat-identifier \"iMessage;-;+14155551212\"",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(
values: ParsedValues,
runtime: RuntimeOptions,
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
markAsRead: @escaping (String) async throws -> Void = {
try await IMCoreBridge.shared.markAsRead(handle: $0)
}
) async throws {
let dbPath = values.option("db") ?? MessageStore.defaultPath
let input = ChatTargetInput(
recipient: values.option("to") ?? "",
chatID: values.optionInt64("chatID"),
chatIdentifier: values.option("chatIdentifier") ?? "",
chatGUID: values.option("chatGUID") ?? ""
)
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: ParsedValuesError.invalidOption("to"),
missingRecipientError: ParsedValuesError.missingOption("to")
)
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in
let store = try storeFactory(dbPath)
return try store.chatInfo(chatID: chatID)
},
unknownChatError: { chatID in
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
}
)
let resolvedIdentifier: String
if let preferred = resolvedTarget.preferredIdentifier {
resolvedIdentifier = preferred
} else if input.hasChatTarget {
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
} else {
resolvedIdentifier = input.recipient
}
try await markAsRead(resolvedIdentifier)
if runtime.jsonOutput {
try JSONLines.print(ReadResult(success: true, handle: resolvedIdentifier, markedAsRead: true))
} else {
Swift.print("marked as read: \(resolvedIdentifier)")
}
}
}
private struct ReadResult: Codable {
let success: Bool
let handle: String
let markedAsRead: Bool
enum CodingKeys: String, CodingKey {
case success
case handle
case markedAsRead = "marked_as_read"
}
}

View File

@ -17,7 +17,8 @@ enum RpcCommand {
) { values, runtime in
let dbPath = values.option("db") ?? MessageStore.defaultPath
let store = try MessageStore(path: dbPath)
let server = RPCServer(store: store, verbose: runtime.verbose)
let contacts = await ContactResolver.create()
let server = RPCServer(store: store, verbose: runtime.verbose, contactResolver: contacts)
try await server.run()
}
}

View File

@ -39,20 +39,46 @@ enum SendCommand {
values: ParsedValues,
runtime: RuntimeOptions,
sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) },
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) }
resolveSentMessage:
@escaping (
MessageStore,
MessageSendOptions,
Int64?,
Date
) async throws -> Message? = SentMessageVerifier.resolveSentMessage,
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
contactResolverFactory: @escaping (String) async -> any ContactResolving = { region in
await ContactResolver.create(region: region)
}
) async throws {
let dbPath = values.option("db") ?? MessageStore.defaultPath
let recipient = values.option("to") ?? ""
let chatID = values.optionInt64("chatID")
let chatIdentifier = values.option("chatIdentifier") ?? ""
let chatGUID = values.option("chatGUID") ?? ""
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
if hasChatTarget && !recipient.isEmpty {
throw ParsedValuesError.invalidOption("to")
}
if !hasChatTarget && recipient.isEmpty {
throw ParsedValuesError.missingOption("to")
let store = try storeFactory(dbPath)
let region = values.option("region") ?? "US"
let rawRecipient = values.option("to") ?? ""
let rawInput = ChatTargetInput(
recipient: rawRecipient,
chatID: values.optionInt64("chatID"),
chatIdentifier: values.option("chatIdentifier") ?? "",
chatGUID: values.option("chatGUID") ?? ""
)
try ChatTargetResolver.validateRecipientRequirements(
input: rawInput,
mixedTargetError: ParsedValuesError.invalidOption("to"),
missingRecipientError: ParsedValuesError.missingOption("to")
)
let recipient: String
if !rawInput.hasChatTarget && ChatTargetResolver.looksLikeContactName(rawRecipient) {
let contacts = await contactResolverFactory(region)
recipient = try ChatTargetResolver.resolveRecipientName(rawRecipient, contacts: contacts)
} else {
recipient = rawRecipient
}
let input = ChatTargetInput(
recipient: recipient,
chatID: rawInput.chatID,
chatIdentifier: rawInput.chatIdentifier,
chatGUID: rawInput.chatGUID
)
let text = values.option("text") ?? ""
let file = values.option("file") ?? ""
@ -63,37 +89,52 @@ enum SendCommand {
guard let service = MessageService(rawValue: serviceRaw) else {
throw IMsgError.invalidService(serviceRaw)
}
let region = values.option("region") ?? "US"
var resolvedChatIdentifier = chatIdentifier
var resolvedChatGUID = chatGUID
if let chatID {
let store = try storeFactory(dbPath)
guard let info = try store.chatInfo(chatID: chatID) else {
throw IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in
return try store.chatInfo(chatID: chatID)
},
unknownChatError: { chatID in
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
}
resolvedChatIdentifier = info.identifier
resolvedChatGUID = info.guid
}
if hasChatTarget && resolvedChatIdentifier.isEmpty && resolvedChatGUID.isEmpty {
)
if input.hasChatTarget && resolvedTarget.preferredIdentifier == nil {
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
}
try sendMessage(
MessageSendOptions(
recipient: recipient,
text: text,
attachmentPath: file,
service: service,
region: region,
chatIdentifier: resolvedChatIdentifier,
chatGUID: resolvedChatGUID
))
let options = MessageSendOptions(
recipient: input.recipient,
text: text,
attachmentPath: file,
service: service,
region: region,
chatIdentifier: resolvedTarget.chatIdentifier,
chatGUID: resolvedTarget.chatGUID
)
let sentAt = Date()
try sendMessage(options)
if input.hasChatTarget {
let verificationChatID =
input.chatID
?? resolvedTarget.preferredIdentifier.flatMap {
try? store.chatInfo(matchingTarget: $0)?.id
}
let sentMessage = try? await resolveSentMessage(store, options, verificationChatID, sentAt)
if sentMessage == nil {
try SentMessageVerifier.throwIfMisroutedChatSend(
store: store,
options: options,
sentAt: sentAt
)
}
}
if runtime.jsonOutput {
try JSONLines.print(["status": "sent"])
try StdoutWriter.writeJSONLine(["status": "sent"])
} else {
Swift.print("sent")
StdoutWriter.writeLine("sent")
}
}
}

View File

@ -0,0 +1,155 @@
import Commander
import Foundation
import IMsgCore
enum StatusCommand {
static let spec = CommandSpec(
name: "status",
abstract: "Check availability of imsg advanced features",
discussion: """
Display the current status of imsg features and permissions.
Shows which advanced features (typing indicators, read receipts) are
available and provides setup instructions if needed.
""",
signature: CommandSignatures.withRuntimeFlags(CommandSignature()),
usageExamples: [
"imsg status",
"imsg status --json",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
let bridge = IMCoreBridge.shared
let availability = bridge.checkAvailability()
let sipStatus: String = {
switch MessagesLauncher.currentSIPStatus() {
case .enabled:
return "enabled"
case .disabled:
return "disabled"
case .unknown:
return "unknown"
}
}()
// Probe the bridge for v2 readiness + selector availability.
var bridgeVersion: Int = 0
var v2Ready: Bool = false
var selectors: [String: Bool] = [:]
if availability.available {
do {
let data = try await IMsgBridgeClient.shared.invoke(
action: .status, params: [:], timeout: 3.0)
bridgeVersion = (data["bridge_version"] as? Int) ?? 0
v2Ready = (data["v2_ready"] as? Bool) ?? false
if let raw = data["selectors"] as? [String: Bool] { selectors = raw }
} catch {
// Bridge probe failure is non-fatal.
}
}
if runtime.jsonOutput {
let payload = StatusPayload(
basicFeatures: true,
advancedFeatures: availability.available,
typingIndicators: availability.available,
readReceipts: availability.available,
sip: sipStatus,
message: availability.message,
bridgeVersion: bridgeVersion,
v2Ready: v2Ready,
selectors: selectors,
rpcMethods: kSupportedRPCMethods
)
try JSONLines.print(payload)
} else {
StdoutWriter.writeLine("imsg Status Report")
StdoutWriter.writeLine("==================")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Basic features (send, receive, history):")
StdoutWriter.writeLine(" Available")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("System Integrity Protection (SIP):")
StdoutWriter.writeLine(" \(sipStatus)")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Advanced features (typing, read receipts):")
if availability.available {
StdoutWriter.writeLine(" Available - IMCore bridge connected")
StdoutWriter.writeLine(
" bridge version: v\(bridgeVersion)\(v2Ready ? " (v2 inbox active)" : "")")
if !selectors.isEmpty {
StdoutWriter.writeLine(" selectors:")
for key in selectors.keys.sorted() {
let ok = selectors[key] ?? false
StdoutWriter.writeLine(" \(key): \(ok ? "" : "")")
}
}
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Available bridge commands:")
StdoutWriter.writeLine(" Send: imsg send-rich, send-multipart, send-attachment, tapback")
StdoutWriter.writeLine(" Mutate: imsg edit, unsend, delete-message, notify-anyways")
StdoutWriter.writeLine(
" 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(" Local DB: imsg search")
StdoutWriter.writeLine(" Watch with events: imsg watch --bb-events")
} else {
StdoutWriter.writeLine(" Not available")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("To enable advanced features:")
StdoutWriter.writeLine(" 1. Disable System Integrity Protection (SIP)")
StdoutWriter.writeLine(" - Restart Mac holding Cmd+R")
StdoutWriter.writeLine(" - Open Terminal from Utilities menu")
StdoutWriter.writeLine(" - Run: csrutil disable")
StdoutWriter.writeLine(" - Restart normally")
StdoutWriter.writeLine("")
StdoutWriter.writeLine(" 2. Grant Full Disk Access")
StdoutWriter.writeLine(" - System Settings > Privacy & Security > Full Disk Access")
StdoutWriter.writeLine(" - Add Terminal or your terminal app")
StdoutWriter.writeLine("")
StdoutWriter.writeLine(" 3. Build and launch:")
StdoutWriter.writeLine(" make build-dylib")
StdoutWriter.writeLine(" imsg launch")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("macOS 26/Tahoe note:")
StdoutWriter.writeLine(
" Advanced IMCore features may still be blocked by library validation"
)
StdoutWriter.writeLine(
" or imagent private entitlement checks. Basic commands still work."
)
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Note: Basic messaging features work without these steps.")
}
}
}
}
private struct StatusPayload: Encodable {
let basicFeatures: Bool
let advancedFeatures: Bool
let typingIndicators: Bool
let readReceipts: Bool
let sip: String
let message: String
let bridgeVersion: Int
let v2Ready: Bool
let selectors: [String: Bool]
let rpcMethods: [String]
enum CodingKeys: String, CodingKey {
case basicFeatures = "basic_features"
case advancedFeatures = "advanced_features"
case typingIndicators = "typing_indicators"
case readReceipts = "read_receipts"
case sip
case message
case bridgeVersion = "bridge_version"
case v2Ready = "v2_ready"
case selectors
case rpcMethods = "rpc_methods"
}
}

View File

@ -0,0 +1,141 @@
import Commander
import Foundation
import IMsgCore
enum TypingCommand {
static let spec = CommandSpec(
name: "typing",
abstract: "Send typing indicator to a chat",
discussion: """
Sends typing indicators via IMCore advanced features.
Requires SIP disabled and Messages launched with `imsg launch`.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "to", names: [.long("to")], help: "phone number or email"),
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid"),
.make(
label: "chatIdentifier", names: [.long("chat-identifier")],
help: "chat identifier (e.g. iMessage;-;+14155551212)"),
.make(label: "chatGUID", names: [.long("chat-guid")], help: "chat guid"),
.make(
label: "duration", names: [.long("duration")],
help: "how long to show typing (e.g. 5s, 3000ms); omit for start-only"),
.make(
label: "stop", names: [.long("stop")],
help: "stop typing indicator instead of starting"),
.make(
label: "service", names: [.long("service")],
help: "service to use: imessage|sms|auto"),
]
)
),
usageExamples: [
"imsg typing --to +14155551212",
"imsg typing --to +14155551212 --duration 5s",
"imsg typing --to +14155551212 --stop true",
"imsg typing --chat-identifier \"iMessage;-;+14155551212\"",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(
values: ParsedValues,
runtime: RuntimeOptions,
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
startTyping: @escaping (String) throws -> Void = {
try TypingIndicator.startTyping(chatIdentifier: $0)
},
stopTyping: @escaping (String) throws -> Void = {
try TypingIndicator.stopTyping(chatIdentifier: $0)
},
typeForDuration: @escaping (String, TimeInterval) async throws -> Void = {
try await TypingIndicator.typeForDuration(chatIdentifier: $0, duration: $1)
}
) async throws {
let dbPath = values.option("db") ?? MessageStore.defaultPath
let input = ChatTargetInput(
recipient: values.option("to") ?? "",
chatID: values.optionInt64("chatID"),
chatIdentifier: values.option("chatIdentifier") ?? "",
chatGUID: values.option("chatGUID") ?? ""
)
let stopFlag = try parseStopFlag(values.option("stop"))
let durationRaw = values.option("duration") ?? ""
let serviceRaw = values.option("service") ?? "imessage"
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: ParsedValuesError.invalidOption("to"),
missingRecipientError: ParsedValuesError.missingOption("to")
)
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in
let store = try storeFactory(dbPath)
return try store.chatInfo(chatID: chatID)
},
unknownChatError: { chatID in
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
}
)
let resolvedIdentifier: String
if let preferred = resolvedTarget.preferredIdentifier {
resolvedIdentifier = preferred
} else if input.hasChatTarget {
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
} else {
resolvedIdentifier = try ChatTargetResolver.directTypingIdentifier(
recipient: input.recipient,
serviceRaw: serviceRaw,
invalidServiceError: { IMsgError.invalidService($0) }
)
}
if stopFlag {
try stopTyping(resolvedIdentifier)
if runtime.jsonOutput {
try JSONLines.print(["status": "stopped"])
} else {
Swift.print("typing indicator stopped")
}
return
}
if !durationRaw.isEmpty {
let seconds = try parseDurationToSeconds(durationRaw)
try await typeForDuration(resolvedIdentifier, seconds)
if runtime.jsonOutput {
try JSONLines.print(["status": "completed", "duration_s": "\(seconds)"])
} else {
Swift.print("typing indicator shown for \(durationRaw)")
}
return
}
try startTyping(resolvedIdentifier)
if runtime.jsonOutput {
try JSONLines.print(["status": "started"])
} else {
Swift.print("typing indicator started")
}
}
private static func parseStopFlag(_ raw: String?) throws -> Bool {
guard let raw else { return false }
if raw == "true" { return true }
if raw == "false" { return false }
throw ParsedValuesError.invalidOption("stop")
}
private static func parseDurationToSeconds(_ raw: String) throws -> TimeInterval {
guard let seconds = DurationParser.parse(raw), seconds > 0 else {
throw IMsgError.typingIndicatorFailed(
"Invalid duration: \(raw). Use e.g. 5s, 3000ms, 1m, or 1h")
}
return seconds
}
}

View File

@ -26,7 +26,19 @@ enum WatchCommand {
flags: [
.make(
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
)
),
.make(
label: "convertAttachments", names: [.long("convert-attachments")],
help: "convert CAF/GIF attachments to model-compatible cached files"
),
.make(
label: "reactions", names: [.long("reactions")],
help: "include reaction events (tapback add/remove) in the stream"
),
.make(
label: "bbEvents", names: [.long("bb-events")],
help: "include dylib-pushed events (typing, alias-removed) when injection is active"
),
]
)
),
@ -42,6 +54,9 @@ enum WatchCommand {
values: ParsedValues,
runtime: RuntimeOptions,
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
contactResolverFactory: @escaping () async -> any ContactResolving = {
await ContactResolver.create()
},
streamProvider:
@escaping (
MessageWatcher,
@ -60,6 +75,9 @@ enum WatchCommand {
}
let sinceRowID = values.optionInt64("sinceRowID")
let showAttachments = values.flag("attachments")
let attachmentOptions = AttachmentQueryOptions(
convertUnsupported: values.flag("convertAttachments"))
let includeReactions = values.flag("reactions")
let participants = values.optionValues("participants")
.flatMap { $0.split(separator: ",").map { String($0) } }
.filter { !$0.isEmpty }
@ -71,41 +89,76 @@ enum WatchCommand {
let store = try storeFactory(dbPath)
let watcher = MessageWatcher(store: store)
let cache = ChatCache(store: store)
let contacts = await contactResolverFactory()
let config = MessageWatcherConfiguration(
debounceInterval: debounceInterval,
batchLimit: 100
batchLimit: 100,
includeReactions: includeReactions
)
let bbEvents = values.flag("bbEvents")
if bbEvents {
let path = MessagesLauncher.shared.bridgeEventsFile
let tailer = IMsgEventTailer(path: path)
Task {
for await event in tailer.events() {
if runtime.jsonOutput {
var obj: [String: Any] = [
"kind": "bridge-event",
"event": event.name,
]
if let ts = event.timestamp { obj["ts"] = ts }
obj["data"] = event.decodedPayload()
try? JSONLines.printObject(obj)
} else {
let stamp = event.timestamp ?? CLIISO8601.format(Date())
StdoutWriter.writeLine("\(stamp) [bridge] \(event.name)")
}
}
}
}
let stream = streamProvider(watcher, chatID, sinceRowID, config)
for try await message in stream {
if !filter.allows(message) {
continue
}
if runtime.jsonOutput {
let attachments = try store.attachments(for: message.rowID)
let reactions = try store.reactions(for: message.rowID)
let payload = MessagePayload(
let payload = try await buildMessagePayload(
store: store,
cache: cache,
message: message,
attachments: attachments,
reactions: reactions
includeAttachments: true,
includeReactions: true,
attachmentOptions: attachmentOptions,
contactResolver: contacts
)
try JSONLines.print(payload)
try JSONLines.printObject(payload)
continue
}
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
let sender =
message.isFromMe
? message.sender : (contacts.displayName(for: message.sender) ?? message.sender)
if message.isReaction, let reactionType = message.reactionType {
let action = (message.isReactionAdd ?? true) ? "added" : "removed"
let targetGUID = message.reactedToGUID ?? "unknown"
StdoutWriter.writeLine(
"\(timestamp) [\(direction)] \(sender) \(action) \(reactionType.emoji) reaction to \(targetGUID)"
)
continue
}
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(sender): \(message.text)")
if message.attachmentsCount > 0 {
if showAttachments {
let metas = try store.attachments(for: message.rowID)
let metas = try store.attachments(for: message.rowID, options: attachmentOptions)
for meta in metas {
let name = displayName(for: meta)
Swift.print(
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
)
StdoutWriter.writeLine(attachmentMetadataLine(for: meta))
}
} else {
Swift.print(
StdoutWriter.writeLine(
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
)
}

View File

@ -4,13 +4,13 @@ import Foundation
struct HelpPrinter {
static func printRoot(version: String, rootName: String, commands: [CommandSpec]) {
for line in renderRoot(version: version, rootName: rootName, commands: commands) {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}
static func printCommand(rootName: String, spec: CommandSpec) {
for line in renderCommand(rootName: rootName, spec: spec) {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}

View File

@ -15,7 +15,13 @@ enum JSONLines {
static func print<T: Encodable>(_ value: T) throws {
let line = try encode(value)
if !line.isEmpty {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}
static func printObject(_ value: Any) throws {
let data = try JSONSerialization.data(withJSONObject: value, options: [])
guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return }
StdoutWriter.writeLine(line)
}
}

View File

@ -7,13 +7,33 @@ struct ChatPayload: Codable {
let identifier: String
let service: String
let lastMessageAt: String
let guid: String?
let displayName: String?
let contactName: String?
let isGroup: Bool
let participants: [String]?
let accountID: String?
let accountLogin: String?
let lastAddressedHandle: String?
init(chat: Chat) {
init(
chat: Chat, chatInfo: ChatInfo? = nil, participants: [String]? = nil, contactName: String? = nil
) {
let identifier = chatInfo?.identifier ?? chat.identifier
let guid = chatInfo?.guid ?? ""
self.id = chat.id
self.name = chat.name
self.identifier = chat.identifier
self.service = chat.service
self.identifier = identifier
self.service = chatInfo?.service ?? chat.service
self.lastMessageAt = CLIISO8601.format(chat.lastMessageAt)
self.guid = guid.isEmpty ? nil : guid
self.displayName = chatInfo?.name
self.contactName = contactName
self.isGroup = isGroupHandle(identifier: identifier, guid: guid)
self.participants = participants
self.accountID = chatInfo?.accountID ?? chat.accountID
self.accountLogin = chatInfo?.accountLogin ?? chat.accountLogin
self.lastAddressedHandle = chatInfo?.lastAddressedHandle ?? chat.lastAddressedHandle
}
enum CodingKeys: String, CodingKey {
@ -22,6 +42,14 @@ struct ChatPayload: Codable {
case identifier
case service
case lastMessageAt = "last_message_at"
case guid
case displayName = "display_name"
case contactName = "contact_name"
case isGroup = "is_group"
case participants
case accountID = "account_id"
case accountLogin = "account_login"
case lastAddressedHandle = "last_addressed_handle"
}
}
@ -30,24 +58,63 @@ struct MessagePayload: Codable {
let chatID: Int64
let guid: String
let replyToGUID: String?
let threadOriginatorGUID: String?
let sender: String
let senderName: String?
let isFromMe: Bool
let text: String
let createdAt: String
let attachments: [AttachmentPayload]
let reactions: [ReactionPayload]
/// The destination_caller_id from the database. For messages where is_from_me is true,
/// this can help distinguish between messages actually sent by the local user vs
/// messages received on a secondary phone number registered with the same Apple ID.
let destinationCallerID: String?
init(message: Message, attachments: [AttachmentMeta], reactions: [Reaction] = []) {
// Reaction event metadata (populated when this message is a reaction event)
let isReaction: Bool?
let reactionType: String?
let reactionEmoji: String?
let isReactionAdd: Bool?
let reactedToGUID: String?
init(
message: Message,
attachments: [AttachmentMeta],
reactions: [Reaction] = [],
senderName: String? = nil,
reactionSenderNames: [Int64: String] = [:]
) {
self.id = message.rowID
self.chatID = message.chatID
self.guid = message.guid
self.replyToGUID = message.replyToGUID
self.threadOriginatorGUID = message.threadOriginatorGUID
self.sender = message.sender
self.senderName = senderName
self.isFromMe = message.isFromMe
self.text = message.text
self.createdAt = CLIISO8601.format(message.date)
self.attachments = attachments.map { AttachmentPayload(meta: $0) }
self.reactions = reactions.map { ReactionPayload(reaction: $0) }
self.reactions = reactions.map {
ReactionPayload(reaction: $0, senderName: reactionSenderNames[$0.rowID])
}
self.destinationCallerID = message.destinationCallerID
// Reaction event metadata
if message.isReaction {
self.isReaction = true
self.reactionType = message.reactionType?.name
self.reactionEmoji = message.reactionType?.emoji
self.isReactionAdd = message.isReactionAdd
self.reactedToGUID = message.reactedToGUID
} else {
self.isReaction = nil
self.reactionType = nil
self.reactionEmoji = nil
self.isReactionAdd = nil
self.reactedToGUID = nil
}
}
enum CodingKeys: String, CodingKey {
@ -55,28 +122,50 @@ struct MessagePayload: Codable {
case chatID = "chat_id"
case guid
case replyToGUID = "reply_to_guid"
case threadOriginatorGUID = "thread_originator_guid"
case sender
case senderName = "sender_name"
case isFromMe = "is_from_me"
case text
case createdAt = "created_at"
case attachments
case reactions
case destinationCallerID = "destination_caller_id"
case isReaction = "is_reaction"
case reactionType = "reaction_type"
case reactionEmoji = "reaction_emoji"
case isReactionAdd = "is_reaction_add"
case reactedToGUID = "reacted_to_guid"
}
}
extension MessagePayload {
func asDictionary() throws -> [String: Any] {
let data = try MessagePayload.encoder.encode(self)
let json = try JSONSerialization.jsonObject(with: data)
return (json as? [String: Any]) ?? [:]
}
private static let encoder: JSONEncoder = {
JSONEncoder()
}()
}
struct ReactionPayload: Codable {
let id: Int64
let type: String
let emoji: String
let sender: String
let senderName: String?
let isFromMe: Bool
let createdAt: String
init(reaction: Reaction) {
init(reaction: Reaction, senderName: String? = nil) {
self.id = reaction.rowID
self.type = reaction.reactionType.name
self.emoji = reaction.reactionType.emoji
self.sender = reaction.sender
self.senderName = senderName
self.isFromMe = reaction.isFromMe
self.createdAt = CLIISO8601.format(reaction.date)
}
@ -86,11 +175,51 @@ struct ReactionPayload: Codable {
case type
case emoji
case sender
case senderName = "sender_name"
case isFromMe = "is_from_me"
case createdAt = "created_at"
}
}
struct GroupPayload: Codable {
let id: Int64
let identifier: String
let guid: String
let name: String
let service: String
let isGroup: Bool
let participants: [String]
let accountID: String?
let accountLogin: String?
let lastAddressedHandle: String?
init(chatInfo: ChatInfo, participants: [String]) {
self.id = chatInfo.id
self.identifier = chatInfo.identifier
self.guid = chatInfo.guid
self.name = chatInfo.name
self.service = chatInfo.service
self.isGroup = isGroupHandle(identifier: chatInfo.identifier, guid: chatInfo.guid)
self.participants = participants
self.accountID = chatInfo.accountID
self.accountLogin = chatInfo.accountLogin
self.lastAddressedHandle = chatInfo.lastAddressedHandle
}
enum CodingKeys: String, CodingKey {
case id
case identifier
case guid
case name
case service
case isGroup = "is_group"
case participants
case accountID = "account_id"
case accountLogin = "account_login"
case lastAddressedHandle = "last_addressed_handle"
}
}
struct AttachmentPayload: Codable {
let filename: String
let transferName: String
@ -99,6 +228,8 @@ struct AttachmentPayload: Codable {
let totalBytes: Int64
let isSticker: Bool
let originalPath: String
let convertedPath: String?
let convertedMimeType: String?
let missing: Bool
init(meta: AttachmentMeta) {
@ -109,6 +240,8 @@ struct AttachmentPayload: Codable {
self.totalBytes = meta.totalBytes
self.isSticker = meta.isSticker
self.originalPath = meta.originalPath
self.convertedPath = meta.convertedPath
self.convertedMimeType = meta.convertedMimeType
self.missing = meta.missing
}
@ -120,6 +253,8 @@ struct AttachmentPayload: Codable {
case totalBytes = "total_bytes"
case isSticker = "is_sticker"
case originalPath = "original_path"
case convertedPath = "converted_path"
case convertedMimeType = "converted_mime_type"
case missing = "missing"
}
}

View File

@ -8,9 +8,10 @@ func chatPayload(
name: String,
service: String,
lastMessageAt: Date,
participants: [String]
participants: [String],
contactName: String? = nil
) -> [String: Any] {
return [
var payload: [String: Any] = [
"id": id,
"identifier": identifier,
"guid": guid,
@ -20,6 +21,10 @@ func chatPayload(
"participants": participants,
"is_group": isGroupHandle(identifier: identifier, guid: guid),
]
if let contactName {
payload["contact_name"] = contactName
}
return payload
}
func messagePayload(
@ -27,35 +32,31 @@ func messagePayload(
chatInfo: ChatInfo?,
participants: [String],
attachments: [AttachmentMeta],
reactions: [Reaction]
) -> [String: Any] {
reactions: [Reaction],
senderName: String? = nil,
reactionSenderNames: [Int64: String] = [:]
) throws -> [String: Any] {
let identifier = chatInfo?.identifier ?? ""
let guid = chatInfo?.guid ?? ""
let name = chatInfo?.name ?? ""
var payload: [String: Any] = [
"id": message.rowID,
"chat_id": message.chatID,
"guid": message.guid,
"sender": message.sender,
"is_from_me": message.isFromMe,
"text": message.text,
"created_at": CLIISO8601.format(message.date),
"attachments": attachments.map { attachmentPayload($0) },
"reactions": reactions.map { reactionPayload($0) },
"chat_identifier": identifier,
"chat_guid": guid,
"chat_name": name,
"participants": participants,
"is_group": isGroupHandle(identifier: identifier, guid: guid),
]
if let replyToGUID = message.replyToGUID, !replyToGUID.isEmpty {
payload["reply_to_guid"] = replyToGUID
}
let core = MessagePayload(
message: message,
attachments: attachments,
reactions: reactions,
senderName: senderName,
reactionSenderNames: reactionSenderNames
)
var payload = try core.asDictionary()
payload["chat_identifier"] = identifier
payload["chat_guid"] = guid
payload["chat_name"] = name
payload["participants"] = participants
payload["is_group"] = isGroupHandle(identifier: identifier, guid: guid)
return payload
}
func attachmentPayload(_ meta: AttachmentMeta) -> [String: Any] {
return [
var payload: [String: Any] = [
"filename": meta.filename,
"transfer_name": meta.transferName,
"uti": meta.uti,
@ -65,10 +66,17 @@ func attachmentPayload(_ meta: AttachmentMeta) -> [String: Any] {
"original_path": meta.originalPath,
"missing": meta.missing,
]
if let convertedPath = meta.convertedPath {
payload["converted_path"] = convertedPath
}
if let convertedMimeType = meta.convertedMimeType {
payload["converted_mime_type"] = convertedMimeType
}
return payload
}
func reactionPayload(_ reaction: Reaction) -> [String: Any] {
return [
func reactionPayload(_ reaction: Reaction, senderName: String? = nil) -> [String: Any] {
var payload: [String: Any] = [
"id": reaction.rowID,
"type": reaction.reactionType.name,
"emoji": reaction.reactionType.emoji,
@ -76,11 +84,14 @@ func reactionPayload(_ reaction: Reaction) -> [String: Any] {
"is_from_me": reaction.isFromMe,
"created_at": CLIISO8601.format(reaction.date),
]
if let senderName {
payload["sender_name"] = senderName
}
return payload
}
func isGroupHandle(identifier: String, guid: String) -> Bool {
let handle = identifier.isEmpty ? guid : identifier
return handle.contains(";+;") || handle.contains(";-;")
return guid.contains(";+;") || identifier.contains(";+;")
}
func stringParam(_ value: Any?) -> String? {
@ -128,3 +139,14 @@ func stringArrayParam(_ value: Any?) -> [String] {
}
return []
}
let defaultRPCWatchDebounceInterval: TimeInterval = 0.5
func watchDebounceIntervalParam(_ params: [String: Any]) throws -> TimeInterval {
let raw = params["debounce_ms"] ?? params["debounceMs"]
guard let raw else { return defaultRPCWatchDebounceInterval }
guard let milliseconds = intParam(raw), milliseconds >= 0 else {
throw RPCError.invalidParams("debounce_ms must be a non-negative integer")
}
return Double(milliseconds) / 1000
}

View File

@ -0,0 +1,136 @@
import Foundation
import IMsgCore
/// Chat/group lifecycle and management methods. Each handler resolves the
/// caller's chat target (`chat_guid` / `chat_identifier` / `chat_id`) into a
/// chat GUID and then dispatches into the v2 bridge action that the dylib
/// already implements.
extension RPCServer {
func handleChatsCreate(id: Any?, params: [String: Any]) async throws {
let addresses = stringArrayParam(params["addresses"])
guard !addresses.isEmpty else {
throw RPCError.invalidParams("addresses is required (non-empty array of phone/email)")
}
let service = stringParam(params["service"]) ?? "iMessage"
var bridgeParams: [String: Any] = [
"addresses": addresses,
"service": service,
]
if let name = stringParam(params["name"]), !name.isEmpty {
bridgeParams["displayName"] = name
}
if let text = stringParam(params["text"]), !text.isEmpty {
bridgeParams["message"] = text
}
let data = try await invokeBridge(action: .createChat, params: bridgeParams)
var result: [String: Any] = ["ok": true]
if let guid = data["chatGuid"] as? String, !guid.isEmpty {
result["chat_guid"] = guid
}
respond(id: id, result: result)
}
func handleChatsDelete(id: Any?, params: [String: Any]) async throws {
let chatGUID = try await resolveChatGUIDParam(params)
_ = try await invokeBridge(action: .deleteChat, params: ["chatGuid": chatGUID])
respond(id: id, result: ["ok": true])
}
func handleChatsMarkUnread(id: Any?, params: [String: Any]) async throws {
let chatGUID = try await resolveChatGUIDParam(params)
_ = try await invokeBridge(action: .markChatUnread, params: ["chatGuid": chatGUID])
respond(id: id, result: ["ok": true])
}
func handleGroupRename(id: Any?, params: [String: Any]) async throws {
let chatGUID = try await resolveChatGUIDParam(params)
guard let name = stringParam(params["name"]) else {
throw RPCError.invalidParams("name is required")
}
_ = try await invokeBridge(
action: .setDisplayName,
params: ["chatGuid": chatGUID, "newName": name]
)
respond(id: id, result: ["ok": true])
}
func handleGroupSetIcon(id: Any?, params: [String: Any]) async throws {
let chatGUID = try await resolveChatGUIDParam(params)
var bridgeParams: [String: Any] = ["chatGuid": chatGUID]
if let file = stringParam(params["file"]), !file.isEmpty {
bridgeParams["filePath"] = (file as NSString).expandingTildeInPath
}
_ = try await invokeBridge(action: .updateGroupPhoto, params: bridgeParams)
respond(id: id, result: ["ok": true])
}
func handleGroupAddParticipant(id: Any?, params: [String: Any]) async throws {
let chatGUID = try await resolveChatGUIDParam(params)
guard let address = stringParam(params["address"]), !address.isEmpty else {
throw RPCError.invalidParams("address is required")
}
_ = try await invokeBridge(
action: .addParticipant,
params: ["chatGuid": chatGUID, "address": address]
)
respond(id: id, result: ["ok": true])
}
func handleGroupRemoveParticipant(id: Any?, params: [String: Any]) async throws {
let chatGUID = try await resolveChatGUIDParam(params)
guard let address = stringParam(params["address"]), !address.isEmpty else {
throw RPCError.invalidParams("address is required")
}
_ = try await invokeBridge(
action: .removeParticipant,
params: ["chatGuid": chatGUID, "address": address]
)
respond(id: id, result: ["ok": true])
}
func handleGroupLeave(id: Any?, params: [String: Any]) async throws {
let chatGUID = try await resolveChatGUIDParam(params)
_ = try await invokeBridge(action: .leaveChat, params: ["chatGuid": chatGUID])
respond(id: id, result: ["ok": true])
}
// MARK: - Helpers
/// Resolve a chat GUID from `chat_guid`, `chat_identifier`, or `chat_id`.
/// Bridge management actions (rename/leave/etc.) require a real chat GUID;
/// rejecting up-front gives callers a clearer error than the dylib's
/// downstream "chat not found".
private func resolveChatGUIDParam(_ params: [String: Any]) async throws -> String {
let input = ChatTargetInput(
recipient: "",
chatID: int64Param(params["chat_id"]),
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
chatGUID: stringParam(params["chat_guid"]) ?? ""
)
if !input.hasChatTarget {
throw RPCError.invalidParams("chat_guid, chat_identifier, or chat_id is required")
}
let resolved = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in try await cache.info(chatID: chatID) },
unknownChatError: { chatID in RPCError.invalidParams("unknown chat_id \(chatID)") }
)
if !resolved.chatGUID.isEmpty {
return resolved.chatGUID
}
if !resolved.chatIdentifier.isEmpty {
return resolved.chatIdentifier
}
throw RPCError.invalidParams("could not resolve chat GUID for chat target")
}
private func invokeBridge(
action: BridgeAction, params: [String: Any]
) async throws -> [String: Any] {
do {
return try await IMsgBridgeClient.shared.invoke(action: action, params: params)
} catch {
throw RPCError.internalError(String(describing: error))
}
}
}

View File

@ -0,0 +1,361 @@
import Foundation
import IMsgCore
extension RPCServer {
func handleChatsList(id: Any?, params: [String: Any]) async throws {
let limit = intParam(params["limit"]) ?? 20
let chats = try store.listChats(limit: max(limit, 1))
var payloads: [[String: Any]] = []
payloads.reserveCapacity(chats.count)
for chat in chats {
let info = try await cache.info(chatID: chat.id)
let participants = try await cache.participants(chatID: chat.id)
let identifier = info?.identifier ?? chat.identifier
let guid = info?.guid ?? ""
let name = (info?.name.isEmpty == false ? info?.name : nil) ?? chat.name
let service = info?.service ?? chat.service
let contactName =
isGroupHandle(identifier: identifier, guid: guid)
? nil : contactResolver.displayName(for: identifier)
payloads.append(
chatPayload(
id: chat.id,
identifier: identifier,
guid: guid,
name: name,
service: service,
lastMessageAt: chat.lastMessageAt,
participants: participants,
contactName: contactName
))
}
respond(id: id, result: ["chats": payloads])
}
func handleMessagesHistory(id: Any?, params: [String: Any]) async throws {
guard let chatID = int64Param(params["chat_id"]) else {
throw RPCError.invalidParams("chat_id is required")
}
let limit = intParam(params["limit"]) ?? 50
let participants = stringArrayParam(params["participants"])
let startISO = stringParam(params["start"])
let endISO = stringParam(params["end"])
let includeAttachments = boolParam(params["attachments"]) ?? false
let attachmentOptions = AttachmentQueryOptions(
convertUnsupported: boolParam(params["convert_attachments"]) ?? false)
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: startISO,
endISO: endISO
)
let filtered = try store.messages(chatID: chatID, limit: max(limit, 1), filter: filter)
var payloads: [[String: Any]] = []
payloads.reserveCapacity(filtered.count)
for message in filtered {
let payload = try await buildMessagePayload(
store: store,
cache: cache,
message: message,
includeAttachments: includeAttachments,
includeReactions: true,
attachmentOptions: attachmentOptions,
contactResolver: contactResolver
)
payloads.append(payload)
}
respond(id: id, result: ["messages": payloads])
}
func handleWatchSubscribe(id: Any?, params: [String: Any]) async throws {
let chatID = int64Param(params["chat_id"])
let sinceRowID = int64Param(params["since_rowid"])
let participants = stringArrayParam(params["participants"])
let startISO = stringParam(params["start"])
let endISO = stringParam(params["end"])
let includeAttachments = boolParam(params["attachments"]) ?? false
let attachmentOptions = AttachmentQueryOptions(
convertUnsupported: boolParam(params["convert_attachments"]) ?? false)
let includeReactions = boolParam(params["include_reactions"]) ?? false
let debounceInterval = try watchDebounceIntervalParam(params)
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: startISO,
endISO: endISO
)
let config = MessageWatcherConfiguration(
debounceInterval: debounceInterval,
includeReactions: includeReactions
)
let subID = await subscriptions.allocateID()
let localStore = store
let localWatcher = watcher
let localCache = cache
let localWriter = output
let localFilter = filter
let localChatID = chatID
let localSinceRowID = sinceRowID
let localConfig = config
let localIncludeAttachments = includeAttachments
let localAttachmentOptions = attachmentOptions
let localIncludeReactions = includeReactions
let localContactResolver = contactResolver
let task = Task {
do {
for try await message in localWatcher.stream(
chatID: localChatID,
sinceRowID: localSinceRowID,
configuration: localConfig
) {
if Task.isCancelled { return }
if !localFilter.allows(message) { continue }
let payload = try await buildMessagePayload(
store: localStore,
cache: localCache,
message: message,
includeAttachments: localIncludeAttachments,
includeReactions: localIncludeReactions,
attachmentOptions: localAttachmentOptions,
contactResolver: localContactResolver
)
localWriter.sendNotification(
method: "message",
params: ["subscription": subID, "message": payload]
)
}
} catch {
localWriter.sendNotification(
method: "error",
params: [
"subscription": subID,
"error": ["message": String(describing: error)],
]
)
}
}
await subscriptions.insert(task, for: subID)
respond(id: id, result: ["subscription": subID])
}
func handleWatchUnsubscribe(id: Any?, params: [String: Any]) async throws {
guard let subID = intParam(params["subscription"]) else {
throw RPCError.invalidParams("subscription is required")
}
if let task = await subscriptions.remove(subID) {
task.cancel()
}
respond(id: id, result: ["ok": true])
}
func handleSend(params: [String: Any], id: Any?) async throws {
let text = stringParam(params["text"]) ?? ""
let file = stringParam(params["file"]) ?? ""
let serviceRaw = stringParam(params["service"]) ?? "auto"
guard let service = MessageService(rawValue: serviceRaw) else {
throw RPCError.invalidParams("invalid service")
}
let region = stringParam(params["region"]) ?? "US"
let rawRecipient = stringParam(params["to"]) ?? ""
let rawInput = ChatTargetInput(
recipient: rawRecipient,
chatID: int64Param(params["chat_id"]),
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
chatGUID: stringParam(params["chat_guid"]) ?? ""
)
try ChatTargetResolver.validateRecipientRequirements(
input: rawInput,
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
missingRecipientError: RPCError.invalidParams("to is required for direct sends")
)
let recipient: String
do {
recipient =
rawInput.hasChatTarget || rawRecipient.isEmpty
? rawRecipient
: try ChatTargetResolver.resolveRecipientName(rawRecipient, contacts: contactResolver)
} catch {
throw RPCError.invalidParams(error.localizedDescription)
}
let input = ChatTargetInput(
recipient: recipient,
chatID: rawInput.chatID,
chatIdentifier: rawInput.chatIdentifier,
chatGUID: rawInput.chatGUID
)
if text.isEmpty && file.isEmpty {
throw RPCError.invalidParams("text or file is required")
}
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in try await cache.info(chatID: chatID) },
unknownChatError: { chatID in
RPCError.invalidParams("unknown chat_id \(chatID)")
}
)
if input.hasChatTarget && resolvedTarget.preferredIdentifier == nil {
throw RPCError.invalidParams("missing chat identifier or guid")
}
let options = MessageSendOptions(
recipient: input.recipient,
text: text,
attachmentPath: file,
service: service,
region: region,
chatIdentifier: resolvedTarget.chatIdentifier,
chatGUID: resolvedTarget.chatGUID
)
let sentAt = Date()
try sendMessage(options)
let verificationChatID =
input.chatID
?? resolvedTarget.preferredIdentifier.flatMap { try? store.chatInfo(matchingTarget: $0)?.id }
let sentMessage = try? await resolveSentMessage(store, options, verificationChatID, sentAt)
if sentMessage == nil {
try SentMessageVerifier.throwIfMisroutedChatSend(
store: store,
options: options,
sentAt: sentAt
)
}
var result: [String: Any] = ["ok": true]
if let sentMessage {
result["id"] = sentMessage.rowID
if !sentMessage.guid.isEmpty {
result["guid"] = sentMessage.guid
}
}
respond(id: id, result: result)
}
/// `typing` start/stop the local-user typing indicator. Mirrors the
/// `imsg typing` CLI surface (which is purely a wrapper over `TypingIndicator`)
/// so callers that talk to `imsg rpc` over JSON-RPC have parity with the CLI.
func handleTyping(params: [String: Any], id: Any?) async throws {
let isTyping = boolParam(params["typing"]) ?? true
let serviceRaw = stringParam(params["service"]) ?? "imessage"
let input = ChatTargetInput(
recipient: stringParam(params["to"]) ?? "",
chatID: int64Param(params["chat_id"]),
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
chatGUID: stringParam(params["chat_guid"]) ?? ""
)
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
missingRecipientError: RPCError.invalidParams("to is required")
)
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in try await cache.info(chatID: chatID) },
unknownChatError: { chatID in
RPCError.invalidParams("unknown chat_id \(chatID)")
}
)
let identifier: String
if let preferred = resolvedTarget.preferredIdentifier {
identifier = preferred
} else if input.hasChatTarget {
throw RPCError.invalidParams("missing chat identifier or guid")
} else {
do {
identifier = try ChatTargetResolver.directTypingIdentifier(
recipient: input.recipient,
serviceRaw: serviceRaw,
invalidServiceError: { RPCError.invalidParams($0) }
)
} catch let err as RPCError {
throw err
}
}
if isTyping {
try TypingIndicator.startTyping(chatIdentifier: identifier)
} else {
try TypingIndicator.stopTyping(chatIdentifier: identifier)
}
respond(id: id, result: ["ok": true])
}
/// `read` mark all messages in a chat as read on this device, which also
/// fires a read-receipt to the sender if the chat has receipts enabled.
func handleRead(params: [String: Any], id: Any?) async throws {
let input = ChatTargetInput(
recipient: stringParam(params["to"]) ?? "",
chatID: int64Param(params["chat_id"]),
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
chatGUID: stringParam(params["chat_guid"]) ?? ""
)
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
missingRecipientError: RPCError.invalidParams("to is required")
)
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in try await cache.info(chatID: chatID) },
unknownChatError: { chatID in
RPCError.invalidParams("unknown chat_id \(chatID)")
}
)
let handle: String
if let preferred = resolvedTarget.preferredIdentifier {
handle = preferred
} else if input.hasChatTarget {
throw RPCError.invalidParams("missing chat identifier or guid")
} else {
handle = input.recipient
}
try await IMCoreBridge.shared.markAsRead(handle: handle)
respond(id: id, result: ["ok": true])
}
}
func buildMessagePayload(
store: MessageStore,
cache: ChatCache,
message: Message,
includeAttachments: Bool,
includeReactions: Bool,
prefetchedAttachments: [AttachmentMeta]? = nil,
prefetchedReactions: [Reaction]? = nil,
attachmentOptions: AttachmentQueryOptions = .default,
contactResolver: any ContactResolving = NoOpContactResolver()
) async throws -> [String: Any] {
let chatInfo = try await cache.info(chatID: message.chatID)
let participants = try await cache.participants(chatID: message.chatID)
let attachments: [AttachmentMeta]
if includeAttachments {
attachments =
try prefetchedAttachments ?? store.attachments(for: message.rowID, options: attachmentOptions)
} else {
attachments = []
}
let reactions: [Reaction]
if includeReactions {
reactions = try prefetchedReactions ?? store.reactions(for: message.rowID)
} else {
reactions = []
}
let senderName = message.isFromMe ? nil : contactResolver.displayName(for: message.sender)
var reactionSenderNames: [Int64: String] = [:]
for reaction in reactions where !reaction.isFromMe {
if let name = contactResolver.displayName(for: reaction.sender) {
reactionSenderNames[reaction.rowID] = name
}
}
return try messagePayload(
message: message,
chatInfo: chatInfo,
participants: participants,
attachments: attachments,
reactions: reactions,
senderName: senderName,
reactionSenderNames: reactionSenderNames
)
}

View File

@ -0,0 +1,123 @@
import Foundation
import IMsgCore
final class RPCWriter: RPCOutput, Sendable {
func sendResponse(id: Any, result: Any) {
send(["jsonrpc": "2.0", "id": id, "result": result])
}
func sendError(id: Any?, error: RPCError) {
let payload: [String: Any] = [
"jsonrpc": "2.0",
"id": id ?? NSNull(),
"error": error.asDictionary(),
]
send(payload)
}
func sendNotification(method: String, params: Any) {
send(["jsonrpc": "2.0", "method": method, "params": params])
}
private func send(_ object: Any) {
do {
let data = try JSONSerialization.data(withJSONObject: object, options: [])
if let output = String(data: data, encoding: .utf8) {
StdoutWriter.writeLine(output)
}
} catch {
StdoutWriter.writeLine(
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}"
)
}
}
}
struct RPCError: Error {
let code: Int
let message: String
let data: String?
static func parseError(_ message: String) -> RPCError {
RPCError(code: -32700, message: "Parse error", data: message)
}
static func invalidRequest(_ message: String) -> RPCError {
RPCError(code: -32600, message: "Invalid Request", data: message)
}
static func methodNotFound(_ method: String) -> RPCError {
RPCError(code: -32601, message: "Method not found", data: method)
}
static func invalidParams(_ message: String) -> RPCError {
RPCError(code: -32602, message: "Invalid params", data: message)
}
static func internalError(_ message: String) -> RPCError {
RPCError(code: -32603, message: "Internal error", data: message)
}
func asDictionary() -> [String: Any] {
var dict: [String: Any] = [
"code": code,
"message": message,
]
if let data {
dict["data"] = data
}
return dict
}
}
actor SubscriptionStore {
private var nextID = 1
private var tasks: [Int: Task<Void, Never>] = [:]
func allocateID() -> Int {
let id = nextID
nextID += 1
return id
}
func insert(_ task: Task<Void, Never>, for id: Int) {
tasks[id] = task
}
func remove(_ id: Int) -> Task<Void, Never>? {
tasks.removeValue(forKey: id)
}
func cancelAll() {
for task in tasks.values {
task.cancel()
}
tasks.removeAll()
}
}
actor ChatCache {
private let store: MessageStore
private var infoCache: [Int64: ChatInfo] = [:]
private var participantsCache: [Int64: [String]] = [:]
init(store: MessageStore) {
self.store = store
}
func info(chatID: Int64) throws -> ChatInfo? {
if let cached = infoCache[chatID] { return cached }
if let info = try store.chatInfo(chatID: chatID) {
infoCache[chatID] = info
return info
}
return nil
}
func participants(chatID: Int64) throws -> [String] {
if let cached = participantsCache[chatID] { return cached }
let participants = try store.participants(chatID: chatID)
participantsCache[chatID] = participants
return participants
}
}

View File

@ -1,27 +1,61 @@
import Foundation
import IMsgCore
typealias SentMessageResolver = (
_ store: MessageStore,
_ options: MessageSendOptions,
_ chatID: Int64?,
_ sentAt: Date
) async throws -> Message?
protocol RPCOutput: Sendable {
func sendResponse(id: Any, result: Any)
func sendError(id: Any?, error: RPCError)
func sendNotification(method: String, params: Any)
}
/// Methods exposed by `imsg rpc` over JSON-RPC. Advertised to clients via
/// `imsg status --json` (`rpc_methods` field) so capability-aware consumers
/// (like the openclaw imessage channel plugin) can gate features off when
/// running against an older imsg build that doesn't implement a given method.
///
/// Keep in sync with the dispatch switch in `RPCServer.handleLine`.
let kSupportedRPCMethods: [String] = [
"chats.list",
"chats.create",
"chats.delete",
"chats.markUnread",
"messages.history",
"watch.subscribe",
"watch.unsubscribe",
"send",
"typing",
"read",
"group.rename",
"group.setIcon",
"group.addParticipant",
"group.removeParticipant",
"group.leave",
]
final class RPCServer {
private let store: MessageStore
private let watcher: MessageWatcher
private let output: RPCOutput
private let cache: ChatCache
private let verbose: Bool
private let sendMessage: (MessageSendOptions) throws -> Void
private var nextSubscriptionID = 1
private var subscriptions: [Int: Task<Void, Never>] = [:]
let store: MessageStore
let watcher: MessageWatcher
let output: RPCOutput
let cache: ChatCache
let subscriptions = SubscriptionStore()
let verbose: Bool
let sendMessage: (MessageSendOptions) throws -> Void
let resolveSentMessage: SentMessageResolver
let contactResolver: any ContactResolving
init(
store: MessageStore,
verbose: Bool,
output: RPCOutput = RPCWriter(),
sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) }
sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) },
resolveSentMessage: @escaping SentMessageResolver = RPCServer.resolveSentMessage,
contactResolver: any ContactResolving = NoOpContactResolver()
) {
self.store = store
self.watcher = MessageWatcher(store: store)
@ -29,6 +63,8 @@ final class RPCServer {
self.verbose = verbose
self.output = output
self.sendMessage = sendMessage
self.resolveSentMessage = resolveSentMessage
self.contactResolver = contactResolver
}
func run() async throws {
@ -37,15 +73,18 @@ final class RPCServer {
if trimmed.isEmpty { continue }
await handleLine(trimmed)
}
for task in subscriptions.values {
task.cancel()
}
await subscriptions.cancelAll()
}
func handleLineForTesting(_ line: String) async {
await handleLine(line)
}
func respond(id: Any?, result: Any) {
guard let id else { return }
output.sendResponse(id: id, result: result)
}
private func handleLine(_ line: String) async {
guard let data = line.data(using: .utf8) else {
output.sendError(id: nil, error: RPCError.parseError("invalid utf8"))
@ -77,117 +116,35 @@ final class RPCServer {
do {
switch method {
case "chats.list":
let limit = intParam(params["limit"]) ?? 20
let chats = try store.listChats(limit: max(limit, 1))
let payloads = try chats.map { chat in
let info = try cache.info(chatID: chat.id)
let participants = try cache.participants(chatID: chat.id)
let identifier = info?.identifier ?? chat.identifier
let guid = info?.guid ?? ""
let name = (info?.name.isEmpty == false ? info?.name : nil) ?? chat.name
let service = info?.service ?? chat.service
return chatPayload(
id: chat.id,
identifier: identifier,
guid: guid,
name: name,
service: service,
lastMessageAt: chat.lastMessageAt,
participants: participants
)
}
respond(id: id, result: ["chats": payloads])
try await handleChatsList(id: id, params: params)
case "messages.history":
guard let chatID = int64Param(params["chat_id"]) else {
throw RPCError.invalidParams("chat_id is required")
}
let limit = intParam(params["limit"]) ?? 50
let participants = stringArrayParam(params["participants"])
let startISO = stringParam(params["start"])
let endISO = stringParam(params["end"])
let includeAttachments = boolParam(params["attachments"]) ?? false
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: startISO,
endISO: endISO
)
let messages = try store.messages(chatID: chatID, limit: max(limit, 1))
let filtered = messages.filter { filter.allows($0) }
let payloads = try filtered.map { message in
try buildMessagePayload(
store: store,
cache: cache,
message: message,
includeAttachments: includeAttachments
)
}
respond(id: id, result: ["messages": payloads])
try await handleMessagesHistory(id: id, params: params)
case "watch.subscribe":
let chatID = int64Param(params["chat_id"])
let sinceRowID = int64Param(params["since_rowid"])
let participants = stringArrayParam(params["participants"])
let startISO = stringParam(params["start"])
let endISO = stringParam(params["end"])
let includeAttachments = boolParam(params["attachments"]) ?? false
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: startISO,
endISO: endISO
)
let config = MessageWatcherConfiguration()
let subID = nextSubscriptionID
nextSubscriptionID += 1
let localStore = store
let localWatcher = watcher
let localCache = cache
let localWriter = output
let localFilter = filter
let localChatID = chatID
let localSinceRowID = sinceRowID
let localConfig = config
let localIncludeAttachments = includeAttachments
let task = Task {
do {
for try await message in localWatcher.stream(
chatID: localChatID,
sinceRowID: localSinceRowID,
configuration: localConfig
) {
if Task.isCancelled { return }
if !localFilter.allows(message) { continue }
let payload = try buildMessagePayload(
store: localStore,
cache: localCache,
message: message,
includeAttachments: localIncludeAttachments
)
localWriter.sendNotification(
method: "message",
params: ["subscription": subID, "message": payload]
)
}
} catch {
localWriter.sendNotification(
method: "error",
params: [
"subscription": subID,
"error": ["message": String(describing: error)],
]
)
}
}
subscriptions[subID] = task
respond(id: id, result: ["subscription": subID])
try await handleWatchSubscribe(id: id, params: params)
case "watch.unsubscribe":
guard let subID = intParam(params["subscription"]) else {
throw RPCError.invalidParams("subscription is required")
}
if let task = subscriptions.removeValue(forKey: subID) {
task.cancel()
}
respond(id: id, result: ["ok": true])
try await handleWatchUnsubscribe(id: id, params: params)
case "send":
try handleSend(params: params, id: id)
try await handleSend(params: params, id: id)
case "typing":
try await handleTyping(params: params, id: id)
case "read":
try await handleRead(params: params, id: id)
case "chats.create":
try await handleChatsCreate(id: id, params: params)
case "chats.delete":
try await handleChatsDelete(id: id, params: params)
case "chats.markUnread":
try await handleChatsMarkUnread(id: id, params: params)
case "group.rename":
try await handleGroupRename(id: id, params: params)
case "group.setIcon":
try await handleGroupSetIcon(id: id, params: params)
case "group.addParticipant":
try await handleGroupAddParticipant(id: id, params: params)
case "group.removeParticipant":
try await handleGroupRemoveParticipant(id: id, params: params)
case "group.leave":
try await handleGroupLeave(id: id, params: params)
default:
output.sendError(id: id, error: RPCError.methodNotFound(method))
}
@ -208,183 +165,17 @@ final class RPCServer {
}
}
private func respond(id: Any?, result: Any) {
guard let id else { return }
output.sendResponse(id: id, result: result)
}
private func handleSend(params: [String: Any], id: Any?) throws {
let text = stringParam(params["text"]) ?? ""
let file = stringParam(params["file"]) ?? ""
let serviceRaw = stringParam(params["service"]) ?? "auto"
guard let service = MessageService(rawValue: serviceRaw) else {
throw RPCError.invalidParams("invalid service")
}
let region = stringParam(params["region"]) ?? "US"
let chatID = int64Param(params["chat_id"])
let chatIdentifier = stringParam(params["chat_identifier"]) ?? ""
let chatGUID = stringParam(params["chat_guid"]) ?? ""
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
let recipient = stringParam(params["to"]) ?? ""
if hasChatTarget && !recipient.isEmpty {
throw RPCError.invalidParams("use to or chat_*; not both")
}
if !hasChatTarget && recipient.isEmpty {
throw RPCError.invalidParams("to is required for direct sends")
}
if text.isEmpty && file.isEmpty {
throw RPCError.invalidParams("text or file is required")
}
var resolvedChatIdentifier = chatIdentifier
var resolvedChatGUID = chatGUID
if let chatID {
guard let info = try cache.info(chatID: chatID) else {
throw RPCError.invalidParams("unknown chat_id \(chatID)")
}
resolvedChatIdentifier = info.identifier
resolvedChatGUID = info.guid
}
if hasChatTarget && resolvedChatIdentifier.isEmpty && resolvedChatGUID.isEmpty {
throw RPCError.invalidParams("missing chat identifier or guid")
}
try sendMessage(
MessageSendOptions(
recipient: recipient,
text: text,
attachmentPath: file,
service: service,
region: region,
chatIdentifier: resolvedChatIdentifier,
chatGUID: resolvedChatGUID
)
static func resolveSentMessage(
store: MessageStore,
options: MessageSendOptions,
chatID: Int64?,
sentAt: Date
) async throws -> Message? {
try await SentMessageVerifier.resolveSentMessage(
store: store,
options: options,
chatID: chatID,
sentAt: sentAt
)
respond(id: id, result: ["ok": true])
}
}
private func buildMessagePayload(
store: MessageStore,
cache: ChatCache,
message: Message,
includeAttachments: Bool
) throws -> [String: Any] {
let chatInfo = try cache.info(chatID: message.chatID)
let participants = try cache.participants(chatID: message.chatID)
let attachments = includeAttachments ? try store.attachments(for: message.rowID) : []
let reactions = includeAttachments ? try store.reactions(for: message.rowID) : []
return messagePayload(
message: message,
chatInfo: chatInfo,
participants: participants,
attachments: attachments,
reactions: reactions
)
}
private final class RPCWriter: RPCOutput, @unchecked Sendable {
private let queue = DispatchQueue(label: "imsg.rpc.writer")
func sendResponse(id: Any, result: Any) {
send(["jsonrpc": "2.0", "id": id, "result": result])
}
func sendError(id: Any?, error: RPCError) {
let payload: [String: Any] = [
"jsonrpc": "2.0",
"id": id ?? NSNull(),
"error": error.asDictionary(),
]
send(payload)
}
func sendNotification(method: String, params: Any) {
send(["jsonrpc": "2.0", "method": method, "params": params])
}
private func send(_ object: Any) {
queue.sync {
do {
let data = try JSONSerialization.data(withJSONObject: object, options: [])
if let output = String(data: data, encoding: .utf8) {
FileHandle.standardOutput.write(Data(output.utf8))
FileHandle.standardOutput.write(Data("\n".utf8))
}
} catch {
if let fallback =
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}\n"
.data(using: .utf8)
{
FileHandle.standardOutput.write(fallback)
}
}
}
}
}
struct RPCError: Error {
let code: Int
let message: String
let data: String?
static func parseError(_ message: String) -> RPCError {
RPCError(code: -32700, message: "Parse error", data: message)
}
static func invalidRequest(_ message: String) -> RPCError {
RPCError(code: -32600, message: "Invalid Request", data: message)
}
static func methodNotFound(_ method: String) -> RPCError {
RPCError(code: -32601, message: "Method not found", data: method)
}
static func invalidParams(_ message: String) -> RPCError {
RPCError(code: -32602, message: "Invalid params", data: message)
}
static func internalError(_ message: String) -> RPCError {
RPCError(code: -32603, message: "Internal error", data: message)
}
func asDictionary() -> [String: Any] {
var dict: [String: Any] = [
"code": code,
"message": message,
]
if let data {
dict["data"] = data
}
return dict
}
}
private final class ChatCache: @unchecked Sendable {
private let store: MessageStore
private var infoCache: [Int64: ChatInfo] = [:]
private var participantsCache: [Int64: [String]] = [:]
init(store: MessageStore) {
self.store = store
}
func info(chatID: Int64) throws -> ChatInfo? {
if let cached = infoCache[chatID] { return cached }
if let info = try store.chatInfo(chatID: chatID) {
infoCache[chatID] = info
return info
}
return nil
}
func participants(chatID: Int64) throws -> [String] {
if let cached = participantsCache[chatID] { return cached }
let participants = try store.participants(chatID: chatID)
participantsCache[chatID] = participants
return participants
}
}

View File

@ -11,9 +11,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.4.0</string>
<string>0.8.1</string>
<key>CFBundleVersion</key>
<string>0.4.0</string>
<string>0.8.1</string>
<key>NSAppleEventsUsageDescription</key>
<string>Send messages via Messages.app.</string>
</dict>

View File

@ -0,0 +1,50 @@
import Foundation
import IMsgCore
enum SentMessageVerifier {
static func resolveSentMessage(
store: MessageStore,
options: MessageSendOptions,
chatID: Int64?,
sentAt: Date
) async throws -> Message? {
guard !options.text.isEmpty else { return nil }
let lowerBound = sentAt.addingTimeInterval(-2)
let deadline = Date().addingTimeInterval(2)
repeat {
if Task.isCancelled { return nil }
if let message = try store.latestSentMessage(
matchingText: options.text,
chatID: chatID,
since: lowerBound
) {
return message
}
try await Task.sleep(nanoseconds: 100_000_000)
} while Date() < deadline
return nil
}
static func throwIfMisroutedChatSend(
store: MessageStore,
options: MessageSendOptions,
sentAt: Date
) throws {
let handles = [options.chatGUID, options.chatIdentifier].filter { !$0.isEmpty }
guard !handles.isEmpty else { return }
let lowerBound = sentAt.addingTimeInterval(-2)
guard
let rowID = try store.latestUnjoinedSentMessageRowID(
matchingTargetHandles: handles,
since: lowerBound
)
else {
return
}
throw IMsgError.appleScriptFailure(
"Messages accepted the chat send but wrote an unjoined empty outgoing row (\(rowID)); delivery to the target chat was not confirmed"
)
}
}

View File

@ -0,0 +1,24 @@
import Dispatch
import Foundation
enum StdoutWriter {
private static let queue = DispatchQueue(label: "imsg.stdout.writer")
private static let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
return encoder
}()
static func writeLine(_ line: String) {
queue.sync {
FileHandle.standardOutput.write(Data((line + "\n").utf8))
}
}
static func writeJSONLine<T: Encodable>(_ value: T) throws {
let data = try jsonEncoder.encode(value)
guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return }
writeLine(line)
}
}

View File

@ -1,4 +1,4 @@
// Generated by scripts/generate-version.sh. Do not edit.
enum IMsgVersion {
static let current = "0.4.0"
static let current = "0.8.1"
}

View File

@ -0,0 +1,18 @@
import Testing
@testable import IMsgCore
@Test
func noOpContactResolverReturnsNoMatches() {
let resolver = NoOpContactResolver()
#expect(resolver.contactsUnavailable == false)
#expect(resolver.displayName(for: "+15551234567") == nil)
#expect(resolver.displayNames(for: ["+15551234567"]).isEmpty)
#expect(resolver.searchByName("John").isEmpty)
}
@Test
func noOpContactResolverCanRepresentUnavailableContacts() {
let resolver = NoOpContactResolver(contactsUnavailable: true)
#expect(resolver.contactsUnavailable == true)
}

View File

@ -0,0 +1,66 @@
import Foundation
import Testing
@testable import IMsgCore
@Test
func imCoreBridgeIsNotAvailableWithoutDylib() {
// In the test environment there's no dylib built, so isAvailable should be false
// unless one happens to exist at a search path. We test the shared instance exists.
let bridge = IMCoreBridge.shared
// Just verify the API exists and doesn't crash
_ = bridge.isAvailable
}
@Test
func imCoreBridgeCheckAvailabilityReturnsDiagnostic() {
let bridge = IMCoreBridge.shared
let (_, message) = bridge.checkAvailability()
// Should return a non-empty diagnostic message regardless of availability
#expect(!message.isEmpty)
}
@Test
func messagesLauncherSharedInstanceExists() {
let launcher = MessagesLauncher.shared
// Verify the launcher can be accessed
#expect(launcher.dylibPath.contains("imsg-bridge-helper.dylib"))
}
@Test
func messagesLauncherIsNotReadyWithoutInjection() {
let launcher = MessagesLauncher.shared
// Without actually launching Messages.app with injection, this should return false
// (unless Messages happens to be running with our dylib, which is unlikely in CI)
_ = launcher.isInjectedAndReady()
// Just verify it doesn't crash
}
@Test
func messagesLauncherErrorDescriptions() {
let errors: [MessagesLauncherError] = [
.dylibNotFound("/fake/path"),
.launchFailed("test reason"),
.socketTimeout,
.socketError("test error"),
.invalidResponse,
]
for error in errors {
#expect(!error.description.isEmpty)
}
}
@Test
func imCoreBridgeErrorDescriptions() {
let errors: [IMCoreBridgeError] = [
.dylibNotFound,
.connectionFailed("test"),
.chatNotFound("test-handle"),
.operationFailed("test reason"),
]
for error in errors {
#expect(!error.description.isEmpty)
}
}

View File

@ -0,0 +1,84 @@
import Testing
@testable import IMsgCore
@Suite("IMsgBridgeProtocol")
struct IMsgBridgeProtocolTests {
@Test
func actionRawValuesMatchDylibVocabulary() {
#expect(BridgeAction.sendMessage.rawValue == "send-message")
#expect(BridgeAction.sendReaction.rawValue == "send-reaction")
#expect(BridgeAction.editMessage.rawValue == "edit-message")
#expect(BridgeAction.unsendMessage.rawValue == "unsend-message")
#expect(BridgeAction.createChat.rawValue == "create-chat")
#expect(BridgeAction.searchMessages.rawValue == "search-messages")
#expect(BridgeAction.checkImessageAvailability.rawValue == "check-imessage-availability")
// Legacy compat: the integer-id v1 protocol still uses these names.
#expect(BridgeAction.typing.rawValue == "typing")
#expect(BridgeAction.read.rawValue == "read")
#expect(BridgeAction.listChats.rawValue == "list_chats")
}
@Test
func reactionKindMapsToStableAssociatedTypes() {
#expect(BridgeReactionKind.love.associatedMessageType == 2000)
#expect(BridgeReactionKind.like.associatedMessageType == 2001)
#expect(BridgeReactionKind.dislike.associatedMessageType == 2002)
#expect(BridgeReactionKind.laugh.associatedMessageType == 2003)
#expect(BridgeReactionKind.emphasize.associatedMessageType == 2004)
#expect(BridgeReactionKind.question.associatedMessageType == 2005)
// Removal kinds are exactly +1000.
for kind in BridgeReactionKind.allCases where !kind.rawValue.hasPrefix("remove-") {
let removeName = "remove-\(kind.rawValue)"
let remove = BridgeReactionKind(rawValue: removeName)
#expect(remove != nil, "missing remove case for \(kind.rawValue)")
#expect(remove?.associatedMessageType == kind.associatedMessageType + 1000)
}
}
@Test
func parseAcceptsV2Envelope() throws {
let raw: [String: Any] = [
"v": 2,
"id": "abc-123",
"success": true,
"data": ["messageGuid": "M-1"],
"timestamp": "2026-05-04T12:00:00Z",
]
let response = try BridgeResponse.parse(raw)
#expect(response.id == "abc-123")
#expect(response.success == true)
#expect(response.error == nil)
#expect(response.data["messageGuid"] as? String == "M-1")
}
@Test
func parseAcceptsLegacyEnvelopeWithoutDataKey() throws {
let raw: [String: Any] = [
"id": 42,
"success": true,
"handle": "+15551234567",
"marked_as_read": true,
"timestamp": "2026-05-04T12:00:00Z",
]
let response = try BridgeResponse.parse(raw)
#expect(response.id == "42")
#expect(response.success == true)
#expect(response.data["handle"] as? String == "+15551234567")
#expect(response.data["marked_as_read"] as? Bool == true)
#expect(response.data["timestamp"] == nil, "envelope keys should be stripped")
}
@Test
func parsePropagatesError() throws {
let raw: [String: Any] = [
"v": 2,
"id": "x",
"success": false,
"error": "Chat not found",
]
let response = try BridgeResponse.parse(raw)
#expect(response.success == false)
#expect(response.error == "Chat not found")
}
}

View File

@ -0,0 +1,115 @@
import Foundation
import Testing
@testable import IMsgCore
/// Smoke tests for the events-jsonl tailer. Writes a temp file, appends a few
/// JSON lines, asserts they surface in order through the AsyncStream.
@Suite("IMsgEventTailer")
struct IMsgEventTailerTests {
@Test
func tailerEmitsAppendedLines() async throws {
let dir = NSTemporaryDirectory() + "imsg-tailer-test-\(UUID().uuidString)"
try FileManager.default.createDirectory(
atPath: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: dir) }
let path = (dir as NSString).appendingPathComponent("events.jsonl")
FileManager.default.createFile(atPath: path, contents: Data(), attributes: nil)
let tailer = IMsgEventTailer(path: path)
let stream = tailer.events()
// Append two events on a background task so the tailer has a chance to
// open and start watching before lines arrive.
Task.detached {
try? await Task.sleep(nanoseconds: 200_000_000)
let line1 = """
{"event":"started-typing","data":{"chatGuid":"iMessage;-;+15551"}}
""" + "\n"
let line2 = """
{"event":"stopped-typing","data":{"chatGuid":"iMessage;-;+15551"}}
""" + "\n"
let fp = fopen(path, "a")
if let fp = fp {
line1.utf8CString.withUnsafeBufferPointer { buf in
guard let base = buf.baseAddress else { return }
fwrite(base, 1, strlen(base), fp)
}
line2.utf8CString.withUnsafeBufferPointer { buf in
guard let base = buf.baseAddress else { return }
fwrite(base, 1, strlen(base), fp)
}
fflush(fp)
fclose(fp)
}
}
var collected: [String] = []
let deadline = Date().addingTimeInterval(3.0)
for await event in stream {
collected.append(event.name)
if collected.count >= 2 { break }
if Date() > deadline { break }
}
tailer.stop()
#expect(collected == ["started-typing", "stopped-typing"])
}
@Test
func tailerSkipsExistingLinesByDefault() async throws {
let dir = NSTemporaryDirectory() + "imsg-tailer-test-\(UUID().uuidString)"
try FileManager.default.createDirectory(
atPath: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: dir) }
let path = (dir as NSString).appendingPathComponent("events.jsonl")
let oldLine = """
{"event":"old-typing","data":{"chatGuid":"iMessage;-;+15551"}}
""" + "\n"
FileManager.default.createFile(
atPath: path,
contents: oldLine.data(using: .utf8),
attributes: nil
)
let tailer = IMsgEventTailer(path: path)
let stream = tailer.events()
Task.detached {
try? await Task.sleep(nanoseconds: 200_000_000)
let newLine = """
{"event":"started-typing","data":{"chatGuid":"iMessage;-;+15551"}}
""" + "\n"
let fp = fopen(path, "a")
if let fp = fp {
newLine.utf8CString.withUnsafeBufferPointer { buf in
guard let base = buf.baseAddress else { return }
fwrite(base, 1, strlen(base), fp)
}
fflush(fp)
fclose(fp)
}
}
var first: String?
for await event in stream {
first = event.name
break
}
tailer.stop()
#expect(first == "started-typing")
}
@Test
func eventDecodedPayloadRoundTrip() throws {
let raw: [String: Any] = ["chatGuid": "iMessage;-;+1", "extra": 42]
let data = try JSONSerialization.data(withJSONObject: raw, options: [])
let event = IMsgEventTailer.Event(timestamp: nil, name: "x", payloadJSON: data)
let decoded = event.decodedPayload()
#expect(decoded["chatGuid"] as? String == "iMessage;-;+1")
#expect(decoded["extra"] as? Int == 42)
}
}

View File

@ -0,0 +1,100 @@
import SQLite
@testable import IMsgCore
enum MessageDatabaseFixture {
struct SchemaOptions {
var includeAttributedBody = false
var includeReactionColumns = false
var includeThreadOriginatorGUID = false
var includeDestinationCallerID = false
var includeAudioMessage = false
var includeBalloonBundleID = false
var includeAttachmentUserInfo = false
var includeChatMessageDate = false
var includeChatRouting = true
var includeChatHandleJoin = true
}
static func createSchema(_ db: Connection, options: SchemaOptions = SchemaOptions()) throws {
let attributedBodyColumn = options.includeAttributedBody ? "attributedBody BLOB," : ""
let reactionColumns =
options.includeReactionColumns
? "guid TEXT, associated_message_guid TEXT, associated_message_type INTEGER,"
: ""
let threadOriginatorColumn =
options.includeThreadOriginatorGUID ? "thread_originator_guid TEXT," : ""
let destinationCallerColumn =
options.includeDestinationCallerID ? "destination_caller_id TEXT," : ""
let audioMessageColumn = options.includeAudioMessage ? "is_audio_message INTEGER," : ""
let balloonColumn = options.includeBalloonBundleID ? "balloon_bundle_id TEXT," : ""
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
\(attributedBodyColumn)
\(reactionColumns)
\(threadOriginatorColumn)
\(destinationCallerColumn)
\(audioMessageColumn)
\(balloonColumn)
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
let chatRoutingColumns =
options.includeChatRouting
? "account_id TEXT, account_login TEXT, last_addressed_handle TEXT,"
: ""
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT,
\(chatRoutingColumns)
reserved TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
if options.includeChatHandleJoin {
try db.execute("CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);")
}
let messageDateColumn = options.includeChatMessageDate ? ", message_date INTEGER" : ""
try db.execute(
"CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER\(messageDateColumn));")
let attachmentUserInfoColumn = options.includeAttachmentUserInfo ? ", user_info BLOB" : ""
try db.execute(
"""
CREATE TABLE attachment (
ROWID INTEGER PRIMARY KEY,
filename TEXT,
transfer_name TEXT,
uti TEXT,
mime_type TEXT,
total_bytes INTEGER,
is_sticker INTEGER
\(attachmentUserInfoColumn)
);
"""
)
try db.execute(
"""
CREATE TABLE message_attachment_join (
message_id INTEGER,
attachment_id INTEGER
);
"""
)
}
}

View File

@ -0,0 +1,106 @@
import Foundation
import Testing
@testable import IMsgCore
private func normalizeForTest(_ input: String) -> String {
let result =
input
.replacingOccurrences(of: "imessage:", with: "")
.replacingOccurrences(of: "sms:", with: "")
.replacingOccurrences(of: "auto:", with: "")
.filter { "+0123456789".contains($0) }
return result.isEmpty ? input : result
}
private final class TestMessageSender {
private var captured: [String] = []
func send(_ options: MessageSendOptions) throws -> [String] {
var resolved = options
let chatTarget = resolveChatTarget(&resolved)
let useChat = !chatTarget.isEmpty
if useChat == false {
if resolved.region.isEmpty { resolved.region = "US" }
resolved.recipient = normalizeForTest(resolved.recipient)
if resolved.service == .auto { resolved.service = .imessage }
}
captured = [
resolved.recipient,
resolved.text,
resolved.service.rawValue,
resolved.attachmentPath,
resolved.attachmentPath.isEmpty ? "0" : "1",
chatTarget,
useChat ? "1" : "0",
]
return captured
}
private func resolveChatTarget(_ options: inout MessageSendOptions) -> String {
let guid = options.chatGUID.trimmingCharacters(in: .whitespacesAndNewlines)
let identifier = options.chatIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
if !identifier.isEmpty && looksLikeHandle(identifier) {
if options.recipient.isEmpty {
options.recipient = identifier
}
return ""
}
if !guid.isEmpty {
return guid
}
if identifier.isEmpty {
return ""
}
return identifier
}
private func looksLikeHandle(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
let lower = trimmed.lowercased()
if lower.hasPrefix("imessage:") || lower.hasPrefix("sms:") || lower.hasPrefix("auto:") {
return true
}
if trimmed.contains("@") { return true }
let allowed = CharacterSet(charactersIn: "+0123456789 ()-")
return trimmed.rangeOfCharacter(from: allowed.inverted) == nil
}
}
@Test
func messageSenderPrefersHandleWhenChatIdentifierLooksLikeHandle() throws {
let sender = TestMessageSender()
let options = MessageSendOptions(
recipient: "",
text: "hi",
attachmentPath: "",
service: .auto,
region: "US",
chatIdentifier: "imessage:+15551234567",
chatGUID: "iMessage;+;chat123"
)
let captured = try sender.send(options)
#expect(captured[5].isEmpty)
#expect(captured[6] == "0")
#expect(captured[0].contains("15551234567"))
}
@Test
func messageSenderUsesChatGuidWhenIdentifierIsGroupHandle() throws {
let sender = TestMessageSender()
let options = MessageSendOptions(
recipient: "",
text: "hi",
attachmentPath: "",
service: .auto,
region: "US",
chatIdentifier: "iMessage;+;group123",
chatGUID: "iMessage;+;group123"
)
let captured = try sender.send(options)
#expect(captured[5] == "iMessage;+;group123")
#expect(captured[6] == "1")
}

View File

@ -0,0 +1,66 @@
import Foundation
import Testing
@testable import IMsgCore
@Test
func attachmentsByMessageReportsConvertedMetadata() throws {
let source = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("gif")
try Data("gif".utf8).write(to: source)
defer { try? FileManager.default.removeItem(at: source) }
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "png")
try FileManager.default.createDirectory(
at: converted.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("png".utf8).write(to: converted)
defer { try? FileManager.default.removeItem(at: converted) }
let store = try TestDatabase.makeStore(
attachmentFilename: source.path,
attachmentTransferName: "animation.gif",
attachmentUTI: "com.compuserve.gif",
attachmentMimeType: "image/gif"
)
let attachments = try store.attachments(
for: 2,
options: AttachmentQueryOptions(convertUnsupported: true)
)
#expect(attachments.first?.originalPath == source.path)
#expect(attachments.first?.convertedPath == converted.path)
#expect(attachments.first?.convertedMimeType == "image/png")
}
@Test
func attachmentsByMessagesReportsConvertedMetadata() throws {
let source = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("caf")
try Data("caf".utf8).write(to: source)
defer { try? FileManager.default.removeItem(at: source) }
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "m4a")
try FileManager.default.createDirectory(
at: converted.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("m4a".utf8).write(to: converted)
defer { try? FileManager.default.removeItem(at: converted) }
let store = try TestDatabase.makeStore(
attachmentFilename: source.path,
attachmentTransferName: "voice.caf",
attachmentUTI: "com.apple.coreaudio-format",
attachmentMimeType: "audio/x-caf"
)
let attachmentsByMessageID = try store.attachments(
for: [2],
options: AttachmentQueryOptions(convertUnsupported: true)
)
#expect(attachmentsByMessageID[2]?.first?.originalPath == source.path)
#expect(attachmentsByMessageID[2]?.first?.convertedPath == converted.path)
#expect(attachmentsByMessageID[2]?.first?.convertedMimeType == "audio/mp4")
}

View File

@ -133,6 +133,73 @@ func messagesByChatUsesLengthPrefixedAttributedBodyFallback() throws {
#expect(messages.first?.text == "length prefixed")
}
@Test
func messagesByChatUsesUTF16LittleEndianAttributedBodyFallback() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
attributedBody BLOB,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"""
CREATE TABLE message_attachment_join (
message_id INTEGER,
attachment_id INTEGER
);
"""
)
let now = Date()
var bodyData = Data([0xff, 0xfe])
let bodyText = "hello 🌤️"
let encoded = try #require(bodyText.data(using: .utf16LittleEndian))
bodyData.append(encoded)
let body = Blob(bytes: [UInt8](bodyData))
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES (1, '+123', 'iMessage;-;+123', 'Direct Chat', 'iMessage')
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, attributedBody, date, is_from_me, service)
VALUES (1, 1, NULL, ?, ?, 0, 'iMessage')
""",
body,
TestDatabase.appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
let store = try MessageStore(connection: db, path: ":memory:")
let messages = try store.messages(chatID: 1, limit: 10)
#expect(messages.count == 1)
#expect(messages.first?.text == bodyText)
}
@Test
func messagesAfterUsesAttributedBodyFallback() throws {
let db = try Connection(.inMemory)

View File

@ -0,0 +1,67 @@
import Foundation
import SQLite
import Testing
@testable import IMsgCore
@Test
func listChatsReturnsChat() throws {
let store = try TestDatabase.makeStore()
let chats = try store.listChats(limit: 5)
#expect(chats.count == 1)
#expect(chats.first?.identifier == "+123")
#expect(chats.first?.accountID == "iMessage;+;me@icloud.com")
#expect(chats.first?.accountLogin == "me@icloud.com")
#expect(chats.first?.lastAddressedHandle == "+15551234567")
}
@Test
func listChatsUsesChatMessageJoinDateWithoutMessageJoinWhenAvailable() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat_message_join (
chat_id INTEGER,
message_id INTEGER,
message_date INTEGER
);
"""
)
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES
(1, '+111', 'iMessage;-;+111', 'Old Chat', 'iMessage'),
(2, '+222', 'iMessage;-;+222', 'New Chat', 'iMessage')
"""
)
try db.run(
"""
INSERT INTO chat_message_join(chat_id, message_id, message_date)
VALUES
(1, 100, ?),
(2, 200, ?)
""",
TestDatabase.appleEpoch(Date(timeIntervalSince1970: 100)),
TestDatabase.appleEpoch(Date(timeIntervalSince1970: 200))
)
let store = try MessageStore(connection: db, path: ":memory:")
let chats = try store.listChats(limit: 1)
#expect(chats.count == 1)
#expect(chats.first?.identifier == "+222")
#expect(chats.first?.accountID == nil)
#expect(chats.first?.accountLogin == nil)
#expect(chats.first?.lastAddressedHandle == nil)
}

View File

@ -137,6 +137,86 @@ func reactionsForMessageReturnsReactions() throws {
#expect(reactions[3].sender == "+456")
}
@Test
func bulkReactionsForMessagesGroupsByMessageID() throws {
let db = try ReactionTestDatabase.makeConnection()
let now = Date()
try ReactionTestDatabase.seedBaseMessage(db, now: now)
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, date, is_from_me, service)
VALUES (10, 2, 'Second message', 'msg-guid-2', NULL, 0, ?, 0, 'iMessage')
""",
ReactionTestDatabase.appleEpoch(now.addingTimeInterval(-550))
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 10)")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, date, is_from_me, service)
VALUES (2, 2, '', 'reaction-guid-1', 'p:0/msg-guid-1', 2000, ?, 0, 'iMessage')
""",
ReactionTestDatabase.appleEpoch(now.addingTimeInterval(-500))
)
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, date, is_from_me, service)
VALUES (3, 1, '', 'reaction-guid-2', 'msg-guid-2', 2001, ?, 1, 'iMessage')
""",
ReactionTestDatabase.appleEpoch(now.addingTimeInterval(-450))
)
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, date, is_from_me, service)
VALUES (4, 2, 'Removed a love', 'reaction-guid-3', 'p:0/msg-guid-1', 3000, ?, 0, 'iMessage')
""",
ReactionTestDatabase.appleEpoch(now.addingTimeInterval(-400))
)
let store = try MessageStore(connection: db, path: ":memory:")
let messages = try store.messages(chatID: 1, limit: 10)
let reactionsByMessageID = try store.reactions(for: messages)
#expect(reactionsByMessageID[1]?.isEmpty != false)
#expect(reactionsByMessageID[10]?.count == 1)
#expect(reactionsByMessageID[10]?.first?.reactionType == .like)
#expect(reactionsByMessageID[10]?.first?.isFromMe == true)
}
@Test
func bulkReactionsReturnsEmptyWhenColumnsMissing() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
let store = try MessageStore(connection: db, path: ":memory:")
let reactionsByMessageID = try store.reactions(for: [
Message(
rowID: 1,
chatID: 1,
sender: "+123",
text: "hello",
date: Date(),
isFromMe: false,
service: "iMessage",
handleID: nil,
attachmentsCount: 0,
guid: "msg-guid-1"
)
])
#expect(reactionsByMessageID.isEmpty)
}
@Test
func reactionsForMessageWithNoReactionsReturnsEmpty() throws {
let db = try ReactionTestDatabase.makeConnection()

View File

@ -0,0 +1,106 @@
import Foundation
import SQLite
import Testing
@testable import IMsgCore
private func makeStoreWithoutChatRouting() throws -> MessageStore {
let db = try Connection(.inMemory)
try MessageDatabaseFixture.createSchema(
db,
options: MessageDatabaseFixture.SchemaOptions(includeChatRouting: false)
)
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES (1, '+123', 'iMessage;+;chat123', 'Legacy Chat', 'iMessage')
"""
)
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (1, NULL, 'hello', ?, 0, 'iMessage')
""",
TestDatabase.appleEpoch(Date(timeIntervalSince1970: 1_700_000_000))
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
return try MessageStore(connection: db, path: ":memory:")
}
@Test
func schemaDetectsOptionalMessageColumns() throws {
let db = try Connection(.inMemory)
try MessageDatabaseFixture.createSchema(
db,
options: MessageDatabaseFixture.SchemaOptions(
includeAttributedBody: true,
includeReactionColumns: true,
includeThreadOriginatorGUID: true,
includeDestinationCallerID: true,
includeAudioMessage: true,
includeBalloonBundleID: true,
includeAttachmentUserInfo: true,
includeChatMessageDate: true,
includeChatRouting: true
)
)
let store = try MessageStore(connection: db, path: ":memory:")
#expect(store.schema.hasAttributedBody)
#expect(store.schema.hasReactionColumns)
#expect(store.schema.hasThreadOriginatorGUIDColumn)
#expect(store.schema.hasDestinationCallerID)
#expect(store.schema.hasAudioMessageColumn)
#expect(store.schema.hasBalloonBundleIDColumn)
#expect(store.schema.hasAttachmentUserInfo)
#expect(store.schema.hasChatMessageJoinMessageDateColumn)
#expect(store.schema.hasChatAccountIDColumn)
#expect(store.schema.hasChatAccountLoginColumn)
#expect(store.schema.hasChatLastAddressedHandleColumn)
}
@Test
func schemaOverridesKeepLegacyTestFixturesExplicit() throws {
let db = try Connection(.inMemory)
try MessageDatabaseFixture.createSchema(
db,
options: MessageDatabaseFixture.SchemaOptions(
includeAttributedBody: true,
includeReactionColumns: true
)
)
let store = try MessageStore(
connection: db,
path: ":memory:",
hasAttributedBody: false,
hasReactionColumns: false
)
#expect(!store.schema.hasAttributedBody)
#expect(!store.schema.hasReactionColumns)
}
@Test
func listChatsHandlesMissingRoutingColumns() throws {
let store = try makeStoreWithoutChatRouting()
let chats = try store.listChats(limit: 1)
#expect(chats.count == 1)
#expect(chats.first?.name == "Legacy Chat")
#expect(chats.first?.accountID == nil)
#expect(chats.first?.accountLogin == nil)
#expect(chats.first?.lastAddressedHandle == nil)
}
@Test
func chatInfoHandlesMissingRoutingColumns() throws {
let store = try makeStoreWithoutChatRouting()
let info = try #require(try store.chatInfo(chatID: 1))
#expect(info.name == "Legacy Chat")
#expect(info.accountID == nil)
#expect(info.accountLogin == nil)
#expect(info.lastAddressedHandle == nil)
}

View File

@ -60,4 +60,5 @@ func messagesUseDestinationCallerIDWhenSenderMissing() throws {
let store = try MessageStore(connection: db, path: ":memory:")
let messages = try store.messages(chatID: 1, limit: 5)
#expect(messages.first?.sender == "me@icloud.com")
#expect(messages.first?.destinationCallerID == "me@icloud.com")
}

View File

@ -0,0 +1,205 @@
import Foundation
import SQLite
import Testing
@testable import IMsgCore
@Test
func latestSentMessageMatchesNewestOutgoingTextInChat() throws {
let db = try makeSentMessageDatabase()
let now = Date()
try insertSentMessageFixture(
db,
rowID: 1,
chatID: 1,
text: "same",
guid: "old-guid",
date: now.addingTimeInterval(-20),
isFromMe: true
)
try insertSentMessageFixture(
db,
rowID: 2,
chatID: 1,
text: "same",
guid: "incoming-guid",
date: now.addingTimeInterval(-5),
isFromMe: false
)
try insertSentMessageFixture(
db,
rowID: 3,
chatID: 1,
text: "same",
guid: "chat-guid",
date: now,
isFromMe: true
)
try insertSentMessageFixture(
db,
rowID: 4,
chatID: 2,
text: "same",
guid: "other-chat-guid",
date: now.addingTimeInterval(5),
isFromMe: true
)
let store = try MessageStore(connection: db, path: ":memory:")
let message = try store.latestSentMessage(
matchingText: "same",
chatID: 1,
since: now.addingTimeInterval(-10)
)
#expect(message?.rowID == 3)
#expect(message?.chatID == 1)
#expect(message?.guid == "chat-guid")
}
@Test
func latestSentMessageFallsBackToNewestOutgoingTextWithoutChatFilter() throws {
let db = try makeSentMessageDatabase()
let now = Date()
try insertSentMessageFixture(
db,
rowID: 1,
chatID: 1,
text: "same",
guid: "chat-one-guid",
date: now,
isFromMe: true
)
try insertSentMessageFixture(
db,
rowID: 2,
chatID: 2,
text: "same",
guid: "chat-two-guid",
date: now.addingTimeInterval(5),
isFromMe: true
)
let store = try MessageStore(connection: db, path: ":memory:")
let message = try store.latestSentMessage(
matchingText: "same",
chatID: nil,
since: now.addingTimeInterval(-1)
)
#expect(message?.rowID == 2)
#expect(message?.chatID == 2)
#expect(message?.guid == "chat-two-guid")
}
@Test
func chatInfoMatchingTargetHandlesAnyGroupPolarityMismatch() throws {
let db = try makeSentMessageDatabase()
try db.run(
"""
UPDATE chat
SET chat_identifier = 'any;+;chat134', guid = 'any;+;chat134'
WHERE ROWID = 1
"""
)
let store = try MessageStore(connection: db, path: ":memory:")
let info = try store.chatInfo(matchingTarget: "any;-;chat134")
#expect(info?.id == 1)
#expect(info?.guid == "any;+;chat134")
}
@Test
func latestUnjoinedSentMessageRowIDMatchesAnyGroupTargetVariants() throws {
let db = try makeSentMessageDatabase()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (2, 'any;-;chat134')")
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
date, is_from_me, service
)
VALUES (20, 2, '', 'ghost-guid', NULL, 0, ?, 1, 'SMS')
""",
TestDatabase.appleEpoch(now)
)
let store = try MessageStore(connection: db, path: ":memory:")
let rowID = try store.latestUnjoinedSentMessageRowID(
matchingTargetHandles: ["any;+;chat134"],
since: now.addingTimeInterval(-1)
)
#expect(rowID == 20)
}
private func makeSentMessageDatabase() throws -> Connection {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES (1, 'iMessage;+;one', 'iMessage;+;one', 'One', 'iMessage'),
(2, 'iMessage;+;two', 'iMessage;+;two', 'Two', 'iMessage')
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, 'me@icloud.com')")
return db
}
private func insertSentMessageFixture(
_ db: Connection,
rowID: Int64,
chatID: Int64,
text: String,
guid: String,
date: Date,
isFromMe: Bool
) throws {
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
date, is_from_me, service
)
VALUES (?, 1, ?, ?, NULL, 0, ?, ?, 'iMessage')
""",
rowID,
text,
guid,
TestDatabase.appleEpoch(date),
isFromMe ? 1 : 0
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (?, ?)", chatID, rowID)
}

View File

@ -11,73 +11,32 @@ enum TestDatabase {
static func makeStore(
includeAttributedBody: Bool = false,
includeReactionColumns: Bool = false
includeReactionColumns: Bool = false,
attachmentFilename: String = "~/Library/Messages/Attachments/test.dat",
attachmentTransferName: String = "test.dat",
attachmentUTI: String = "public.data",
attachmentMimeType: String = "application/octet-stream"
) throws -> MessageStore {
let db = try Connection(.inMemory)
let attributedBodyColumn = includeAttributedBody ? "attributedBody BLOB," : ""
let reactionColumns: String
if includeReactionColumns {
reactionColumns = "guid TEXT, associated_message_guid TEXT, associated_message_type INTEGER,"
} else {
reactionColumns = ""
}
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
\(attributedBodyColumn)
\(reactionColumns)
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"""
CREATE TABLE attachment (
ROWID INTEGER PRIMARY KEY,
filename TEXT,
transfer_name TEXT,
uti TEXT,
mime_type TEXT,
total_bytes INTEGER,
is_sticker INTEGER
);
"""
)
try db.execute(
"""
CREATE TABLE message_attachment_join (
message_id INTEGER,
attachment_id INTEGER
);
"""
try MessageDatabaseFixture.createSchema(
db,
options: MessageDatabaseFixture.SchemaOptions(
includeAttributedBody: includeAttributedBody,
includeReactionColumns: includeReactionColumns
)
)
let now = Date()
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES (1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage')
INSERT INTO chat(
ROWID, chat_identifier, guid, display_name, service_name,
account_id, account_login, last_addressed_handle
)
VALUES (
1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage',
'iMessage;+;me@icloud.com', 'me@icloud.com', '+15551234567'
)
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'Me')")
@ -114,8 +73,12 @@ enum TestDatabase {
total_bytes,
is_sticker
)
VALUES (1, '~/Library/Messages/Attachments/test.dat', 'test.dat', 'public.data', 'application/octet-stream', 123, 0)
"""
VALUES (1, ?, ?, ?, ?, 123, 0)
""",
attachmentFilename,
attachmentTransferName,
attachmentUTI,
attachmentMimeType
)
try db.run(
"""

View File

@ -4,12 +4,35 @@ import Testing
@testable import IMsgCore
@Test
func listChatsReturnsChat() throws {
let store = try TestDatabase.makeStore()
let chats = try store.listChats(limit: 5)
#expect(chats.count == 1)
#expect(chats.first?.identifier == "+123")
private func makeInMemoryMessageDB(
includeThreadOriginatorGUID: Bool = false,
includeBalloonBundleID: Bool = false
) throws -> Connection {
let db = try Connection(.inMemory)
let threadOriginatorColumn = includeThreadOriginatorGUID ? "thread_originator_guid TEXT," : ""
let balloonColumn = includeBalloonBundleID ? "balloon_bundle_id TEXT," : ""
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
\(threadOriginatorColumn)
\(balloonColumn)
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
return db
}
@Test
@ -20,6 +43,22 @@ func chatInfoReturnsMetadata() throws {
#expect(info?.guid == "iMessage;+;chat123")
#expect(info?.name == "Test Chat")
#expect(info?.service == "iMessage")
#expect(info?.accountID == "iMessage;+;me@icloud.com")
#expect(info?.accountLogin == "me@icloud.com")
#expect(info?.lastAddressedHandle == "+15551234567")
}
@Test
func sqlRowDecodingThrowsWhenRequiredAliasIsMissing() throws {
let db = try Connection(.inMemory)
let store = try MessageStore(connection: db, path: ":memory:")
try store.withConnection { db in
let rows = try db.prepareRowIterator("SELECT 1 AS actual_value")
let row = try #require(try rows.failableNext())
#expect(throws: (any Error).self) {
_ = try store.int64Value(row, "expected_value")
}
}
}
@Test
@ -63,6 +102,46 @@ func messagesByChatReturnsMessages() throws {
#expect(messages[0].attachmentsCount == 0)
}
@Test
func messagesByChatAppliesDateFilterBeforeLimit() throws {
let store = try TestDatabase.makeStore()
let all = try store.messages(chatID: 1, limit: 10)
let target = all.first { $0.rowID == 2 }
#expect(target != nil)
// Build a tight window around message 2's date so the filter matches it but not the newest message.
guard let target else { return }
let filter = MessageFilter(
startDate: target.date.addingTimeInterval(-1),
endDate: target.date.addingTimeInterval(1)
)
let filtered = try store.messages(chatID: 1, limit: 1, filter: filter)
#expect(filtered.count == 1)
#expect(filtered.first?.rowID == 2)
}
@Test
func messagesByChatAppliesParticipantFilterBeforeLimit() throws {
let store = try TestDatabase.makeStore()
// Insert a newer "from me" message so limit=1 would pick it unless filtering happens in SQL.
try store.withConnection { db in
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (4, 2, 'newest from me', ?, 1, 'iMessage')
""",
TestDatabase.appleEpoch(Date().addingTimeInterval(5))
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 4)")
}
let filter = MessageFilter(participants: ["+123"])
let filtered = try store.messages(chatID: 1, limit: 1, filter: filter)
#expect(filtered.count == 1)
#expect(filtered.first?.sender == "+123")
}
@Test
func messagesAfterReturnsMessages() throws {
let store = try TestDatabase.makeStore()
@ -72,27 +151,93 @@ func messagesAfterReturnsMessages() throws {
}
@Test
func messagesAfterExcludesReactionRows() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
func messagesAfterDeduplicatesURLBalloonsAcrossPolls() throws {
let db = try makeInMemoryMessageDB(includeBalloonBundleID: true)
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
balloon_bundle_id, date, is_from_me, service
)
VALUES (1, 1, 'https://example.com', 'msg-guid-1', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
""",
TestDatabase.appleEpoch(now)
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
let store = try MessageStore(connection: db, path: ":memory:")
let firstPoll = try store.messagesAfter(afterRowID: 0, chatID: 1, limit: 10)
#expect(firstPoll.map(\.rowID) == [1])
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
balloon_bundle_id, date, is_from_me, service
)
VALUES (2, 1, 'https://example.com', 'msg-guid-2', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
""",
TestDatabase.appleEpoch(now.addingTimeInterval(30))
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 2)")
let secondPoll = try store.messagesAfter(afterRowID: 1, chatID: 1, limit: 10)
#expect(secondPoll.isEmpty)
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
balloon_bundle_id, date, is_from_me, service
)
VALUES (3, 1, 'https://example.com', 'msg-guid-3', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
""",
TestDatabase.appleEpoch(now.addingTimeInterval(5 * 60))
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 3)")
let thirdPoll = try store.messagesAfter(afterRowID: 1, chatID: 1, limit: 10)
#expect(thirdPoll.map(\.rowID) == [3])
}
@Test
func messagesAfterURLBalloonDedupingDoesNotCrossChats() throws {
let db = try makeInMemoryMessageDB(includeBalloonBundleID: true)
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
balloon_bundle_id, date, is_from_me, service
)
VALUES (1, 1, 'https://example.com', 'msg-guid-1', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
""",
TestDatabase.appleEpoch(now)
)
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
balloon_bundle_id, date, is_from_me, service
)
VALUES (2, 1, 'https://example.com', 'msg-guid-2', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
""",
TestDatabase.appleEpoch(now.addingTimeInterval(15))
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (2, 2)")
let store = try MessageStore(connection: db, path: ":memory:")
let messages = try store.messagesAfter(afterRowID: 0, chatID: nil, limit: 10)
#expect(messages.map(\.rowID) == [1, 2])
}
@Test
func messagesAfterExcludesReactionRows() throws {
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -132,26 +277,7 @@ func messagesAfterExcludesReactionRows() throws {
@Test
func messagesExcludeReactionRows() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -180,26 +306,7 @@ func messagesExcludeReactionRows() throws {
@Test
func messagesExposeReplyToGuid() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -229,26 +336,7 @@ func messagesExposeReplyToGuid() throws {
@Test
func messagesReplyToGuidHandlesNoPrefix() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -275,6 +363,30 @@ func messagesReplyToGuidHandlesNoPrefix() throws {
#expect(reply?.replyToGUID == "msg-guid-1")
}
@Test
func messagesExposeThreadOriginatorGuidWhenAvailable() throws {
let db = try makeInMemoryMessageDB(includeThreadOriginatorGUID: true)
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
thread_originator_guid, date, is_from_me, service
)
VALUES (1, 1, 'hello', 'msg-guid-1', NULL, 0, 'thread-guid-1', ?, 0, 'iMessage')
""",
TestDatabase.appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
let store = try MessageStore(connection: db, path: ":memory:")
let messages = try store.messages(chatID: 1, limit: 10)
let message = messages.first { $0.rowID == 1 }
#expect(message?.threadOriginatorGUID == "thread-guid-1")
}
@Test
func attachmentsByMessageReturnsMetadata() throws {
let store = try TestDatabase.makeStore()
@ -283,6 +395,17 @@ func attachmentsByMessageReturnsMetadata() throws {
#expect(attachments.first?.mimeType == "application/octet-stream")
}
@Test
func attachmentsByMessagesReturnsMetadataByMessageID() throws {
let store = try TestDatabase.makeStore()
let attachmentsByMessageID = try store.attachments(for: [1, 2, 2, 3])
#expect(attachmentsByMessageID[1]?.isEmpty != false)
#expect(attachmentsByMessageID[2]?.count == 1)
#expect(attachmentsByMessageID[2]?.first?.mimeType == "application/octet-stream")
#expect(attachmentsByMessageID[3]?.isEmpty != false)
}
@Test
func longRepeatedPatternMessage() throws {
// Test the exact pattern that causes crashes: repeated "aaaaaaaaaaaa " pattern

View File

@ -4,6 +4,11 @@ import Testing
@testable import IMsgCore
private struct WatcherTestStore {
let store: MessageStore
let insertMessage: (Int64, String) throws -> Void
}
private enum WatcherTestDatabase {
static func appleEpoch(_ date: Date) -> Int64 {
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
@ -43,6 +48,47 @@ private enum WatcherTestDatabase {
return try MessageStore(
connection: db, path: ":memory:", hasAttributedBody: false, hasReactionColumns: false)
}
static func makeMutableStore() throws -> WatcherTestStore {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
let store = try MessageStore(
connection: db, path: ":memory:", hasAttributedBody: false, hasReactionColumns: false)
return WatcherTestStore(
store: store,
insertMessage: { rowID, text in
try store.withConnection { db in
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (?, 1, ?, ?, 0, 'iMessage')
""",
rowID,
text,
appleEpoch(Date())
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)", rowID)
}
}
)
}
}
@Test
@ -63,3 +109,30 @@ func messageWatcherYieldsExistingMessages() async throws {
let message = try await task.value
#expect(message?.text == "hello")
}
@Test
func messageWatcherFallbackPollYieldsMessagesWithoutFileEvents() async throws {
let fixture = try WatcherTestDatabase.makeMutableStore()
let watcher = MessageWatcher(store: fixture.store)
let stream = watcher.stream(
chatID: nil,
sinceRowID: 0,
configuration: MessageWatcherConfiguration(
debounceInterval: 60,
fallbackPollInterval: 0.01,
batchLimit: 10
)
)
let task = Task { () throws -> Message? in
var iterator = stream.makeAsyncIterator()
return try await iterator.next()
}
try await Task.sleep(nanoseconds: 20_000_000)
try fixture.insertMessage(2, "fallback")
let message = try await task.value
#expect(message?.rowID == 2)
#expect(message?.text == "fallback")
}

View File

@ -0,0 +1,93 @@
import Foundation
import Testing
@testable import IMsgCore
@Test
func typingIndicatorStopsOnCancellation() async {
var events: [String] = []
do {
try await TypingIndicator.typeForDuration(
chatIdentifier: "iMessage;+;chat123",
duration: 1,
startTyping: { _ in events.append("start") },
stopTyping: { _ in events.append("stop") },
sleep: { _ in throw CancellationError() }
)
#expect(Bool(false))
} catch is CancellationError {
#expect(Bool(true))
} catch {
#expect(Bool(false))
}
#expect(events == ["start", "stop"])
}
@Test
func typingIndicatorStopsAfterNormalDuration() async throws {
var events: [String] = []
var didSleep = false
try await TypingIndicator.typeForDuration(
chatIdentifier: "iMessage;+;chat123",
duration: 1,
startTyping: { _ in events.append("start") },
stopTyping: { _ in events.append("stop") },
sleep: { _ in didSleep = true }
)
#expect(didSleep == true)
#expect(events == ["start", "stop"])
}
@Test
func typingLookupCandidatesExpandAnyPrefixToServiceVariants() {
let candidates = TypingIndicator.chatLookupCandidates(for: "any;-;+15551234567")
#expect(
candidates == [
"any;-;+15551234567",
"+15551234567",
"iMessage;-;+15551234567",
"iMessage;+;+15551234567",
"SMS;-;+15551234567",
"SMS;+;+15551234567",
"any;+;+15551234567",
])
}
@Test
func typingLookupCandidatesAvoidDoublePrefixingDirectIdentifiers() {
let candidates = TypingIndicator.chatLookupCandidates(for: " iMessage;-;user@example.com ")
#expect(
candidates == [
"iMessage;-;user@example.com",
"user@example.com",
"iMessage;+;user@example.com",
"SMS;-;user@example.com",
"SMS;+;user@example.com",
"any;-;user@example.com",
"any;+;user@example.com",
])
}
@Test
func typingLookupCandidatesRejectBlankIdentifier() {
#expect(TypingIndicator.chatLookupCandidates(for: " ").isEmpty)
}
@Test
func typingDaemonUnavailableMessageExplainsTahoeEntitlementBlock() {
let message = TypingIndicator.daemonUnavailableMessage()
#expect(message.contains("imagent"))
#expect(message.contains("macOS 26/Tahoe"))
#expect(message.contains("Apple-private entitlements"))
#expect(message.contains("imsg status"))
#expect(message.contains("send"))
#expect(message.contains("history"))
#expect(message.contains("watch"))
}

View File

@ -29,6 +29,113 @@ func attachmentResolverDisplayNamePrefersTransfer() {
#expect(AttachmentResolver.displayName(filename: "", transferName: "") == "(unknown)")
}
@Test
func attachmentResolverReportsCachedConvertedCAF() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let source = dir.appendingPathComponent("voice.caf")
try Data("caf".utf8).write(to: source)
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "m4a")
try FileManager.default.createDirectory(
at: converted.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("m4a".utf8).write(to: converted)
defer { try? FileManager.default.removeItem(at: converted) }
let meta = AttachmentResolver.metadata(
filename: source.path,
transferName: "voice.caf",
uti: "com.apple.coreaudio-format",
mimeType: "audio/x-caf",
totalBytes: 3,
isSticker: false,
options: AttachmentQueryOptions(convertUnsupported: true)
)
#expect(meta.originalPath == source.path)
#expect(meta.convertedPath == converted.path)
#expect(meta.convertedMimeType == "audio/mp4")
#expect(meta.mimeType == "audio/x-caf")
}
@Test
func attachmentResolverLeavesUnsupportedFilesUnconverted() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let source = dir.appendingPathComponent("file.txt")
try Data("text".utf8).write(to: source)
let meta = AttachmentResolver.metadata(
filename: source.path,
transferName: "file.txt",
uti: "public.plain-text",
mimeType: "text/plain",
totalBytes: 4,
isSticker: false,
options: AttachmentQueryOptions(convertUnsupported: true)
)
#expect(meta.convertedPath == nil)
#expect(meta.convertedMimeType == nil)
}
@Test
func securePathDetectsFinalSymlink() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let target = dir.appendingPathComponent("target.txt")
let link = dir.appendingPathComponent("link.txt")
try Data("hello".utf8).write(to: target)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target)
#expect(SecurePath.hasSymlinkComponent(target.path) == false)
#expect(SecurePath.hasSymlinkComponent(link.path) == true)
}
@Test
func securePathDetectsParentSymlink() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let realParent = dir.appendingPathComponent("real")
let linkParent = dir.appendingPathComponent("linked")
try FileManager.default.createDirectory(at: realParent, withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: linkParent, withDestinationURL: realParent)
let realChild = realParent.appendingPathComponent("child.txt")
let linkedChild = linkParent.appendingPathComponent("child.txt")
try Data("hello".utf8).write(to: realChild)
#expect(SecurePath.hasSymlinkComponent(realChild.path) == false)
#expect(SecurePath.hasSymlinkComponent(linkedChild.path) == true)
}
@Test
func securePathAllowsTrustedSystemAliasPrefixes() throws {
let privateTmp = URL(fileURLWithPath: "/private/tmp", isDirectory: true)
let dirName = "imsg-secure-path-\(UUID().uuidString)"
let realDir = privateTmp.appendingPathComponent(dirName)
try FileManager.default.createDirectory(at: realDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: realDir) }
let realFile = realDir.appendingPathComponent("target.txt")
try Data("hello".utf8).write(to: realFile)
let aliasFile = "/tmp/\(dirName)/target.txt"
#expect(SecurePath.hasSymlinkComponent(aliasFile) == false)
let link = realDir.appendingPathComponent("link.txt")
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: realFile)
#expect(SecurePath.hasSymlinkComponent("/tmp/\(dirName)/link.txt") == true)
}
@Test
func iso8601ParserParsesFormats() {
let fractional = "2024-01-02T03:04:05.678Z"
@ -102,6 +209,92 @@ func typedStreamParserTrimsControlCharacters() {
#expect(TypedStreamParser.parseAttributedBody(data) == "hello")
}
@Test
func typedStreamParserDecodesShortSingleBytePrefix() {
let text = "hello"
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodesMediumMessageWith0x81Prefix() {
let text = String(repeating: "A", count: 140)
let length = UInt8(text.utf8.count)
let bytes: [UInt8] =
[0x01, 0x2b, 0x81, length] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodesLongMessageWith0x82Prefix() {
let text = String(repeating: "B", count: 300)
let length = UInt16(text.utf8.count)
let lengthHi = UInt8((length >> 8) & 0xff)
let lengthLo = UInt8(length & 0xff)
let bytes: [UInt8] =
[0x01, 0x2b, 0x82, lengthHi, lengthLo] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDoesNotPrependPrintableAsciiLengthByte() {
// 64-byte body of 'A' length byte 0x40 ('@'), printable.
// Without the structured-prefix-wins rule, the raw decode keeps the '@' and beats the stripped body by one character.
let text = String(repeating: "A", count: 64)
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodes32ByteBodyAtLowerRegressionEdge() {
// 32-byte body length byte 0x20 (space). Lower edge of the 32126 printable-ASCII window.
let text = String(repeating: "A", count: 32)
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodes126ByteBodyAtUpperRegressionEdge() {
// 126-byte body length byte 0x7E ('~'). Upper edge of the window 0x7F is DEL/control and
// would be trimmed (not prepended), so 0x7E is the precise top of the failure range.
let text = String(repeating: "A", count: 126)
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodesMultibyteUTF8BodyInRegressionWindow() {
// 12 × 🎉 = 48 UTF-8 bytes length byte 0x30 ('0'), printable. Confirms the structured-prefix
// preference works for non-ASCII bodies too the bug is byte-count driven, not ASCII-specific.
let text = String(repeating: "🎉", count: 12)
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserHandlesMixedBinaryNoise() {
// First byte 0x42 is neither 0x81 nor 0x82, and does not equal segment.count - 1 (= 6).
// The decoder should fall back to no-prefix decoding without crashing.
let bytes: [UInt8] =
[0x01, 0x2b, 0x42, 0x68, 0x69, 0x21, 0x86, 0x84]
let result = TypedStreamParser.parseAttributedBody(Data(bytes))
#expect(result == "Bhi!")
}
@Test
func typedStreamParserDecodesUTF16LittleEndianBOM() throws {
var data = Data([0xff, 0xfe])
let body = "hello 🌤️"
let encoded = try #require(body.data(using: .utf16LittleEndian))
data.append(encoded)
#expect(TypedStreamParser.parseAttributedBody(data) == body)
}
@Test
func phoneNumberNormalizerFormatsValidNumber() {
let normalizer = PhoneNumberNormalizer()
@ -317,4 +510,7 @@ func errorDescriptionsIncludeDetails() {
let permissionDescription = permission.errorDescription ?? ""
#expect(permissionDescription.contains("Permission Error") == true)
#expect(permissionDescription.contains("/tmp/chat.db") == true)
#expect(permissionDescription.contains("parent launcher") == true)
#expect(permissionDescription.contains("built-in Terminal.app") == true)
#expect(permissionDescription.contains("stale entries") == true)
}

View File

@ -0,0 +1,142 @@
import Commander
import Foundation
import IMsgCore
import Testing
@testable import imsg
/// Snapshot of the bridge-backed commands we expect to be wired up. Locks in
/// the surface so an accidental drop from CommandRouter.specs gets caught
/// without exercising any IMCore plumbing.
@Test
func commandRouterIncludesAllBridgeCommands() {
let router = CommandRouter()
let expected: [String] = [
"send-rich", "send-multipart", "send-attachment", "tapback",
"edit", "unsend", "delete-message", "notify-anyways",
"chat-create", "chat-name", "chat-photo",
"chat-add-member", "chat-remove-member",
"chat-leave", "chat-delete", "chat-mark",
"account", "whois", "nickname",
]
let registered = Set(router.specs.map { $0.name })
for name in expected {
#expect(registered.contains(name), "missing bridge command: \(name)")
}
#expect(registered.contains("search"), "missing local search command")
}
@Test
func bridgeMessagingCommandsExposeChatRequirement() async {
// Each new bridge messaging command requires a `--chat` option (the chat
// guid is the universal addressing key in v2). Ensure missing args bubble
// up as a parse-time error rather than dropping into the bridge with empty
// strings.
let router = CommandRouter()
let names = ["send-rich", "edit", "unsend", "delete-message", "tapback"]
for name in names {
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", name])
}
#expect(status == 1, "\(name) should have required missing args")
}
}
@Test
func bridgeAttachmentStagingUsesChatGuid() 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("NSString *chatGuid, NSString **outErr)"))
#expect(source.contains("[inv setArgument:&cg atIndex:5];"))
#expect(
source.contains("saveAttachmentsForTransfer:chatGUID:storeAtExternalLocation:completion:"))
#expect(source.contains("prepareOutgoingTransfer(fileURL, filename, chatGuid, &prepErr)"))
}
@Test
func chatMarkRejectsConflictingFlags() async {
let router = CommandRouter()
let (output, status) = await StdoutCapture.capture {
await router.run(argv: [
"imsg", "chat-mark", "--chat", "iMessage;-;+15551234567", "--read", "--unread",
])
}
#expect(status == 1)
#expect(output.contains("Invalid value for option: --read"))
}
@Test
func expressiveSendEffectExpandsShortNames() {
// Bubble effects map to MobileSMS.expressivesend.<name>.
#expect(
ExpressiveSendEffect.expand("invisibleink")
== "com.apple.MobileSMS.expressivesend.invisibleink")
#expect(
ExpressiveSendEffect.expand("impact")
== "com.apple.MobileSMS.expressivesend.impact")
#expect(
ExpressiveSendEffect.expand("loud")
== "com.apple.MobileSMS.expressivesend.loud")
#expect(
ExpressiveSendEffect.expand("gentle")
== "com.apple.MobileSMS.expressivesend.gentle")
// Screen effects map to messages.effect.CK<TitleCase>Effect.
#expect(
ExpressiveSendEffect.expand("confetti")
== "com.apple.messages.effect.CKConfettiEffect")
#expect(
ExpressiveSendEffect.expand("lasers")
== "com.apple.messages.effect.CKLasersEffect")
#expect(
ExpressiveSendEffect.expand("celebration")
== "com.apple.messages.effect.CKCelebrationEffect")
// Case-insensitive on the short form.
#expect(
ExpressiveSendEffect.expand("InvisibleInk")
== "com.apple.MobileSMS.expressivesend.invisibleink")
// Already-expanded ids pass through untouched.
let expanded = "com.apple.MobileSMS.expressivesend.impact"
#expect(ExpressiveSendEffect.expand(expanded) == expanded)
let screenExpanded = "com.apple.messages.effect.CKHeartEffect"
#expect(ExpressiveSendEffect.expand(screenExpanded) == screenExpanded)
// Unknown short names pass through so the dylib can return its own error.
#expect(ExpressiveSendEffect.expand("totally-not-real") == "totally-not-real")
}
@Test
func chatCreateRejectsUnsupportedServiceBeforeBridgeLaunch() async {
let values = ParsedValues(
positional: [],
options: [
"addresses": ["+15551234567"],
"service": ["SMS"],
],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await ChatCreateCommand.run(values: values, runtime: runtime)
#expect(Bool(false))
} catch let error as IMsgError {
switch error {
case .unsupportedService(let value):
#expect(value == "SMS")
default:
#expect(Bool(false))
}
} catch {
#expect(Bool(false))
}
}

View File

@ -0,0 +1,406 @@
import Commander
import Foundation
import SQLite
import Testing
@testable import IMsgCore
@testable import imsg
@Test
func chatsCommandRunsWithJsonOutput() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "limit": ["5"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await ChatsCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
let payload = try jsonObject(from: output)
#expect(payload["is_group"] as? Bool == true)
#expect(payload["guid"] as? String == "iMessage;+;chat123")
#expect(payload["display_name"] as? String == "Test Chat")
#expect(payload["account_id"] as? String == "iMessage;+;me@icloud.com")
#expect(payload["account_login"] as? String == "me@icloud.com")
#expect(payload["last_addressed_handle"] as? String == "+15551234567")
#expect(payload["participants"] as? [String] == ["+123"])
}
@Test
func chatsCommandJsonReportsDirectChatMetadata() async throws {
let path = try CommandTestDatabase.makePathDirectChat()
let values = ParsedValues(
positional: [],
options: ["db": [path], "limit": ["5"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await ChatsCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
let payload = try jsonObject(from: output)
#expect(payload["is_group"] as? Bool == false)
#expect(payload["guid"] as? String == "iMessage;-;+123")
#expect(payload["display_name"] as? String == "Direct Chat")
#expect(payload["account_id"] as? String == "iMessage;+;me@icloud.com")
#expect(payload["account_login"] as? String == "me@icloud.com")
#expect(payload["last_addressed_handle"] as? String == "+15551234567")
#expect(payload["participants"] as? [String] == ["+123"])
}
@Test
func historyCommandRunsWithChatID() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await HistoryCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
let payload = try jsonObject(from: output)
#expect(payload["is_group"] as? Bool == true)
#expect(payload["chat_identifier"] as? String == "+123")
#expect(payload["chat_guid"] as? String == "iMessage;+;chat123")
#expect(payload["chat_name"] as? String == "Test Chat")
#expect(payload["participants"] as? [String] == ["+123"])
}
@Test
func historyCommandJsonReportsDirectChatMetadata() async throws {
let path = try CommandTestDatabase.makePathDirectChat()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await HistoryCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
let payload = try jsonObject(from: output)
#expect(payload["is_group"] as? Bool == false)
#expect(payload["chat_identifier"] as? String == "+123")
#expect(payload["chat_guid"] as? String == "iMessage;-;+123")
#expect(payload["chat_name"] as? String == "Direct Chat")
#expect(payload["participants"] as? [String] == ["+123"])
}
@Test
func searchCommandUsesLocalMessageStore() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "query": ["ell"], "match": ["contains"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await SearchCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
let payload = try jsonObject(from: output)
#expect(payload["text"] as? String == "hello")
#expect(payload["chat_id"] as? Int == 1)
}
@Test
func historyCommandRunsWithAttachmentsNonJson() async throws {
let path = try CommandTestDatabase.makePathWithAttachment()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
flags: ["attachments"]
)
let runtime = RuntimeOptions(parsedValues: values)
_ = try await StdoutCapture.capture {
try await HistoryCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
}
@Test
func historyCommandReportsConvertedAttachmentPath() async throws {
let source = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("gif")
try Data("gif".utf8).write(to: source)
defer { try? FileManager.default.removeItem(at: source) }
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "png")
try FileManager.default.createDirectory(
at: converted.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("png".utf8).write(to: converted)
defer { try? FileManager.default.removeItem(at: converted) }
let path = try CommandTestDatabase.makePathWithAttachment(
filename: source.path,
transferName: "animation.gif",
uti: "com.compuserve.gif",
mimeType: "image/gif"
)
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
flags: ["attachments", "convertAttachments"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await HistoryCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
#expect(output.contains("converted_mime=image/png"))
#expect(output.contains("converted_path=\(converted.path)"))
}
@Test
func chatsCommandRunsWithPlainOutput() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "limit": ["5"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
_ = try await StdoutCapture.capture {
try await ChatsCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
}
@Test
func chatsCommandIncludesContactNameInJson() async throws {
let path = try CommandTestDatabase.makePathDirectChat()
let values = ParsedValues(
positional: [],
options: ["db": [path], "limit": ["5"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let resolver = MockContactResolver(names: ["+123": "Alice"])
let (output, _) = try await StdoutCapture.capture {
try await ChatsCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { resolver }
)
}
let payload = try jsonObject(from: output)
#expect(payload["contact_name"] as? String == "Alice")
#expect(payload["identifier"] as? String == "+123")
}
@Test
func historyCommandUsesContactNameForPlainIncomingSender() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
let resolver = MockContactResolver(names: ["+123": "Alice"])
let (output, _) = try await StdoutCapture.capture {
try await HistoryCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { resolver }
)
}
#expect(output.contains("[recv] Alice: hello"))
}
@Test
func sendCommandRejectsMissingRecipient() async {
let values = ParsedValues(
positional: [],
options: ["text": ["hi"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await SendCommand.spec.run(values, runtime)
#expect(Bool(false))
} catch let error as ParsedValuesError {
#expect(error.description.contains("Missing required option"))
} catch {
#expect(Bool(false))
}
}
@Test
func sendCommandResolvesUniqueContactName() async throws {
let values = ParsedValues(
positional: [],
options: ["to": ["Alice"], "text": ["hi"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
let resolver = MockContactResolver(
matches: [ContactMatch(name: "Alice Smith", handle: "+15551234567")]
)
var captured: MessageSendOptions?
_ = try await StdoutCapture.capture {
try await SendCommand.run(
values: values,
runtime: runtime,
sendMessage: { options in captured = options },
resolveSentMessage: { _, _, _, _ in nil },
contactResolverFactory: { _ in resolver }
)
}
#expect(captured?.recipient == "+15551234567")
}
@Test
func sendCommandRejectsAmbiguousContactName() async {
let values = ParsedValues(
positional: [],
options: ["to": ["John"], "text": ["hi"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
let resolver = MockContactResolver(
matches: [
ContactMatch(name: "John Smith", handle: "+15551234567"),
ContactMatch(name: "John Doe", handle: "+15557654321"),
]
)
do {
try await SendCommand.run(
values: values,
runtime: runtime,
sendMessage: { _ in },
resolveSentMessage: { _, _, _, _ in nil },
contactResolverFactory: { _ in resolver }
)
#expect(Bool(false))
} catch let error as IMsgError {
#expect(error.localizedDescription.contains("Multiple contacts match"))
} catch {
#expect(Bool(false))
}
}
@Test
func sendCommandRunsWithStubSender() async throws {
let values = ParsedValues(
positional: [],
options: ["to": ["+15551234567"], "text": ["hi"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var captured: MessageSendOptions?
_ = try await StdoutCapture.capture {
try await SendCommand.run(
values: values,
runtime: runtime,
sendMessage: { options in
captured = options
},
resolveSentMessage: { _, _, _, _ in nil }
)
}
#expect(captured?.recipient == "+15551234567")
#expect(captured?.text == "hi")
}
@Test
func sendCommandResolvesChatID() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "text": ["hi"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var captured: MessageSendOptions?
_ = try await StdoutCapture.capture {
try await SendCommand.run(
values: values,
runtime: runtime,
sendMessage: { options in
captured = options
},
resolveSentMessage: { _, _, _, _ in nil }
)
}
#expect(captured?.chatIdentifier == "+123")
#expect(captured?.chatGUID == "iMessage;+;chat123")
#expect(captured?.recipient.isEmpty == true)
}
@Test
func sendCommandRejectsMisroutedChatGhost() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "text": ["hi"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await SendCommand.run(
values: values,
runtime: runtime,
sendMessage: { _ in
let db = try Connection(path)
try db.run("INSERT INTO handle(ROWID, id) VALUES (99, 'iMessage;+;chat123')")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (99, 99, '', ?, 1, 'SMS')
""",
CommandTestDatabase.appleEpoch(Date())
)
},
resolveSentMessage: { _, _, _, _ in nil }
)
#expect(Bool(false))
} catch let error as IMsgError {
#expect(error.localizedDescription.contains("unjoined empty outgoing row"))
}
}
private func jsonObject(from output: String) throws -> [String: Any] {
let line = output.split(separator: "\n").first.map(String.init) ?? ""
let data = Data(line.utf8)
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
}

View File

@ -4,25 +4,90 @@ import Testing
@testable import imsg
@Test
func commandRouterPrintsVersionFromEnv() async throws {
func commandRouterPrintsVersionFromEnv() async {
setenv("IMSG_VERSION", "9.9.9-test", 1)
defer { unsetenv("IMSG_VERSION") }
let router = CommandRouter()
#expect(router.version == "9.9.9-test")
let status = await router.run(argv: ["imsg", "--version"])
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "--version"])
}
#expect(status == 0)
}
@Test
func commandRouterPrintsHelp() async {
let router = CommandRouter()
let status = await router.run(argv: ["imsg", "--help"])
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "--help"])
}
#expect(status == 0)
}
@Test
func commandRouterUnknownCommand() async {
let router = CommandRouter()
let status = await router.run(argv: ["imsg", "nope"])
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "nope"])
}
#expect(status == 1)
}
@Test
func commandRouterIncludesGroupCommand() {
let router = CommandRouter()
#expect(router.specs.contains { $0.name == "group" })
}
@Test
func commandRouterIncludesCompletionsCommand() {
let router = CommandRouter()
#expect(router.specs.contains { $0.name == "completions" })
}
@Test
func completionsGenerateAllFormats() throws {
let specs = CommandRouter().specs
let bash = try CompletionGenerator.generate(shell: "bash", rootName: "imsg", specs: specs)
let zsh = try CompletionGenerator.generate(shell: "zsh", rootName: "imsg", specs: specs)
let fish = try CompletionGenerator.generate(shell: "fish", rootName: "imsg", specs: specs)
let llm = try CompletionGenerator.generate(shell: "llm", rootName: "imsg", specs: specs)
#expect(bash.contains("complete -F _imsg imsg"))
#expect(zsh.contains("#compdef imsg"))
#expect(fish.contains("complete -c imsg"))
#expect(llm.contains("# imsg CLI Reference"))
}
@Test
func completionsIncludeCurrentCommandsAndOptions() throws {
let specs = CommandRouter().specs
let output = try CompletionGenerator.generate(shell: "llm", rootName: "imsg", specs: specs)
for spec in specs {
#expect(output.contains("### \(spec.name)"))
}
#expect(output.contains("--convert-attachments"))
#expect(output.contains("--reaction, -r <value>"))
}
@Test
func completionsRejectUnknownShell() {
do {
_ = try CompletionGenerator.generate(shell: "powershell", rootName: "imsg", specs: [])
#expect(Bool(false))
} catch let error as CompletionError {
#expect(error.description.contains("Unknown shell"))
} catch {
#expect(Bool(false))
}
}
@Test
func completionsCommandRunsThroughRouter() async {
let router = CommandRouter()
let (output, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "completions", "fish"])
}
#expect(status == 0)
#expect(output.contains("complete -c imsg"))
}

View File

@ -0,0 +1,252 @@
import Foundation
import SQLite
@testable import IMsgCore
enum CommandTestDatabase {
static func appleEpoch(_ date: Date) -> Int64 {
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
return Int64(seconds * 1_000_000_000)
}
static func makePath() throws -> String {
let path = try makeDatabasePath()
let db = try Connection(path)
try createSchema(db, includeChatHandleJoin: true)
try seedBasicChat(db)
return path
}
static func makePathDirectChat() throws -> String {
let path = try makePath()
let db = try Connection(path)
try db.run(
"""
UPDATE chat
SET chat_identifier = '+123', guid = 'iMessage;-;+123', display_name = 'Direct Chat'
WHERE ROWID = 1
"""
)
return path
}
static func makePathWithAttachment(
filename: String = "/tmp/file.dat",
transferName: String = "file.dat",
uti: String = "public.data",
mimeType: String = "application/octet-stream"
) throws -> String {
let path = try makePath()
let db = try Connection(path)
try db.run(
"""
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
VALUES (1, ?, ?, ?, ?, 10, 0)
""",
filename,
transferName,
uti,
mimeType
)
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
return path
}
static func makeStoreForRPC() throws -> MessageStore {
let db = try Connection(.inMemory)
try createSchema(db, includeChatHandleJoin: true)
try seedRPCChat(db)
return try MessageStore(
connection: db,
path: ":memory:",
hasAttributedBody: false,
hasReactionColumns: false
)
}
static func makeStoreForRPCDirectChat() throws -> MessageStore {
let db = try Connection(.inMemory)
try createSchema(db, includeChatHandleJoin: true)
try seedRPCChat(db)
try db.run(
"""
UPDATE chat
SET chat_identifier = '+123', guid = 'iMessage;-;+123', display_name = 'Direct Chat'
WHERE ROWID = 1
"""
)
return try MessageStore(
connection: db,
path: ":memory:",
hasAttributedBody: false,
hasReactionColumns: false
)
}
static func makeStoreForRPCWithAttachment(
filename: String,
transferName: String,
uti: String,
mimeType: String
) throws -> MessageStore {
let db = try Connection(.inMemory)
try createSchema(db, includeChatHandleJoin: true)
try seedRPCChat(db)
try db.run(
"""
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
VALUES (1, ?, ?, ?, ?, 10, 0)
""",
filename,
transferName,
uti,
mimeType
)
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (5, 1)")
return try MessageStore(
connection: db,
path: ":memory:",
hasAttributedBody: false,
hasReactionColumns: false
)
}
static func makeStoreForRPCWithReaction() throws -> MessageStore {
let db = try Connection(.inMemory)
try createSchema(db, includeChatHandleJoin: true, includeReactionColumns: true)
try seedRPCChat(db)
try db.run("UPDATE message SET guid = 'msg-guid-5' WHERE ROWID = 5")
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
date, is_from_me, service
)
VALUES (6, 2, '', 'reaction-guid-6', 'p:0/msg-guid-5', 2001, ?, 0, 'iMessage')
""",
appleEpoch(Date().addingTimeInterval(1))
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 6)")
return try MessageStore(connection: db, path: ":memory:")
}
private static func makeDatabasePath() throws -> String {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("chat.db").path
}
private static func createSchema(
_ db: Connection,
includeChatHandleJoin: Bool,
includeReactionColumns: Bool = false
) throws {
let reactionColumns =
includeReactionColumns
? [
"guid TEXT",
"associated_message_guid TEXT",
"associated_message_type INTEGER",
].joined(separator: ",\n") + ","
: ""
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
\(reactionColumns)
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT,
account_id TEXT,
account_login TEXT,
last_addressed_handle TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
if includeChatHandleJoin {
try db.execute("CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);")
}
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
try db.execute(
"""
CREATE TABLE attachment (
ROWID INTEGER PRIMARY KEY,
filename TEXT,
transfer_name TEXT,
uti TEXT,
mime_type TEXT,
total_bytes INTEGER,
is_sticker INTEGER
);
"""
)
}
private static func seedBasicChat(_ db: Connection) throws {
let now = Date()
try db.run(
"""
INSERT INTO chat(
ROWID, chat_identifier, guid, display_name, service_name,
account_id, account_login, last_addressed_handle
)
VALUES (
1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage',
'iMessage;+;me@icloud.com', 'me@icloud.com', '+15551234567'
)
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1)")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (1, 1, 'hello', ?, 0, 'iMessage')
""",
appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
}
private static func seedRPCChat(_ db: Connection) throws {
let now = Date()
try db.run(
"""
INSERT INTO chat(
ROWID, chat_identifier, guid, display_name, service_name,
account_id, account_login, last_addressed_handle
)
VALUES (
1, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage',
'iMessage;+;me@icloud.com', 'me@icloud.com', 'me@icloud.com'
)
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'me@icloud.com')")
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (5, 1, 'hello', ?, 0, 'iMessage')
""",
appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 5)")
}
}

View File

@ -1,329 +0,0 @@
import Commander
import Foundation
import SQLite
import Testing
@testable import IMsgCore
@testable import imsg
private enum CommandTestDatabase {
static func appleEpoch(_ date: Date) -> Int64 {
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
return Int64(seconds * 1_000_000_000)
}
static func makePath() throws -> String {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("chat.db").path
let db = try Connection(path)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
try db.execute(
"""
CREATE TABLE attachment (
ROWID INTEGER PRIMARY KEY,
filename TEXT,
transfer_name TEXT,
uti TEXT,
mime_type TEXT,
total_bytes INTEGER,
is_sticker INTEGER
);
"""
)
let now = Date()
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES (1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage')
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (1, 1, 'hello', ?, 0, 'iMessage')
""",
appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
return path
}
static func makePathWithAttachment() throws -> String {
let path = try makePath()
let db = try Connection(path)
try db.run(
"""
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
VALUES (1, '/tmp/file.dat', 'file.dat', 'public.data', 'application/octet-stream', 10, 0)
"""
)
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
return path
}
}
@Test
func chatsCommandRunsWithJsonOutput() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "limit": ["5"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
try await ChatsCommand.spec.run(values, runtime)
}
@Test
func historyCommandRunsWithChatID() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
try await HistoryCommand.spec.run(values, runtime)
}
@Test
func historyCommandRunsWithAttachmentsNonJson() async throws {
let path = try CommandTestDatabase.makePathWithAttachment()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
flags: ["attachments"]
)
let runtime = RuntimeOptions(parsedValues: values)
try await HistoryCommand.spec.run(values, runtime)
}
@Test
func chatsCommandRunsWithPlainOutput() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "limit": ["5"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
try await ChatsCommand.spec.run(values, runtime)
}
@Test
func sendCommandRejectsMissingRecipient() async {
let values = ParsedValues(
positional: [],
options: ["text": ["hi"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await SendCommand.spec.run(values, runtime)
#expect(Bool(false))
} catch let error as ParsedValuesError {
#expect(error.description.contains("Missing required option"))
} catch {
#expect(Bool(false))
}
}
@Test
func sendCommandRunsWithStubSender() async throws {
let values = ParsedValues(
positional: [],
options: ["to": ["+15551234567"], "text": ["hi"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var captured: MessageSendOptions?
try await SendCommand.run(
values: values, runtime: runtime,
sendMessage: { options in
captured = options
})
#expect(captured?.recipient == "+15551234567")
#expect(captured?.text == "hi")
}
@Test
func sendCommandResolvesChatID() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "text": ["hi"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var captured: MessageSendOptions?
try await SendCommand.run(
values: values, runtime: runtime,
sendMessage: { options in
captured = options
})
#expect(captured?.chatIdentifier == "+123")
#expect(captured?.chatGUID == "iMessage;+;chat123")
#expect(captured?.recipient.isEmpty == true)
}
@Test
func watchCommandRejectsInvalidDebounce() async {
let values = ParsedValues(
positional: [],
options: ["debounce": ["nope"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await WatchCommand.spec.run(values, runtime)
#expect(Bool(false))
} catch let error as ParsedValuesError {
#expect(error.description.contains("Invalid value"))
} catch {
#expect(Bool(false))
}
}
@Test
func watchCommandRunsWithStubStream() async throws {
let values = ParsedValues(
positional: [],
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
let db = try Connection(.inMemory)
let store = try MessageStore(
connection: db,
path: ":memory:",
hasAttributedBody: false,
hasReactionColumns: false
)
let message = Message(
rowID: 1,
chatID: 1,
sender: "+123",
text: "hello",
date: Date(),
isFromMe: false,
service: "iMessage",
handleID: nil,
attachmentsCount: 2
)
let streamProvider:
(
MessageWatcher,
Int64?,
Int64?,
MessageWatcherConfiguration
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
AsyncThrowingStream { continuation in
continuation.yield(message)
continuation.finish()
}
}
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
}
@Test
func watchCommandRunsWithJsonOutput() async throws {
let values = ParsedValues(
positional: [],
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE attachment (
ROWID INTEGER PRIMARY KEY,
filename TEXT,
transfer_name TEXT,
uti TEXT,
mime_type TEXT,
total_bytes INTEGER,
is_sticker INTEGER
);
"""
)
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
try db.run(
"""
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
VALUES (1, '/tmp/file.dat', 'file.dat', 'public.data', 'application/octet-stream', 10, 0)
"""
)
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
let store = try MessageStore(
connection: db,
path: ":memory:",
hasAttributedBody: false,
hasReactionColumns: false
)
let message = Message(
rowID: 1,
chatID: 1,
sender: "+123",
text: "hello",
date: Date(),
isFromMe: false,
service: "iMessage",
handleID: nil,
attachmentsCount: 1
)
let streamProvider:
(
MessageWatcher,
Int64?,
Int64?,
MessageWatcherConfiguration
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
AsyncThrowingStream { continuation in
continuation.yield(message)
continuation.finish()
}
}
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
}

View File

@ -0,0 +1,102 @@
import Foundation
import Testing
@testable import IMsgCore
@testable import imsg
@Test
func contactNameDetectionIgnoresPhonesAndEmails() {
#expect(ChatTargetResolver.looksLikeContactName("+15551234567") == false)
#expect(ChatTargetResolver.looksLikeContactName("(555) 123-4567") == false)
#expect(ChatTargetResolver.looksLikeContactName("user@example.com") == false)
#expect(ChatTargetResolver.looksLikeContactName("") == false)
}
@Test
func contactNameDetectionAcceptsNames() {
#expect(ChatTargetResolver.looksLikeContactName("John Smith") == true)
#expect(ChatTargetResolver.looksLikeContactName("Alice") == true)
}
@Test
func contactNameResolutionPassesThroughUnknownNames() throws {
let resolver = MockContactResolver()
let resolved = try ChatTargetResolver.resolveRecipientName("Unknown Person", contacts: resolver)
#expect(resolved == "Unknown Person")
}
@Test
func contactNameResolutionReturnsUniqueMatch() throws {
let resolver = MockContactResolver(
matches: [ContactMatch(name: "John Smith", handle: "+15551234567")]
)
let resolved = try ChatTargetResolver.resolveRecipientName("John", contacts: resolver)
#expect(resolved == "+15551234567")
}
@Test
func contactNameResolutionRejectsAmbiguousMatches() {
let resolver = MockContactResolver(
matches: [
ContactMatch(name: "John Smith", handle: "+15551234567"),
ContactMatch(name: "John Doe", handle: "+15557654321"),
]
)
#expect(throws: (any Error).self) {
try ChatTargetResolver.resolveRecipientName("John", contacts: resolver)
}
}
@Test
func encodedChatPayloadIncludesContactName() throws {
let chat = Chat(
id: 1,
identifier: "+15551234567",
name: "+15551234567",
service: "iMessage",
lastMessageAt: Date(timeIntervalSince1970: 0)
)
let payload = ChatPayload(chat: chat, contactName: "John Smith")
let data = try JSONEncoder().encode(payload)
let object = try JSONSerialization.jsonObject(with: data)
let json = try #require(object as? [String: Any])
#expect(json["contact_name"] as? String == "John Smith")
}
@Test
func messagePayloadIncludesSenderName() throws {
let message = Message(
rowID: 1,
chatID: 1,
sender: "+15551234567",
text: "hello",
date: Date(timeIntervalSince1970: 0),
isFromMe: false,
service: "iMessage",
handleID: nil,
attachmentsCount: 0,
guid: "msg-1"
)
let payload = MessagePayload(message: message, attachments: [], senderName: "John Smith")
let data = try JSONEncoder().encode(payload)
let object = try JSONSerialization.jsonObject(with: data)
let json = try #require(object as? [String: Any])
#expect(json["sender_name"] as? String == "John Smith")
}
@Test
func reactionPayloadIncludesSenderName() throws {
let reaction = Reaction(
rowID: 2,
reactionType: .like,
sender: "+15551234567",
isFromMe: false,
date: Date(timeIntervalSince1970: 0),
associatedMessageID: 1
)
let payload = ReactionPayload(reaction: reaction, senderName: "John Smith")
let data = try JSONEncoder().encode(payload)
let object = try JSONSerialization.jsonObject(with: data)
let json = try #require(object as? [String: Any])
#expect(json["sender_name"] as? String == "John Smith")
}

View File

@ -0,0 +1,98 @@
import Commander
import Foundation
import Testing
@testable import IMsgCore
@testable import imsg
@Test
func groupCommandRequiresChatID() async {
let values = ParsedValues(
positional: [],
options: [:],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await GroupCommand.spec.run(values, runtime)
#expect(Bool(false))
} catch let error as ParsedValuesError {
#expect(error.description.contains("Missing required option"))
} catch {
#expect(Bool(false))
}
}
@Test
func groupCommandThrowsOnUnknownChatID() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["9999"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await GroupCommand.spec.run(values, runtime)
#expect(Bool(false))
} catch let error as IMsgError {
#expect(error.errorDescription?.contains("9999") == true)
} catch {
#expect(Bool(false))
}
}
@Test
func groupCommandPrintsPlainTextForGroup() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await GroupCommand.spec.run(values, runtime)
}
#expect(output.contains("id: 1"))
#expect(output.contains("identifier: +123"))
#expect(output.contains("guid: iMessage;+;chat123"))
#expect(output.contains("name: Test Chat"))
#expect(output.contains("service: iMessage"))
#expect(output.contains("account_id: iMessage;+;me@icloud.com"))
#expect(output.contains("account_login: me@icloud.com"))
#expect(output.contains("last_addressed_handle: +15551234567"))
#expect(output.contains("is_group: true"))
#expect(output.contains("- +123"))
}
@Test
func groupCommandEmitsJsonPayload() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await GroupCommand.spec.run(values, runtime)
}
let payload = try jsonObject(from: output)
#expect(payload["id"] as? Int == 1)
#expect(payload["identifier"] as? String == "+123")
#expect(payload["guid"] as? String == "iMessage;+;chat123")
#expect(payload["name"] as? String == "Test Chat")
#expect(payload["service"] as? String == "iMessage")
#expect(payload["account_id"] as? String == "iMessage;+;me@icloud.com")
#expect(payload["account_login"] as? String == "me@icloud.com")
#expect(payload["last_addressed_handle"] as? String == "+15551234567")
#expect(payload["is_group"] as? Bool == true)
#expect(payload["participants"] as? [String] == ["+123"])
}
private func jsonObject(from output: String) throws -> [String: Any] {
let line = output.split(separator: "\n").first.map(String.init) ?? ""
let data = Data(line.utf8)
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
}

View File

@ -0,0 +1,59 @@
import Commander
import Foundation
import Testing
@testable import IMsgCore
@testable import imsg
@Test
func commandRouterIncludesLaunchCommand() async {
let router = CommandRouter()
let names = router.specs.map(\.name)
#expect(names.contains("launch"))
}
@Test
func commandRouterIncludesReadCommand() async {
let router = CommandRouter()
let names = router.specs.map(\.name)
#expect(names.contains("read"))
}
@Test
func commandRouterIncludesStatusCommand() async {
let router = CommandRouter()
let names = router.specs.map(\.name)
#expect(names.contains("status"))
}
@Test
func statusCommandProducesJsonOutput() async throws {
let values = ParsedValues(
positional: [],
options: [:],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = await StdoutCapture.capture {
try? await StatusCommand.run(values: values, runtime: runtime)
}
// JSON output should contain expected keys
#expect(output.contains("basic_features"))
#expect(output.contains("advanced_features"))
}
@Test
func statusCommandProducesTextOutput() async throws {
let values = ParsedValues(
positional: [],
options: [:],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = await StdoutCapture.capture {
try? await StatusCommand.run(values: values, runtime: runtime)
}
#expect(output.contains("imsg Status Report"))
}

View File

@ -0,0 +1,36 @@
import IMsgCore
final class MockContactResolver: ContactResolving, Sendable {
let contactsUnavailable: Bool
private let names: [String: String]
private let matches: [ContactMatch]
init(
names: [String: String] = [:],
matches: [ContactMatch] = [],
contactsUnavailable: Bool = false
) {
self.names = names
self.matches = matches
self.contactsUnavailable = contactsUnavailable
}
func displayName(for handle: String) -> String? {
names[handle]
}
func displayNames(for handles: [String]) -> [String: String] {
var resolved: [String: String] = [:]
for handle in handles {
if let name = displayName(for: handle) {
resolved[handle] = name
}
}
return resolved
}
func searchByName(_ query: String) -> [ContactMatch] {
let normalizedQuery = query.lowercased()
return matches.filter { $0.name.lowercased().contains(normalizedQuery) }
}
}

View File

@ -0,0 +1,106 @@
# Live bridge smoke tests
These exercises run on a real SIP-disabled Mac with `Messages.app` signed in
and the helper dylib injected. They are gated by `IMSG_LIVE_BRIDGE=1` so they
never run in CI. Each step prints what should happen so you can eyeball the
result in `Messages.app` (the dylib has no way to fake-confirm a UI mutation).
## Prerequisites
```bash
# In Recovery mode
csrutil disable
# Back in normal boot:
make build && make build-dylib
imsg launch # kills + relaunches Messages with DYLD_INSERT
imsg status # expect: bridge version: v2 (v2 inbox active)
```
## Pick a target chat
```bash
imsg chats --limit 10 --json | jq -r '.[] | "\(.guid)\t\(.name // .identifier)"'
export CHAT='iMessage;-;+15551234567' # paste guid from above
```
## 1. send-rich + effects
```bash
imsg send-rich --chat "$CHAT" --text "test from imsg v2"
imsg send-rich --chat "$CHAT" --text "BOOM" \
--effect com.apple.MobileSMS.expressivesend.impact
imsg send-rich --chat "$CHAT" --text "📜 ---" \
--effect com.apple.MobileSMS.expressivesend.invisibleink
```
Expect: each message shows in Messages.app immediately. The 2nd applies the
slam effect; the 3rd shows as invisible ink.
## 2. tapback round-trip
```bash
# Capture the messageGuid of an existing message you want to react to
imsg history --chat-id 1 --limit 1 --json | jq -r '.guid'
export MSG=<paste guid>
imsg tapback --chat "$CHAT" --message "$MSG" --kind love
imsg tapback --chat "$CHAT" --message "$MSG" --kind love --remove
```
Expect: 💖 appears, then disappears.
## 3. edit / unsend (macOS 13+ only)
```bash
imsg send-rich --chat "$CHAT" --text "rough draft"
# Capture the new guid:
imsg history --chat-id 1 --limit 1 --json | jq -r '.guid'
export MSG=<paste guid>
imsg edit --chat "$CHAT" --message "$MSG" --new-text "polished version"
imsg unsend --chat "$CHAT" --message "$MSG"
```
Expect: the message text changes, then a "You unsent a message" placeholder
appears. If `imsg status` shows `editMessageItem: ✗` AND `editMessage: ✗`,
your macOS is too old (pre-13) — these will return an error.
## 4. chat creation + member management
```bash
imsg chat-create --addresses '+15551111111,+15552222222' \
--name 'imsg test' --text 'hello' --json
# Capture the new chatGuid from the JSON output:
export GROUP=<paste chatGuid>
imsg chat-add-member --chat "$GROUP" --address +15553333333
imsg chat-name --chat "$GROUP" --name 'imsg test renamed'
imsg chat-photo --chat "$GROUP" --file ~/Pictures/test.jpg
imsg chat-remove-member --chat "$GROUP" --address +15553333333
imsg chat-leave --chat "$GROUP"
```
`chat-create` is iMessage-only; use `imsg send --service sms` for SMS sends.
Expect: each step is visible in Messages.app within a second or two.
## 5. typing events streaming
```bash
imsg watch --bb-events --json &
# from another device or simulator, type into your conversation
# you should see started-typing / stopped-typing JSON objects emit
kill %1
```
## 6. introspection
```bash
imsg account
imsg whois --address +15551234567 --type phone
imsg nickname --address +15551234567
```
## Cleanup
```bash
killall Messages # un-inject; next launch is normal
csrutil enable # in Recovery, re-enable SIP when done
```

View File

@ -7,7 +7,7 @@ import Testing
@Test
func isGroupHandleFlagsGroup() {
#expect(isGroupHandle(identifier: "iMessage;+;chat123", guid: "") == true)
#expect(isGroupHandle(identifier: "", guid: "iMessage;-;chat999") == true)
#expect(isGroupHandle(identifier: "", guid: "iMessage;-;chat999") == false)
#expect(isGroupHandle(identifier: "+1555", guid: "") == false)
}
@ -30,7 +30,22 @@ func chatPayloadIncludesParticipantsAndGroupFlag() {
}
@Test
func messagePayloadIncludesChatFields() {
func chatPayloadIncludesContactName() {
let payload = chatPayload(
id: 2,
identifier: "+15551234567",
guid: "iMessage;-;+15551234567",
name: "+15551234567",
service: "iMessage",
lastMessageAt: Date(timeIntervalSince1970: 0),
participants: ["+15551234567"],
contactName: "Alice"
)
#expect(payload["contact_name"] as? String == "Alice")
}
@Test
func messagePayloadIncludesChatFields() throws {
let message = Message(
rowID: 5,
chatID: 10,
@ -42,7 +57,9 @@ func messagePayloadIncludesChatFields() {
handleID: nil,
attachmentsCount: 1,
guid: "msg-guid-5",
replyToGUID: "msg-guid-1"
replyToGUID: "msg-guid-1",
threadOriginatorGUID: "thread-guid-5",
destinationCallerID: "me@icloud.com"
)
let chatInfo = ChatInfo(
id: 10,
@ -59,6 +76,8 @@ func messagePayloadIncludesChatFields() {
totalBytes: 12,
isSticker: false,
originalPath: "/tmp/file.dat",
convertedPath: "/tmp/file.png",
convertedMimeType: "image/png",
missing: false
)
let reaction = Reaction(
@ -69,7 +88,7 @@ func messagePayloadIncludesChatFields() {
date: Date(timeIntervalSince1970: 2),
associatedMessageID: 5
)
let payload = messagePayload(
let payload = try messagePayload(
message: message,
chatInfo: chatInfo,
participants: ["+111"],
@ -79,17 +98,58 @@ func messagePayloadIncludesChatFields() {
#expect(payload["chat_id"] as? Int64 == 10)
#expect(payload["guid"] as? String == "msg-guid-5")
#expect(payload["reply_to_guid"] as? String == "msg-guid-1")
#expect(payload["destination_caller_id"] as? String == "me@icloud.com")
#expect(payload["thread_originator_guid"] as? String == "thread-guid-5")
#expect(payload["chat_identifier"] as? String == "iMessage;+;chat123")
#expect(payload["chat_name"] as? String == "Group")
#expect(payload["is_group"] as? Bool == true)
#expect((payload["attachments"] as? [[String: Any]])?.count == 1)
let attachmentPayload = (payload["attachments"] as? [[String: Any]])?.first
#expect(attachmentPayload?["converted_path"] as? String == "/tmp/file.png")
#expect(attachmentPayload?["converted_mime_type"] as? String == "image/png")
#expect(
(payload["reactions"] as? [[String: Any]])?.first?["emoji"] as? String
== ReactionType.like.emoji)
}
@Test
func messagePayloadOmitsEmptyReplyToGuid() {
func messagePayloadIncludesSenderAndReactionNames() throws {
let message = Message(
rowID: 7,
chatID: 10,
sender: "+123",
text: "hello",
date: Date(timeIntervalSince1970: 1),
isFromMe: false,
service: "iMessage",
handleID: nil,
attachmentsCount: 0,
guid: "msg-guid-7"
)
let reaction = Reaction(
rowID: 101,
reactionType: .love,
sender: "+456",
isFromMe: false,
date: Date(timeIntervalSince1970: 2),
associatedMessageID: 7
)
let payload = try messagePayload(
message: message,
chatInfo: nil,
participants: [],
attachments: [],
reactions: [reaction],
senderName: "Alice",
reactionSenderNames: [101: "Bob"]
)
#expect(payload["sender_name"] as? String == "Alice")
let reactions = payload["reactions"] as? [[String: Any]]
#expect(reactions?.first?["sender_name"] as? String == "Bob")
}
@Test
func messagePayloadOmitsEmptyReplyToGuid() throws {
let message = Message(
rowID: 6,
chatID: 10,
@ -103,7 +163,7 @@ func messagePayloadOmitsEmptyReplyToGuid() {
guid: "msg-guid-6",
replyToGUID: nil
)
let payload = messagePayload(
let payload = try messagePayload(
message: message,
chatInfo: nil,
participants: [],
@ -111,9 +171,35 @@ func messagePayloadOmitsEmptyReplyToGuid() {
reactions: []
)
#expect(payload["reply_to_guid"] == nil)
#expect(payload["destination_caller_id"] == nil)
#expect(payload["thread_originator_guid"] == nil)
#expect(payload["guid"] as? String == "msg-guid-6")
}
@Test
func watchDebounceIntervalDefaultsToHalfSecond() throws {
#expect(try watchDebounceIntervalParam([:]) == 0.5)
}
@Test
func watchDebounceIntervalAcceptsSnakeAndCamelCaseMilliseconds() throws {
#expect(try watchDebounceIntervalParam(["debounce_ms": 750]) == 0.75)
#expect(try watchDebounceIntervalParam(["debounceMs": "125"]) == 0.125)
}
@Test
func watchDebounceIntervalRejectsInvalidValues() {
do {
_ = try watchDebounceIntervalParam(["debounce_ms": -1])
#expect(Bool(false))
} catch let error as RPCError {
#expect(error.code == -32602)
#expect(error.data?.contains("debounce_ms") == true)
} catch {
#expect(Bool(false))
}
}
@Test
func paramParsingHelpers() {
#expect(stringParam(123 as NSNumber) == "123")

View File

@ -5,79 +5,6 @@ import Testing
@testable import IMsgCore
@testable import imsg
private enum RPCTestDatabase {
static func appleEpoch(_ date: Date) -> Int64 {
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
return Int64(seconds * 1_000_000_000)
}
static func makeStore() throws -> MessageStore {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"""
CREATE TABLE attachment (
ROWID INTEGER PRIMARY KEY,
filename TEXT,
transfer_name TEXT,
uti TEXT,
mime_type TEXT,
total_bytes INTEGER,
is_sticker INTEGER
);
"""
)
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let now = Date()
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES (1, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage')
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'me@icloud.com')")
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (5, 1, 'hello', ?, 0, 'iMessage')
""",
appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 5)")
return try MessageStore(
connection: db, path: ":memory:", hasAttributedBody: false, hasReactionColumns: false)
}
}
final class TestRPCOutput: RPCOutput, @unchecked Sendable {
private let lock = NSLock()
private(set) var responses: [[String: Any]] = []
@ -117,9 +44,10 @@ private func int64Value(_ value: Any?) -> Int64? {
@Test
func rpcChatsListReturnsChatPayload() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
let resolver = MockContactResolver(names: ["iMessage;+;chat123": "Family"])
let server = RPCServer(store: store, verbose: false, output: output, contactResolver: resolver)
let line = #"{"jsonrpc":"2.0","id":"1","method":"chats.list","params":{"limit":10}}"#
await server.handleLineForTesting(line)
@ -132,12 +60,13 @@ func rpcChatsListReturnsChatPayload() async throws {
#expect(int64Value(chat["id"]) == 1)
#expect(chat["identifier"] as? String == "iMessage;+;chat123")
#expect(chat["is_group"] as? Bool == true)
#expect(chat["contact_name"] == nil)
#expect((chat["participants"] as? [String])?.count == 2)
}
@Test
func rpcMessagesHistoryIncludesChatFields() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -154,16 +83,53 @@ func rpcMessagesHistoryIncludesChatFields() async throws {
#expect(message["is_group"] as? Bool == true)
}
@Test
func rpcMessagesHistoryReportsConvertedAttachmentsWhenRequested() async throws {
let source = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("caf")
try Data("caf".utf8).write(to: source)
defer { try? FileManager.default.removeItem(at: source) }
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "m4a")
try FileManager.default.createDirectory(
at: converted.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("m4a".utf8).write(to: converted)
defer { try? FileManager.default.removeItem(at: converted) }
let store = try CommandTestDatabase.makeStoreForRPCWithAttachment(
filename: source.path,
transferName: "voice.caf",
uti: "com.apple.coreaudio-format",
mimeType: "audio/x-caf"
)
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
let line =
#"{"jsonrpc":"2.0","id":2,"method":"messages.history","params":{"chat_id":1,"attachments":true,"convert_attachments":true}}"#
await server.handleLineForTesting(line)
let result = output.responses.first?["result"] as? [String: Any]
let messages = result?["messages"] as? [[String: Any]] ?? []
let attachments = messages.first?["attachments"] as? [[String: Any]]
#expect(attachments?.first?["original_path"] as? String == source.path)
#expect(attachments?.first?["converted_path"] as? String == converted.path)
#expect(attachments?.first?["converted_mime_type"] as? String == "audio/mp4")
}
@Test
func rpcSendResolvesChatID() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
var captured: MessageSendOptions?
let server = RPCServer(
store: store,
verbose: false,
output: output,
sendMessage: { options in captured = options }
sendMessage: { options in captured = options },
resolveSentMessage: { _, _, _, _ in nil }
)
let line = #"{"jsonrpc":"2.0","id":"3","method":"send","params":{"chat_id":1,"text":"yo"}}"#
@ -175,9 +141,138 @@ func rpcSendResolvesChatID() async throws {
#expect(output.responses.first?["result"] as? [String: Any] != nil)
}
@Test
func rpcSendResolvesUniqueContactName() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let resolver = MockContactResolver(
matches: [ContactMatch(name: "Alice Smith", handle: "+15551234567")]
)
var captured: MessageSendOptions?
let server = RPCServer(
store: store,
verbose: false,
output: output,
sendMessage: { options in captured = options },
resolveSentMessage: { _, _, _, _ in nil },
contactResolver: resolver
)
let line = #"{"jsonrpc":"2.0","id":"3n","method":"send","params":{"to":"Alice","text":"yo"}}"#
await server.handleLineForTesting(line)
#expect(captured?.recipient == "+15551234567")
#expect(output.responses.first?["result"] as? [String: Any] != nil)
}
@Test
func rpcSendRejectsAmbiguousContactName() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let resolver = MockContactResolver(
matches: [
ContactMatch(name: "John Smith", handle: "+15551234567"),
ContactMatch(name: "John Doe", handle: "+15557654321"),
]
)
let server = RPCServer(store: store, verbose: false, output: output, contactResolver: resolver)
let line = #"{"jsonrpc":"2.0","id":"3m","method":"send","params":{"to":"John","text":"yo"}}"#
await server.handleLineForTesting(line)
let error = output.errors.first?["error"] as? [String: Any]
#expect(int64Value(error?["code"]) == -32602)
}
@Test
func rpcSendReturnsSentMessageIdentifiersWhenResolved() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(
store: store,
verbose: false,
output: output,
sendMessage: { _ in },
resolveSentMessage: { _, options, chatID, _ in
Message(
rowID: 1_979,
chatID: chatID ?? 0,
sender: "me@icloud.com",
text: options.text,
date: Date(),
isFromMe: true,
service: "iMessage",
handleID: nil,
attachmentsCount: 0,
guid: "8DF1B3D7"
)
}
)
let line = #"{"jsonrpc":"2.0","id":"3b","method":"send","params":{"chat_id":1,"text":"yo"}}"#
await server.handleLineForTesting(line)
let result = output.responses.first?["result"] as? [String: Any]
#expect(result?["ok"] as? Bool == true)
#expect(int64Value(result?["id"]) == 1_979)
#expect(result?["guid"] as? String == "8DF1B3D7")
}
@Test
func rpcSendKeepsOkResponseWhenSentMessageIsNotResolved() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(
store: store,
verbose: false,
output: output,
sendMessage: { _ in },
resolveSentMessage: { _, _, _, _ in nil }
)
let line = #"{"jsonrpc":"2.0","id":"3c","method":"send","params":{"chat_id":1,"text":"yo"}}"#
await server.handleLineForTesting(line)
let result = output.responses.first?["result"] as? [String: Any]
#expect(result?["ok"] as? Bool == true)
#expect(result?["id"] == nil)
#expect(result?["guid"] == nil)
}
@Test
func rpcSendReportsMisroutedChatGhost() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(
store: store,
verbose: false,
output: output,
sendMessage: { _ in
try store.withConnection { db in
try db.run("INSERT INTO handle(ROWID, id) VALUES (99, 'iMessage;+;chat123')")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (99, 99, '', ?, 1, 'SMS')
""",
CommandTestDatabase.appleEpoch(Date())
)
}
},
resolveSentMessage: { _, _, _, _ in nil }
)
let line = #"{"jsonrpc":"2.0","id":"3d","method":"send","params":{"chat_id":1,"text":"yo"}}"#
await server.handleLineForTesting(line)
let error = output.errors.first?["error"] as? [String: Any]
#expect(int64Value(error?["code"]) == -32603)
#expect((error?["data"] as? String)?.contains("unjoined empty outgoing row") == true)
}
@Test
func rpcSendRejectsMissingTextAndFile() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -191,7 +286,7 @@ func rpcSendRejectsMissingTextAndFile() async throws {
@Test
func rpcRejectsInvalidJSON() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -203,7 +298,7 @@ func rpcRejectsInvalidJSON() async throws {
@Test
func rpcRejectsNonObjectRequest() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -215,7 +310,7 @@ func rpcRejectsNonObjectRequest() async throws {
@Test
func rpcRejectsInvalidJSONRPCVersion() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -228,7 +323,7 @@ func rpcRejectsInvalidJSONRPCVersion() async throws {
@Test
func rpcRejectsMissingMethod() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -241,7 +336,7 @@ func rpcRejectsMissingMethod() async throws {
@Test
func rpcReportsMethodNotFound() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -254,7 +349,7 @@ func rpcReportsMethodNotFound() async throws {
@Test
func rpcHistoryRequiresChatID() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -267,7 +362,7 @@ func rpcHistoryRequiresChatID() async throws {
@Test
func rpcSendRejectsInvalidService() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -281,7 +376,7 @@ func rpcSendRejectsInvalidService() async throws {
@Test
func rpcSendRejectsMissingRecipientForDirectSend() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -294,7 +389,7 @@ func rpcSendRejectsMissingRecipientForDirectSend() async throws {
@Test
func rpcSendRejectsChatAndRecipient() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -308,7 +403,7 @@ func rpcSendRejectsChatAndRecipient() async throws {
@Test
func rpcSendRejectsUnknownChatID() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -321,7 +416,7 @@ func rpcSendRejectsUnknownChatID() async throws {
@Test
func rpcWatchSubscribeEmitsNotificationAndUnsubscribe() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -349,9 +444,33 @@ func rpcWatchSubscribeEmitsNotificationAndUnsubscribe() async throws {
#expect(output.responses.count >= 2)
}
@Test
func rpcWatchIncludeReactionsDoesNotRequireAttachments() async throws {
let store = try CommandTestDatabase.makeStoreForRPCWithReaction()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
let subscribe =
#"{"jsonrpc":"2.0","id":13,"method":"watch.subscribe","params":{"chat_id":1,"#
+ #""since_rowid":-1,"include_reactions":true,"attachments":false}}"#
await server.handleLineForTesting(subscribe)
for _ in 0..<20 {
if output.notifications.count >= 1 { break }
try await Task.sleep(nanoseconds: 50_000_000)
}
let params = output.notifications.first?["params"] as? [String: Any]
let message = params?["message"] as? [String: Any]
let reactions = message?["reactions"] as? [[String: Any]] ?? []
#expect(reactions.count == 1)
#expect(reactions.first?["type"] as? String == "like")
#expect((message?["attachments"] as? [[String: Any]])?.isEmpty == true)
}
@Test
func rpcWatchUnsubscribeRequiresSubscription() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)

View File

@ -0,0 +1,92 @@
import Commander
import Foundation
import Testing
@testable import IMsgCore
@testable import imsg
@Test
func reactCommandRejectsMultiCharacterEmojiInput() async {
do {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "reaction": ["🎉 party"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
try await ReactCommand.run(values: values, runtime: runtime)
#expect(Bool(false))
} catch let error as IMsgError {
switch error {
case .invalidReaction(let value):
#expect(value == "🎉 party")
default:
#expect(Bool(false))
}
} catch {
#expect(Bool(false))
}
}
@Test
func reactCommandBuildsParameterizedAppleScriptForStandardTapback() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "reaction": ["like"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var capturedScript = ""
var capturedArguments: [String] = []
_ = try await StdoutCapture.capture {
try await ReactCommand.run(
values: values,
runtime: runtime,
appleScriptRunner: { source, arguments in
capturedScript = source
capturedArguments = arguments
}
)
}
#expect(capturedArguments == ["iMessage;+;chat123", "Test Chat", "2"])
#expect(capturedScript.contains("on run argv"))
#expect(capturedScript.contains("keystroke \"f\" using command down"))
#expect(capturedScript.contains("set targetChat to chat id chatGUID"))
#expect(capturedScript.contains("keystroke reactionKey"))
#expect(capturedScript.contains("keystroke reactionKey\n delay 0.1\n key code 36"))
#expect(capturedScript.contains("chat123") == false)
}
@Test
func reactCommandRejectsCustomEmojiSend() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "reaction": ["🎉"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await ReactCommand.run(
values: values,
runtime: runtime,
appleScriptRunner: { _, _ in
#expect(Bool(false))
}
)
#expect(Bool(false))
} catch let error as IMsgError {
switch error {
case .unsupportedReaction(let message):
#expect(message.contains("custom emoji tapback"))
#expect(message.contains("AppleScript automation"))
#expect(message.contains("love"))
default:
#expect(Bool(false))
}
} catch {
#expect(Bool(false))
}
}

Some files were not shown because too many files have changed in this diff Show More