Compare commits

..

No commits in common. "main" and "v0.6.0" have entirely different histories.
main ... v0.6.0

66 changed files with 1037 additions and 9549 deletions

View File

@ -1,11 +1,11 @@
---
name: imsg
description: Use for local iMessage/SMS archive reads, iMessage contact lookup, visible Messages.app contact lookup, chat history, watch, and explicitly requested sends.
description: Use for local iMessage/SMS archive reads, chat history, watch, and explicitly requested sends.
---
# imsg
Use this for Messages.app history, chat lookup, streaming, visible UI contact lookup, and sends. Reading is local DB access; sending uses Messages automation and must be explicitly requested.
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.
## Sources
@ -22,21 +22,19 @@ Check DB access:
sqlite3 ~/Library/Messages/chat.db 'pragma quick_check;'
```
For a visible Messages.app person/name, start with chats. The UI-resolved name usually appears as `contact_name`; it may not appear in `imsg search`, raw `message.text`, or the `handle` table.
List chats:
```bash
imsg chats --limit 200 --json | jq -s '.[] | select((.contact_name // .display_name // .name // .identifier // "" | ascii_downcase) | contains("beatrix"))'
imsg chats --json | jq -s
```
Then read the chat by id:
Read a chat:
```bash
imsg history --chat-id ID --json | jq -s
```
Use `imsg search --query ... --json` for message-body search only; do not treat no search hits as proof that a visible UI contact does not exist. Use `--attachments` when attachment metadata matters. Use `--start`/`--end` with absolute timestamps for date-scoped questions.
Direct DB checks are only a fallback. The `handle` table is keyed by phone/email and often lacks the contact display name that `imsg chats` resolves.
Use `--attachments` when attachment metadata matters. Use `--start`/`--end` with absolute timestamps for date-scoped questions.
## Sends

View File

@ -6,10 +6,10 @@ on:
pull_request:
jobs:
macos:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Swift version
run: swift --version
- name: Install SwiftLint
@ -19,26 +19,4 @@ jobs:
- name: Test
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
run: make build ARCHES=$(uname -m)

View File

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

View File

@ -7,22 +7,16 @@ 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:
macos-release:
if: ${{ inputs.include_macos }}
release:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
@ -38,9 +32,7 @@ jobs:
- 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 }}
run: git checkout ${{ steps.tag.outputs.tag }}
- name: Resolve packages
run: swift package resolve
@ -70,7 +62,7 @@ jobs:
)
- name: Publish release assets
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: ${{ steps.tag.outputs.tag }}
@ -100,112 +92,3 @@ jobs:
fi
gh release edit "$TAG" --notes-file "$notes_file"
linux-release:
runs-on: ubuntu-latest
container: swift:6.2.4-noble
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Determine tag
id: tag
shell: bash
run: |
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git checkout ${{ steps.tag.outputs.tag }}
- name: Install Python
run: |
apt-get update
apt-get install -y --no-install-recommends python3
- name: Resolve packages
run: swift package resolve
- name: Patch dependencies
run: scripts/patch-deps.sh
- name: Sync version
run: scripts/generate-version.sh
- name: Build Linux archive
run: |
rm -rf dist
OUTPUT_DIR=dist scripts/build-linux.sh
- name: Publish Linux release asset
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: ${{ steps.tag.outputs.tag }}
files: dist/imsg-linux-x86_64.tar.gz
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-homebrew-tap:
if: ${{ inputs.include_macos }}
runs-on: ubuntu-latest
needs: macos-release
steps:
- name: Resolve release tag
run: echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
- name: Dispatch tap formula update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
exit 1
fi
request_id="imsg-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update imsg for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
--ref main \
-f formula=imsg \
-f tag="$RELEASE_TAG" \
-f repository=openclaw/imsg \
-f macos_artifact=imsg-macos.zip \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo steipete/homebrew-tap \
--exit-status \
--interval 10

1
.gitignore vendored
View File

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

View File

@ -1,66 +1,6 @@
# Changelog
## 0.8.1 - Unreleased
## 0.8.0 - 2026-05-08
### Linux Read-Only Preview
- feat: add a Linux read-only core build with fixture-backed tests and GitHub
CI coverage for copied Messages databases.
- build: add Linux release archive packaging for `imsg-linux-x86_64.tar.gz`.
- docs: document Linux as read-only support for existing copied Messages
databases.
### Message Decoding
- fix: strip printable typedstream length bytes from recovered `attributedBody`
text for 32-126 byte messages (#107, thanks @SagarSDagdu).
## 0.7.3 - 2026-05-06
### Private API Bridge
- fix: restore macOS 26 bridge sends, replies, tapbacks, typing/read RPC, and
chat/group lifecycle RPC methods after the BlueBubbles-inspired bridge port
regressed on Tahoe (#101, thanks @omarshahine).
- fix: stage bridge attachments with the target chat GUID and fall back to the
modern IMDPersistence save API when the legacy persistent-path API returns
nil (#102, #103, thanks @omarshahine).
### Security
- fix: harden bridge IPC queue directories and attachment paths against
symlink traversal while preserving trusted macOS system aliases like `/tmp`
(#105, thanks @omarshahine).
## 0.7.2 - 2026-05-06
### Release Packaging
- fix: publish a fresh signed and notarized macOS patch archive with matching
Homebrew metadata.
## 0.7.1 - 2026-05-06
### Release Packaging
- fix: ship a signed and notarized macOS release archive and refresh the
Homebrew checksum for the patch release.
## 0.7.0 - 2026-05-06
### Private API Bridge
- feat: port the BlueBubbles-inspired private-API bridge surface for rich sends,
message mutation, chat management, account/nickname introspection, and live
bridge events; add local DB search and v2 concurrent bridge IPC (#100, thanks
@omarshahine).
- fix: route default bridge calls over v2 IPC when available and reject
unsupported `chat-create --service SMS` requests instead of reporting a
service that was not applied.
- fix: decode typedstream attributed bodies with `0x81`/`0x82` length prefixes
so long fallback message text is preserved in history and watch output (#99,
thanks @SagarSDagdu).
### Docs And CI
- docs: publish the per-feature docs site at `imsg.sh` and add
syntax-highlighted code examples.
- ci: update GitHub Actions for the Node 24 runtime and quote workflow
architecture lookup.
## Unreleased
## 0.6.0 - 2026-05-05

View File

@ -1,6 +1,6 @@
SHELL := /bin/bash
.PHONY: help format lint test build imsg clean build-dylib docs-site
.PHONY: help format lint test build imsg clean build-dylib
help:
@printf "%s\n" \
@ -10,14 +10,13 @@ help:
"make build - universal release build into bin/" \
"make build-dylib - build injectable dylib for Messages.app" \
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
"make docs-site - build the imsg.sh docs site into dist/docs-site" \
"make clean - swift package clean"
format:
swift format --in-place --recursive Sources Tests TestsLinux
swift format --in-place --recursive Sources Tests
lint:
swift format lint --recursive Sources Tests TestsLinux
swift format lint --recursive Sources Tests
swiftlint
test:
@ -53,10 +52,6 @@ imsg:
swift build -c debug --product imsg
./.build/debug/imsg $(ARGS)
docs-site:
node scripts/build-docs-site.mjs
clean:
swift package clean
@rm -f .build/release/imsg-bridge-helper.dylib
@rm -rf dist/docs-site

View File

@ -2,85 +2,59 @@
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: {
var targets: [Target] = [
.target(
name: "IMsgCore",
dependencies: [
.product(name: "SQLite", package: "SQLite.swift"),
.product(name: "PhoneNumberKit", package: "PhoneNumberKit"),
],
linkerSettings: [
.linkedFramework("ScriptingBridge", .when(platforms: [.macOS])),
.linkedFramework("Contacts", .when(platforms: [.macOS])),
]
),
.executableTarget(
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",
dependencies: [
"IMsgCore",
.product(name: "Commander", package: "Commander"),
"IMsgCore",
.product(name: "Commander", package: "Commander"),
],
exclude: [
"Resources/Info.plist"
"Resources/Info.plist",
],
linkerSettings: [
.unsafeFlags(
[
"-Xlinker", "-sectcreate",
"-Xlinker", "__TEXT",
"-Xlinker", "__info_plist",
"-Xlinker", "Sources/imsg/Resources/Info.plist",
],
.when(platforms: [.macOS])
)
.unsafeFlags([
"-Xlinker", "-sectcreate",
"-Xlinker", "__TEXT",
"-Xlinker", "__info_plist",
"-Xlinker", "Sources/imsg/Resources/Info.plist",
])
]
),
),
.testTarget(
name: "IMsgCoreTests",
dependencies: [
"IMsgCore",
]
),
.testTarget(
name: "imsgTests",
dependencies: [
"imsg",
"IMsgCore",
]
),
]
#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
}()
)

340
README.md
View File

@ -1,47 +1,33 @@
# imsg
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.
`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.
`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)
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.
## Highlights
- **Local-first reads.** Chats, history, attachments, and search query
`chat.db` directly — no daemon, no network round-trip.
- **Live streams.** `imsg watch` follows filesystem events on `chat.db` and
falls back to a lightweight poll when macOS drops the event.
- **Send through Messages.app.** Text, files, and standard tapbacks ride the
public AppleScript surface — no private send APIs required.
- **Group-aware.** Direct chats, group threads, participants, GUIDs, and
per-chat account routing hints all show up in JSON.
- **Built for agents.** Stable JSON-RPC over stdio, deterministic JSON
schemas, and `imsg completions llm` for in-context CLI help.
- **Contacts integration.** Resolves names from Address Book when permission
is granted, while keeping raw handles in the output.
- **Attachment-aware.** Filenames, UTIs, byte counts, resolved paths, and
optional CAF→M4A / GIF→PNG conversion for model consumers.
- **Advanced IMCore (opt-in).** Edit, unsend, delete, rich-text formatting,
effects, reply threading, group create/rename/photo, member add/remove,
read receipts, typing indicators, and live event streams via the bridge.
- **Linux read-only preview.** Inspect a copied Messages database from a Linux
host. No sending, no Messages.app integration.
- 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.
## Requirements
- macOS 14 or newer (macOS 26 / Tahoe supported, with caveats noted below).
- macOS 14 or newer.
- 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`.
@ -50,15 +36,10 @@ Full docs: **[imsg.sh](https://imsg.sh)**.
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:
@ -68,76 +49,80 @@ make build
./bin/imsg --help
```
## Quickstart
## Common Workflows
List recent chats:
```bash
# List recent chats.
imsg chats --limit 10 --json | jq -s
# Inspect one chat before automating against it.
imsg group --chat-id 42 --json
# Read history with attachment metadata.
imsg history --chat-id 42 --limit 20 --attachments --json
# Stream new messages, including tapbacks.
imsg watch --chat-id 42 --reactions --json
# Send a message — auto-pick iMessage or SMS.
imsg send --to "+14155551212" --text "on my way"
# Send a file (image, audio, document).
imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
# Send a standard tapback.
imsg react --chat-id 42 --reaction like
# Search local history.
imsg search --query "pizza" --match contains
imsg chats --limit 10
imsg chats --limit 10 --json
```
`--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.
Inspect one chat before sending or wiring automation:
```bash
imsg group --chat-id 42 --json
```
Read history:
```bash
imsg history --chat-id 42 --limit 20
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:
```bash
imsg watch --chat-id 42 --json
imsg watch --chat-id 42 --since-rowid 9000 --attachments --reactions --debounce 250ms --json
```
Send a message or file:
```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
imsg react --chat-id 42 --reaction like
```
Generate integration help:
```bash
imsg completions zsh
imsg completions llm
```
## 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`
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
`react` intentionally sends only the standard tapbacks that Messages.app exposes
reliably through automation. Custom emoji tapbacks can be read from
history/watch output, but are sent through the bridge `tapback` command.
history/watch output, but are not sent by the CLI.
## 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:
@ -150,7 +135,7 @@ Message objects include:
- `id`, `chat_id`, `chat_identifier`, `chat_guid`, `chat_name`
- `participants`, `is_group`
- `guid`, `reply_to_guid`, `thread_originator_guid`, `destination_caller_id`
- `guid`, `reply_to_guid`, `destination_caller_id`
- `sender`, `sender_name`, `is_from_me`, `text`, `created_at`
- `attachments`, `reactions`
@ -160,18 +145,27 @@ 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: `send`. See [docs/rpc.md](docs/rpc.md) for
request and response shapes.
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.
## Attachments
@ -180,39 +174,38 @@ 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` exposes cached, model-compatible receive-side
`--convert-attachments` can expose 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, through Messages.app.
`send --file` sends regular files, including audio files, 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.
@ -224,19 +217,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`, `search`, and read-only `rpc`
workflows do not require IMCore injection.
Default `send`, `chats`, `history`, `watch`, 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`, and IMCore bridge status are
opt-in. They require SIP to be disabled and a helper dylib to be injected into
Messages.app:
```bash
make build-dylib
@ -248,111 +241,15 @@ 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, re-enable SIP from Recovery mode with
To revert after testing advanced features, re-enable SIP from Recovery mode with
`csrutil enable`.
### Bridge command surface
The bridge implements a manual port of the BlueBubbles private-API surface
(inspired by their Apache-2.0 helper) into our own dylib — no third-party
binary. Most commands take a `--chat` argument that is the chat GUID
(e.g. `iMessage;-;+15551234567` for direct, `iMessage;+;chat0000` for
groups). Get a chat GUID via `imsg chats --json`.
Messaging:
```bash
# Rich send with effect + reply
imsg send-rich --chat 'iMessage;-;+15551234567' --text "boom" \
--effect com.apple.MobileSMS.expressivesend.impact \
--reply-to <messageGuid>
# Text formatting (macOS 15+ Sequoia): bold/italic/underline/strikethrough
# applied to specific ranges of the message body.
imsg send-rich --chat ... --text 'hello world' \
--format '[{"start":0,"length":5,"styles":["bold"]},
{"start":6,"length":5,"styles":["italic","underline"]}]'
# Multipart send (text-only in v1; per-part textFormatting also supported)
imsg send-multipart --chat 'iMessage;+;chat0000' \
--parts '[{"text":"hi"},
{"text":"there","textFormatting":[{"start":0,"length":5,"styles":["bold"]}]}]'
# Attachment (file or audio)
imsg send-attachment --chat ... --file ~/Pictures/img.jpg
imsg send-attachment --chat ... --file ~/audio.caf --audio
# Bridge tapback (custom emoji + remove supported here, unlike `imsg react`)
imsg tapback --chat ... --message <guid> --kind love
imsg tapback --chat ... --message <guid> --kind love --remove
```
Mutate (macOS 13+ — selector availability surfaced in `imsg status`):
```bash
imsg edit --chat ... --message <guid> --new-text "actually..."
imsg unsend --chat ... --message <guid>
imsg delete-message --chat ... --message <guid>
imsg notify-anyways --chat ... --message <guid>
```
Chat management:
```bash
imsg chat-create --addresses '+15551111111,+15552222222' --name 'Crew' --text 'gm'
imsg chat-name --chat ... --name 'Renamed'
imsg chat-photo --chat ... --file ~/Downloads/g.jpg # set
imsg chat-photo --chat ... # clear
imsg chat-add-member --chat ... --address +15553333333
imsg chat-remove-member --chat ... --address +15553333333
imsg chat-leave --chat ...
imsg chat-delete --chat ...
imsg chat-mark --chat ... --read # or --unread
```
`chat-create` currently creates iMessage chats only. SMS sending remains
available through `imsg send --service sms`.
Introspection:
```bash
imsg account # active iMessage account + aliases
imsg whois --address +15551234567 --type phone
imsg whois --address foo@bar.com --type email
imsg nickname --address +15551234567
```
Live events (typing indicators surfaced through the dylib):
```bash
imsg watch --bb-events # merge dylib events into stdout
imsg watch --bb-events --json # one JSON object per event
```
### v2 IPC under the hood
The dylib v1 used a single overwriting `.imsg-command.json` polled at 100ms,
which races when multiple CLI invocations run concurrently. v2 uses a
per-request UUID-keyed queue:
```
~/Library/Containers/com.apple.MobileSMS/Data/
.imsg-bridge-ready PID lock — set when injection is live
.imsg-rpc/in/<uuid>.json requests dropped here by the CLI (atomic rename)
.imsg-rpc/out/<uuid>.json responses written by the dylib (atomic rename)
.imsg-events.jsonl inbound async events (typing, alias-removed)
```
Set `IMSG_BRIDGE_LEGACY_IPC=1` to force the legacy single-file path for
debugging (existing v1 callers and un-rebuilt dylibs continue to work
without this).
## Development
```bash
@ -365,9 +262,4 @@ make build
tests.
The reusable Swift core lives in `Sources/IMsgCore`; the CLI target lives in
`Sources/imsg`; the injected helper lives in `Sources/IMsgHelper`.
## License
MIT. Not affiliated with Apple. iMessage and SMS are trademarks of their
respective owners.
`Sources/imsg`.

View File

@ -1,9 +1,6 @@
import CryptoKit
import Foundation
#if canImport(CryptoKit)
import CryptoKit
#endif
enum AttachmentResolver {
private struct ConversionPlan {
let targetExtension: String
@ -61,7 +58,9 @@ enum AttachmentResolver {
let modification = values?.contentModificationDate?.timeIntervalSince1970 ?? 0
let size = values?.fileSize ?? 0
let token = "\(sourceURL.path)|\(size)|\(modification)"
let digest = cacheDigest(for: token)
let digest = SHA256.hash(data: Data(token.utf8))
.map { String(format: "%02x", $0) }
.joined()
let base = sourceURL.deletingPathExtension().lastPathComponent
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
@ -159,23 +158,6 @@ 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,9 +1,6 @@
@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
@ -35,181 +32,153 @@ public final class NoOpContactResolver: ContactResolving, Sendable {
}
public final class ContactResolver: ContactResolving, @unchecked Sendable {
#if os(macOS)
private let phoneToName: [String: String]
private let emailToName: [String: String]
private let contacts: [ContactRecord]
private let normalizer = PhoneNumberNormalizer()
private let region: String
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
}
#else
public let contactsUnavailable = true
#endif
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
}
public static func create(region: String = "US") async -> any ContactResolving {
#if os(macOS)
let store = CNContactStore()
switch CNContactStore.authorizationStatus(for: .contacts) {
case .authorized:
return load(store: store, region: region)
case .notDetermined:
let granted = await requestAccess(store: store)
return granted
? load(store: store, region: region) : NoOpContactResolver(contactsUnavailable: true)
case .denied, .restricted:
return NoOpContactResolver(contactsUnavailable: true)
@unknown default:
return NoOpContactResolver(contactsUnavailable: true)
}
#else
_ = region
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)
#endif
@unknown default:
return NoOpContactResolver(contactsUnavailable: true)
}
}
public func displayName(for handle: String) -> String? {
#if os(macOS)
let lookup = normalizedLookupHandle(handle)
if lookup.contains("@") {
return emailToName[lookup.lowercased()]
}
return phoneToName[normalizer.normalize(lookup, region: region)]
#else
_ = handle
return nil
#endif
let lookup = normalizedLookupHandle(handle)
if lookup.contains("@") {
return emailToName[lookup.lowercased()]
}
return phoneToName[normalizer.normalize(lookup, region: region)]
}
public func displayNames(for handles: [String]) -> [String: String] {
#if os(macOS)
var resolved: [String: String] = [:]
for handle in handles {
if let name = displayName(for: handle) {
resolved[handle] = name
}
var resolved: [String: String] = [:]
for handle in handles {
if let name = displayName(for: handle) {
resolved[handle] = name
}
return resolved
#else
_ = handles
return [:]
#endif
}
return resolved
}
public func searchByName(_ query: String) -> [ContactMatch] {
#if os(macOS)
let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalizedQuery.isEmpty else { return [] }
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))
}
var matches: [ContactMatch] = []
for contact in contacts where contact.name.lowercased().contains(normalizedQuery) {
if let phone = contact.phones.first {
matches.append(ContactMatch(name: contact.name, handle: phone))
} else if let email = contact.emails.first {
matches.append(ContactMatch(name: contact.name, handle: email))
}
return matches
#else
_ = query
return []
#endif
}
return matches
}
#if os(macOS)
private static func requestAccess(store: CNContactStore) async -> Bool {
await withCheckedContinuation { continuation in
store.requestAccess(for: .contacts) { granted, _ in
continuation.resume(returning: granted)
private static func requestAccess(store: CNContactStore) async -> Bool {
await withCheckedContinuation { continuation in
store.requestAccess(for: .contacts) { granted, _ in
continuation.resume(returning: granted)
}
}
}
private static func load(store: CNContactStore, region: String) -> any ContactResolving {
let keysToFetch: [CNKeyDescriptor] = [
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactNicknameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
]
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
let normalizer = PhoneNumberNormalizer()
var phoneToName: [String: String] = [:]
var emailToName: [String: String] = [:]
var contacts: [ContactRecord] = []
do {
try store.enumerateContacts(with: request) { contact, _ in
guard let name = displayName(for: contact) else { return }
var phones: [String] = []
var emails: [String] = []
for number in contact.phoneNumbers {
let normalized = normalizer.normalize(number.value.stringValue, region: region)
phones.append(normalized)
phoneToName[normalized] = phoneToName[normalized] ?? name
}
for email in contact.emailAddresses {
let normalized = String(email.value).lowercased()
emails.append(normalized)
emailToName[normalized] = emailToName[normalized] ?? name
}
if !phones.isEmpty || !emails.isEmpty {
contacts.append(ContactRecord(name: name, phones: phones, emails: emails))
}
}
} catch {
return NoOpContactResolver(contactsUnavailable: true)
}
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] = []
return ContactResolver(
phoneToName: phoneToName,
emailToName: emailToName,
contacts: contacts,
region: region
)
}
do {
try store.enumerateContacts(with: request) { contact, _ in
guard let name = displayName(for: contact) else { return }
var phones: [String] = []
var emails: [String] = []
for number in contact.phoneNumbers {
let normalized = normalizer.normalize(number.value.stringValue, region: region)
phones.append(normalized)
phoneToName[normalized] = phoneToName[normalized] ?? name
}
for email in contact.emailAddresses {
let normalized = String(email.value).lowercased()
emails.append(normalized)
emailToName[normalized] = emailToName[normalized] ?? name
}
if !phones.isEmpty || !emails.isEmpty {
contacts.append(ContactRecord(name: name, phones: phones, emails: emails))
}
}
} catch {
return NoOpContactResolver(contactsUnavailable: true)
}
return ContactResolver(
phoneToName: phoneToName,
emailToName: emailToName,
contacts: contacts,
region: region
)
private static func displayName(for contact: CNContact) -> String? {
if !contact.nickname.isEmpty {
return contact.nickname
}
let name = [contact.givenName, contact.familyName]
.filter { !$0.isEmpty }
.joined(separator: " ")
return name.isEmpty ? nil : name
}
private 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))
}
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
return trimmed
}
}
#if os(macOS)
private struct ContactRecord: Sendable {
let name: String
let phones: [String]
let emails: [String]
}
#endif
private struct ContactRecord: Sendable {
let name: String
let phones: [String]
let emails: [String]
}

View File

@ -1,10 +1,9 @@
import Foundation
public enum IMsgError: LocalizedError, CustomStringConvertible, Sendable {
public enum IMsgError: LocalizedError, Sendable {
case permissionDenied(path: String, underlying: Error)
case invalidISODate(String)
case invalidService(String)
case unsupportedService(String)
case invalidChatTarget(String)
case appleScriptFailure(String)
case typingIndicatorFailed(String)
@ -36,8 +35,6 @@ public enum IMsgError: LocalizedError, CustomStringConvertible, Sendable {
return "Invalid ISO8601 date: \(value)"
case .invalidService(let value):
return "Invalid service: \(value)"
case .unsupportedService(let value):
return "Unsupported service: \(value)"
case .invalidChatTarget(let value):
return "Invalid chat target: \(value)"
case .appleScriptFailure(let message):
@ -56,8 +53,4 @@ public enum IMsgError: LocalizedError, CustomStringConvertible, Sendable {
return "Chat not found: \(chatID)"
}
}
public var description: String {
errorDescription ?? "Unknown imsg error"
}
}

View File

@ -55,23 +55,23 @@ public final class IMCoreBridge: @unchecked Sendable {
"handle": handle,
"typing": typing,
]
_ = try await invokeBridge(action: .typing, params: params)
_ = try await sendCommand(action: "typing", params: params)
}
/// Mark all messages as read in a conversation.
public func markAsRead(handle: String) async throws {
_ = try await invokeBridge(action: .read, params: ["handle": handle])
_ = try await sendCommand(action: "read", params: ["handle": handle])
}
/// List all available chats (for debugging).
public func listChats() async throws -> [[String: Any]] {
let response = try await invokeBridge(action: .listChats, params: [:])
let response = try await sendCommand(action: "list_chats", params: [:])
return response["chats"] as? [[String: Any]] ?? []
}
/// Get detailed status from the injected helper.
public func getStatus() async throws -> [String: Any] {
return try await invokeBridge(action: .status, params: [:])
return try await sendCommand(action: "status", params: [:])
}
/// Check availability and return a diagnostic message.
@ -131,7 +131,7 @@ public final class IMCoreBridge: @unchecked Sendable {
break
}
if launcher.hasReadyLockFile() {
if launcher.isInjectedAndReady() {
return (true, "Connected to Messages.app. IMCore features available.")
}
@ -150,22 +150,22 @@ public final class IMCoreBridge: @unchecked Sendable {
// MARK: - Private
private func invokeBridge(
action: BridgeAction, params: [String: Any]
private func sendCommand(
action: String, params: [String: Any]
) async throws -> [String: Any] {
do {
return try await IMsgBridgeClient.shared.invoke(action: action, params: params)
} catch let error as IMsgBridgeError {
switch error {
case .dylibReturnedError(let message):
if message.contains("Chat not found") {
let handle = params["handle"] as? String ?? "unknown"
throw IMCoreBridgeError.chatNotFound(handle)
}
throw IMCoreBridgeError.operationFailed(message)
default:
throw IMCoreBridgeError.connectionFailed(error.description)
let response = try await launcher.sendCommand(action: action, params: params)
if response["success"] as? Bool == true {
return response
}
let error = response["error"] as? String ?? "Unknown error"
if error.contains("Chat not found") {
let handle = params["handle"] as? String ?? "unknown"
throw IMCoreBridgeError.chatNotFound(handle)
}
throw IMCoreBridgeError.operationFailed(error)
} catch let error as MessagesLauncherError {
throw IMCoreBridgeError.connectionFailed(error.description)
}

View File

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

View File

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

View File

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

View File

@ -1,9 +1,6 @@
import Carbon
import Foundation
#if os(macOS)
import Carbon
#endif
public enum MessageService: String, Sendable, CaseIterable {
case auto
case imessage
@ -65,26 +62,20 @@ public struct MessageSender {
}
public func send(_ options: MessageSendOptions) throws {
#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 }
}
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)
#endif
try sendViaAppleScript(resolved, chatTarget: chatTarget, useChat: useChat)
}
private func stageAttachment(at path: String) throws -> String {
@ -211,60 +202,48 @@ public struct MessageSender {
}
private static func runAppleScript(source: String, arguments: [String]) throws {
#if os(macOS)
guard let script = NSAppleScript(source: source) else {
throw IMsgError.appleScriptFailure("Unable to compile AppleScript")
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
}
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
let message =
(errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown AppleScript error"
throw IMsgError.appleScriptFailure(message)
}
}
private static func shouldFallbackToOsascript(errorInfo: NSDictionary) -> Bool {
#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
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
}
private static func runOsascript(source: String, arguments: [String]) throws {

View File

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

View File

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

View File

@ -1,9 +1,6 @@
import Darwin
import Foundation
#if os(macOS)
import Darwin
#endif
public struct MessageWatcherConfiguration: Sendable, Equatable {
public var debounceInterval: TimeInterval
public var fallbackPollInterval: TimeInterval?
@ -60,9 +57,7 @@ private final class WatchState: @unchecked Sendable {
private let queue = DispatchQueue(label: "imsg.watch", qos: .userInitiated)
private var cursor: Int64
#if os(macOS)
private var sources: [DispatchSourceFileSystemObject] = []
#endif
private var sources: [DispatchSourceFileSystemObject] = []
private var pending = false
private var stopped = false
@ -92,14 +87,12 @@ private final class WatchState: @unchecked Sendable {
}
}
#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)
}
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()
@ -109,34 +102,30 @@ private final class WatchState: @unchecked Sendable {
func stop() {
queue.async {
self.stopped = true
#if os(macOS)
for source in self.sources {
source.cancel()
}
self.sources.removeAll()
#endif
for source in self.sources {
source.cancel()
}
self.sources.removeAll()
}
}
#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
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()
}
#endif
source.setCancelHandler {
close(fd)
}
source.resume()
return source
}
private func schedulePoll() {
if stopped { return }

View File

@ -1,371 +1,254 @@
import Foundation
#if os(macOS)
/// Manages Messages.app lifecycle for DYLD injection.
///
/// Kills any running Messages.app, relaunches with `DYLD_INSERT_LIBRARIES`
/// pointing to the imsg-bridge dylib, then waits for the lock file that
/// confirms the dylib is ready for commands.
public final class MessagesLauncher: @unchecked Sendable {
public static let shared = MessagesLauncher()
/// 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
}
private let messagesAppPath =
"/System/Applications/Messages.app/Contents/MacOS/Messages"
private let queue = DispatchQueue(label: "imsg.messages.launcher")
private let lock = NSLock()
/// 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 to inject.
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
/// Path to the dylib's append-only event log.
public var bridgeEventsFile: String {
containerPath + "/" + IMsgBridgeProtocol.eventsFileName
}
private let messagesAppPath =
"/System/Applications/Messages.app/Contents/MacOS/Messages"
private let queue = DispatchQueue(label: "imsg.messages.launcher")
private let lock = NSLock()
/// Path to the dylib to inject.
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
private init() {
let possiblePaths = [
"/usr/local/lib/imsg-bridge-helper.dylib",
".build/release/imsg-bridge-helper.dylib",
".build/debug/imsg-bridge-helper.dylib",
]
for path in possiblePaths {
if FileManager.default.fileExists(atPath: path) {
self.dylibPath = path
break
}
}
}
/// Check if Messages.app has published the bridge-ready lock file.
public func hasReadyLockFile() -> Bool {
FileManager.default.fileExists(atPath: lockFile)
}
/// Check if Messages.app is running with our dylib (lock file exists and responds to ping).
public func isInjectedAndReady() -> Bool {
guard hasReadyLockFile() else {
return false
}
do {
let response = try sendCommandSync(action: "ping", params: [:])
return response["success"] as? Bool == true
} catch {
return false
}
}
/// Ensure Messages.app is running with our dylib injected.
public func ensureRunning() throws {
if isInjectedAndReady() { return }
try launchInjectedMessages()
}
/// Ensure Messages.app is launched with the helper without touching legacy IPC.
public func ensureLaunched() throws {
if hasReadyLockFile() { return }
try launchInjectedMessages()
}
private func launchInjectedMessages() throws {
switch Self.currentSIPStatus() {
case .disabled:
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
case .enabled:
throw MessagesLauncherError.sipEnabled
case .unknown(let details):
throw MessagesLauncherError.sipStatusUnknown(details)
}
guard FileManager.default.fileExists(atPath: dylibPath) else {
throw MessagesLauncherError.dylibNotFound(dylibPath)
}
killMessages()
Thread.sleep(forTimeInterval: 1.0)
// Clean up stale IPC files
try? FileManager.default.removeItem(atPath: commandFile)
try? FileManager.default.removeItem(atPath: responseFile)
try? FileManager.default.removeItem(atPath: lockFile)
// Pre-create v2 RPC queue directories so the dylib can FSEvent-watch them
// immediately on startup (FSEventStream registration on a missing path
// silently fails to deliver events).
try ensureSecureQueueDirectory(bridgeInboxDirectory)
try ensureSecureQueueDirectory(bridgeOutboxDirectory)
try cleanQueueDirectory(bridgeInboxDirectory)
try cleanQueueDirectory(bridgeOutboxDirectory)
try launchWithInjection()
try waitForReady(timeout: 15.0)
}
private func ensureSecureQueueDirectory(_ path: String) throws {
if SecurePath.hasSymlinkComponent(path) {
throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)")
}
do {
try FileManager.default.createDirectory(
atPath: path,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
if SecurePath.hasSymlinkComponent(path) {
throw MessagesLauncherError.socketError(
"RPC queue path traverses a symlink (post-mkdir): \(path)")
}
try FileManager.default.setAttributes(
[.posixPermissions: 0o700], ofItemAtPath: path)
} catch let error as MessagesLauncherError {
throw error
} catch {
throw MessagesLauncherError.socketError("mkdir \(path): \(error.localizedDescription)")
}
}
}
private func cleanQueueDirectory(_ path: String) throws {
if SecurePath.hasSymlinkComponent(path) {
throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)")
}
let entries = try FileManager.default.contentsOfDirectory(atPath: path)
for entry in entries {
try FileManager.default.removeItem(atPath: (path as NSString).appendingPathComponent(entry))
}
/// Check if Messages.app is running with our dylib (lock file exists and responds to ping).
public func isInjectedAndReady() -> Bool {
guard FileManager.default.fileExists(atPath: lockFile) else {
return false
}
do {
let response = try sendCommandSync(action: "ping", params: [:])
return response["success"] as? Bool == true
} catch {
return false
}
}
/// Ensure Messages.app is running with our dylib injected.
public func ensureRunning() throws {
if isInjectedAndReady() { return }
switch Self.currentSIPStatus() {
case .disabled:
break
case .enabled:
throw MessagesLauncherError.sipEnabled
case .unknown(let details):
throw MessagesLauncherError.sipStatusUnknown(details)
}
/// 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()
guard FileManager.default.fileExists(atPath: dylibPath) else {
throw MessagesLauncherError.dylibNotFound(dylibPath)
}
/// 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)
}
killMessages()
Thread.sleep(forTimeInterval: 1.0)
// Clean up stale IPC files
try? FileManager.default.removeItem(atPath: commandFile)
try? FileManager.default.removeItem(atPath: responseFile)
try? FileManager.default.removeItem(atPath: lockFile)
try launchWithInjection()
try waitForReady(timeout: 15.0)
}
/// Kill Messages.app if running.
public func killMessages() {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
task.arguments = ["Messages"]
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
try? task.run()
task.waitUntilExit()
}
/// Send a command asynchronously.
public func sendCommand(
action: String, params: [String: Any]
) async throws -> [String: Any] {
try ensureRunning()
// Serialize params to JSON data to cross the Sendable boundary safely
let paramsData = try JSONSerialization.data(withJSONObject: params, options: [])
return try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<[String: Any], Error>) in
queue.async {
do {
let deserializedParams =
(try? JSONSerialization.jsonObject(with: paramsData, options: []))
as? [String: Any] ?? [:]
let response = try self.sendCommandSync(action: action, params: deserializedParams)
continuation.resume(returning: response)
} catch {
continuation.resume(throwing: error)
}
}
}
}
// 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)
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)
}
public enum SIPStatus: Equatable, Sendable {
case enabled
case disabled
case unknown(String)
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 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 waitForReady(timeout: TimeInterval) throws {
let deadline = Date().addingTimeInterval(timeout)
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
}
while Date() < deadline {
if FileManager.default.fileExists(atPath: lockFile) {
Thread.sleep(forTimeInterval: 0.5)
return
}
throw MessagesLauncherError.socketTimeout
Thread.sleep(forTimeInterval: 0.5)
}
private func sendCommandSync(
action: String, params: [String: Any]
) throws -> [String: Any] {
lock.lock()
defer { lock.unlock() }
throw MessagesLauncherError.socketTimeout
}
let command: [String: Any] = [
"id": Int(Date().timeIntervalSince1970 * 1000),
"action": action,
"params": params,
]
private func sendCommandSync(
action: String, params: [String: Any]
) throws -> [String: Any] {
lock.lock()
defer { lock.unlock() }
let jsonData = try JSONSerialization.data(withJSONObject: command, options: [])
try jsonData.write(to: URL(fileURLWithPath: commandFile))
let command: [String: Any] = [
"id": Int(Date().timeIntervalSince1970 * 1000),
"action": action,
"params": params,
]
let deadline = Date().addingTimeInterval(10.0)
while Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
let jsonData = try JSONSerialization.data(withJSONObject: command, options: [])
try jsonData.write(to: URL(fileURLWithPath: commandFile))
let deadline = Date().addingTimeInterval(10.0)
while Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
guard
let responseData = try? Data(contentsOf: URL(fileURLWithPath: responseFile)),
responseData.count > 2
else { continue }
// Check if command file was cleared (indicates processing completed)
if let cmdData = try? Data(contentsOf: URL(fileURLWithPath: commandFile)),
cmdData.count <= 2
{
guard
let 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
let response = try? JSONSerialization.jsonObject(with: responseData, options: [])
as? [String: Any]
else {
throw MessagesLauncherError.invalidResponse
}
// Clear response file
try? "".write(toFile: responseFile, atomically: true, encoding: .utf8)
return response
}
throw MessagesLauncherError.socketError("Timeout waiting for response")
}
throw MessagesLauncherError.socketError("Timeout waiting for response")
}
#else
/// Non-macOS stub. Linux can read copied Messages databases, but there is no
/// Messages.app process, SIP state, or DYLD injection bridge to launch.
public final class MessagesLauncher: @unchecked Sendable {
public static let shared = MessagesLauncher()
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
public var bridgeInboxDirectory: String { "/nonexistent/.imsg-rpc/in" }
public var bridgeOutboxDirectory: String { "/nonexistent/.imsg-rpc/out" }
public var bridgeEventsFile: String { "/nonexistent/.imsg-events.jsonl" }
private init() {}
public func hasReadyLockFile() -> Bool { false }
public func isInjectedAndReady() -> Bool { false }
public func ensureRunning() throws {
throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.")
}
public func ensureLaunched() throws {
throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.")
}
public func killMessages() {}
public func sendCommand(action: String, params: [String: Any]) async throws -> [String: Any] {
_ = action
_ = params
throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.")
}
public enum SIPStatus: Equatable, Sendable {
case enabled
case disabled
case unknown(String)
}
public static func currentSIPStatus() -> SIPStatus {
.unknown("System Integrity Protection is a macOS-only concept.")
}
}
#endif
}
public enum MessagesLauncherError: Error, CustomStringConvertible {
case dylibNotFound(String)

View File

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

View File

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

View File

@ -1,345 +1,314 @@
import Foundation
#if os(macOS)
/// Sends typing indicators for iMessage chats.
///
/// Prefers the IMCore bridge (via DYLD injection into Messages.app) which
/// is reliable on stock macOS with SIP disabled. Falls back to direct
/// IMCore access via `dlopen` when the bridge is unavailable.
public struct TypingIndicator: Sendable {
private static let daemonConnectionTracker = DaemonConnectionTracker()
/// 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
}
}
// Fallback: direct IMCore access (requires AMFI disabled + XPC plist)
try setTypingDirect(chatIdentifier: chatIdentifier, isTyping: isTyping)
}
/// Synchronous wrapper for the async bridge call using a Sendable result box.
private static func setTypingViaBridge(
bridge: IMCoreBridge, chatIdentifier: String, isTyping: Bool
) throws {
let semaphore = DispatchSemaphore(value: 0)
let box = BridgeResultBox()
Task { @Sendable in
do {
try await bridge.setTyping(for: chatIdentifier, typing: isTyping)
} catch {
box.setError(error)
}
semaphore.signal()
}
semaphore.wait()
if let error = box.error {
throw error
private static func 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 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)")
// 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)
}
defer { dlclose(handle) }
semaphore.signal()
}
semaphore.wait()
if let error = box.error {
throw error
}
}
try ensureDaemonConnection()
let chat = try lookupChat(identifier: chatIdentifier)
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) }
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 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 implementation = method_getImplementation(method)
}
try await sleep(UInt64(duration * 1_000_000_000))
try stopTyping(chatIdentifier)
stopped = true
}
typealias SetTypingFunc = @convention(c) (AnyObject, Selector, Bool) -> Void
let setTypingFunc = unsafeBitCast(implementation, to: SetTypingFunc.self)
setTypingFunc(chat, selector, isTyping)
private static func ensureDaemonConnection() throws {
guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else {
throw IMsgError.typingIndicatorFailed("IMDaemonController class not found")
}
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
let sharedSel = sel_registerName("sharedInstance")
guard controllerClass.responds(to: sharedSel) else {
throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available")
}
private static func ensureDaemonConnection() throws {
guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else {
throw IMsgError.typingIndicatorFailed("IMDaemonController class not found")
}
guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else {
throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance")
}
let sharedSel = sel_registerName("sharedInstance")
guard controllerClass.responds(to: sharedSel) else {
throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available")
}
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.hasAttemptedConnection = true
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else {
throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance")
}
daemonConnectionTracker.lock.lock()
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
if shouldAttemptConnection {
daemonConnectionTracker.hasAttemptedConnection = true
}
daemonConnectionTracker.lock.unlock()
if !shouldAttemptConnection { return }
let connectSel = sel_registerName("connectToDaemon")
if controller.responds(to: connectSel) {
_ = controller.perform(connectSel)
}
let maxAttempts = 50
for _ in 0..<maxAttempts {
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.hasAttemptedConnection = true
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
daemonConnectionTracker.lock.lock()
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
if shouldAttemptConnection {
daemonConnectionTracker.hasAttemptedConnection = true
}
daemonConnectionTracker.lock.unlock()
if !shouldAttemptConnection { return }
let connectSel = sel_registerName("connectToDaemon")
if controller.responds(to: connectSel) {
_ = controller.perform(connectSel)
}
let maxAttempts = 50
for _ in 0..<maxAttempts {
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
Thread.sleep(forTimeInterval: 0.1)
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
}
if !hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = true
daemonConnectionTracker.lock.unlock()
throw IMsgError.typingIndicatorFailed(
daemonUnavailableMessage()
)
}
Thread.sleep(forTimeInterval: 0.1)
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
}
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
}
if !hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = true
daemonConnectionTracker.lock.unlock()
throw IMsgError.typingIndicatorFailed(
daemonUnavailableMessage()
)
}
}
private static func hasLiveDaemonConnection(_ controller: AnyObject) -> Bool {
let isConnectedSel = sel_registerName("isConnected")
guard controller.responds(to: isConnectedSel) else { return false }
guard let value = controller.perform(isConnectedSel)?.takeUnretainedValue() else {
return false
}
if let number = value as? NSNumber {
return number.boolValue
}
return false
}
private static func lookupChat(identifier: String) throws -> NSObject {
guard let registryClass = objc_getClass("IMChatRegistry") as? NSObject.Type else {
throw IMsgError.typingIndicatorFailed("IMChatRegistry class not found")
}
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."
daemonConnectionTracker.lock.lock()
let connectionKnownUnavailable = daemonConnectionTracker.connectionKnownUnavailable
daemonConnectionTracker.lock.unlock()
if connectionKnownUnavailable {
throw IMsgError.typingIndicatorFailed(daemonUnavailableMessage())
}
static func chatLookupCandidates(for identifier: String) -> [String] {
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
let bareIdentifier = stripKnownChatPrefix(trimmed) ?? trimmed
var candidates = [trimmed]
if bareIdentifier != trimmed {
candidates.append(bareIdentifier)
}
for prefix in chatIdentifierPrefixes {
candidates.append(prefix + bareIdentifier)
}
return dedupe(candidates)
}
private static let chatIdentifierPrefixes = [
"iMessage;-;",
"iMessage;+;",
"SMS;-;",
"SMS;+;",
"any;-;",
"any;+;",
]
private static func stripKnownChatPrefix(_ value: String) -> String? {
for prefix in chatIdentifierPrefixes where value.hasPrefix(prefix) {
return String(value.dropFirst(prefix.count))
}
return nil
}
private static func dedupe(_ values: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for value in values where !value.isEmpty {
if seen.insert(value).inserted {
result.append(value)
}
}
return result
}
}
#else
/// Non-macOS stub. Linux can read copied databases, but typing indicators
/// require private IMCore APIs inside Messages.app.
public struct TypingIndicator: Sendable {
public static func startTyping(chatIdentifier: String) throws {
_ = chatIdentifier
throw unsupported()
}
public static func stopTyping(chatIdentifier: String) throws {
_ = chatIdentifier
throw unsupported()
}
public static func typeForDuration(chatIdentifier: String, duration: TimeInterval) async throws
{
_ = chatIdentifier
_ = duration
throw unsupported()
}
private static func unsupported() -> IMsgError {
IMsgError.typingIndicatorFailed(
"Typing indicators require Messages.app/IMCore and are only supported on macOS.")
}
}
#endif
#if os(macOS)
private final class DaemonConnectionTracker: @unchecked Sendable {
let lock = NSLock()
var hasAttemptedConnection = false
var connectionKnownUnavailable = false
throw IMsgError.typingIndicatorFailed(
"Chat not found for identifier: \(identifier). "
+ "Make sure Messages.app has an active conversation with this contact.")
}
/// 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()
}
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."
}
#endif
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
}
}
private final class DaemonConnectionTracker: @unchecked Sendable {
let lock = NSLock()
var hasAttemptedConnection = false
var connectionKnownUnavailable = false
}
/// Thread-safe box for passing an error out of a Task back to the calling thread.
private final class BridgeResultBox: @unchecked Sendable {
private let lock = NSLock()
private var _error: Error?
var error: Error? {
lock.lock()
defer { lock.unlock() }
return _error
}
func setError(_ error: Error) {
lock.lock()
_error = error
lock.unlock()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -22,27 +22,6 @@ struct CommandRouter {
StatusCommand.spec,
RpcCommand.spec,
CompletionsCommand.spec,
// Bridge-backed (require `imsg launch` + SIP off)
SendRichCommand.spec,
SendMultipartCommand.spec,
SendAttachmentCommand.spec,
BridgeReactCommand.spec,
EditCommand.spec,
UnsendCommand.spec,
DeleteMessageCommand.spec,
NotifyAnywaysCommand.spec,
ChatCreateCommand.spec,
ChatNameCommand.spec,
ChatPhotoCommand.spec,
ChatAddMemberCommand.spec,
ChatRemoveMemberCommand.spec,
ChatLeaveCommand.spec,
ChatDeleteCommand.spec,
ChatMarkCommand.spec,
SearchCommand.spec,
AccountCommand.spec,
WhoisCommand.spec,
NicknameCommand.spec,
]
let descriptor = CommandDescriptor(
name: rootName,
@ -82,8 +61,6 @@ struct CommandRouter {
do {
try await spec.run(invocation.parsedValues, runtime)
return 0
} catch is BridgeOutput.EmittedError {
return 1
} catch {
StdoutWriter.writeLine(String(describing: error))
return 1

View File

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

View File

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

View File

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

View File

@ -34,22 +34,6 @@ enum StatusCommand {
}
}()
// Probe the bridge for v2 readiness + selector availability.
var bridgeVersion: Int = 0
var v2Ready: Bool = false
var selectors: [String: Bool] = [:]
if availability.available {
do {
let data = try await IMsgBridgeClient.shared.invoke(
action: .status, params: [:], timeout: 3.0)
bridgeVersion = (data["bridge_version"] as? Int) ?? 0
v2Ready = (data["v2_ready"] as? Bool) ?? false
if let raw = data["selectors"] as? [String: Bool] { selectors = raw }
} catch {
// Bridge probe failure is non-fatal.
}
}
if runtime.jsonOutput {
let payload = StatusPayload(
basicFeatures: true,
@ -57,11 +41,7 @@ enum StatusCommand {
typingIndicators: availability.available,
readReceipts: availability.available,
sip: sipStatus,
message: availability.message,
bridgeVersion: bridgeVersion,
v2Ready: v2Ready,
selectors: selectors,
rpcMethods: kSupportedRPCMethods
message: availability.message
)
try JSONLines.print(payload)
} else {
@ -77,25 +57,12 @@ enum StatusCommand {
StdoutWriter.writeLine("Advanced features (typing, read receipts):")
if availability.available {
StdoutWriter.writeLine(" Available - IMCore bridge connected")
StdoutWriter.writeLine(
" bridge version: v\(bridgeVersion)\(v2Ready ? " (v2 inbox active)" : "")")
if !selectors.isEmpty {
StdoutWriter.writeLine(" selectors:")
for key in selectors.keys.sorted() {
let ok = selectors[key] ?? false
StdoutWriter.writeLine(" \(key): \(ok ? "" : "")")
}
}
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Available bridge commands:")
StdoutWriter.writeLine(" Send: imsg send-rich, send-multipart, send-attachment, tapback")
StdoutWriter.writeLine(" Mutate: imsg edit, unsend, delete-message, notify-anyways")
StdoutWriter.writeLine(
" Chat: imsg chat-create, chat-name, chat-photo, chat-add/remove-member, chat-leave/delete, chat-mark"
)
StdoutWriter.writeLine(" Introspect: imsg account, whois, nickname")
StdoutWriter.writeLine(" Local DB: imsg search")
StdoutWriter.writeLine(" Watch with events: imsg watch --bb-events")
StdoutWriter.writeLine("Available commands:")
StdoutWriter.writeLine(" imsg read --to <handle>")
StdoutWriter.writeLine(" imsg typing --to <handle>")
StdoutWriter.writeLine(" imsg launch")
StdoutWriter.writeLine(" imsg status")
} else {
StdoutWriter.writeLine(" Not available")
StdoutWriter.writeLine("")
@ -135,10 +102,6 @@ private struct StatusPayload: Encodable {
let readReceipts: Bool
let sip: String
let message: String
let bridgeVersion: Int
let v2Ready: Bool
let selectors: [String: Bool]
let rpcMethods: [String]
enum CodingKeys: String, CodingKey {
case basicFeatures = "basic_features"
@ -147,9 +110,5 @@ private struct StatusPayload: Encodable {
case readReceipts = "read_receipts"
case sip
case message
case bridgeVersion = "bridge_version"
case v2Ready = "v2_ready"
case selectors
case rpcMethods = "rpc_methods"
}
}

View File

@ -35,10 +35,6 @@ enum WatchCommand {
label: "reactions", names: [.long("reactions")],
help: "include reaction events (tapback add/remove) in the stream"
),
.make(
label: "bbEvents", names: [.long("bb-events")],
help: "include dylib-pushed events (typing, alias-removed) when injection is active"
),
]
)
),
@ -97,28 +93,6 @@ enum WatchCommand {
includeReactions: includeReactions
)
let bbEvents = values.flag("bbEvents")
if bbEvents {
let path = MessagesLauncher.shared.bridgeEventsFile
let tailer = IMsgEventTailer(path: path)
Task {
for await event in tailer.events() {
if runtime.jsonOutput {
var obj: [String: Any] = [
"kind": "bridge-event",
"event": event.name,
]
if let ts = event.timestamp { obj["ts"] = ts }
obj["data"] = event.decodedPayload()
try? JSONLines.printObject(obj)
} else {
let stamp = event.timestamp ?? CLIISO8601.format(Date())
StdoutWriter.writeLine("\(stamp) [bridge] \(event.name)")
}
}
}
}
let stream = streamProvider(watcher, chatID, sinceRowID, config)
for try await message in stream {
if !filter.allows(message) {

View File

@ -1,136 +0,0 @@
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,87 +233,6 @@ 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,30 +14,6 @@ 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
@ -125,26 +101,6 @@ 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.8.1</string>
<string>0.6.0</string>
<key>CFBundleVersion</key>
<string>0.8.1</string>
<string>0.6.0</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.8.1"
static let current = "0.6.0"
}

View File

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

View File

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

View File

@ -83,59 +83,6 @@ 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"
@ -209,83 +156,6 @@ func typedStreamParserTrimsControlCharacters() {
#expect(TypedStreamParser.parseAttributedBody(data) == "hello")
}
@Test
func typedStreamParserDecodesShortSingleBytePrefix() {
let text = "hello"
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodesMediumMessageWith0x81Prefix() {
let text = String(repeating: "A", count: 140)
let length = UInt8(text.utf8.count)
let bytes: [UInt8] =
[0x01, 0x2b, 0x81, length] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodesLongMessageWith0x82Prefix() {
let text = String(repeating: "B", count: 300)
let length = UInt16(text.utf8.count)
let lengthHi = UInt8((length >> 8) & 0xff)
let lengthLo = UInt8(length & 0xff)
let bytes: [UInt8] =
[0x01, 0x2b, 0x82, lengthHi, lengthLo] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDoesNotPrependPrintableAsciiLengthByte() {
// 64-byte body of 'A' length byte 0x40 ('@'), printable.
// Without the structured-prefix-wins rule, the raw decode keeps the '@' and beats the stripped body by one character.
let text = String(repeating: "A", count: 64)
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodes32ByteBodyAtLowerRegressionEdge() {
// 32-byte body length byte 0x20 (space). Lower edge of the 32126 printable-ASCII window.
let text = String(repeating: "A", count: 32)
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodes126ByteBodyAtUpperRegressionEdge() {
// 126-byte body length byte 0x7E ('~'). Upper edge of the window 0x7F is DEL/control and
// would be trimmed (not prepended), so 0x7E is the precise top of the failure range.
let text = String(repeating: "A", count: 126)
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserDecodesMultibyteUTF8BodyInRegressionWindow() {
// 12 × 🎉 = 48 UTF-8 bytes length byte 0x30 ('0'), printable. Confirms the structured-prefix
// preference works for non-ASCII bodies too the bug is byte-count driven, not ASCII-specific.
let text = String(repeating: "🎉", count: 12)
let bytes: [UInt8] =
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
}
@Test
func typedStreamParserHandlesMixedBinaryNoise() {
// First byte 0x42 is neither 0x81 nor 0x82, and does not equal segment.count - 1 (= 6).
// The decoder should fall back to no-prefix decoding without crashing.
let bytes: [UInt8] =
[0x01, 0x2b, 0x42, 0x68, 0x69, 0x21, 0x86, 0x84]
let result = TypedStreamParser.parseAttributedBody(Data(bytes))
#expect(result == "Bhi!")
}
@Test
func typedStreamParserDecodesUTF16LittleEndianBOM() throws {
var data = Data([0xff, 0xfe])

View File

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

View File

@ -106,27 +106,6 @@ func historyCommandJsonReportsDirectChatMetadata() async throws {
#expect(payload["participants"] as? [String] == ["+123"])
}
@Test
func searchCommandUsesLocalMessageStore() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "query": ["ell"], "match": ["contains"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await SearchCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
let payload = try jsonObject(from: output)
#expect(payload["text"] as? String == "hello")
#expect(payload["chat_id"] as? Int == 1)
}
@Test
func historyCommandRunsWithAttachmentsNonJson() async throws {
let path = try CommandTestDatabase.makePathWithAttachment()

View File

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

View File

@ -1,163 +0,0 @@
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

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

View File

@ -1,31 +1,22 @@
---
title: Releasing
description: "Cutting an imsg release: changelog, version bump, signed/notarized build, tag, GitHub release, Homebrew tap update."
---
# Releasing
## Release notes source
- GitHub Release notes come from `CHANGELOG.md` for the matching version section (`## X.Y.Z - YYYY-MM-DD`).
- 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`.
- Keep `## Unreleased` at the top (empty is fine).
## Steps
1. Update `CHANGELOG.md` and version
- Move entries from `Unreleased` into a new `## X.Y.Z - YYYY-MM-DD` section,
or date the existing `## X.Y.Z - Unreleased` section.
- Move entries from `Unreleased` into a new `## X.Y.Z - YYYY-MM-DD` 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`
@ -35,9 +26,6 @@ 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.
@ -46,14 +34,3 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,90 +0,0 @@
---
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,35 +0,0 @@
#!/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

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

View File

@ -13,7 +13,7 @@ gh workflow run update-formula.yml \
--ref main \
-f formula=imsg \
-f tag="$TAG" \
-f repository=openclaw/imsg \
-f repository=steipete/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.8.1
MARKETING_VERSION=0.6.0