Compare commits

..

28 Commits
v0.6.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
66 changed files with 9544 additions and 1032 deletions

View File

@ -1,11 +1,11 @@
---
name: imsg
description: Use for local iMessage/SMS archive reads, chat history, watch, and explicitly requested sends.
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, and sends. Reading is local DB access; sending uses Messages automation and must be explicitly requested.
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
@ -22,19 +22,21 @@ Check DB access:
sqlite3 ~/Library/Messages/chat.db 'pragma quick_check;'
```
List chats:
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 --json | jq -s
imsg chats --limit 200 --json | jq -s '.[] | select((.contact_name // .display_name // .name // .identifier // "" | ascii_downcase) | contains("beatrix"))'
```
Read a chat:
Then read the chat by id:
```bash
imsg history --chat-id ID --json | jq -s
```
Use `--attachments` when attachment metadata matters. Use `--start`/`--end` with absolute timestamps for date-scoped questions.
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

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 ${{ steps.tag.outputs.tag }}
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git checkout ${{ steps.tag.outputs.tag }}
- name: Resolve packages
run: swift package resolve
@ -62,7 +70,7 @@ 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 }}
@ -92,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,66 @@
# 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

View File

@ -1,6 +1,6 @@
SHELL := /bin/bash
.PHONY: help format lint test build imsg clean build-dylib
.PHONY: help format lint test build imsg clean build-dylib docs-site
help:
@printf "%s\n" \
@ -10,13 +10,14 @@ help:
"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:
@ -52,6 +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,59 +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.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: [
.target(
name: "IMsgCore",
dependencies: [
.product(name: "SQLite", package: "SQLite.swift"),
.product(name: "PhoneNumberKit", package: "PhoneNumberKit"),
],
linkerSettings: [
.linkedFramework("ScriptingBridge"),
.linkedFramework("Contacts"),
]
),
.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
}()
)

322
README.md
View File

@ -1,33 +1,47 @@
# imsg
`imsg` is a macOS command-line tool for Messages.app. It reads your local
Messages database, streams new iMessage/SMS rows, sends messages through
Messages.app automation, and exposes the same surfaces over JSON and JSON-RPC.
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.
Most read workflows need only Full Disk Access. Sending and standard tapbacks
also need macOS Automation permission for Messages.app. Advanced IMCore features
such as read receipts, typing indicators, and injection status are opt-in and
are increasingly limited by macOS 26.
`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
- Read recent chats and message history without modifying `chat.db`.
- Stream new messages with `watch`, including a fallback poll when macOS misses
file events.
- Send text and files through Messages.app AppleScript, without private send
APIs.
- Inspect direct chats and groups, including participants, GUIDs, service, and
account routing hints.
- Emit newline-delimited JSON for automation, agents, and scripts.
- Resolve Contacts names when permission is granted, while keeping raw handles
in the output.
- Report attachment metadata, and optionally expose model-compatible converted
receive-side CAF/GIF files.
- Use JSON-RPC over stdio for long-running integrations.
- **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 or newer.
- 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`.
@ -36,10 +50,15 @@ are increasingly limited by macOS 26.
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:
@ -49,80 +68,76 @@ make build
./bin/imsg --help
```
## Common Workflows
List recent chats:
## Quickstart
```bash
imsg chats --limit 10
imsg chats --limit 10 --json
```
# List recent chats.
imsg chats --limit 10 --json | jq -s
Inspect one chat before sending or wiring automation:
```bash
# Inspect one chat before automating against it.
imsg group --chat-id 42 --json
```
Read history:
```bash
imsg history --chat-id 42 --limit 20
# Read history with attachment metadata.
imsg history --chat-id 42 --limit 20 --attachments --json
imsg history --chat-id 42 --start 2026-05-01T00:00:00Z --end 2026-05-06T00:00:00Z --json
```
Stream new messages:
# Stream new messages, including tapbacks.
imsg watch --chat-id 42 --reactions --json
```bash
imsg watch --chat-id 42 --json
imsg watch --chat-id 42 --since-rowid 9000 --attachments --reactions --debounce 250ms --json
```
# Send a message — auto-pick iMessage or SMS.
imsg send --to "+14155551212" --text "on my way"
Send a message or file:
# Send a file (image, audio, document).
imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
```bash
imsg send --to "+14155551212" --text "hi" --service imessage
imsg send --to "Jane Appleseed" --text "voice note" --file ~/Desktop/voice.m4a
imsg send --chat-id 42 --text "same thread"
```
Send a standard tapback:
```bash
# Send a standard tapback.
imsg react --chat-id 42 --reaction like
# Search local history.
imsg search --query "pizza" --match contains
```
Generate integration help:
```bash
imsg completions zsh
imsg completions llm
```
`--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
Read, watch, and send (no special permissions beyond Full Disk Access and
Automation):
- `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 read --to <handle> [--chat-id <id> | --chat-identifier <id> | --chat-guid <guid>]`
- `imsg typing --to <handle> [--duration 5s] [--stop true] [--service imessage|sms|auto]`
- `imsg status [--json]`
- `imsg launch [--dylib <path>] [--kill-only] [--json]`
- `imsg rpc`
- `imsg completions bash|zsh|fish|llm`
`react` intentionally sends only the standard tapbacks that Messages.app exposes
Advanced IMCore (require `imsg launch` with SIP off — see
[Advanced IMCore](#advanced-imcore-features)):
- `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`
`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 not sent by the CLI.
history/watch output, but are sent through the bridge `tapback` command.
## JSON Output
`--json` emits one JSON object per line, so consumers can stream it directly or
collect it with `jq -s`.
`--json` emits one JSON object per line, so consumers can stream it directly
or collect it with `jq -s`.
Chat objects include:
@ -135,7 +150,7 @@ Message objects include:
- `id`, `chat_id`, `chat_identifier`, `chat_guid`, `chat_name`
- `participants`, `is_group`
- `guid`, `reply_to_guid`, `destination_caller_id`
- `guid`, `reply_to_guid`, `thread_originator_guid`, `destination_caller_id`
- `sender`, `sender_name`, `is_from_me`, `text`, `created_at`
- `attachments`, `reactions`
@ -145,27 +160,18 @@ 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.
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.
`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 method:
- `send`
See [docs/rpc.md](docs/rpc.md) for request and response shapes.
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
@ -174,38 +180,39 @@ See [docs/rpc.md](docs/rpc.md) for request and response shapes.
Attachment metadata includes filename, transfer name, UTI, MIME type, byte
count, sticker flag, missing flag, and resolved original path.
`--convert-attachments` can expose cached, model-compatible receive-side
`--convert-attachments` exposes cached, model-compatible receive-side
variants:
- CAF audio -> M4A
- GIF image -> first-frame PNG
- 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
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 files, through Messages.app.
`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.
`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`.
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.
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.
@ -217,19 +224,19 @@ If reads fail with `unable to open database file`, empty output, or
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.
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`, and read-only `rpc` workflows do
not require IMCore injection.
Default `send`, `chats`, `history`, `watch`, `search`, and read-only `rpc`
workflows do not require IMCore injection.
Advanced features such as `read`, `typing`, `launch`, and IMCore bridge status are
opt-in. They require SIP to be disabled and a helper dylib to be injected into
Messages.app:
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:
```bash
make build-dylib
@ -241,15 +248,111 @@ 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`
- 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.
- These limits affect advanced IMCore features such as typing indicators,
not normal send/history/watch usage.
To revert after testing advanced features, re-enable SIP from Recovery mode with
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
```bash
@ -262,4 +365,9 @@ make build
tests.
The reusable Swift core lives in `Sources/IMsgCore`; the CLI target lives in
`Sources/imsg`.
`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

@ -1,6 +1,9 @@
import CryptoKit
import Foundation
#if canImport(CryptoKit)
import CryptoKit
#endif
enum AttachmentResolver {
private struct ConversionPlan {
let targetExtension: String
@ -58,9 +61,7 @@ enum AttachmentResolver {
let modification = values?.contentModificationDate?.timeIntervalSince1970 ?? 0
let size = values?.fileSize ?? 0
let token = "\(sourceURL.path)|\(size)|\(modification)"
let digest = SHA256.hash(data: Data(token.utf8))
.map { String(format: "%02x", $0) }
.joined()
let digest = cacheDigest(for: token)
let base = sourceURL.deletingPathExtension().lastPathComponent
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
@ -158,6 +159,23 @@ enum AttachmentResolver {
)
}
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 =

View File

@ -1,6 +1,9 @@
@preconcurrency import Contacts
import Foundation
#if os(macOS)
@preconcurrency import Contacts
#endif
public struct ContactMatch: Equatable, Sendable {
public let name: String
public let handle: String
@ -32,153 +35,181 @@ public final class NoOpContactResolver: ContactResolving, Sendable {
}
public final class ContactResolver: ContactResolving, @unchecked Sendable {
private let phoneToName: [String: String]
private let emailToName: [String: String]
private let contacts: [ContactRecord]
private let normalizer = PhoneNumberNormalizer()
private let region: String
#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
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
}
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 {
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:
#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)
@unknown default:
return NoOpContactResolver(contactsUnavailable: true)
}
#endif
}
public func displayName(for handle: String) -> String? {
let lookup = normalizedLookupHandle(handle)
if lookup.contains("@") {
return emailToName[lookup.lowercased()]
}
return phoneToName[normalizer.normalize(lookup, region: region)]
#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] {
var resolved: [String: String] = [:]
for handle in handles {
if let name = displayName(for: handle) {
resolved[handle] = name
#if os(macOS)
var resolved: [String: String] = [:]
for handle in handles {
if let name = displayName(for: handle) {
resolved[handle] = name
}
}
}
return resolved
return resolved
#else
_ = handles
return [:]
#endif
}
public func searchByName(_ query: String) -> [ContactMatch] {
let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalizedQuery.isEmpty else { return [] }
#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
}
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))
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))
}
}
} catch {
return NoOpContactResolver(contactsUnavailable: true)
}
return ContactResolver(
phoneToName: phoneToName,
emailToName: emailToName,
contacts: contacts,
region: region
)
return matches
#else
_ = query
return []
#endif
}
private static func displayName(for contact: CNContact) -> String? {
if !contact.nickname.isEmpty {
return contact.nickname
#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)
}
}
}
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))
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
)
}
return trimmed
}
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
}
private struct ContactRecord: Sendable {
let name: String
let phones: [String]
let emails: [String]
}
#if os(macOS)
private struct ContactRecord: Sendable {
let name: String
let phones: [String]
let emails: [String]
}
#endif

View File

@ -1,9 +1,10 @@
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)
@ -35,6 +36,8 @@ 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):
@ -53,4 +56,8 @@ public enum IMsgError: LocalizedError, Sendable {
return "Chat not found: \(chatID)"
}
}
public var description: String {
errorDescription ?? "Unknown imsg error"
}
}

View File

@ -55,23 +55,23 @@ public final class IMCoreBridge: @unchecked Sendable {
"handle": handle,
"typing": typing,
]
_ = try await sendCommand(action: "typing", params: params)
_ = try await invokeBridge(action: .typing, params: params)
}
/// Mark all messages as read in a conversation.
public func markAsRead(handle: String) async throws {
_ = try await sendCommand(action: "read", params: ["handle": handle])
_ = 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 sendCommand(action: "list_chats", params: [:])
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 sendCommand(action: "status", params: [:])
return try await invokeBridge(action: .status, params: [:])
}
/// Check availability and return a diagnostic message.
@ -131,7 +131,7 @@ public final class IMCoreBridge: @unchecked Sendable {
break
}
if launcher.isInjectedAndReady() {
if launcher.hasReadyLockFile() {
return (true, "Connected to Messages.app. IMCore features available.")
}
@ -150,22 +150,22 @@ public final class IMCoreBridge: @unchecked Sendable {
// MARK: - Private
private func sendCommand(
action: String, params: [String: Any]
private func invokeBridge(
action: BridgeAction, params: [String: Any]
) async throws -> [String: Any] {
do {
let response = try await launcher.sendCommand(action: action, params: params)
if response["success"] as? Bool == true {
return response
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)
}
let error = response["error"] as? String ?? "Unknown error"
if error.contains("Chat not found") {
let handle = params["handle"] as? String ?? "unknown"
throw IMCoreBridgeError.chatNotFound(handle)
}
throw IMCoreBridgeError.operationFailed(error)
} 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 {
@ -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

@ -1,7 +1,7 @@
import Foundation
import SQLite
private struct MessageRowColumns {
struct MessageRowColumns {
static let balloonBundleID = "balloon_bundle_id"
let rowID: String
@ -43,7 +43,7 @@ private struct MessageRowColumns {
}
}
private struct DecodedMessageRow {
struct DecodedMessageRow {
let rowID: Int64
let chatID: Int64
let handleID: Int64?
@ -60,7 +60,7 @@ private struct DecodedMessageRow {
let threadOriginatorGUID: String
}
private struct MessageRowSelection {
struct MessageRowSelection {
let selectList: String
let columns: MessageRowColumns
@ -422,7 +422,7 @@ extension MessageStore {
}
}
private func decodeMessageRow(
func decodeMessageRow(
_ row: Row,
columns: MessageRowColumns,
fallbackChatID: Int64?

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

@ -1,6 +1,9 @@
import Darwin
import Foundation
#if os(macOS)
import Darwin
#endif
public struct MessageWatcherConfiguration: Sendable, Equatable {
public var debounceInterval: TimeInterval
public var fallbackPollInterval: TimeInterval?
@ -57,7 +60,9 @@ 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
@ -87,12 +92,14 @@ 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()
@ -102,30 +109,34 @@ private final class WatchState: @unchecked Sendable {
func stop() {
queue.async {
self.stopped = true
for source in self.sources {
source.cancel()
}
self.sources.removeAll()
#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 }

View File

@ -1,254 +1,371 @@
import Foundation
/// 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()
#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"
}
// 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 responseFile: String {
containerPath + "/.imsg-response.json"
}
private var lockFile: String {
containerPath + "/.imsg-bridge-ready"
}
private var lockFile: String {
containerPath + "/.imsg-bridge-ready"
}
private var containerPath: String {
NSHomeDirectory() + "/Library/Containers/com.apple.MobileSMS/Data"
}
private var containerPath: String {
NSHomeDirectory() + "/Library/Containers/com.apple.MobileSMS/Data"
}
private let messagesAppPath =
"/System/Applications/Messages.app/Contents/MacOS/Messages"
private let queue = DispatchQueue(label: "imsg.messages.launcher")
private let lock = NSLock()
/// 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
}
/// Path to the dylib to inject.
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
/// 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
}
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
/// 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)")
}
}
}
/// Check if Messages.app is running with our dylib (lock file exists and responds to ping).
public func isInjectedAndReady() -> Bool {
guard FileManager.default.fileExists(atPath: lockFile) 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 }
switch Self.currentSIPStatus() {
case .disabled:
break
case .enabled:
throw MessagesLauncherError.sipEnabled
case .unknown(let details):
throw MessagesLauncherError.sipStatusUnknown(details)
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))
}
}
guard FileManager.default.fileExists(atPath: dylibPath) else {
throw MessagesLauncherError.dylibNotFound(dylibPath)
/// 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()
}
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)
try launchWithInjection()
try waitForReady(timeout: 15.0)
}
/// 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)
/// 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
// 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)
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)
}
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)
public enum SIPStatus: Equatable, Sendable {
case enabled
case disabled
case unknown(String)
}
}
private func waitForReady(timeout: TimeInterval) throws {
let deadline = Date().addingTimeInterval(timeout)
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)
}
while Date() < deadline {
if FileManager.default.fileExists(atPath: lockFile) {
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)
return
}
Thread.sleep(forTimeInterval: 0.5)
throw MessagesLauncherError.socketTimeout
}
throw MessagesLauncherError.socketTimeout
}
private func sendCommandSync(
action: String, params: [String: Any]
) throws -> [String: Any] {
lock.lock()
defer { lock.unlock() }
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 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 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)
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
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
}
// 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.")
}
throw MessagesLauncherError.socketError("Timeout waiting for response")
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)

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

@ -19,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
}
@ -42,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

