Compare commits

..

17 Commits
v0.7.2 ... 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
36 changed files with 3513 additions and 1124 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,7 +6,7 @@ on:
pull_request:
jobs:
build:
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
@ -20,3 +20,25 @@ jobs:
run: make test
- name: Build
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

View File

@ -7,12 +7,18 @@ 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
@ -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
@ -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

View File

@ -1,6 +1,34 @@
# 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

View File

@ -14,10 +14,10 @@ help:
"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:

View File

@ -2,62 +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",
],
exclude: [
"README-live.md",
]
),
.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
}()
)

257
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`, 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:
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,40 +248,37 @@ 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). Commands in this section require `imsg launch` first, which means
SIP-disabled DYLD injection into Messages.app. Most commands take a `--chat`
argument that is the chat guid (e.g. `iMessage;-;+15551234567` or
`iMessage;+;chat0000` for groups). Get a chat guid via `imsg chats --json`.
(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 only): bold/italic/underline/strikethrough
# 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"]}]'
# Or load the ranges from a file
imsg send-rich --chat ... --text "$(cat msg.txt)" --format-file ranges.json
# Multipart send (text-only in v1; per-part textFormatting also supported)
imsg send-multipart --chat 'iMessage;+;chat0000' \
--parts '[{"text":"hi"},
@ -284,12 +288,13 @@ imsg send-multipart --chat 'iMessage;+;chat0000' \
imsg send-attachment --chat ... --file ~/Pictures/img.jpg
imsg send-attachment --chat ... --file ~/audio.caf --audio
# Tapback (bridge-backed; `imsg react` remains the AppleScript variant)
# 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>
@ -298,6 +303,7 @@ 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'
@ -309,10 +315,12 @@ 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
@ -320,12 +328,8 @@ imsg whois --address foo@bar.com --type email
imsg nickname --address +15551234567
```
Local history search (does not require the bridge):
```bash
imsg search --query "pizza" --match contains
```
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
@ -346,8 +350,8 @@ per-request UUID-keyed queue:
```
Set `IMSG_BRIDGE_LEGACY_IPC=1` to force the legacy single-file path for
debugging (existing v1 callers / un-rebuilt dylibs continue to work without
this).
debugging (existing v1 callers and un-rebuilt dylibs continue to work
without this).
## Development
@ -361,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

@ -126,6 +126,9 @@ public final class IMsgBridgeClient: @unchecked Sendable {
}
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 }
@ -133,7 +136,14 @@ public final class IMsgBridgeClient: @unchecked Sendable {
}
do {
try FileManager.default.createDirectory(
atPath: path, withIntermediateDirectories: true)
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

@ -1,5 +1,9 @@
import Foundation
#if os(macOS)
import Darwin
#endif
/// Live tailer for `.imsg-events.jsonl` written by the injected dylib.
///
/// Uses `DispatchSource.makeFileSystemObjectSource` watching `.write`,
@ -36,9 +40,11 @@ public final class IMsgEventTailer: @unchecked Sendable {
private let path: String
private let replayExisting: Bool
private var source: DispatchSourceFileSystemObject?
private var fd: Int32 = -1
private var pending = Data()
#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")
@ -55,109 +61,115 @@ public final class IMsgEventTailer: @unchecked Sendable {
continuation.onTermination = { @Sendable _ in
self.stop()
}
self.queue.async {
self.openAndStart()
}
#if os(macOS)
self.queue.async {
self.openAndStart()
}
#endif
}
}
public func stop() {
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
#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
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)
#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
}
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
private func reopen() {
source?.cancel()
source = nil
if fd >= 0 {
close(fd)
fd = -1
}
self.drainAvailable()
}
src.setCancelHandler { [weak self] in
guard let self else { return }
if self.fd >= 0 {
close(self.fd)
self.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()
}
}
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)
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()
}
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))
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,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,305 +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"
}
/// 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
}
/// Inbox directory for v2 RPC requests (`<uuid>.json` files dropped here by
/// the CLI; consumed by the dylib).
public var bridgeInboxDirectory: String {
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
+ IMsgBridgeProtocol.inboxDirectoryName
}
/// Outbox directory for v2 RPC responses (`<uuid>.json` files written by
/// the dylib; consumed by the CLI).
public var bridgeOutboxDirectory: String {
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
+ IMsgBridgeProtocol.outboxDirectoryName
}
/// Outbox directory for v2 RPC responses (`<uuid>.json` files written by
/// the dylib; consumed by the CLI).
public var bridgeOutboxDirectory: String {
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
+ IMsgBridgeProtocol.outboxDirectoryName
}
/// Path to the dylib's append-only event log.
public var bridgeEventsFile: String {
containerPath + "/" + IMsgBridgeProtocol.eventsFileName
}
/// 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()
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"
/// 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
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 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)
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)
// 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? FileManager.default.createDirectory(
atPath: bridgeInboxDirectory, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(
atPath: bridgeOutboxDirectory, withIntermediateDirectories: true)
cleanQueueDirectory(bridgeInboxDirectory)
cleanQueueDirectory(bridgeOutboxDirectory)
try launchWithInjection()
try waitForReady(timeout: 15.0)
}
private func cleanQueueDirectory(_ path: String) {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: path)
else { return }
for entry in entries {
try? FileManager.default.removeItem(atPath: (path as NSString).appendingPathComponent(entry))
}
}
/// Kill Messages.app if running.
public func killMessages() {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
task.arguments = ["Messages"]
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
try? task.run()
task.waitUntilExit()
}
/// Send a command asynchronously.
public func sendCommand(
action: String, params: [String: Any]
) async throws -> [String: Any] {
try ensureRunning()
// Serialize params to JSON data to cross the Sendable boundary safely
let paramsData = try JSONSerialization.data(withJSONObject: params, options: [])
return try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<[String: Any], Error>) in
queue.async {
do {
let deserializedParams =
(try? JSONSerialization.jsonObject(with: paramsData, options: []))
as? [String: Any] ?? [:]
let response = try self.sendCommandSync(action: action, params: deserializedParams)
continuation.resume(returning: response)
} catch {
continuation.resume(throwing: error)
/// 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

@ -38,36 +38,43 @@ enum TypedStreamParser {
}
/// 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`. The older
/// implementation only handled the single-byte form, which silently dropped any message longer
/// than 127 bytes because the unstripped 0x81/0x82 byte is invalid as a UTF-8 leading byte.
/// 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 prefixLengths: Set<Int> = [0]
var structuredPrefixes: [Int] = []
if first < 0x80, Int(first) == segment.count - 1 {
prefixLengths.insert(1)
structuredPrefixes.append(1)
}
if first == 0x81, segment.count >= 2 {
prefixLengths.insert(2)
structuredPrefixes.append(2)
}
if first == 0x82, segment.count >= 3 {
prefixLengths.insert(3)
structuredPrefixes.append(3)
}
var best = ""
for prefixLen in prefixLengths {
guard prefixLen <= segment.count else { continue }
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 }
if candidate.count > best.count {
best = candidate
anyStructuredValid = true
if candidate.count > bestStructured.count {
bestStructured = candidate
}
}
return best
if anyStructuredValid {
return bestStructured
}
return String(bytes: segment, encoding: .utf8)?
.trimmingLeadingControlCharacters() ?? ""
}
private static func findSequence(_ needle: [UInt8], in haystack: [UInt8], from start: 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

@ -2,6 +2,43 @@ 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 {}
@ -81,7 +118,8 @@ enum SendRichCommand {
),
usageExamples: [
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi'",
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'BOOM' --effect com.apple.MobileSMS.expressivesend.impact",
"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
@ -99,7 +137,9 @@ enum SendRichCommand {
"partIndex": Int(values.option("part") ?? "0") ?? 0,
"ddScan": !values.flag("noDDScan"),
]
if let effect = values.option("effect"), !effect.isEmpty { params["effectId"] = effect }
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
@ -185,7 +225,9 @@ enum SendMultipartCommand {
throw ParsedValuesError.invalidOption("parts")
}
var params: [String: Any] = ["chatGuid": chat, "parts": parts]
if let effect = values.option("effect"), !effect.isEmpty { params["effectId"] = effect }
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(

View File

@ -60,7 +60,8 @@ enum StatusCommand {
message: availability.message,
bridgeVersion: bridgeVersion,
v2Ready: v2Ready,
selectors: selectors
selectors: selectors,
rpcMethods: kSupportedRPCMethods
)
try JSONLines.print(payload)
} else {
@ -137,6 +138,7 @@ private struct StatusPayload: Encodable {
let bridgeVersion: Int
let v2Ready: Bool
let selectors: [String: Bool]
let rpcMethods: [String]
enum CodingKeys: String, CodingKey {
case basicFeatures = "basic_features"
@ -148,5 +150,6 @@ private struct StatusPayload: Encodable {
case bridgeVersion = "bridge_version"
case v2Ready = "v2_ready"
case selectors
case rpcMethods = "rpc_methods"
}
}

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.7.2</string>
<string>0.8.1</string>
<key>CFBundleVersion</key>
<string>0.7.2</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.7.2"
static let current = "0.8.1"
}

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"
@ -184,6 +237,45 @@ func typedStreamParserDecodesLongMessageWith0x82Prefix() {
#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).

View File

@ -42,6 +42,24 @@ func bridgeMessagingCommandsExposeChatRequirement() async {
}
}
@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()
@ -54,6 +72,48 @@ func chatMarkRejectsConflictingFlags() async {
#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(

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)
}

View File

@ -5,21 +5,27 @@ description: "Cutting an imsg release: changelog, version bump, signed/notarized
## 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`
@ -29,6 +35,9 @@ description: "Cutting an imsg release: changelog, version bump, signed/notarized
- `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.
@ -37,3 +46,14 @@ description: "Cutting an imsg release: changelog, version bump, signed/notarized
## 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.

View File

@ -36,10 +36,12 @@ imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
- **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).

View File

@ -5,6 +5,9 @@ description: "Install imsg with Homebrew, build it from source, or pin a specifi
`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
@ -39,6 +42,18 @@ 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

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
```

View File

@ -14,6 +14,10 @@ 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.
@ -93,4 +97,5 @@ imsg send --chat-id 42 --text "same thread"
- [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.

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

@ -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.7.2
MARKETING_VERSION=0.8.1