Compare commits
No commits in common. "main" and "v0.6.0" have entirely different histories.
@ -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
|
||||
|
||||
|
||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@ -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)
|
||||
|
||||
54
.github/workflows/pages.yml
vendored
54
.github/workflows/pages.yml
vendored
@ -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
|
||||
125
.github/workflows/release.yml
vendored
125
.github/workflows/release.yml
vendored
@ -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
1
.gitignore
vendored
@ -39,7 +39,6 @@ Package.resolved
|
||||
|
||||
# Build artifacts
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# Node.js / pnpm
|
||||
pnpm-lock.yaml
|
||||
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@ -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
|
||||
|
||||
|
||||
11
Makefile
11
Makefile
@ -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
|
||||
|
||||
120
Package.swift
120
Package.swift
@ -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
340
README.md
@ -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`.
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 11–15. 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 32–126), 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?
|
||||
{
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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")" }
|
||||
}
|
||||
}
|
||||
@ -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)")"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 32–126 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])
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
```
|
||||
@ -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)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
imsg.sh
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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).
|
||||
@ -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.
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
103
docs/json.md
103
docs/json.md
@ -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.
|
||||
@ -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
|
||||
```
|
||||
@ -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.
|
||||
@ -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.
|
||||
185
docs/rpc.md
185
docs/rpc.md
@ -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..."}}
|
||||
```
|
||||
|
||||
122
docs/send.md
122
docs/send.md
@ -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`. |
|
||||
@ -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.
|
||||
102
docs/watch.md
102
docs/watch.md
@ -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.
|
||||
@ -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(/<(https?:\/\/[^\s<>]+)>/g, '<a href="$1">$1</a>');
|
||||
out = out.replace(/\\\|/g, "|");
|
||||
out = out.replace(/<br>/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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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();
|
||||
}
|
||||
@ -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}"
|
||||
@ -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>`;
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -1 +1 @@
|
||||
MARKETING_VERSION=0.8.1
|
||||
MARKETING_VERSION=0.6.0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user