@ -1,314 +1,345 @@
import Foundation
/// 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()
#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)
}
/// 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)
}
/// 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) }
)
}
/// 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
// 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
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
}
}
// 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)
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)")
}
semaphore.signal()
}
semaphore.wait()
if let error = box.error {
throw error
}
}
defer { dlclose(handle) }
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)
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)
let selector = sel_registerName("setLocalUserIsTyping:")
guard let method = class_getInstanceMethod(object_getClass(chat), selector) else {
throw IMsgError.typingIndicatorFailed(
"setLocalUserIsTyping: method not found on IMChat")
}
}
try await sleep(UInt64(duration * 1_000_000_000))
try stopTyping(chatIdentifier)
stopped = true
}
let implementation = method_getImplementation(method)
private static func ensureDaemonConnection() throws {
guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else {
throw IMsgError.typingIndicatorFailed("IMDaemonController class not found")
typealias SetTypingFunc = @convention(c) (AnyObject, Selector, Bool) -> Void
let setTypingFunc = unsafeBitCast(implementation, to: SetTypingFunc.self)
setTypingFunc(chat, selector, isTyping)
}
let sharedSel = sel_registerName("sharedInstance")
guard controllerClass.responds(to: sharedSel) else {
throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available")
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
}
guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else {
throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance")
}
private static func ensureDaemonConnection() throws {
guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else {
throw IMsgError.typingIndicatorFailed("IMDaemonController class not found")
}
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.hasAttemptedConnection = true
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
let sharedSel = sel_registerName("sharedInstance")
guard controllerClass.responds(to: sharedSel) else {
throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available")
}
daemonConnectionTracker.lock.lock()
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
if shouldAttemptConnection {
daemonConnectionTracker.hasAttemptedConnection = true
}
daemonConnectionTracker.lock.unlock()
if !shouldAttemptConnection { return }
guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else {
throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance")
}
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.hasAttemptedConnection = true
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
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
if shouldAttemptConnection {
daemonConnectionTracker.hasAttemptedConnection = true
}
daemonConnectionTracker.lock.unlock()
throw IMsgError.typingIndicatorFailed(
daemonUnavailableMessage()
)
}
}
if !shouldAttemptConnection { return }
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 {
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
}
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")
}
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")
}
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")
}
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 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 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
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)
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
}
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()
}
private final class DaemonConnectionTracker: @unchecked Sendable {
let lock = NSLock()
var hasAttemptedConnection = false
var connectionKnownUnavailable = false
}
public static func stopTyping(chatIdentifier: String) throws {
_ = chatIdentifier
throw unsupported()
}
/// 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?
public static func typeForDuration(chatIdentifier: String, duration: TimeInterval) async throws
{
_ = chatIdentifier
_ = duration
throw unsupported()
}
var error: Error? {
lock.lock()
defer { lock.unlock() }
return _error
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
}
func setError(_ error: Error) {
lock.lock()
_error = error
lock.unlock()
/// 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

@ -22,6 +22,27 @@ struct CommandRouter {
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,
@ -61,6 +82,8 @@ struct CommandRouter {
do {
try await spec.run(invocation.parsedValues, runtime)
return 0
} catch is BridgeOutput.EmittedError {
return 1
} catch {
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

@ -34,6 +34,22 @@ enum StatusCommand {
}
}()
// 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,
@ -41,7 +57,11 @@ enum StatusCommand {
typingIndicators: availability.available,
readReceipts: availability.available,
sip: sipStatus,
message: availability.message
message: availability.message,
bridgeVersion: bridgeVersion,
v2Ready: v2Ready,
selectors: selectors,
rpcMethods: kSupportedRPCMethods
)
try JSONLines.print(payload)
} else {
@ -57,12 +77,25 @@ enum StatusCommand {
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 commands:")
StdoutWriter.writeLine(" imsg read --to <handle>")
StdoutWriter.writeLine(" imsg typing --to <handle>")
StdoutWriter.writeLine(" imsg launch")
StdoutWriter.writeLine(" imsg status")
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("")
@ -102,6 +135,10 @@ private struct StatusPayload: Encodable {
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"
@ -110,5 +147,9 @@ private struct StatusPayload: Encodable {
case readReceipts = "read_receipts"
case sip
case message
case bridgeVersion = "bridge_version"
case v2Ready = "v2_ready"
case selectors
case rpcMethods = "rpc_methods"
}
}

View File

@ -35,6 +35,10 @@ enum WatchCommand {
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"
),
]
)
),
@ -93,6 +97,28 @@ enum WatchCommand {
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) {

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

@ -233,6 +233,87 @@ extension RPCServer {
}
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(

View File

@ -14,6 +14,30 @@ protocol RPCOutput: Sendable {
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 {
let store: MessageStore
let watcher: MessageWatcher
@ -101,6 +125,26 @@ final class RPCServer {
try await handleWatchUnsubscribe(id: id, params: params)
case "send":
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))
}

View File

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

View File

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

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

@ -83,6 +83,59 @@ func attachmentResolverLeavesUnsupportedFilesUnconverted() throws {
#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"
@ -156,6 +209,83 @@ 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])

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

@ -106,6 +106,27 @@ func historyCommandJsonReportsDirectChatMetadata() async throws {
#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()

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

@ -0,0 +1,163 @@
import Foundation
import SQLite
import Testing
@testable import IMsgCore
@Test
func readsMessageDatabaseFromCopiedFile() throws {
let databaseURL = try makeTemporaryDatabase()
try seedDatabase(at: databaseURL)
let store = try MessageStore(path: databaseURL.path)
let chats = try store.listChats(limit: 10)
#expect(chats.count == 1)
#expect(chats.first?.identifier == "+15551234567")
#expect(chats.first?.name == "Linux Fixture")
let messages = try store.messages(chatID: 1, limit: 10)
#expect(messages.map(\.text) == ["reply from mac", "hello from linux"])
#expect(messages.first?.isFromMe == true)
#expect(messages.last?.sender == "+15551234567")
#expect(messages.last?.isFromMe == false)
let matches = try store.searchMessages(query: "reply", match: "contains", limit: 5)
#expect(matches.count == 1)
#expect(matches.first?.text == "reply from mac")
}
@Test
func linuxContactResolverIsExplicitlyUnavailable() async {
let resolver = await ContactResolver.create(region: "US")
#expect(resolver.contactsUnavailable)
#expect(resolver.displayName(for: "+15551234567") == nil)
#expect(resolver.displayNames(for: ["+15551234567"]).isEmpty)
#expect(resolver.searchByName("Jane").isEmpty)
}
@Test
func linuxSendFailsWithPlatformMessage() throws {
let sender = MessageSender()
do {
try sender.send(MessageSendOptions(recipient: "+15551234567", text: "no-op"))
Issue.record("send unexpectedly succeeded on Linux")
} catch let error as IMsgError {
#expect(error.description.contains("only supported on macOS"))
}
}
@Test
func linuxTypingIndicatorFailsWithPlatformMessage() throws {
do {
try TypingIndicator.startTyping(chatIdentifier: "iMessage;-;+15551234567")
Issue.record("typing unexpectedly succeeded on Linux")
} catch let error as IMsgError {
#expect(error.description.contains("only supported on macOS"))
}
}
private func makeTemporaryDatabase() throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
"imsg-linux-tests-\(UUID().uuidString)",
isDirectory: true
)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent("chat.db")
}
private func seedDatabase(at url: URL) throws {
let db = try Connection(url.path)
try createSchema(db)
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, '+15551234567', 'iMessage;+;linux-fixture', 'Linux Fixture', 'iMessage',
'iMessage;+;me@example.com', 'me@example.com', '+15551234567'
)
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+15551234567'), (2, 'Me')")
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)")
let rows: [(Int64, Int64, String, Bool, Date)] = [
(1, 1, "hello from linux", false, now.addingTimeInterval(-60)),
(2, 2, "reply from mac", true, now),
]
for row in rows {
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (?, ?, ?, ?, ?, 'iMessage')
""",
row.0,
row.1,
row.2,
appleEpoch(row.4),
row.3 ? 1 : 0
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)", row.0)
}
}
private func createSchema(_ db: Connection) throws {
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,
account_id TEXT,
account_login TEXT,
last_addressed_handle 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);")
}
private func appleEpoch(_ date: Date) -> Int64 {
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
return Int64(seconds * 1_000_000_000)
}

1
docs/CNAME Normal file
View File

@ -0,0 +1 @@
imsg.sh

View File

@ -1,22 +1,31 @@
# Releasing
---
title: Releasing
description: "Cutting an imsg release: changelog, version bump, signed/notarized build, tag, GitHub release, Homebrew tap update."
---
## Release notes source
- GitHub Release notes come from `CHANGELOG.md` for the matching version section (`## X.Y.Z - YYYY-MM-DD`).
- Keep `## Unreleased` at the top (empty is fine).
- Keep the unreleased section at the top. During a release train it may be
versioned, for example `## 0.8.0 - Unreleased`; before tagging, change it to
`## X.Y.Z - YYYY-MM-DD`.
## Steps
1. Update `CHANGELOG.md` and version
- Move entries from `Unreleased` into a new `## X.Y.Z - YYYY-MM-DD` section.
- Move entries from `Unreleased` into a new `## X.Y.Z - YYYY-MM-DD` section,
or date the existing `## X.Y.Z - Unreleased` section.
- Credit contributors (e.g. `thanks @user`).
- Update `version.env` to `X.Y.Z`.
- Run `scripts/generate-version.sh` (also refreshes `Sources/imsg/Resources/Info.plist`).
2. Ensure CI is green on `main`
- `make lint`
- `make test`
- GitHub Actions `linux-read-core`
- `make format` (optional, if formatting changes are expected)
3. Build, sign, and notarize
- Requires `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`.
- `scripts/sign-and-notarize.sh` (outputs `/tmp/imsg-macos.zip` by default)
- Linux release archives are built by `.github/workflows/release.yml` with
`scripts/build-linux.sh` and uploaded as `imsg-linux-x86_64.tar.gz`.
- Verify the zip contains required SwiftPM bundles (e.g. `PhoneNumberKit_PhoneNumberKit.bundle`).
- Verify entitlements/signing:
- `unzip -q /tmp/imsg-macos.zip -d /tmp/imsg-check`
@ -26,6 +35,9 @@
- `git tag -a vX.Y.Z -m "vX.Y.Z"`
- `git push origin vX.Y.Z`
- `gh release create vX.Y.Z /tmp/imsg-macos.zip -t "vX.Y.Z" -F /tmp/release-notes.txt`
- Run `.github/workflows/release.yml` for the tag to upload the Linux archive
(`imsg-linux-x86_64.tar.gz`). Leave `include_macos` off unless you
intentionally want a manual macOS rebuild.
- `gh release edit vX.Y.Z --notes-file /tmp/release-notes.txt` (if needed)
5. Update Homebrew tap
- Run `scripts/update-homebrew.sh vX.Y.Z` to trigger the centralized formula updater.
@ -34,3 +46,14 @@
## What happens in CI
- Release signing + notarization are done locally via `scripts/sign-and-notarize.sh`.
- `.github/workflows/release.yml` is only for manual rebuilds, not the primary release path.
## Linux support schedule
- 0.8.0 is the Linux read-only preview release. It may include an experimental
Linux `x86_64` archive, but docs must keep describing Linux as read-only
support for existing copied Messages databases.
- Linux support is staged as a read-only core pass: SwiftPM build, Linux-only
tests, release archive generation, and CI coverage for reading copied
Messages database fixtures.
- Do not document Linux send/watch/Contacts/IMCore support unless those features
are implemented and proven on Linux. They currently depend on macOS frameworks
or Messages.app automation.

99
docs/advanced-imcore.md Normal file
View File

@ -0,0 +1,99 @@
---
title: Advanced IMCore features
description: "Read receipts, typing indicators, IMCore status, and Messages launch control — opt-in, SIP-disabled, and increasingly limited on macOS 26."
---
Most `imsg` workflows — `chats`, `history`, `watch`, `send`, `react` — are explicitly designed to *not* require any private framework or process injection. They go through Messages.app's published surfaces (SQLite, AppleScript, file events) and need only the documented permissions covered in [Permissions](permissions.md).
The features documented here are the exception. They drive Messages.app from the inside via a helper dylib injected into the Messages process, and they trigger several macOS protections you have to disable to use them.
You almost certainly do not need any of this for normal use.
## What's in scope
- `imsg read --to <handle> [--chat-id <id>]` — mark a chat as read.
- `imsg typing --to <handle> [--duration 5s] [--stop true]` — show or stop the typing indicator.
- `imsg launch [--dylib <path>] [--kill-only]` — launch Messages.app with the helper dylib injected.
- `imsg status` — read-only IMCore bridge status.
## Why they're separate
These features depend on private IMCore APIs that aren't reachable from outside the Messages process. To touch them, `imsg` injects a small helper dylib into Messages.app via `DYLD_INSERT_LIBRARIES` (built by `make build-dylib`).
That injection requires three things to be true on the target machine:
1. **SIP disabled.** System Integrity Protection blocks `DYLD_INSERT_LIBRARIES` into protected system apps. Without disabling SIP, the launch step refuses to proceed.
2. **Library validation off.** macOS 26 (Tahoe) tightened library validation; even with SIP off, a dylib that isn't signed against Messages' team identifier can be rejected.
3. **No private-entitlement gate.** macOS 26 also added `imagent` entitlement checks that can refuse direct IMCore clients regardless of injection success.
You should expect at least one of these gates to be active on a current macOS install. The features are documented because they remain useful for research, testing, and CI — not because they're stable user-facing functionality.
## Building and launching
```bash
make build-dylib # produces .build/release/imsg-bridge-helper.dylib (arm64e)
imsg launch # launches Messages.app with the dylib injected
imsg status # confirms the bridge is up
```
`imsg launch` refuses to inject when SIP is enabled. There's no override.
`imsg status` is read-only. It does not auto-launch or auto-inject. Run `imsg launch` first.
To revert: re-enable SIP from Recovery mode (`csrutil enable`), then reboot.
## Read receipts
```bash
imsg read --to "+14155551212"
imsg read --to "+14155551212" --chat-id 42
imsg read --to "+14155551212" --chat-identifier "iMessage;+;chat..."
imsg read --to "+14155551212" --chat-guid "iMessage;+;chat..."
```
Marks the chat for that handle as read. Useful when you want a programmatic agent to clear the unread counter in Messages without spawning a UI action.
## Typing indicators
```bash
imsg typing --to "+14155551212" --duration 5s
imsg typing --to "+14155551212" --duration 30s --service imessage
imsg typing --to "+14155551212" --stop true
```
Displays or hides the "typing" bubble on the recipient's device.
`--service` accepts `imessage`, `sms`, or `auto`. The IMCore typing chat lookup normalizes across `iMessage`, `SMS`, and `any` prefixes so the same handle works on either service.
On macOS 26, typing indicators frequently fail with an entitlement error. `imsg` reports this as an advanced-feature setup error rather than a misleading "chat not found" — see `CHANGELOG.md` 0.6.0 for the issue history.
## Status
```bash
imsg status
imsg status --json
```
Reports whether Messages is running, whether the helper dylib is loaded, and whether the IMCore bridge is responding. Read-only; safe to run on any machine.
When the bridge isn't loaded, `status` prints the reason rather than attempting to fix it. Use `imsg launch` if you want to bring it up.
## Launching Messages with a custom dylib
```bash
imsg launch --dylib /path/to/custom.dylib
imsg launch --kill-only # quit Messages without launching
imsg launch --json # machine-readable launch result
```
`--kill-only` is the inverse: it tears Messages down (to drop a stale injection) without relaunching.
## When to use any of this
The honest answer for most readers: **don't**. The macOS 26 limits make these features unstable in production. They're useful when:
- You're doing macOS / Messages.app research.
- You're running CI inside a controlled VM with SIP disabled by configuration.
- You need a typing-indicator demo on a single hand-tuned machine.
For agent integrations, prefer the standard CLI surfaces (`send`, `react`, `watch`). They cover the realistic interaction surface without touching SIP.

68
docs/attachments.md Normal file
View File

@ -0,0 +1,68 @@
---
title: Attachments
description: "Attachment metadata, resolved paths, and optional model-friendly conversion for CAF audio and GIF images."
---
`imsg` reports attachment metadata only. It never copies, modifies, or uploads the underlying files. Optional conversion exposes cached, model-friendly variants for CAF audio and GIF images.
## Reading attachments
```bash
imsg history --chat-id 42 --attachments --json
imsg watch --chat-id 42 --attachments --json
```
Each message gains an `attachments` array. Per-attachment fields:
| Field | Type | Notes |
|-------|------|-------|
| `filename` | string | Stored filename inside Messages' attachments dir. |
| `transfer_name` | string | Original filename as sent. |
| `uti` | string | Apple Uniform Type Identifier. |
| `mime_type` | string | Best-effort MIME from UTI. |
| `byte_size` | int | Size in bytes. |
| `is_sticker` | bool | True for sticker-pack attachments. |
| `missing` | bool | True when the file couldn't be located on disk. |
| `path` | string | Resolved absolute path under `~/Library/Messages/Attachments/`. |
| `converted_path` | string | Set only with `--convert-attachments`; see below. |
| `converted_mime_type` | string | Set only with `--convert-attachments`. |
When an attachment is referenced in `chat.db` but the underlying file has been pruned (Messages can age out big files), `missing` is `true` and `path` may be empty.
## Converted variants
```bash
imsg history --chat-id 42 --attachments --convert-attachments --json
imsg watch --chat-id 42 --attachments --convert-attachments --json
```
This adds `converted_path` and `converted_mime_type` to attachments where conversion is supported:
- **CAF audio → M4A.** Messages' on-device voice memos are stored as CAF; most LLMs and downstream tools want M4A.
- **GIF image → first-frame PNG.** Useful when a static thumbnail is enough for downstream models.
Originals are never modified. Converted files live alongside in a cache directory and are reused on subsequent reads.
`--convert-attachments` requires `ffmpeg` on `PATH`. If `ffmpeg` is missing, the command still succeeds — `converted_path` is simply omitted from the output and the original metadata is unchanged.
`brew install ffmpeg` to enable.
## Sending attachments
```bash
imsg send --to "+14155551212" --file ~/Desktop/photo.jpg
imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
imsg send --chat-id 42 --file ~/Desktop/note.pdf
```
`--file` accepts any regular file. Audio files (`.m4a`, `.caf`, `.aiff`, …) ride the same code path as images and documents.
Before invoking AppleScript, `imsg` stages the file under `~/Library/Messages/Attachments/imsg/`. Messages reads attachments from inside its own attachments directory more reliably than from `~/Desktop` or `~/Downloads`, particularly under newer macOS sandboxing.
The staged copies live under `imsg/`, distinct from Messages' own subdirectories, and are not pruned automatically. Clear them periodically if disk space matters.
## Why not just copy or upload?
The CLI's contract is "read what's there, send what you give it." Anything beyond that — bulk archival, cloud upload, format conversion at rest — is left to callers, who know their retention and privacy requirements. The conversion feature is the one exception, and only because some receive-side formats (CAF, animated GIF) are awkward for downstream tools to handle.
If you want a full archive workflow, pipe `--attachments --json` through your own scripts and copy the files out of `~/Library/Messages/Attachments/` yourself.

80
docs/chats.md Normal file
View File

@ -0,0 +1,80 @@
---
title: Chats
description: "List recent conversations and inspect a single chat's identifiers, participants, and routing hints."
---
`imsg chats` lists conversations sorted by most recent activity. `imsg group` zooms in on one chat. Both work for direct chats and group threads.
## List recent chats
```bash
imsg chats --limit 20
imsg chats --limit 20 --json | jq -s
```
Columns (text mode): `id`, `name`, `service`, `last_message_at`.
`name` is the resolved display name when available — group title, contact match, or raw handle as a fallback.
## Inspect one chat
```bash
imsg group --chat-id 42
imsg group --chat-id 42 --json
```
Use this before scripting a send. It returns identifier, GUID, service, participants, group/direct flag, and account routing hints in one shot.
`imsg group` works for direct chats too, despite the name. Treat it as "chat detail," not "groups only."
## Chat object
Every chat object — from `chats`, `group`, or any nested chat metadata in `history`/`watch` — includes:
| Field | Type | Notes |
|-------|------|-------|
| `id` | int | `chat.ROWID`. Stable within one Messages database. Preferred routing handle. |
| `name` | string | Display name, contact match, or raw handle fallback. |
| `display_name` | string | `chat.display_name` (group title) when set. |
| `contact_name` | string | Resolved Contacts name when permission granted. |
| `identifier` | string | `chat.chat_identifier` — Messages' portable handle. |
| `guid` | string | `chat.guid` — Messages' portable GUID. |
| `service` | string | `iMessage`, `SMS`, etc. |
| `last_message_at` | ISO8601 | Newest activity in the chat. |
| `is_group` | bool | True when `identifier` or `guid` contains `;+;`. See [Groups](groups.md). |
| `participants` | array | External handles only. The local user is implicit; see below. |
| `account_id` | string | Routing diagnostic. Read-only. |
| `account_login` | string | Routing diagnostic. Read-only. |
| `last_addressed_handle` | string | Routing diagnostic. Read-only. |
## Routing identifiers — which one to use
Three handles can identify a chat. Pick by use case:
- **`chat_id`** (rowid): preferred. Fastest, most stable within one database. Use this whenever both reader and sender are on the same machine.
- **`chat_identifier`**: portable across DBs/installs. Use when you store handles externally and need to tolerate a Messages reset.
- **`chat_guid`**: also portable. Same use cases as `chat_identifier`.
For sends, `imsg send --chat-id` is preferred. `--chat-identifier` and `--chat-guid` are fallbacks for callers that only have the portable handle.
## Participants vs. local identity
`participants` lists external handles only. The local user is intentionally absent because Messages stores it implicitly per-message rather than on the chat row.
To distinguish your own messages from others':
- Use `is_from_me` on each message.
- For multi-number Apple IDs, check `destination_caller_id` on outgoing messages — it tells you which of your numbers Messages routed through.
`account_id`, `account_login`, and `last_addressed_handle` are diagnostic *reads* from Messages. AppleScript's `send` does not let `imsg` force a specific outbound number when several phone numbers share one Apple ID. The fields are there so you can audit what Messages picked, not steer it.
## Filtering tips
`imsg chats` does not take filter flags — it's designed to be cheap. Pipe through `jq` or `grep` for ad-hoc filtering:
```bash
imsg chats --json | jq -s 'map(select(.is_group == true))'
imsg chats --json | jq -s 'map(select(.service == "SMS"))'
```
For more targeted history queries with date and participant filters, use [`imsg history`](history.md).

68
docs/completions.md Normal file
View File

@ -0,0 +1,68 @@
---
title: Completions
description: "Shell completions for bash, zsh, and fish — plus an LLM-oriented Markdown reference."
---
`imsg completions` generates completion scripts for interactive shells and a Markdown CLI reference for in-context LLM use.
## Shell completions
### Bash
```bash
imsg completions bash > ~/.bash_completion.d/imsg
# or, system-wide:
sudo imsg completions bash > /usr/local/etc/bash_completion.d/imsg
```
Reload your shell, then tab-completion for `imsg` is live.
### Zsh
```bash
mkdir -p ~/.zsh/completions
imsg completions zsh > ~/.zsh/completions/_imsg
```
Make sure `~/.zsh/completions` is on `fpath` and `compinit` is called. A standard `~/.zshrc` snippet:
```zsh
fpath=(~/.zsh/completions $fpath)
autoload -U compinit && compinit
```
### Fish
```bash
imsg completions fish > ~/.config/fish/completions/imsg.fish
```
Fish picks up new completions on next launch; no extra setup required.
## LLM reference
```bash
imsg completions llm
```
Emits a Markdown CLI reference that documents every command, flag, argument, and example. It's designed to be embedded in an agent's system prompt or a tool's documentation index so the model always has accurate, current help for the locally installed version of `imsg`.
Because it's generated from the same `CommandSpec` the parser uses, the output is always in sync with the binary. There's no separate doc-comment drift.
A common pattern:
```bash
imsg completions llm > /tmp/imsg-help.md
# Embed /tmp/imsg-help.md in your agent's tool description, prompt, or memory.
```
## What's covered
The same source-of-truth (`CommandSpec`) feeds all four generators:
- Command names and abstracts.
- Flag names (long and short), argument labels, optional/required status, help text.
- Constrained value lists (e.g. `--service imessage|sms|auto`, `--reaction love|like|dislike|laugh|emphasis|question`).
- Per-command examples.
Completions and the LLM reference are emitted to stdout. There's no install step beyond redirecting to a file in the right location for your shell.

View File

@ -1,33 +1,51 @@
# Groups
---
title: Groups
description: "How imsg detects group chats, the identifiers that route to them, and the Tahoe-era failure modes."
---
Messages encodes group chats with a different identifier shape than direct chats. `imsg` surfaces that distinction explicitly so callers don't have to parse handles themselves.
## What counts as a group
- `chat.chat_identifier` or `chat.guid` contains `;+;`, for example
`iMessage;+;chat1234567890`.
- `SERVICE;-;TARGET` is a direct 1:1 chat, for example `iMessage;-;+15551234567`,
and is deliberately not flagged as a group.
- Direct chats typically use a single handle (phone/email) with no `;+;`.
- `chat.chat_identifier` or `chat.guid` contains `;+;`, for example `iMessage;+;chat1234567890`.
- `SERVICE;-;TARGET` is a direct 1:1 chat, for example `iMessage;-;+15551234567`. Deliberately not flagged as a group.
- Direct chats typically use a single handle (phone or email) with no `;+;`.
The `is_group` boolean on every chat object encodes this for you.
## Where the identifiers live
- `chat.ROWID` -> `chat_id` (stable within one DB).
- `chat.chat_identifier` -> group handle (used by Messages).
- `chat.guid` -> group GUID (often same chat handle semantics).
- `chat.display_name` -> group name (optional).
- `chat.account_id`, `chat.account_login`, `chat.last_addressed_handle` ->
read-only Messages routing hints for the local account/identity state.
- Participants in `chat_handle_join` + `handle`.
| Field | Source | Notes |
|-------|--------|-------|
| `chat.ROWID``chat_id` | local rowid | Stable within one DB. Preferred routing handle. |
| `chat.chat_identifier` | Messages | Portable group handle. |
| `chat.guid` | Messages | Portable GUID. Often the same shape as `chat_identifier` for groups. |
| `chat.display_name` | Messages | Optional group name. |
| `chat.account_id` / `account_login` / `last_addressed_handle` | Messages | Read-only routing diagnostics. |
| `participants` | `chat_handle_join` + `handle` | External handles only. |
## Sending to a group
- `imsg send --chat-id <rowid>` (preferred; DB local).
- `imsg send --chat-identifier <handle>` (portable).
- `imsg send --chat-guid <guid>` (portable).
- Uses AppleScript `chat id "<handle>"` for group sends (Jared pattern).
- Attachments supported same as direct sends.
- On macOS 26/Tahoe, Messages.app can report success while creating an empty
unjoined SMS row instead of delivering to the group. `imsg` detects that ghost
row and reports the send as failed.
Pick the most stable identifier you have:
```bash
imsg send --chat-id 42 --text "hi" # preferred (DB local)
imsg send --chat-identifier "iMessage;+;chat1234567890" --text "hi" # portable
imsg send --chat-guid "iMessage;+;chat1234567890" --text "hi" # portable
```
Group sends use AppleScript `chat id "<handle>"` (the "Jared pattern"). Attachments work the same as direct sends; see [Send](send.md).
### Tahoe ghost-row failure
On macOS 26 (Tahoe), Messages.app sometimes reports AppleScript success while writing an empty unjoined SMS row instead of delivering to the target group. `imsg send` detects that ghost row by inspecting `chat.db` after the AppleScript call and reports an error rather than success.
This check is automatic for chat-target sends. Direct sends (`--to`) aren't affected.
## Inbound metadata (JSON)
The direct CLI (`imsg chats`, `imsg history`, `imsg watch`) and JSON-RPC surface include:
`imsg chats`, `imsg history`, and `imsg watch` — and the JSON-RPC equivalents — all include the same group fields:
- `chat_id`
- `chat_identifier`
- `chat_guid`
@ -38,26 +56,31 @@ The direct CLI (`imsg chats`, `imsg history`, `imsg watch`) and JSON-RPC surface
- `participants` (array of handles)
- `is_group`
`chat_id` is preferred for routing within one machine/DB.
Within one machine and one Messages database, `chat_id` is the preferred routing key. For sync across machines (or after a Messages reset), persist `chat_identifier` or `chat_guid` instead.
### Participants exclude the local user
`participants` is sourced from Messages.app's `chat_handle_join` table, which
stores external handles. The local user's handle is implicit and message-specific:
use `is_from_me` plus `destination_caller_id` on sent messages when that distinction
matters.
`participants` is sourced from Messages' `chat_handle_join` table, which only stores external handles. Your own handle is implicit and message-specific.
When the distinction matters, combine these per-message fields:
- `is_from_me` — outbound vs. inbound.
- `destination_caller_id` (outbound only) — which of your numbers Messages routed through.
### Multiple local identities
Messages.app can store multiple local-account hints for a chat, but its
AppleScript `send` command does not expose a `from` or account selector. `imsg`
reports `account_id`, `account_login`, `last_addressed_handle`, and sent-message
`destination_caller_id` so callers can diagnose routing, but normal sends cannot
force a specific phone number when several numbers share one Apple ID.
## Focused group lookup
- `imsg group --chat-id <rowid>` prints id, identifier, guid, name, service,
`is_group`, and participants for one chat. It works for direct chats too and
supports `--json`.
Messages stores per-chat hints for which of your numbers should be used (`account_id`, `account_login`, `last_addressed_handle`). `imsg` exposes these as diagnostics, but its `send` cannot force a specific outbound number — AppleScript `send` has no `from` selector. To change the default for new outbound traffic, adjust Messages' Settings → iMessage section.
## Focused chat lookup
```bash
imsg group --chat-id 42
imsg group --chat-id 42 --json
```
`imsg group` prints id, identifier, GUID, name, service, `is_group`, participants, and routing hints for one chat. It works for direct chats too; treat it as a "chat detail" command rather than groups-only.
## Notes
- Group send uses chat handle, not `buddy`.
- Messages from self may have empty `sender`; prefer `SenderName` + chat metadata.
- Group send uses the chat handle, not `buddy`.
- Outgoing messages from the local user can have an empty `sender` value. Prefer `sender_name` plus chat metadata when displaying who sent what.

84
docs/history.md Normal file
View File

@ -0,0 +1,84 @@
---
title: History
description: "Read message history from one chat with optional date, participant, and attachment filters."
---
`imsg history` reads messages from a single chat in chronological order. It's the bread-and-butter command for one-shot reads — search, archive, summarize, transcribe.
## Basic read
```bash
imsg history --chat-id 42 --limit 50
imsg history --chat-id 42 --limit 50 --json | jq -s
```
`--limit` defaults to 50 and applies *after* filters. So `--limit 20 --start ...` returns up to 20 messages from inside the date window, not 20 messages globally then date-filtered.
## Date windows
```bash
imsg history --chat-id 42 \
--start 2026-05-01T00:00:00Z \
--end 2026-05-06T00:00:00Z \
--json
```
Both bounds accept ISO 8601 with explicit timezone. Either bound is optional:
```bash
# Everything since May 1st.
imsg history --chat-id 42 --start 2026-05-01T00:00:00Z --json
# Everything before May 6th.
imsg history --chat-id 42 --end 2026-05-06T00:00:00Z --json
```
## Participant filters
For group chats, narrow to messages from specific people:
```bash
imsg history --chat-id 42 --participants "+14155551212,jane@example.com" --json
```
Match is on the message's `sender` (raw handle), not the resolved contact name. Pass a comma-separated list.
## Attachments
`--attachments` adds an `attachments` array to each message containing filename, UTI, MIME type, byte count, and resolved on-disk path:
```bash
imsg history --chat-id 42 --attachments --json
```
`--convert-attachments` additionally exposes model-friendly variants when `ffmpeg` is available — CAF audio → M4A, GIF → first-frame PNG. See [Attachments](attachments.md).
## Recovering text from attributed bodies
Some Messages rows store rich text in a binary `attributedBody` column with the plain `text` column empty. `imsg history` decodes the typed-stream payload (including UTF-16LE BOM bodies) and surfaces the recovered text in the standard `text` field. No flag needed; this is on by default.
If a message is still empty, the source row genuinely had no text — usually a sticker, link preview, or attachment-only message.
## Reactions in history
Tapback rows (`Liked "..."`, `Loved "..."`, etc.) are hidden from `history` output by design. They'd otherwise duplicate every reacted message. To see tapbacks, use [`imsg watch --reactions`](watch.md#reactions); the live stream surfaces add and remove events with `is_reaction`, `reaction_type`, and `reacted_to_guid`.
## Performance
JSON history batches attachment and reaction lookups in one pass per request, so large `--limit` values stay cheap. Reading 1000 messages with `--attachments --json` is bound by SQLite, not by per-row queries.
For very large reads, prefer streaming through `jq` rather than buffering the whole result:
```bash
imsg history --chat-id 42 --limit 5000 --json \
| jq -c 'select(.is_from_me == false)' \
> inbound.ndjson
```
## Message object
See [JSON output](json.md#message) for the canonical schema. Every history result has at minimum:
`id`, `chat_id`, `chat_identifier`, `chat_guid`, `chat_name`, `participants`, `is_group`, `guid`, `reply_to_guid`, `destination_caller_id`, `sender`, `sender_name`, `is_from_me`, `text`, `created_at`.
When `--attachments` is set, also: `attachments[]`. Reactions only appear in `watch --reactions` output.

52
docs/index.md Normal file
View File

@ -0,0 +1,52 @@
---
title: Overview
permalink: /
description: "imsg is a macOS command-line tool for Messages.app — read your local chat database, stream new iMessage/SMS rows, send text and files through Messages automation, and expose the same surfaces over JSON and JSON-RPC."
---
## Try it
After granting Full Disk Access (covered in the [Quickstart](quickstart.md)), every workflow is a one-liner.
```bash
# List the 10 most recent chats.
imsg chats --limit 10 --json | jq -s
# Read history from one chat, with attachment metadata.
imsg history --chat-id 42 --limit 20 --attachments --json
# Stream new messages live, 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
```
`--json` emits newline-delimited JSON on stdout; human progress and warnings always go to stderr so pipes stay parseable.
## What imsg does
- **Local-first reads.** Chats, history, and attachments come straight from `~/Library/Messages/chat.db` — no network round-trip, no daemon.
- **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, attachments, and standard tapbacks ride Messages' AppleScript automation surface — no private send APIs.
- **Group-aware.** Direct chats, group threads, participants, GUIDs, and per-chat account routing hints all show up in JSON output.
- **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 your Address Book when permission is granted, while keeping raw handles in the output.
- **Attachment-aware.** Reports filenames, UTIs, byte counts, and resolved paths. Optional `--convert-attachments` exposes model-friendly CAF→M4A and GIF→PNG variants.
- **Linux read-only preview.** Linux builds can inspect an existing Messages database copied from macOS. They do not send, mutate, or connect to Messages.app.
## Pick your path
- **Trying it.** [Install](install.md) → [Quickstart](quickstart.md). Five minutes from `brew install` to a streaming watch.
- **Reading on Linux.** [Linux read-only preview](linux.md) covers copying an existing database from macOS and running read-only commands.
- **Wiring up an agent.** [JSON output](json.md) and [JSON-RPC](rpc.md) cover the stable contracts; [completions](completions.md) shows how to feed the CLI reference into an LLM.
- **Sending messages.** [Send](send.md) and [React](send.md#standard-tapbacks) explain text/file/group sends and how the Tahoe ghost-row check works.
- **Diagnosing access.** [Permissions](permissions.md) and [Troubleshooting](troubleshooting.md).
- **Advanced IMCore.** [Read receipts, typing, status, launch](advanced-imcore.md). SIP-disabled and increasingly limited on macOS 26.
## Project
Active development; the [changelog](https://github.com/steipete/imsg/blob/main/CHANGELOG.md) tracks what shipped recently. Released under the [MIT license](https://github.com/steipete/imsg/blob/main/LICENSE). Not affiliated with Apple.

75
docs/install.md Normal file
View File

@ -0,0 +1,75 @@
---
title: Install
description: "Install imsg with Homebrew, build it from source, or pin a specific release."
---
`imsg` ships as a signed, notarized universal macOS binary. It runs on macOS 14 (Sonoma) and newer, including macOS 26 (Tahoe).
0.8.0 and newer releases also publish Linux builds as a read-only preview for
existing Messages databases copied from macOS. See [Linux read-only preview](linux.md).
## Homebrew
```bash
brew install steipete/tap/imsg
```
This is the recommended path. Homebrew downloads the universal binary for your architecture, installs it onto your `PATH`, and tracks updates with `brew upgrade`.
To uninstall:
```bash
brew uninstall imsg
brew untap steipete/tap # optional
```
## Build from source
```bash
git clone https://github.com/steipete/imsg.git
cd imsg
make build
./bin/imsg --help
```
`make build` runs the universal release build through Swift Package Manager and patches `SQLite.swift` with the repo's required adjustments. The binary lands at `bin/imsg`.
For day-to-day development:
```bash
make imsg ARGS="chats --limit 5"
```
This is a clean debug rebuild that runs the resulting binary with the supplied arguments.
## Linux read-only preview
Linux support is for reading an existing `chat.db` copied from macOS. It opens
the database read-only and supports inspection commands such as `chats`,
`group`, `history`, and `search`.
It does not send messages, react, mark chats read, show typing, launch
Messages.app, use Contacts, or access iMessage/SMS accounts on Linux. Those
features depend on macOS frameworks or Messages.app automation.
For setup and copy-safe database commands, see [Linux read-only preview](linux.md).
## Verify the install
```bash
imsg --version
imsg chats --limit 3
```
If `chats` returns `unable to open database file` or `authorization denied`, jump to [Permissions](permissions.md). The CLI is installed correctly; macOS just hasn't granted it Full Disk Access yet.
## Optional dependencies
- **`ffmpeg`** on your `PATH`. Required only for `--convert-attachments`; see [Attachments](attachments.md).
- **`jq`**. Not required, but every example here uses it to pretty-print JSON streams.
## What you don't need
- No Node, Python, or Ruby runtime.
- No background daemon, launch agent, or login item.
- No private API patches. Default reads use a read-only handle on `chat.db`; sends use Messages' published AppleScript surface. Only the [advanced IMCore features](advanced-imcore.md) need a helper dylib, and even those are off by default.

103
docs/json.md Normal file
View File

@ -0,0 +1,103 @@
---
title: JSON output
description: "The stable JSON schema imsg emits for chats, messages, attachments, and reaction events."
---
Every read command supports `--json`. Output is **newline-delimited JSON (NDJSON)**: one self-contained JSON object per line. This shape works equally well for streaming consumers and for batch readers that pipe through `jq -s` to materialize an array.
```bash
imsg chats --json | jq -s
imsg history --chat-id 42 --json | jq -s
imsg watch --chat-id 42 --json
```
Human progress, prompts, and warnings are written to **stderr**, not stdout. Stdout is reserved for parseable JSON so pipelines stay clean.
## Chat
Returned by `imsg chats`, `imsg group`, and embedded in nested chat references in messages.
| Field | Type | Notes |
|-------|------|-------|
| `id` | int | `chat.ROWID`. Stable within one DB. Preferred routing handle. |
| `name` | string | Display name, contact match, or raw handle fallback. |
| `display_name` | string | Group title from `chat.display_name`. Empty for direct chats without a custom name. |
| `contact_name` | string | Resolved Contacts name (when permission granted). |
| `identifier` | string | `chat.chat_identifier`. Portable. |
| `guid` | string | `chat.guid`. Portable. |
| `service` | string | `iMessage`, `SMS`, etc. |
| `last_message_at` | ISO8601 | Newest activity time. |
| `is_group` | bool | True when identifier or guid contains `;+;`. |
| `participants` | array of strings | External handles only; local user implicit. |
| `account_id` | string | Routing diagnostic. Read-only. |
| `account_login` | string | Routing diagnostic. Read-only. |
| `last_addressed_handle` | string | Routing diagnostic. Read-only. |
## Message
Returned by `imsg history`, `imsg watch`, and the JSON-RPC `messages.history` and `watch.subscribe` notifications.
| Field | Type | Notes |
|-------|------|-------|
| `id` | int | rowid. Use as the `--since-rowid` cursor in watch. |
| `chat_id` | int | Always present. Preferred routing handle. |
| `chat_identifier` | string | Portable handle. |
| `chat_guid` | string | Portable GUID. |
| `chat_name` | string | Display name for the chat. |
| `participants` | array | External handles. |
| `is_group` | bool | True for group threads. |
| `guid` | string | Message GUID. Stable across machines. |
| `reply_to_guid` | string | When set, this message is an inline reply to that GUID. |
| `destination_caller_id` | string | Outgoing only — which of your numbers Messages routed through. |
| `sender` | string | Raw handle. Empty for some self-sent messages. |
| `sender_name` | string | Resolved Contacts name when permission granted. |
| `is_from_me` | bool | True for outbound. |
| `text` | string | Plain text. Recovered from `attributedBody` when `text` column is empty. |
| `created_at` | ISO8601 | Message timestamp. |
| `attachments` | array | Present when `--attachments` is set. See below. |
| `thread_originator_guid` | string | For inline-reply threads. |
### Reaction extensions
Present on `imsg watch --reactions` events:
| Field | Type | Notes |
|-------|------|-------|
| `is_reaction` | bool | `true` for tapback events. |
| `reaction_type` | string | `love`, `like`, `dislike`, `laugh`, `emphasis`, `question`, or a custom emoji marker. |
| `reaction_emoji` | string | Custom emoji, when present. |
| `is_reaction_add` | bool | `true` for add, `false` for remove. |
| `reacted_to_guid` | string | The message guid this tapback targets. |
`history` deliberately hides reaction rows so they don't duplicate the reacted message. Reaction events only surface in the live watch stream.
## Attachment
Inside the `attachments` array on a message:
| Field | Type | Notes |
|-------|------|-------|
| `filename` | string | Stored filename. |
| `transfer_name` | string | Original filename as sent. |
| `uti` | string | Apple UTI. |
| `mime_type` | string | Best-effort MIME. |
| `byte_size` | int | Size in bytes. |
| `is_sticker` | bool | Sticker-pack attachments. |
| `missing` | bool | Underlying file not on disk. |
| `path` | string | Resolved absolute path. |
| `converted_path` | string | Present with `--convert-attachments`. |
| `converted_mime_type` | string | Present with `--convert-attachments`. |
## Conventions
- Every numeric field is a JSON number. `id`, `chat_id`, and `byte_size` are integers; nothing requires 64-bit JSON-string encoding.
- Times are ISO 8601 with explicit timezone (typically `Z`).
- Strings that aren't applicable are omitted, not set to `null`. Test with `field in obj`, not `obj.field === null`.
- Booleans are explicit `true` / `false`, never 0/1.
- Arrays are always present when documented (possibly empty).
## Stability
The JSON schema is treated as a public API. Field renames or removals are tracked in `CHANGELOG.md` with a "change" or "deprecation" note and gated to a minor release.
The 0.2.0 → 0.3.0 cycle did one large rename (camelCase → snake_case). Since 0.3.0 the schema has been additive only.

90
docs/linux.md Normal file
View File

@ -0,0 +1,90 @@
---
title: Linux Read-Only Preview
description: "Use imsg on Linux to inspect an existing Messages database copied from macOS."
---
Linux support is a read-only preview. It is for inspecting an existing
`chat.db` copied from macOS; it is not a Linux Messages client.
`imsg` opens the database in SQLite read-only mode. It does not write to the
copied database, and it cannot send or mutate messages on Linux.
## What Works
Use Linux for offline inspection and automation:
```bash
imsg chats --db ./chat.db --limit 20 --json | jq -s
imsg group --db ./chat.db --chat-id 42 --json
imsg history --db ./chat.db --chat-id 42 --limit 50 --json | jq -s
imsg search --db ./chat.db --query "invoice" --limit 20 --json | jq -s
```
The JSON shape matches macOS for these read paths, including chat identifiers,
participants, message GUIDs, timestamps, text, reactions, and attachment
metadata when the copied database contains it.
## What Does Not Work
These features require macOS frameworks, Messages.app, or AppleScript
automation and are not supported on Linux:
- `send`
- `react`
- `read`
- `typing`
- `launch`
- IMCore bridge features
- Contacts name resolution
- live access to iMessage or SMS accounts
Attachment paths inside `chat.db` usually point to macOS locations under the
original user's home directory. Linux can report that metadata, but files only
exist if you copy the attachment tree too.
## Copy A Database From macOS
Do not copy a live SQLite database with plain `cp`. Use SQLite's backup command
so the snapshot is consistent:
```bash
mkdir -p /tmp/imsg-linux
sqlite3 "$HOME/Library/Messages/chat.db" \
".backup '/tmp/imsg-linux/chat.db'"
sqlite3 /tmp/imsg-linux/chat.db 'pragma quick_check;'
```
Then transfer `/tmp/imsg-linux/chat.db` to the Linux machine and point `imsg`
at it:
```bash
imsg chats --db ./chat.db --limit 5 --json | jq -s
```
For repeatable tests, keep copied databases in a private, ignored directory and
avoid printing raw message output in CI logs.
## Build From Source On Linux
The Linux build requires Swift 6.2 or newer:
```bash
git clone https://github.com/steipete/imsg.git
cd imsg
scripts/generate-version.sh
swift package resolve
scripts/patch-deps.sh
swift build -c release --product imsg
.build/release/imsg chats --db ./chat.db --limit 5
```
Release builds for 0.8.0 and newer publish `imsg-linux-x86_64.tar.gz` from the
GitHub release workflow.
Once a release is tagged, install the archive like this:
```bash
curl -LO https://github.com/steipete/imsg/releases/download/v0.8.0/imsg-linux-x86_64.tar.gz
tar -xzf imsg-linux-x86_64.tar.gz
./imsg chats --db ./chat.db --limit 5
```

63
docs/permissions.md Normal file
View File

@ -0,0 +1,63 @@
---
title: Permissions
description: "Full Disk Access, Automation, Contacts — what imsg needs and why."
---
`imsg` is local-only, but Messages.app data sits behind macOS privacy gates. Three permissions cover every feature; only the first is mandatory.
## Full Disk Access — required
`imsg` reads `~/Library/Messages/chat.db` directly. macOS denies that path to every process that hasn't been added to **Full Disk Access**.
Grant it under **System Settings → Privacy & Security → Full Disk Access**.
You almost always need to add at least two entries:
- The terminal app you'll launch `imsg` from (Terminal.app, iTerm2, Ghostty, WezTerm, Alacritty, …).
- The built-in Terminal at `/System/Applications/Utilities/Terminal.app`. macOS sometimes consults this default grant even when you're using a different terminal.
If `imsg` is launched indirectly — by an editor's task runner, a Node script, an SSH session, an automation gateway — the *parent* process needs the grant, not the terminal you opened. Add that parent app too.
After changing entries, quit and relaunch the parent process. macOS only re-reads Full Disk Access on launch.
`imsg` opens `chat.db` read-only. It does not pass SQLite's `immutable=1` flag because immutable handles can miss WAL-backed updates that Messages writes during normal use.
## Automation — required for sends and tapbacks
`imsg send`, `imsg react`, `imsg typing`, and `imsg read` drive Messages.app via AppleScript. macOS gates that under **Automation**.
The first time you run a send, macOS prompts:
> "Terminal" wants to control "Messages".
Approve it, or pre-approve under **System Settings → Privacy & Security → Automation → Messages**. Toggle the terminal (or wrapper app) on.
If you previously denied the prompt, the toggle will appear here and you can re-enable it without re-prompting.
## Contacts — optional
When granted, `imsg` resolves names from your Address Book and includes them as `contact_name` / `display_name` / `sender_name` in JSON output. Raw `handle` and `sender` values are always preserved, so automation that keys on phone numbers or email addresses is unaffected.
Grant it under **System Settings → Privacy & Security → Contacts**.
If you skip this, JSON output simply leaves the resolved name fields empty. Nothing else changes.
## Why these grants live in three different places
macOS treats each gate as a separate consent decision:
| Gate | What it protects | Triggered by |
|------|------------------|--------------|
| Full Disk Access | `~/Library/Messages/`, Mail, Safari history, … | `imsg chats`, `history`, `watch`, `group`, anything that opens `chat.db`. |
| Automation | One app driving another via Apple Events | `imsg send`, `react`, `read`, `typing`. |
| Contacts | Address Book entries | Name resolution in any read or send command. |
Only Full Disk Access is mandatory. Skip Automation if you don't send. Skip Contacts if you don't need name resolution. The CLI degrades cleanly — it tells you which gate is missing instead of silently failing.
## Stale grants after updates
After Homebrew, terminal, or macOS updates, Full Disk Access entries can go stale. The symptom is `unable to open database file` or empty output even though the entry looks toggled on.
Fix it by toggling the entry **off**, then **on** again. macOS regenerates the underlying TCC record. Do the same after replacing the parent app (e.g. updating Ghostty).
See [Troubleshooting](troubleshooting.md) for the full diagnosis loop.

101
docs/quickstart.md Normal file
View File

@ -0,0 +1,101 @@
---
title: Quickstart
description: "Five minutes from brew install to streaming Messages over stdout."
---
Goal: install `imsg`, grant the two permissions it needs, and walk through the read → watch → send loop.
## 1. Install
```bash
brew install steipete/tap/imsg
imsg --version
```
If you'd rather build from source, follow [Install](install.md).
On Linux, use the [read-only preview](linux.md) with an existing Messages
database copied from macOS. The rest of this quickstart is macOS-focused because
watching the live database and sending require Messages.app.
## 2. Grant Full Disk Access
`imsg` reads `~/Library/Messages/chat.db` directly. macOS protects that file behind Full Disk Access.
1. **System Settings → Privacy & Security → Full Disk Access.**
2. Add the terminal you'll run `imsg` from (Terminal.app, iTerm2, Ghostty, WezTerm, …).
3. If your shell launches `imsg` from another app — an editor, a Node process, an SSH server — grant Full Disk Access to that parent process too.
4. Quit and re-open the terminal so the new grant takes effect.
Sanity-check:
```bash
imsg chats --limit 3
```
You should see the three most recent conversations. If not, see [Permissions](permissions.md).
## 3. Read history
```bash
# Pick a chat from `imsg chats`, then:
imsg history --chat-id 42 --limit 10
imsg history --chat-id 42 --limit 10 --json | jq -s
```
`--json` is one JSON object per line. Pipe it to `jq -s` to materialize an array, or stream it to whatever consumer you're wiring up.
Filter by date or participant:
```bash
imsg history --chat-id 42 \
--start 2026-05-01T00:00:00Z \
--end 2026-05-06T00:00:00Z \
--json
```
## 4. Stream new messages
```bash
imsg watch --chat-id 42 --json
```
Leave it running. Send yourself a message from another device — you'll see the row arrive within a second or so. To include tapbacks:
```bash
imsg watch --chat-id 42 --reactions --json
```
To resume from a saved cursor (useful for agents that store the last seen `id`):
```bash
imsg watch --chat-id 42 --since-rowid 9000 --json
```
See [Watch](watch.md) for debounce tuning, the polling fallback, and the full event schema.
## 5. Send a message
Sending requires one more permission:
1. **System Settings → Privacy & Security → Automation → Messages.**
2. Toggle on the terminal (and any wrapper app) so it can drive Messages.app.
Then:
```bash
imsg send --to "+14155551212" --text "hi"
imsg send --to "Jane Appleseed" --text "see attached" --file ~/Desktop/note.pdf
imsg send --chat-id 42 --text "same thread"
```
`send --to` accepts a phone number, an iMessage email, or a contact name (resolved via Contacts). For groups, prefer `--chat-id`. See [Send](send.md) for service selection (`imessage`, `sms`, `auto`) and the Tahoe ghost-row failure check.
## 6. Where to go next
- [Chats](chats.md) — what each field in a chat object means.
- [JSON output](json.md) — the stable schema agents should consume.
- [JSON-RPC](rpc.md) — same surfaces, but over stdio with a single long-running process.
- [Attachments](attachments.md) — metadata, original paths, and CAF/GIF conversion.
- [Linux read-only preview](linux.md) — inspect a copied macOS Messages database on Linux.
- [Troubleshooting](troubleshooting.md) — when reads silently return nothing.

View File

@ -1,141 +1,168 @@
# RPC
---
title: JSON-RPC
description: "Long-running JSON-RPC 2.0 over stdio for chats, history, watch, and send — same surfaces as the CLI, one process."
---
Goal: signal-style JSON-RPC without a daemon. Clawdis spawns `imsg rpc` and talks over stdio.
`imsg rpc` exposes the read and send surfaces over JSON-RPC 2.0 on stdin/stdout. It's designed for agents and gateways that want a single long-lived process for chats, history, send, and watch — without a TCP port, daemon, or system service.
## Transport
- stdin/stdout, one JSON object per line.
- JSON-RPC 2.0 framing (`jsonrpc`, `id`, `method`, `params`).
- One JSON object per line on stdin (request) and stdout (response/notification).
- JSON-RPC 2.0 framing: `jsonrpc`, `id`, `method`, `params`.
- Notifications omit `id`.
- Stderr is reserved for human-readable diagnostics.
## Lifecycle
- Gateway spawns one `imsg rpc` process.
- Process stays alive for watch + send.
- No TCP port, no daemon install.
- The host process spawns one `imsg rpc` child.
- The child stays alive across many requests and one-or-more watch subscriptions.
- No TCP port. No launch agent. No `imsg` daemon to install.
The pattern intentionally mirrors language servers and the way `imsg`'s parent gateway (Clawdis) supervises subprocesses — a single signal-style child that exits cleanly when stdin closes.
## Methods
### `chats.list`
Params:
- `limit` (int, default 20)
Result:
- `{ "chats": [Chat] }`
```json
{ "chats": [Chat] }
```
### `messages.history`
Params:
- `chat_id` (int, required, preferred identifier)
- `chat_id` (int, required) — preferred identifier.
- `limit` (int, default 50)
- `participants` (array, optional)
- `start` / `end` (ISO8601, optional)
- `attachments` (bool, default false)
- `participants` (array of handle strings, optional)
- `start` / `end` (ISO 8601, optional)
- `attachments` (bool, default `false`)
Result:
- `{ "messages": [Message] }`
```json
{ "messages": [Message] }
```
### `watch.subscribe`
Params:
- `chat_id` (int, optional)
- `since_rowid` (int, optional)
- `participants` (array, optional)
- `start` / `end` (ISO8601, optional)
- `attachments` (bool, default false)
- `include_reactions` (bool, default false)
- `debounce_ms` / `debounceMs` (int milliseconds, default 500)
Result:
- `{ "subscription": 1 }`
Notifications:
- `{"jsonrpc":"2.0","method":"message","params":{"subscription":1,"message":<Message>}}`
The RPC default debounce is intentionally higher than the CLI default so macOS
has time to settle follow-up writes such as `is_from_me` updates on outbound
messages. Clients that need lower latency can pass `debounce_ms`. Watch streams
also perform a lightweight periodic poll so missed filesystem events or rotated
SQLite sidecar files do not leave long-running providers silent.
Params:
- `chat_id` (int, optional) — omit for all-chat stream.
- `since_rowid` (int, optional) — exclusive cursor.
- `participants` (array, optional)
- `start` / `end` (ISO 8601, optional)
- `attachments` (bool, default `false`)
- `include_reactions` (bool, default `false`)
- `debounce_ms` (int, default `500`)
Result:
```json
{ "subscription": 1 }
```
Notifications (one per emitted message):
```json
{
"jsonrpc": "2.0",
"method": "message",
"params": {
"subscription": 1,
"message": { ... }
}
}
```
The RPC default debounce (`500ms`) is intentionally higher than the CLI default (`250ms`). RPC's typical caller is an agent that just sent a message and is waiting for the inbound echo to settle (`is_from_me` correction, attachment metadata, …). 500ms is enough for those follow-ups to land before the message is emitted.
Like the CLI watch, RPC watch backs filesystem events with a low-frequency poll so a missed event or a rotated SQLite sidecar doesn't leave the subscription silent.
### `watch.unsubscribe`
Params:
- `subscription` (int, required)
Result:
- `{ "ok": true }`
```json
{ "ok": true }
```
### `send`
Params (direct):
Params (direct send):
- `to` (string, required)
- `text` (string, optional)
- `file` (string, optional)
- `service` ("imessage"|"sms"|"auto", optional)
- `service` (`imessage` | `sms` | `auto`, optional)
- `region` (string, optional)
Params (group):
- `chat_id` or `chat_identifier` or `chat_guid` (one required; `chat_id` preferred)
- `text` / `file` as above
Params (chat target):
- `chat_id` *or* `chat_identifier` *or* `chat_guid` — exactly one. `chat_id` is preferred.
- `text` / `file` as above.
Result:
- `{ "ok": true, "id": 1979, "guid": "8DF..." }`
`id` and `guid` are best-effort. `send` returns them when the sent row can be
observed in `chat.db` after Messages accepts the send. Attachment-only sends,
delayed database writes, or ambiguous direct sends may still return only
`{ "ok": true }`.
```json
{ "ok": true, "id": 1979, "guid": "8DF..." }
```
For chat-target sends, `send` also checks for the Tahoe Messages.app failure
mode where AppleScript returns success but writes an empty outgoing SMS row that
is not joined to the target chat. That case is reported as an error instead of
`{ "ok": true }`.
`id` and `guid` are best-effort. `send` returns them when the inserted row can be observed in `chat.db` after Messages accepts the send. Attachment-only sends, delayed database writes, or ambiguous direct sends may return only `{"ok": true}`.
For chat-target sends, `send` also performs the [Tahoe ghost-row check](send.md#tahoe-ghost-row-protection): if Messages writes an empty unjoined SMS row instead of delivering, the call returns an error rather than `{"ok": true}`.
## Objects
### Chat
- `id` (int)
- `name` (string)
- `identifier` (string)
- `guid` (string, optional)
- `service` (string)
- `last_message_at` (ISO8601)
- `account_id` (string, optional)
- `account_login` (string, optional)
- `last_addressed_handle` (string, optional)
- `participants` (array, optional)
- `is_group` (bool, optional)
See [JSON output → Chat](json.md#chat). Every field documented there appears in the RPC `chats.list` response.
### Message
- `id` (rowid)
- `chat_id` (always present; preferred handle for routing)
- `guid` (string)
- `reply_to_guid` (string, optional)
- `destination_caller_id` (string, optional)
- `sender`
- `is_from_me`
- `text`
- `created_at`
- `attachments` (array)
- `reactions` (array)
- `chat_identifier`
- `chat_guid`
- `chat_name`
- `participants`
- `is_group`
`account_id`, `account_login`, `last_addressed_handle`, and sent-message
`destination_caller_id` are read-only routing diagnostics from Messages. The
AppleScript send API does not expose a `from` account or phone-number selector.
See [JSON output → Message](json.md#message). When `include_reactions: true`, message notifications also include the reaction extension fields (`is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, `reacted_to_guid`).
`account_id`, `account_login`, `last_addressed_handle`, and outgoing `destination_caller_id` are read-only routing diagnostics; the AppleScript send API does not expose a `from` selector.
## Examples
Request:
```
Request `chats.list`:
```json
{"jsonrpc":"2.0","id":"1","method":"chats.list","params":{"limit":10}}
```
Response:
```
```json
{"jsonrpc":"2.0","id":"1","result":{"chats":[...]}}
```
Subscribe:
```
Subscribe to a chat:
```json
{"jsonrpc":"2.0","id":"2","method":"watch.subscribe","params":{"chat_id":1}}
```
Notification:
```
Notification on each new message:
```json
{"jsonrpc":"2.0","method":"message","params":{"subscription":2,"message":{...}}}
```
Send and receive verification:
```json
{"jsonrpc":"2.0","id":"3","method":"send","params":{"to":"+14155551212","text":"hi"}}
{"jsonrpc":"2.0","id":"3","result":{"ok":true,"id":1979,"guid":"8DF..."}}
```

122
docs/send.md Normal file
View File

@ -0,0 +1,122 @@
---
title: Send
description: "Send text and files to direct chats and groups through Messages.app automation, plus standard tapbacks."
---
`imsg send` rides Messages' published AppleScript surface — no private send APIs, no IMCore injection. Sending requires Automation permission for Messages (see [Permissions](permissions.md)).
## Direct sends
```bash
imsg send --to "+14155551212" --text "hi"
imsg send --to "jane@example.com" --text "hi"
imsg send --to "Jane Appleseed" --text "hi"
```
`--to` accepts:
- An E.164 phone number (`+14155551212`) — best.
- A locally-formatted phone number (`415-555-1212`). Pair with `--region US` if you need to override the default.
- An iMessage email address.
- A contact name. Resolved through Address Book; requires Contacts permission.
For unambiguous routing, prefer phone numbers in E.164 form.
## Group sends
You'll typically want `--chat-id`:
```bash
imsg send --chat-id 42 --text "same thread"
```
Use `--chat-identifier` or `--chat-guid` when only the portable handles are available:
```bash
imsg send --chat-identifier "iMessage;+;chat1234567890" --text "hi"
imsg send --chat-guid "iMessage;+;chat1234567890" --text "hi"
```
See [Groups](groups.md) for how Messages encodes group handles.
## Files and audio
```bash
imsg send --to "+14155551212" --text "see attached" --file ~/Desktop/note.pdf
imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
imsg send --chat-id 42 --file ~/Desktop/screenshot.png
```
Both `--text` and `--file` can be supplied together.
Before handing the file to Messages, `imsg` stages it under `~/Library/Messages/Attachments/imsg/`. Messages reads attachments from there reliably across macOS versions; sending directly from `~/Desktop` or `~/Downloads` can hit sandbox-related send failures.
Audio files (`.m4a`, `.caf`, `.aiff`, etc.) send the same way as any other file. Messages exposes them as audio messages on the receiving side.
## Service selection
```bash
imsg send --to "+14155551212" --text "hi" --service auto # default
imsg send --to "+14155551212" --text "hi" --service imessage
imsg send --to "+14155551212" --text "hi" --service sms
```
- `auto` — Messages picks. iMessage when the recipient is an Apple device; SMS when not, given Text Message Forwarding is enabled.
- `imessage` — force iMessage. Fails fast if the recipient isn't on iMessage.
- `sms` — force SMS relay. Requires Text Message Forwarding enabled on your iPhone for this Mac.
For groups, omit `--service`. Group sends always use the chat's existing service.
## Region for phone normalization
```bash
imsg send --to "415-555-1212" --text "hi" --region US
```
Defaults to `US`. Pass an ISO 3166-1 alpha-2 country code to normalize locally-formatted numbers.
## Confirming what was sent
Default text mode prints `sent` on success. JSON mode emits `{"status":"sent"}`.
The [JSON-RPC `send` method](rpc.md#send) goes further: it includes the rowid and GUID of the inserted message when it can observe the row in `chat.db` after Messages accepts the send. Use RPC when you need a verified send acknowledgment.
## Tahoe ghost-row protection
On macOS 26 (Tahoe), Messages.app has a failure mode where AppleScript reports success but writes an empty outgoing SMS row that isn't joined to the target chat. The send looks fine to the caller but never reaches the recipient.
`imsg send` for chat-target sends (`--chat-id`, `--chat-identifier`, `--chat-guid`) checks for this ghost row after the AppleScript call returns. If it finds one, the command reports an error rather than `sent`. Direct sends (`--to`) are not affected by this failure mode.
This check landed in 0.6.0; see `CHANGELOG.md` for the issue history.
## Standard tapbacks
```bash
imsg react --chat-id 42 --reaction love
imsg react --chat-id 42 --reaction like
imsg react --chat-id 42 --reaction dislike
imsg react --chat-id 42 --reaction laugh
imsg react --chat-id 42 --reaction emphasis
imsg react --chat-id 42 --reaction question
```
`react` sends only the six standard tapbacks Messages.app exposes reliably through automation. After the AppleScript call, `imsg` confirms the reaction selection in Messages' UI before reporting success — this guards against silent UI rejections.
Custom emoji tapbacks can be *read* in `watch --reactions` output, but `react` rejects them rather than taking a no-op AppleScript path. There is no published automation surface that sends arbitrary emoji tapbacks reliably.
## Outgoing routing — what you can and can't control
`imsg` reports per-chat routing diagnostics — `account_id`, `account_login`, `last_addressed_handle`, and per-message `destination_caller_id`. They tell you which Apple ID and which of your numbers Messages routed through.
You cannot use `send` to *force* a specific outgoing number when several phone numbers share one Apple ID. AppleScript's `send` has no `from` or account selector. The fields are diagnostic, not steering. If you need to force a specific number, change the default in Messages' settings.
## What requires what
| Send variant | Permission | macOS limits |
|--------------|------------|--------------|
| `send --to <handle>` | Automation → Messages | None unique to this command. |
| `send --chat-id` (groups) | Automation → Messages | Tahoe ghost-row check active. |
| `send --file` | Automation → Messages | Files are auto-staged in Messages' attachments dir. |
| `react` | Automation → Messages + UI scripting | Only the six standard tapbacks are sendable. |
| `read` (mark as read) | [Advanced IMCore](advanced-imcore.md) | SIP-disabled, dylib injection, increasingly limited on macOS 26. |
| `typing` (typing indicator) | [Advanced IMCore](advanced-imcore.md) | Same as `read`. |

108
docs/troubleshooting.md Normal file
View File

@ -0,0 +1,108 @@
---
title: Troubleshooting
description: "Common reasons reads return nothing, sends silently fail, or watch goes quiet — and how to diagnose each one."
---
Most `imsg` issues come down to a permissions gate that hasn't taken effect yet, or a Messages.app behavior change on a recent macOS update. This page walks through the standard diagnoses.
## Reads return `unable to open database file`
The terminal (or its parent process) doesn't have Full Disk Access yet.
1. **System Settings → Privacy & Security → Full Disk Access.**
2. Add the terminal you're running `imsg` from.
3. Add `/System/Applications/Utilities/Terminal.app` even if you don't use it directly — macOS sometimes consults the default terminal grant.
4. If `imsg` is launched indirectly (editor task runner, Node script, SSH session, automation gateway), grant Full Disk Access to that *parent* app, not just the terminal you opened.
5. Quit and relaunch the parent process.
If reads still fail, **toggle the entry off and back on**. Full Disk Access entries can go stale after Homebrew, terminal, or macOS updates. The entry looks correct but no longer carries the underlying TCC grant.
Confirm:
```bash
sqlite3 ~/Library/Messages/chat.db 'pragma quick_check;'
```
If `sqlite3` works but `imsg` doesn't, the parent process of `imsg` is still missing the grant. If `sqlite3` also fails, fix Full Disk Access first.
## Reads succeed but return zero rows
Messages.app isn't signed in, or `chat.db` doesn't exist.
```bash
ls -la ~/Library/Messages/chat.db
```
If the file is missing, open Messages.app and complete iMessage / SMS Forwarding setup. The database is created lazily on first sign-in.
## Sends fail with `not authorized to send Apple events`
Automation permission is missing.
1. **System Settings → Privacy & Security → Automation → Messages.**
2. Toggle the terminal (or wrapper app) on.
3. Re-run the send.
If the toggle isn't visible, run a send once to trigger the prompt, then approve.
## Sends look successful but never arrive
Two possible causes:
**Tahoe ghost-row failure (group sends).** On macOS 26, Messages.app sometimes reports AppleScript success while writing an empty unjoined SMS row instead of delivering. `imsg send` for chat-target sends already detects this and reports an error instead of `sent`. If you're still seeing silent failures with `--chat-id`/`--chat-identifier`/`--chat-guid`, make sure you're on `imsg` 0.6.0 or newer (`imsg --version`).
**Service mismatch.** A send to a phone number with `--service imessage` fails fast if the recipient isn't on iMessage. With `--service sms`, Text Message Forwarding must be enabled on your iPhone for this Mac. With `--service auto`, Messages picks; this is the recommended default.
## `imsg watch` goes silent after a while
macOS occasionally drops or coalesces filesystem events, especially after sleep/wake or under heavy I/O. Older versions of `imsg watch` could go silent in that window.
`imsg` 0.6.0 added a low-frequency polling fallback that runs alongside the event watcher. If the cursor falls behind, the poll catches up. Make sure you're on 0.6.0+ (`imsg --version`).
If you're already on 0.6.0+ and watch still misses messages, file an issue with:
- macOS version (`sw_vers`).
- `imsg --version`.
- A reproduction including the exact `imsg watch` flags.
- The output of `ls -la ~/Library/Messages/chat.db*` taken just after the silence.
## `react` fails with `unsupported reaction`
`imsg react` only sends the six standard tapbacks Messages.app exposes reliably through automation: `love`, `like`, `dislike`, `laugh`, `emphasis`, `question`.
Custom emoji tapbacks can be *read* in `watch --reactions` output, but `react` rejects them rather than taking a no-op AppleScript path. There's no automation surface that sends arbitrary emoji tapbacks reliably.
## `imsg` reports a different version than `brew`
Stale Homebrew install or a manually-built binary on `PATH` ahead of the formula:
```bash
which imsg
brew list --versions imsg
```
If `which imsg` doesn't point at the Homebrew prefix, remove the older binary or reorder your `PATH`.
## Contacts names are missing in JSON output
Contacts permission isn't granted, or the contact isn't matched.
1. Confirm under **System Settings → Privacy & Security → Contacts** that the terminal/wrapper app is enabled.
2. Raw handles are always preserved in `sender`, `chat_identifier`, etc. The optional `contact_name` / `sender_name` fields are simply omitted when no match is found.
If you want partial fallback names (initials, or formatted handles), do that in your consumer — `imsg` doesn't synthesize names that aren't in your Address Book.
## Advanced IMCore features fail
See [Advanced IMCore features](advanced-imcore.md). Most likely SIP is enabled (required to be off), library validation is rejecting the helper dylib, or macOS 26's `imagent` entitlement check is blocking the IMCore client. These are macOS-level gates `imsg` cannot work around.
## Filing issues
If you've worked through the relevant section above and are stuck, open an issue at <https://github.com/steipete/imsg/issues>.
Useful context:
- `imsg --version`.
- `sw_vers` (macOS version).
- The exact command you ran and the full output (with any sensitive content redacted).
- Whether `sqlite3 ~/Library/Messages/chat.db 'pragma quick_check;'` succeeds or fails.

102
docs/watch.md Normal file
View File

@ -0,0 +1,102 @@
---
title: Watch
description: "Stream new iMessage and SMS rows live, with filesystem-event triggers and a poll-based fallback."
---
`imsg watch` follows `chat.db` and emits each new message as soon as Messages writes it. It's the right primitive for agents, dashboards, notifiers, and anything that wants near-real-time inbound.
## Stream all chats
```bash
imsg watch --json
```
You'll see every new inbound and outbound message across every chat the database covers.
## Stream one chat
```bash
imsg watch --chat-id 42 --json
```
`--chat-id` is the simplest filter. For more advanced filtering use `--participants`, `--start`, `--end`, all of which mirror [`history`](history.md).
## Resuming from a cursor
For long-lived consumers — agents, sync jobs — store the last `id` (rowid) you successfully processed and resume:
```bash
imsg watch --chat-id 42 --since-rowid 9000 --json
```
`--since-rowid` is exclusive: `9000` means "everything strictly after rowid 9000."
If you don't pass `--since-rowid`, watch starts at the newest message at the moment of launch. Messages written before then are not replayed; use [`history`](history.md) for that.
## Reactions
By default, tapback events are excluded so the stream stays focused on actual messages. Opt in with `--reactions`:
```bash
imsg watch --chat-id 42 --reactions --json
```
Reaction events extend the message object with:
- `is_reaction``true` for tapback events.
- `reaction_type``love`, `like`, `dislike`, `laugh`, `emphasis`, `question`, or a custom emoji string.
- `reaction_emoji` — for custom emoji tapbacks.
- `is_reaction_add``true` when added, `false` when removed.
- `reacted_to_guid` — the message guid this tapback targets.
## Attachments
```bash
imsg watch --chat-id 42 --attachments --json
imsg watch --chat-id 42 --attachments --convert-attachments --json
```
Attachment metadata is reported the same way as [`history`](history.md). `--convert-attachments` requires `ffmpeg` on `PATH`; see [Attachments](attachments.md).
## Debounce
```bash
imsg watch --chat-id 42 --debounce 250ms --json
```
When Messages writes a message, it often follows up with WAL flushes, attachment metadata updates, and `is_from_me` corrections within a few milliseconds. The debouncer collapses those into one stable emission per row.
- CLI default: `250ms`.
- RPC default: `500ms` (RPC's typical caller is an agent more sensitive to outbound echo races).
Lower the debounce if you need lower latency and can tolerate occasional duplicate emissions during database churn. Raise it if downstream consumers can't keep up.
`--debounce` accepts Go-style durations: `100ms`, `1s`, `2s500ms`.
## How it knows when to read
The watcher listens for `kqueue` filesystem events on:
- `~/Library/Messages/chat.db`
- `~/Library/Messages/chat.db-wal`
- `~/Library/Messages/chat.db-shm`
Whenever any of those files change, the watcher checks for new rows past the cursor.
## Polling fallback
macOS sometimes drops or coalesces filesystem events — especially under heavy I/O, after sleep/wake, or when Messages rotates the WAL sidecars. Without intervention, a watch session can go silent while the database keeps changing.
`imsg watch` runs a low-frequency poll alongside the event watcher. If the cursor falls behind the actual rowid, the poller catches up and emits the missed rows. You don't configure this — it's always on.
This is the fix for the long-standing "watch goes silent after a while" class of bug. See `CHANGELOG.md` 0.6.0 entry.
## URL preview deduplication
When you send a link, Messages writes a "balloon" placeholder row first, then later replaces it once the preview metadata is fetched. Without dedup, watch would emit both. `imsg watch` deduplicates these without dropping unrelated messages from other chats — the dedup is keyed precisely on the balloon update path, not on text similarity.
## Output schema
Each line is a complete JSON object. See [JSON output → Message](json.md#message) for the full field list. For tapback events also see the reaction fields above.
Lines are flushed immediately when stdout is buffered (e.g. piped through `jq -c`), so downstream consumers don't experience batching artifacts.

634
scripts/build-docs-site.mjs Normal file
View File

@ -0,0 +1,634 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { brandMarkSvg, css, faviconSvg, js, preThemeScript, themeToggleHtml } from "./docs-site-assets.mjs";
const root = process.cwd();
const docsDir = path.join(root, "docs");
const outDir = path.join(root, "dist", "docs-site");
const repoBase = "https://github.com/steipete/imsg";
const repoEditBase = `${repoBase}/edit/main/docs`;
const cname = readCname();
const siteBase = cname ? `https://${cname}` : "";
const productName = "imsg";
const productTagline = "Messages.app from your terminal";
const productDescription =
"A macOS command-line tool for Messages.app — read your local chat database, stream new iMessage and SMS messages, send text and files through Messages automation, and expose the same surfaces over JSON and JSON-RPC.";
const brewInstall = "brew install steipete/tap/imsg";
const sections = [
["Start", ["index.md", "install.md", "quickstart.md", "permissions.md"]],
["Read", ["chats.md", "history.md", "watch.md", "groups.md", "attachments.md"]],
["Send", ["send.md"]],
["Integrate", ["json.md", "rpc.md", "completions.md"]],
["Operate", ["troubleshooting.md", "advanced-imcore.md", "RELEASING.md"]],
];
const buildExcludes = [];
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
const allPages = allMarkdown(docsDir).map((file) => {
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
const raw = fs.readFileSync(file, "utf8");
const { frontmatter, body } = parseFrontmatter(raw);
const cleaned = stripStrayDirectives(body);
const title = frontmatter.title || firstHeading(cleaned) || titleize(path.basename(rel, ".md"));
return { file, rel, title, outRel: outPath(rel, frontmatter), markdown: cleaned, frontmatter };
});
const pages = allPages.filter((page) => !buildExcludes.some((re) => re.test(page.rel)));
const pageMap = new Map(pages.map((page) => [page.rel, page]));
const permalinkMap = new Map();
for (const page of pages) {
if (page.frontmatter.permalink) {
permalinkMap.set(normalizePermalink(page.frontmatter.permalink), page);
}
}
const nav = sections
.map(([name, rels]) => ({
name,
pages: rels.map((rel) => pageMap.get(rel)).filter(Boolean),
}))
.filter((section) => section.pages.length);
const sectionByRel = new Map();
for (const section of nav) for (const page of section.pages) sectionByRel.set(page.rel, section.name);
const orderedPages = nav.flatMap((s) => s.pages);
for (const page of pages) {
const html = markdownToHtml(page.markdown, page.rel);
const toc = tocFromHtml(html);
const idx = orderedPages.findIndex((p) => p.rel === page.rel);
const prev = idx > 0 ? orderedPages[idx - 1] : null;
const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : null;
const sectionName = sectionByRel.get(page.rel) || "Reference";
const pageOut = path.join(outDir, page.outRel);
fs.mkdirSync(path.dirname(pageOut), { recursive: true });
fs.writeFileSync(pageOut, layout({ page, html, toc, prev, next, sectionName }), "utf8");
}
fs.writeFileSync(path.join(outDir, "favicon.svg"), faviconSvg(), "utf8");
fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8");
if (cname) fs.writeFileSync(path.join(outDir, "CNAME"), cname, "utf8");
validateLinks(outDir);
console.log(`built docs site: ${path.relative(root, outDir)}`);
function readCname() {
for (const candidate of [path.join(docsDir, "CNAME"), path.join(root, "CNAME")]) {
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf8").trim();
}
return "";
}
function parseFrontmatter(raw) {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) return { frontmatter: {}, body: raw };
const fm = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2];
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
fm[m[1]] = value;
}
return { frontmatter: fm, body: raw.slice(match[0].length) };
}
function stripStrayDirectives(body) {
return body
.replace(/\r\n/g, "\n")
.split("\n")
.filter((line) => !/^\s*\{:\s*[^}]*\}\s*$/.test(line))
.map((line) => line.replace(/\s*\{:\s*[^}]*\}\s*$/, ""))
.join("\n");
}
function normalizePermalink(value) {
let v = value.trim();
if (!v) return "/";
if (!v.startsWith("/")) v = `/${v}`;
if (v.length > 1 && v.endsWith("/")) v = v.slice(0, -1);
return v;
}
function allMarkdown(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allMarkdown(full);
return entry.name.endsWith(".md") ? [full] : [];
})
.sort();
}
function outPath(rel, frontmatter = {}) {
if (frontmatter.permalink) {
const permalink = normalizePermalink(frontmatter.permalink);
if (permalink === "/") return "index.html";
return `${permalink.slice(1)}/index.html`;
}
if (rel === "index.md") return "index.html";
if (rel === "README.md") return "index.html";
if (rel.endsWith("/README.md")) return rel.replace(/README\.md$/, "index.html");
return rel.replace(/\.md$/, ".html");
}
function firstHeading(markdown) {
return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
}
function titleize(input) {
return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase());
}
function markdownToHtml(markdown, currentRel) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const html = [];
let paragraph = [];
let list = null;
let fence = null;
let blockquote = [];
const flushParagraph = () => {
if (!paragraph.length) return;
html.push(`<p>${inline(paragraph.join(" "), currentRel)}</p>`);
paragraph = [];
};
const closeList = () => {
if (!list) return;
html.push(`</${list}>`);
list = null;
};
const flushBlockquote = () => {
if (!blockquote.length) return;
const inner = markdownToHtml(blockquote.join("\n"), currentRel);
html.push(`<blockquote>${inner}</blockquote>`);
blockquote = [];
};
const splitRow = (line) => {
let trimmed = line.trim();
if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
if (trimmed.endsWith("|") && !trimmed.endsWith("\\|")) trimmed = trimmed.slice(0, -1);
const cells = [];
let current = "";
for (let idx = 0; idx < trimmed.length; idx++) {
const char = trimmed[idx];
if (char === "\\" && trimmed[idx + 1] === "|") {
current += "\\|";
idx += 1;
continue;
}
if (char === "|") {
cells.push(current.trim().replace(/\\\|/g, "|"));
current = "";
continue;
}
current += char;
}
cells.push(current.trim().replace(/\\\|/g, "|"));
return cells;
};
const isDivider = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const fenceMatch = line.match(/^```([\w+-]+)?\s*$/);
if (fenceMatch) {
flushParagraph();
closeList();
flushBlockquote();
if (fence) {
html.push(`<pre><code class="language-${escapeAttr(fence.lang)}">${highlightCode(fence.lines.join("\n"), fence.lang)}</code></pre>`);
fence = null;
} else {
fence = { lang: fenceMatch[1] || "text", lines: [] };
}
continue;
}
if (fence) {
fence.lines.push(line);
continue;
}
if (/^>\s?/.test(line)) {
flushParagraph();
closeList();
blockquote.push(line.replace(/^>\s?/, ""));
continue;
}
flushBlockquote();
if (!line.trim()) {
flushParagraph();
closeList();
continue;
}
if (/^\s*---+\s*$/.test(line)) {
flushParagraph();
closeList();
html.push("<hr>");
continue;
}
const heading = line.match(/^(#{1,4})\s+(.+)$/);
if (heading) {
flushParagraph();
closeList();
const level = heading[1].length;
const text = heading[2].trim();
const id = slug(text);
const inner = inline(text, currentRel);
if (level === 1) {
html.push(`<h1 id="${id}">${inner}</h1>`);
} else {
html.push(`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-label="Anchor link">#</a>${inner}</h${level}>`);
}
continue;
}
if (line.trimStart().startsWith("|") && line.includes("|", line.indexOf("|") + 1) && isDivider(lines[i + 1] || "")) {
flushParagraph();
closeList();
const header = splitRow(line);
const aligns = splitRow(lines[i + 1]).map((cell) => {
const left = cell.startsWith(":");
const right = cell.endsWith(":");
return right && left ? "center" : right ? "right" : left ? "left" : "";
});
i += 1;
const rows = [];
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith("|")) {
i += 1;
rows.push(splitRow(lines[i]));
}
const th = header.map((c, idx) => `<th${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</th>`).join("");
const tb = rows.map((r) => `<tr>${r.map((c, idx) => `<td${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</td>`).join("")}</tr>`).join("");
html.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`);
continue;
}
const bullet = line.match(/^\s*-\s+(.+)$/);
const numbered = line.match(/^\s*\d+\.\s+(.+)$/);
if (bullet || numbered) {
flushParagraph();
const tag = bullet ? "ul" : "ol";
if (list && list !== tag) closeList();
if (!list) {
list = tag;
html.push(`<${tag}>`);
}
html.push(`<li>${inline((bullet || numbered)[1], currentRel)}</li>`);
continue;
}
paragraph.push(line.trim());
}
flushParagraph();
closeList();
flushBlockquote();
return html.join("\n");
}
function highlightCode(code, lang) {
const normalized = String(lang || "text").toLowerCase();
if (["bash", "sh", "shell", "zsh"].includes(normalized)) return highlightBash(code);
if (normalized === "json") return highlightJSON(code);
if (["yaml", "yml"].includes(normalized)) return highlightConfig(code, "yaml");
return escapeHtml(code);
}
function highlightBash(code) {
return code.split("\n").map((line) => {
if (/^\s*#/.test(line)) return span("comment", line);
return highlightSegments(line, /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`[^`]*`|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|--?[A-Za-z0-9][A-Za-z0-9_-]*|\b(?:brew|cat|cd|chmod|cp|csrutil|defaults|do|done|else|export|fi|for|grep|if|imsg|in|jq|make|mkdir|osascript|open|rm|sqlite3|swift|tail|then|while|xattr)\b|#.*)/g, (token) => {
if (token.startsWith("#")) return span("comment", token);
if (/^["'`]/.test(token)) return span("string", token);
if (token.startsWith("$")) return span("variable", token);
if (token.startsWith("-")) return span("option", token);
return span("keyword", token);
});
}).join("\n");
}
function highlightJSON(code) {
return highlightSegments(code, /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|\b(?:true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, (token) => {
if (token.endsWith(":")) return `${span("key", token.slice(0, -1))}:`;
if (token.startsWith('"')) return span("string", token);
if (/^(?:true|false|null)$/.test(token)) return span("literal", token);
return span("number", token);
});
}
function highlightConfig(code, lang) {
return code.split("\n").map((line) => {
if (/^\s*#/.test(line)) return span("comment", line);
const commentMatch = line.match(/(^|[^"'])#.*/);
const commentStart = commentMatch ? commentMatch.index + commentMatch[1].length : -1;
const body = commentStart >= 0 ? line.slice(0, commentStart) : line;
const comment = commentStart >= 0 ? line.slice(commentStart) : "";
const highlighted = lang === "yaml"
? highlightSegments(body, /(^\s*[A-Za-z0-9_.-]+(?=\s*:))|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|\b(?:true|false|null)\b|-?\b\d+(?:\.\d+)?\b/g, configToken)
: escapeHtml(body);
return highlighted + (comment ? span("comment", comment) : "");
}).join("\n");
}
function configToken(token) {
if (/^\s*[A-Za-z0-9_.-]+$/.test(token)) {
const leading = token.match(/^\s*/)[0];
return `${escapeHtml(leading)}${span("key", token.slice(leading.length))}`;
}
if (/^["']/.test(token)) return span("string", token);
if (/^(?:true|false|null)$/.test(token)) return span("literal", token);
return span("number", token);
}
function highlightSegments(text, pattern, classify) {
let out = "";
let last = 0;
for (const match of text.matchAll(pattern)) {
out += escapeHtml(text.slice(last, match.index));
out += classify(match[0]);
last = match.index + match[0].length;
}
return out + escapeHtml(text.slice(last));
}
function span(kind, value) {
return `<span class="hl-${kind}">${escapeHtml(value)}</span>`;
}
function inline(text, currentRel) {
const stash = [];
let out = text.replace(/`([^`]+)`/g, (_, code) => {
stash.push(`<code>${escapeHtml(code)}</code>`);
return `\u0000${stash.length - 1}\u0000`;
});
out = escapeHtml(out)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>")
.replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1<em>$2</em>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`)
.replace(/&lt;(https?:\/\/[^\s<>]+)&gt;/g, '<a href="$1">$1</a>');
out = out.replace(/\\\|/g, "|");
out = out.replace(/&lt;br&gt;/g, "<br>");
return out.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]);
}
function rewriteHref(href, currentRel) {
if (/^(https?:|mailto:|tel:|#)/.test(href)) return href;
const [raw, hash = ""] = href.split("#");
if (!raw) return hash ? `#${hash}` : "";
if (raw.startsWith("/")) {
const target = permalinkMap.get(normalizePermalink(raw));
if (target) {
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
const out = hrefToOutRel(target.outRel, currentOut);
return hash ? `${out}#${hash}` : out;
}
return href;
}
if (!raw.endsWith(".md")) return href;
const from = path.posix.dirname(currentRel);
const target = path.posix.normalize(path.posix.join(from, raw));
let rewritten = pageMap.get(target)?.outRel || outPath(target);
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
rewritten = hrefToOutRel(rewritten, currentOut);
return `${rewritten}${hash ? `#${hash}` : ""}`;
}
function tocFromHtml(html) {
const items = [];
const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
let m;
while ((m = re.exec(html))) {
const text = m[3]
.replace(/<a class="anchor"[^>]*>.*?<\/a>/, "")
.replace(/<[^>]+>/g, "")
.trim();
items.push({ level: Number(m[1]), id: m[2], text });
}
if (items.length < 2) return "";
return `<nav class="toc" aria-label="On this page"><h2>On this page</h2>${items
.map((i) => `<a class="toc-l${i.level}" href="#${i.id}">${escapeHtml(i.text)}</a>`)
.join("")}</nav>`;
}
function isHomePage(page) {
if (page.frontmatter.permalink && normalizePermalink(page.frontmatter.permalink) === "/") return true;
return page.rel === "index.md" || page.rel === "README.md";
}
function homeHero(page) {
const description = page.frontmatter.description || productDescription;
const installRel = pageMap.get("install.md")?.outRel
? hrefToOutRel(pageMap.get("install.md").outRel, page.outRel)
: "install.html";
const quickstartRel = pageMap.get("quickstart.md")?.outRel
? hrefToOutRel(pageMap.get("quickstart.md").outRel, page.outRel)
: "quickstart.html";
const surfaces = ["Chats", "History", "Watch", "Send", "React", "Groups", "Attachments", "JSON", "JSON-RPC"];
return `<header class="home-hero">
<p class="eyebrow">macOS · Messages.app</p>
<h1>${escapeHtml(productTagline)}</h1>
<p class="lede">${escapeHtml(description)}</p>
<div class="home-cta">
<a class="btn btn-primary" href="${quickstartRel}">Quickstart</a>
<a class="btn btn-ghost" href="${repoBase}" rel="noopener">GitHub</a>
</div>
<div class="home-install" aria-label="Install with Homebrew">
<span class="prompt" aria-hidden="true">$</span>
<code>${escapeHtml(brewInstall)}</code>
</div>
<div class="home-services" aria-label="Surfaces">
${surfaces.map((s) => `<span>${escapeHtml(s)}</span>`).join("")}
</div>
<p class="muted"><a href="${installRel}">Other install options </a></p>
</header>`;
}
function standardHero(page, sectionName, editUrl) {
return `<header class="hero">
<div class="hero-text">
<p class="eyebrow">${escapeHtml(sectionName)}</p>
<h1>${escapeHtml(page.title)}</h1>
</div>
<div class="hero-meta">
<a class="repo" href="${repoBase}" rel="noopener">GitHub</a>
<a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">Edit page</a>
</div>
</header>`;
}
function layout({ page, html, toc, prev, next, sectionName }) {
const depth = page.outRel.split("/").length - 1;
const rootPrefix = depth ? "../".repeat(depth) : "";
const editUrl = `${repoEditBase}/${page.rel}`;
const home = isHomePage(page);
const prevNext = !home && (prev || next) ? pageNavHtml(prev, next, page.outRel) : "";
const heroBlock = home ? homeHero(page) : standardHero(page, sectionName, editUrl);
const articleClass = home ? "doc doc-home" : "doc";
const tocBlock = home ? "" : toc;
const titleSuffix = home ? `${productName}${productTagline}` : `${page.title}${productName}`;
const description = page.frontmatter.description || (home ? productDescription : `${page.title}${productName} CLI documentation.`);
const canonicalUrl = pageCanonicalUrl(page);
const socialImage = siteBase ? `${siteBase}/favicon.svg` : `${rootPrefix}favicon.svg`;
const socialMeta = [
["link", "rel", "canonical", "href", canonicalUrl],
["meta", "property", "og:type", "content", "website"],
["meta", "property", "og:site_name", "content", productName],
["meta", "property", "og:title", "content", titleSuffix],
["meta", "property", "og:description", "content", description],
["meta", "property", "og:url", "content", canonicalUrl],
["meta", "property", "og:image", "content", socialImage],
["meta", "name", "twitter:card", "content", "summary"],
["meta", "name", "twitter:title", "content", titleSuffix],
["meta", "name", "twitter:description", "content", description],
["meta", "name", "twitter:image", "content", socialImage],
].map(tagHtml).join("\n ");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(titleSuffix)}</title>
<meta name="description" content="${escapeAttr(description)}">
${socialMeta}
<link rel="icon" href="${rootPrefix}favicon.svg" type="image/svg+xml">
<script>${preThemeScript()}</script>
<style>${css()}</style>
</head>
<body${home ? ' class="home"' : ""}>
<button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<a class="brand" href="${hrefToOutRel("index.html", page.outRel)}" aria-label="${productName} docs home">
<span class="mark" aria-hidden="true">${brandMarkSvg()}</span>
<span><strong>${escapeHtml(productName)}</strong><small>Messages.app CLI</small></span>
</a>
${themeToggleHtml()}
</div>
<label class="search"><span>Search</span><input id="doc-search" type="search" placeholder="watch, send, rpc, groups"></label>
<nav>${navHtml(page)}</nav>
</aside>
<main>
${heroBlock}
<div class="doc-grid${home ? " doc-grid-home" : ""}">
<article class="${articleClass}">${html}${prevNext}</article>
${tocBlock}
</div>
</main>
</div>
<script>${js()}</script>
</body>
</html>`;
}
function pageCanonicalUrl(page) {
if (!siteBase) return page.outRel;
if (page.outRel === "index.html") return `${siteBase}/`;
const rel = page.outRel.endsWith("/index.html") ? page.outRel.slice(0, -"index.html".length) : page.outRel;
return `${siteBase}/${rel}`;
}
function tagHtml([tag, k1, v1, k2, v2]) {
return tag === "link" ? `<link ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">` : `<meta ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`;
}
function pageNavHtml(prev, next, currentOutRel) {
const cell = (page, dir) => {
if (!page) return "";
return `<a class="page-nav-${dir}" href="${hrefToOutRel(page.outRel, currentOutRel)}"><small>${dir === "prev" ? "Previous" : "Next"}</small><span>${escapeHtml(page.title)}</span></a>`;
};
return `<nav class="page-nav" aria-label="Pager">${cell(prev, "prev")}${cell(next, "next")}</nav>`;
}
function navHtml(currentPage) {
return nav
.map((section) => `<section><h2>${escapeHtml(section.name)}</h2>${section.pages.map((page) => {
const href = hrefToOutRel(page.outRel, currentPage.outRel);
const active = page.rel === currentPage.rel ? " active" : "";
return `<a class="nav-link${active}" href="${href}">${escapeHtml(navTitle(page))}</a>`;
}).join("")}</section>`)
.join("");
}
function navTitle(page) {
if (page.rel === "index.md") return "Overview";
return page.title;
}
function hrefToOutRel(targetOutRel, currentOutRel) {
const currentDir = path.posix.dirname(currentOutRel);
if (targetOutRel.endsWith("/index.html")) {
const targetDir = targetOutRel.slice(0, -"index.html".length);
const rel = path.posix.relative(currentDir, targetDir || ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
if (targetOutRel === "index.html") {
const rel = path.posix.relative(currentDir, ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
}
function slug(text) {
return text.toLowerCase().replace(/`/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function validateLinks(outputDir) {
const failures = [];
const placeholderHrefs = /^(url|path|file|dir|name)$/i;
for (const file of allHtml(outputDir)) {
const html = fs.readFileSync(file, "utf8");
for (const match of html.matchAll(/href="([^"]+)"/g)) {
const href = match[1];
if (/^(#|https?:|mailto:|tel:|javascript:)/.test(href)) continue;
if (placeholderHrefs.test(href)) continue;
const [rawPath, anchor = ""] = href.split("#");
const targetPath = rawPath
? path.resolve(path.dirname(file), rawPath)
: file;
const target = fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
? path.join(targetPath, "index.html")
: targetPath;
if (!fs.existsSync(target)) {
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing ${path.relative(outputDir, target)}`);
continue;
}
if (anchor) {
const targetHtml = fs.readFileSync(target, "utf8");
if (!targetHtml.includes(`id="${anchor}"`) && !targetHtml.includes(`name="${anchor}"`)) {
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing anchor`);
}
}
}
}
if (failures.length) {
throw new Error(`broken docs links:\n${failures.join("\n")}`);
}
}
function allHtml(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allHtml(full);
return entry.name.endsWith(".html") ? [full] : [];
})
.sort();
}

35
scripts/build-linux.sh Executable file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
APP_NAME="imsg"
OUTPUT_DIR="${OUTPUT_DIR:-${ROOT}/dist}"
BUILD_MODE=${BUILD_MODE:-release}
TARGET_TRIPLE=$(swift -print-target-info | python3 -c 'import json,sys; print(json.load(sys.stdin)["target"]["triple"])')
BUILD_DIR="${ROOT}/.build/${TARGET_TRIPLE}/${BUILD_MODE}"
ARCHIVE_NAME="${APP_NAME}-linux-x86_64.tar.gz"
DIST_DIR="$(mktemp -d "/tmp/${APP_NAME}-linux.XXXXXX")"
cleanup() {
rm -rf "$DIST_DIR"
}
trap cleanup EXIT
if [[ "$(uname -s)" != "Linux" ]]; then
echo "scripts/build-linux.sh must run on Linux." >&2
exit 1
fi
swift build -c "$BUILD_MODE" --product "$APP_NAME"
cp "${BUILD_DIR}/${APP_NAME}" "${DIST_DIR}/${APP_NAME}"
for bundle in "${BUILD_DIR}"/*.bundle; do
if [[ -e "$bundle" ]]; then
cp -R "$bundle" "$DIST_DIR/"
fi
done
mkdir -p "$OUTPUT_DIR"
tar -C "$DIST_DIR" -czf "${OUTPUT_DIR}/${ARCHIVE_NAME}" .
echo "Built ${OUTPUT_DIR}/${ARCHIVE_NAME}"

View File

@ -0,0 +1,304 @@
export function css() {
return `
:root{
--ink:#1d1d1f;
--text:#1d1d1f;
--muted:#6e6e73;
--subtle:#86868b;
--bg:#fbfbfd;
--paper:#ffffff;
--tint:#0071e3;
--tint-hover:#0077ed;
--tint-soft:rgba(0,113,227,.10);
--bubble-blue:#0a84ff;
--bubble-grey:#e9e9eb;
--line:#d2d2d7;
--line-soft:#f0f0f3;
--code-bg:#1d1d1f;
--code-fg:#f5f5f7;
--code-inline-fg:#1d1d1f;
--hl-comment:#9ca3af;
--hl-keyword:#93c5fd;
--hl-string:#86efac;
--hl-number:#fbbf24;
--hl-literal:#c4b5fd;
--hl-key:#67e8f9;
--hl-variable:#f0abfc;
--hl-option:#fda4af;
--pill-border:#d2d2d7;
--shadow-card:0 1px 2px rgba(0,0,0,.04),0 6px 24px rgba(0,0,0,.06);
--scrollbar:#c7c7cc;
--radius-lg:18px;
--radius-md:12px;
--radius-sm:8px;
}
:root[data-theme="dark"]{
--ink:#f5f5f7;
--text:#e8e8ed;
--muted:#a1a1a6;
--subtle:#6e6e73;
--bg:#000000;
--paper:#1c1c1e;
--tint:#0a84ff;
--tint-hover:#409cff;
--tint-soft:rgba(10,132,255,.16);
--bubble-blue:#0a84ff;
--bubble-grey:#2c2c2e;
--line:#2c2c2e;
--line-soft:#1c1c1e;
--code-bg:#0a0a0a;
--code-fg:#f5f5f7;
--code-inline-fg:#f5f5f7;
--hl-comment:#8b949e;
--hl-keyword:#79c0ff;
--hl-string:#a5d6ff;
--hl-number:#ffa657;
--hl-literal:#d2a8ff;
--hl-key:#7ee787;
--hl-variable:#ff7b72;
--hl-option:#f2cc60;
--pill-border:#2c2c2e;
--shadow-card:0 1px 2px rgba(0,0,0,.4),0 8px 28px rgba(0,0,0,.5);
--scrollbar:#3a3a3c;
}
:root{color-scheme:light}
:root[data-theme="dark"]{color-scheme:dark}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:24px;-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","Inter",ui-sans-serif,system-ui,Segoe UI,sans-serif;line-height:1.6;overflow-x:hidden;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-feature-settings:"ss01","ss02","cv11";letter-spacing:-0.003em;transition:background-color .25s ease,color .25s ease}
::selection{background:var(--tint);color:#fff}
a{color:var(--tint);text-decoration:none;transition:color .15s ease}
a:hover{color:var(--tint-hover)}
.shell{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:100vh}
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:28px 22px 32px;background:var(--paper);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent;transition:background-color .25s ease,border-color .25s ease;backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px)}
.sidebar::-webkit-scrollbar{width:6px}
.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px}
.brand{display:flex;align-items:center;gap:12px;color:var(--ink);text-decoration:none;flex:1;min-width:0}
.brand:hover{color:var(--ink)}
.brand .mark{display:flex;align-items:center;justify-content:center;flex:0 0 32px;height:32px;width:32px;border-radius:9px;background:linear-gradient(135deg,#34c759 0%,#0a84ff 60%,#5e5ce6 100%);box-shadow:0 1px 1px rgba(0,0,0,.05),0 4px 10px rgba(10,132,255,.25)}
.brand .mark svg{width:18px;height:18px;color:#fff}
.brand strong{display:block;font-size:1.05rem;line-height:1.1;font-weight:600;letter-spacing:-0.01em;color:var(--ink)}
.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:3px;font-weight:400;letter-spacing:0}
.theme-toggle{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:34px;height:34px;border-radius:50%;border:1px solid var(--line);background:var(--paper);color:var(--muted);cursor:pointer;padding:0;transition:border-color .15s ease,color .15s ease,background-color .18s ease,transform .12s ease}
.theme-toggle:hover{border-color:var(--ink);color:var(--ink)}
.theme-toggle:active{transform:scale(.92)}
.theme-toggle svg{width:16px;height:16px;display:block}
.theme-icon-sun{display:none}
:root[data-theme="dark"] .theme-icon-sun{display:block}
:root[data-theme="dark"] .theme-icon-moon{display:none}
.search{display:block;margin:0 0 22px}
.search span{display:block;color:var(--muted);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:7px}
.search input{width:100%;border:1px solid var(--line);background:var(--paper);border-radius:10px;padding:9px 14px;font:inherit;font-size:.92rem;color:var(--text);outline:none;transition:border-color .15s ease,box-shadow .15s ease,background-color .18s ease}
.search input::placeholder{color:var(--subtle)}
.search input:focus{border-color:var(--tint);box-shadow:0 0 0 4px var(--tint-soft)}
nav section{margin:0 0 18px}
nav h2{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:0 0 6px;font-weight:600}
.nav-link{display:block;color:var(--text);text-decoration:none;border-radius:7px;padding:6px 11px;margin:1px 0;font-size:.93rem;line-height:1.4;transition:background .15s ease,color .15s ease;letter-spacing:-0.005em}
.nav-link:hover{background:var(--line-soft);color:var(--ink)}
.nav-link.active{background:var(--tint-soft);color:var(--tint);font-weight:600}
main{min-width:0;padding:32px clamp(20px,4.5vw,64px) 96px;max-width:1200px;margin:0 auto;width:100%}
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:8px;flex-wrap:wrap}
.hero-text{min-width:0;flex:1 1 320px}
.eyebrow{margin:0 0 8px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.06em;font-size:.7rem}
.hero h1{font-size:2.4rem;line-height:1.08;letter-spacing:-0.022em;margin:0;font-weight:700;color:var(--ink)}
.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap}
.repo,.edit,.btn-ghost{border:1px solid var(--line);color:var(--text);text-decoration:none;border-radius:980px;padding:6px 14px;font-weight:500;font-size:.83rem;background:var(--paper);transition:border-color .15s ease,color .15s ease,background .15s ease}
.repo:hover,.edit:hover,.btn-ghost:hover{border-color:var(--ink);color:var(--ink)}
.edit{color:var(--muted)}
.home-hero{padding:24px 0 36px;margin-bottom:8px;border-bottom:1px solid var(--line)}
.home-hero h1{font-size:clamp(2.6rem,5vw,3.75rem);line-height:1.04;letter-spacing:-0.028em;margin:0 0 .35em;font-weight:700;color:var(--ink);background:linear-gradient(180deg,var(--ink) 0%,var(--ink) 70%,var(--muted) 130%);-webkit-background-clip:text;background-clip:text}
.home-hero .lede{font-size:1.18rem;line-height:1.55;color:var(--muted);margin:0 0 1.6em;max-width:60ch;letter-spacing:-0.005em}
.home-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 22px}
.home-cta .btn{display:inline-flex;align-items:center;gap:7px;border-radius:980px;padding:10px 22px;font-weight:500;font-size:.95rem;text-decoration:none;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .12s ease}
.home-cta .btn-primary{background:var(--tint);color:#fff;border:1px solid var(--tint)}
.home-cta .btn-primary:hover{background:var(--tint-hover);border-color:var(--tint-hover);color:#fff}
.home-cta .btn-ghost{padding:10px 22px}
.home-install{display:flex;align-items:center;gap:12px;background:var(--code-bg);color:var(--code-fg);border-radius:14px;padding:12px 12px 12px 18px;font:500 .9rem/1.2 ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;max-width:32em;border:1px solid #2c2c2e;letter-spacing:0}
.home-install .prompt{color:#86868b;user-select:none;flex:0 0 auto}
.home-install code{flex:1;background:transparent;border:0;color:var(--code-fg);font:inherit;padding:0;white-space:pre;overflow:hidden;text-overflow:ellipsis}
.home-install .copy{flex:0 0 auto;background:rgba(255,255,255,.10);color:var(--code-fg);border:1px solid rgba(255,255,255,.18);border-radius:980px;padding:5px 13px;font:500 .72rem/1 -apple-system,"SF Pro Text",sans-serif;cursor:pointer;transition:background .15s ease,border-color .15s ease;letter-spacing:.01em}
.home-install .copy:hover{background:rgba(255,255,255,.18)}
.home-install .copy.copied{background:var(--tint);border-color:var(--tint)}
.home-services{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 22px}
.home-services span{display:inline-block;padding:4px 12px;border:1px solid var(--line);border-radius:980px;font-size:.78rem;color:var(--muted);background:var(--paper);font-weight:500;letter-spacing:0}
.muted{color:var(--muted);font-size:.92rem}
.muted a{color:var(--tint)}
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:48px;margin-top:24px}
.doc-grid-home{margin-top:8px}
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 220px;justify-content:start}.doc-grid-home{grid-template-columns:minmax(0,76ch);justify-content:start}}
.doc{min-width:0;max-width:72ch;overflow-wrap:break-word}
.doc-home{max-width:76ch}
.doc h1{font-size:2.6rem;line-height:1.05;letter-spacing:-0.024em;margin:0 0 .4em;font-weight:700;color:var(--ink)}
body:not(.home) .doc>h1:first-child{display:none}
.doc h2{font-size:1.55rem;line-height:1.18;margin:2.1em 0 .55em;font-weight:600;letter-spacing:-0.018em;color:var(--ink);position:relative}
.doc h3{font-size:1.18rem;margin:1.7em 0 .4em;position:relative;font-weight:600;color:var(--ink);letter-spacing:-0.012em}
.doc h4{font-size:1rem;margin:1.4em 0 .25em;color:var(--ink);position:relative;font-weight:600;letter-spacing:-0.008em}
.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:.2em}
.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1.05em;top:0;color:var(--subtle);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s ease,color .12s ease}
.doc :is(h2,h3,h4):hover .anchor{opacity:.7}
.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--tint);text-decoration:none}
.doc p{margin:0 0 1.05em;letter-spacing:-0.003em}
.doc ul,.doc ol{padding-left:1.4rem;margin:0 0 1.15em}
.doc li{margin:.3em 0}
.doc li>p{margin:0 0 .4em}
.doc strong{font-weight:600;color:var(--ink)}
.doc em{font-style:italic}
.doc code{font-family:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;font-size:.86em;background:var(--line-soft);border:1px solid var(--line);border-radius:6px;padding:.1em .4em;color:var(--code-inline-fg);letter-spacing:0}
.doc pre{position:relative;overflow:auto;background:var(--code-bg);color:var(--code-fg);border-radius:12px;padding:16px 20px;margin:1.4em 0;font-size:.86em;line-height:1.62;scrollbar-width:thin;scrollbar-color:#3a3a3c transparent;border:1px solid #2c2c2e;letter-spacing:0}
.doc pre::-webkit-scrollbar{height:8px;width:8px}
.doc pre::-webkit-scrollbar-thumb{background:#3a3a3c;border-radius:8px}
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
.doc pre .hl-comment{color:var(--hl-comment);font-style:italic}
.doc pre .hl-keyword{color:var(--hl-keyword);font-weight:500}
.doc pre .hl-string{color:var(--hl-string)}
.doc pre .hl-number{color:var(--hl-number)}
.doc pre .hl-literal{color:var(--hl-literal);font-weight:500}
.doc pre .hl-key{color:var(--hl-key)}
.doc pre .hl-variable{color:var(--hl-variable)}
.doc pre .hl-option{color:var(--hl-option)}
.doc pre .copy{position:absolute;top:10px;right:10px;background:rgba(255,255,255,.08);color:var(--code-fg);border:1px solid rgba(255,255,255,.18);border-radius:980px;padding:4px 12px;font:500 .7rem/1 -apple-system,"SF Pro Text",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s ease,background .15s ease,border-color .15s ease;letter-spacing:.01em}
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
.doc pre .copy:hover{background:rgba(255,255,255,.16)}
.doc pre .copy.copied{background:var(--tint);border-color:var(--tint);opacity:1}
.doc blockquote{margin:1.4em 0;padding:14px 18px;border-left:3px solid var(--tint);background:var(--tint-soft);border-radius:0 12px 12px 0;color:var(--text)}
.doc blockquote p:last-child{margin-bottom:0}
.doc table{width:100%;border-collapse:collapse;margin:1.3em 0;font-size:.93em}
.doc th,.doc td{border-bottom:1px solid var(--line);padding:10px 12px;text-align:left;vertical-align:top;letter-spacing:-0.003em}
.doc th{font-weight:600;color:var(--ink);background:var(--line-soft);border-bottom:1px solid var(--line)}
.doc hr{border:0;border-top:1px solid var(--line);margin:2.4em 0}
.toc{position:sticky;top:24px;align-self:start;font-size:.86rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.toc::-webkit-scrollbar{width:5px}
.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
.toc h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:0 0 10px;font-weight:600}
.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.35;border-left:2px solid transparent;margin-left:-12px;transition:color .12s ease,border-color .12s ease;letter-spacing:-0.003em}
.toc a:hover{color:var(--ink)}
.toc a.active{color:var(--tint);border-left-color:var(--tint);font-weight:500}
.toc-l3{padding-left:22px!important;font-size:.94em}
@media(max-width:1179px){.toc{display:none}}
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:56px;border-top:1px solid var(--line);padding-top:24px}
.page-nav>a{display:block;border:1px solid var(--line);background:var(--paper);border-radius:14px;padding:14px 18px;text-decoration:none;color:var(--text);transition:border-color .15s ease,transform .15s ease,box-shadow .15s ease,background-color .18s ease}
.page-nav>a:hover{border-color:var(--tint);box-shadow:var(--shadow-card);color:var(--ink)}
.page-nav small{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px;font-weight:600}
.page-nav span{display:block;font-weight:600;line-height:1.3;color:var(--ink);letter-spacing:-0.008em}
.page-nav-prev{text-align:left}
.page-nav-next{text-align:right;grid-column:2}
.page-nav-prev:only-child{grid-column:1}
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:42px;height:42px;border-radius:50%;background:var(--paper);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:11px 10px;flex-direction:column;align-items:stretch;justify-content:space-between;box-shadow:var(--shadow-card)}
.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s ease,opacity .2s ease}
.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}
.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}
.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
@media(max-width:900px){
.shell{display:block}
.sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease,background-color .25s ease,border-color .25s ease;box-shadow:0 18px 40px rgba(0,0,0,.18);background:var(--paper);pointer-events:none}
.sidebar.open{transform:translateX(0);pointer-events:auto}
.nav-toggle{display:flex}
main{padding:64px 18px 56px}
.hero{padding-top:6px}
.hero h1{font-size:1.85rem}
.home-hero h1{font-size:2.55rem}
.doc h1{font-size:2.15rem}
.hero-meta{width:100%;justify-content:flex-start}
.home-hero{padding-top:8px}
.doc{padding:0}
.doc-grid{margin-top:18px;gap:24px}
.doc :is(h2,h3,h4) .anchor{display:none}
}
@media(max-width:520px){
main{padding:60px 14px 48px}
.doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0}
.home-install{flex-wrap:wrap}
}
`;
}
export function js() {
return `
const themeRoot=document.documentElement;
function applyTheme(mode){themeRoot.dataset.theme=mode;document.querySelectorAll('[data-theme-toggle]').forEach(b=>b.setAttribute('aria-pressed',mode==='dark'?'true':'false'))}
function storedTheme(){try{return localStorage.getItem('theme')}catch(e){return null}}
function persistTheme(mode){try{localStorage.setItem('theme',mode)}catch(e){}}
applyTheme(themeRoot.dataset.theme==='dark'?'dark':'light');
document.querySelectorAll('[data-theme-toggle]').forEach(btn=>{btn.addEventListener('click',()=>{const next=themeRoot.dataset.theme==='dark'?'light':'dark';applyTheme(next);persistTheme(next)})});
const systemDark=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e){if(storedTheme())return;applyTheme(e.matches?'dark':'light')}
if(systemDark){if(systemDark.addEventListener)systemDark.addEventListener('change',onSystemChange);else if(systemDark.addListener)systemDark.addListener(onSystemChange)}
const sidebar=document.querySelector('.sidebar');
const toggle=document.querySelector('.nav-toggle');
const mobileNav=window.matchMedia('(max-width: 900px)');
const sidebarFocusable='a[href],button,input,select,textarea,[tabindex]';
function setSidebarFocusable(enabled){
sidebar?.querySelectorAll(sidebarFocusable).forEach((el)=>{
if(enabled){
if(el.dataset.sidebarTabindex!==undefined){
if(el.dataset.sidebarTabindex)el.setAttribute('tabindex',el.dataset.sidebarTabindex);
else el.removeAttribute('tabindex');
delete el.dataset.sidebarTabindex;
}
}else if(el.dataset.sidebarTabindex===undefined){
el.dataset.sidebarTabindex=el.getAttribute('tabindex')??'';
el.setAttribute('tabindex','-1');
}
});
}
function setSidebarOpen(open){
if(!sidebar||!toggle)return;
sidebar.classList.toggle('open',open);
toggle.setAttribute('aria-expanded',open?'true':'false');
if(mobileNav.matches){
sidebar.inert=!open;
if(open)sidebar.removeAttribute('aria-hidden');
else sidebar.setAttribute('aria-hidden','true');
setSidebarFocusable(open);
}else{
sidebar.inert=false;
sidebar.removeAttribute('aria-hidden');
setSidebarFocusable(true);
}
}
setSidebarOpen(false);
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
document.addEventListener('click',(e)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(e.target)||toggle?.contains(e.target))return;setSidebarOpen(false)});
document.addEventListener('keydown',(e)=>{if(e.key==='Escape')setSidebarOpen(false)});
const syncSidebarForViewport=()=>setSidebarOpen(sidebar?.classList.contains('open')??false);
if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarForViewport);
else mobileNav.addListener?.(syncSidebarForViewport);
const input=document.getElementById('doc-search');
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
function attachCopy(target,getText){const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='Copy';btn.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());btn.textContent='Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='Failed';setTimeout(()=>{btn.textContent='Copy'},1400)}});target.appendChild(btn)}
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
document.querySelectorAll('.home-install').forEach(el=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
const tocLinks=document.querySelectorAll('.toc a');
if(tocLinks.length){const map=new Map();tocLinks.forEach(a=>{const id=a.getAttribute('href').slice(1);const el=document.getElementById(id);if(el)map.set(el,a)});const setActive=l=>{tocLinks.forEach(x=>x.classList.remove('active'));l.classList.add('active')};const obs=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);if(visible.length){const link=map.get(visible[0].target);if(link)setActive(link)}},{rootMargin:'-15% 0px -65% 0px',threshold:0});map.forEach((_,el)=>obs.observe(el))}
`;
}
export function preThemeScript() {
return `(function(){var s;try{s=localStorage.getItem('theme')}catch(e){}var d=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.dataset.theme=s||(d?'dark':'light')})();`;
}
export function themeToggleHtml() {
return `<button class="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false" data-theme-toggle>
<svg class="theme-icon-moon" viewBox="0 0 20 20" aria-hidden="true"><path d="M14.6 12.1A6.5 6.5 0 0 1 7.4 2.7a6.5 6.5 0 1 0 7.2 9.4z" fill="currentColor"/></svg>
<svg class="theme-icon-sun" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="3.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="10" y1="2" x2="10" y2="4"/><line x1="10" y1="16" x2="10" y2="18"/><line x1="2" y1="10" x2="4" y2="10"/><line x1="16" y1="10" x2="18" y2="10"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="14.4" y1="14.4" x2="15.8" y2="15.8"/><line x1="4.2" y1="15.8" x2="5.6" y2="14.4"/><line x1="14.4" y1="5.6" x2="15.8" y2="4.2"/></g></svg>
</button>`;
}
export function brandMarkSvg() {
return `<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path fill="currentColor" d="M12 3.2C6.9 3.2 2.8 6.5 2.8 10.6c0 2.4 1.4 4.5 3.6 5.9-.1 1-.4 2.2-1.1 3 1.7-.2 3.1-1 4-1.8 1 .3 1.8.4 2.7.4 5.1 0 9.2-3.3 9.2-7.5S17.1 3.2 12 3.2z"/></svg>`;
}
export function faviconSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="imsg">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#34c759"/>
<stop offset="60%" stop-color="#0a84ff"/>
<stop offset="100%" stop-color="#5e5ce6"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<path fill="#ffffff" d="M32 14.4c-9.9 0-17.9 6.4-17.9 14.3 0 4.7 2.8 8.8 7.1 11.5-.3 1.9-.9 4.1-2.1 5.8 3.4-.4 6.1-1.9 7.8-3.5 1.6.4 3.3.7 5.1.7 9.9 0 17.9-6.4 17.9-14.5S41.9 14.4 32 14.4z"/>
</svg>`;
}

View File

@ -13,7 +13,7 @@ gh workflow run update-formula.yml \
--ref main \
-f formula=imsg \
-f tag="$TAG" \
-f repository=steipete/imsg \
-f repository=openclaw/imsg \
-f macos_artifact=imsg-macos.zip
echo "Homebrew tap update dispatched. Monitor: https://github.com/steipete/homebrew-tap/actions"

View File

@ -1 +1 @@
MARKETING_VERSION=0.6.0
MARKETING_VERSION=0.8.1