Compare commits
No commits in common. "main" and "v0.7.2" 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
|
||||
|
||||
|
||||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@ -6,7 +6,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@ -20,25 +20,3 @@ jobs:
|
||||
run: make test
|
||||
- name: Build
|
||||
run: make build ARCHES="$(uname -m)"
|
||||
|
||||
linux-read-core:
|
||||
runs-on: ubuntu-latest
|
||||
container: swift:6.2.4-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Swift version
|
||||
run: swift --version
|
||||
- name: Install Python
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3
|
||||
- name: Generate version
|
||||
run: scripts/generate-version.sh
|
||||
- name: Resolve dependencies
|
||||
run: swift package resolve
|
||||
- name: Patch dependencies
|
||||
run: scripts/patch-deps.sh
|
||||
- name: Test Linux read core
|
||||
run: swift test
|
||||
- name: Build CLI
|
||||
run: swift build --product imsg
|
||||
|
||||
121
.github/workflows/release.yml
vendored
121
.github/workflows/release.yml
vendored
@ -7,18 +7,12 @@ 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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@ -1,34 +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).
|
||||
## Unreleased
|
||||
|
||||
## 0.7.2 - 2026-05-06
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@ -14,10 +14,10 @@ help:
|
||||
"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:
|
||||
|
||||
123
Package.swift
123
Package.swift
@ -2,85 +2,62 @@
|
||||
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",
|
||||
],
|
||||
exclude: [
|
||||
"README-live.md",
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
#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
|
||||
}()
|
||||
)
|
||||
|
||||
275
README.md
275
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`, bridge-backed rich send,
|
||||
message mutation, and chat management are opt-in. They require SIP to be
|
||||
disabled and a helper dylib to be injected into Messages.app:
|
||||
|
||||
```bash
|
||||
make build-dylib
|
||||
@ -248,37 +241,40 @@ 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`.
|
||||
inspired by their Apache-2.0 helper, into our own dylib (no third-party
|
||||
binary). Commands in this section require `imsg launch` first, which means
|
||||
SIP-disabled DYLD injection into Messages.app. Most commands take a `--chat`
|
||||
argument that is the chat guid (e.g. `iMessage;-;+15551234567` or
|
||||
`iMessage;+;chat0000` for groups). Get a chat guid via `imsg chats --json`.
|
||||
|
||||
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
|
||||
# Text formatting (macOS 15+ Sequoia only): bold/italic/underline/strikethrough
|
||||
# applied to specific ranges of the message body.
|
||||
imsg send-rich --chat ... --text 'hello world' \
|
||||
--format '[{"start":0,"length":5,"styles":["bold"]},
|
||||
{"start":6,"length":5,"styles":["italic","underline"]}]'
|
||||
|
||||
# Or load the ranges from a file
|
||||
imsg send-rich --chat ... --text "$(cat msg.txt)" --format-file ranges.json
|
||||
|
||||
# Multipart send (text-only in v1; per-part textFormatting also supported)
|
||||
imsg send-multipart --chat 'iMessage;+;chat0000' \
|
||||
--parts '[{"text":"hi"},
|
||||
@ -288,13 +284,12 @@ imsg send-multipart --chat 'iMessage;+;chat0000' \
|
||||
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`)
|
||||
# Tapback (bridge-backed; `imsg react` remains the AppleScript variant)
|
||||
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>
|
||||
@ -303,7 +298,6 @@ 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'
|
||||
@ -315,12 +309,10 @@ 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
|
||||
@ -328,8 +320,12 @@ imsg whois --address foo@bar.com --type email
|
||||
imsg nickname --address +15551234567
|
||||
```
|
||||
|
||||
Live events (typing indicators surfaced through the dylib):
|
||||
Local history search (does not require the bridge):
|
||||
```bash
|
||||
imsg search --query "pizza" --match contains
|
||||
```
|
||||
|
||||
Live events (typing indicators surfaced through the dylib):
|
||||
```bash
|
||||
imsg watch --bb-events # merge dylib events into stdout
|
||||
imsg watch --bb-events --json # one JSON object per event
|
||||
@ -350,8 +346,8 @@ per-request UUID-keyed queue:
|
||||
```
|
||||
|
||||
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).
|
||||
debugging (existing v1 callers / un-rebuilt dylibs continue to work without
|
||||
this).
|
||||
|
||||
## Development
|
||||
|
||||
@ -365,9 +361,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]
|
||||
}
|
||||
|
||||
@ -126,9 +126,6 @@ public final class IMsgBridgeClient: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func ensureDirectory(_ path: String) throws {
|
||||
if SecurePath.hasSymlinkComponent(path) {
|
||||
throw IMsgBridgeError.ioError("\(path) traverses a symlink")
|
||||
}
|
||||
var isDir: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: path, isDirectory: &isDir) {
|
||||
if isDir.boolValue { return }
|
||||
@ -136,14 +133,7 @@ public final class IMsgBridgeClient: @unchecked Sendable {
|
||||
}
|
||||
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
|
||||
atPath: path, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw IMsgBridgeError.ioError("mkdir \(path): \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
/// Live tailer for `.imsg-events.jsonl` written by the injected dylib.
|
||||
///
|
||||
/// Uses `DispatchSource.makeFileSystemObjectSource` watching `.write`,
|
||||
@ -40,11 +36,9 @@ public final class IMsgEventTailer: @unchecked Sendable {
|
||||
|
||||
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 source: DispatchSourceFileSystemObject?
|
||||
private var fd: Int32 = -1
|
||||
private var pending = Data()
|
||||
private var continuation: AsyncStream<Event>.Continuation?
|
||||
private let queue = DispatchQueue(label: "imsg.event.tailer")
|
||||
|
||||
@ -61,115 +55,109 @@ public final class IMsgEventTailer: @unchecked Sendable {
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
self.stop()
|
||||
}
|
||||
#if os(macOS)
|
||||
self.queue.async {
|
||||
self.openAndStart()
|
||||
}
|
||||
#endif
|
||||
self.queue.async {
|
||||
self.openAndStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
|
||||
private func reopen() {
|
||||
source?.cancel()
|
||||
source = nil
|
||||
if fd >= 0 {
|
||||
close(fd)
|
||||
fd = -1
|
||||
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
|
||||
}
|
||||
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()
|
||||
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 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 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 processPending() {
|
||||
while let nl = pending.firstIndex(of: 0x0A) {
|
||||
let line = pending[..<nl]
|
||||
pending.removeSubrange(...nl)
|
||||
guard !line.isEmpty else { continue }
|
||||
guard
|
||||
let obj = try? JSONSerialization.jsonObject(with: line, options: [])
|
||||
as? [String: Any]
|
||||
else { continue }
|
||||
let name = (obj["event"] as? String) ?? "unknown"
|
||||
let ts = obj["ts"] as? String
|
||||
let data = (obj["data"] as? [String: Any]) ?? [:]
|
||||
let payloadData = (try? JSONSerialization.data(withJSONObject: data, options: [])) ?? Data()
|
||||
continuation?.yield(Event(timestamp: ts, name: name, payloadJSON: payloadData))
|
||||
private func 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()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,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,305 @@
|
||||
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
|
||||
}
|
||||
/// Inbox directory for v2 RPC requests (`<uuid>.json` files dropped here by
|
||||
/// the CLI; consumed by the dylib).
|
||||
public var bridgeInboxDirectory: String {
|
||||
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
|
||||
+ IMsgBridgeProtocol.inboxDirectoryName
|
||||
}
|
||||
|
||||
/// Outbox directory for v2 RPC responses (`<uuid>.json` files written by
|
||||
/// the dylib; consumed by the CLI).
|
||||
public var bridgeOutboxDirectory: String {
|
||||
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
|
||||
+ IMsgBridgeProtocol.outboxDirectoryName
|
||||
}
|
||||
/// Outbox directory for v2 RPC responses (`<uuid>.json` files written by
|
||||
/// the dylib; consumed by the CLI).
|
||||
public var bridgeOutboxDirectory: String {
|
||||
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
|
||||
+ IMsgBridgeProtocol.outboxDirectoryName
|
||||
}
|
||||
|
||||
/// Path to the dylib's append-only event log.
|
||||
public var bridgeEventsFile: String {
|
||||
containerPath + "/" + IMsgBridgeProtocol.eventsFileName
|
||||
}
|
||||
/// Path to the dylib's append-only event log.
|
||||
public var bridgeEventsFile: String {
|
||||
containerPath + "/" + IMsgBridgeProtocol.eventsFileName
|
||||
}
|
||||
|
||||
private let messagesAppPath =
|
||||
"/System/Applications/Messages.app/Contents/MacOS/Messages"
|
||||
private let queue = DispatchQueue(label: "imsg.messages.launcher")
|
||||
private let lock = NSLock()
|
||||
private let messagesAppPath =
|
||||
"/System/Applications/Messages.app/Contents/MacOS/Messages"
|
||||
private let queue = DispatchQueue(label: "imsg.messages.launcher")
|
||||
private let lock = NSLock()
|
||||
|
||||
/// Path to the dylib to inject.
|
||||
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
|
||||
/// Path to the dylib to inject.
|
||||
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
|
||||
|
||||
private init() {
|
||||
let possiblePaths = [
|
||||
"/usr/local/lib/imsg-bridge-helper.dylib",
|
||||
".build/release/imsg-bridge-helper.dylib",
|
||||
".build/debug/imsg-bridge-helper.dylib",
|
||||
]
|
||||
for path in possiblePaths {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
self.dylibPath = path
|
||||
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 has published the bridge-ready lock file.
|
||||
public func hasReadyLockFile() -> Bool {
|
||||
FileManager.default.fileExists(atPath: lockFile)
|
||||
}
|
||||
|
||||
/// Check if Messages.app is running with our dylib (lock file exists and responds to ping).
|
||||
public func isInjectedAndReady() -> Bool {
|
||||
guard hasReadyLockFile() else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let response = try sendCommandSync(action: "ping", params: [:])
|
||||
return response["success"] as? Bool == true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure Messages.app is running with our dylib injected.
|
||||
public func ensureRunning() throws {
|
||||
if isInjectedAndReady() { return }
|
||||
try launchInjectedMessages()
|
||||
}
|
||||
|
||||
/// Ensure Messages.app is launched with the helper without touching legacy IPC.
|
||||
public func ensureLaunched() throws {
|
||||
if hasReadyLockFile() { return }
|
||||
try launchInjectedMessages()
|
||||
}
|
||||
|
||||
private func launchInjectedMessages() throws {
|
||||
switch Self.currentSIPStatus() {
|
||||
case .disabled:
|
||||
break
|
||||
case .enabled:
|
||||
throw MessagesLauncherError.sipEnabled
|
||||
case .unknown(let details):
|
||||
throw MessagesLauncherError.sipStatusUnknown(details)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
// Pre-create v2 RPC queue directories so the dylib can FSEvent-watch them
|
||||
// immediately on startup (FSEventStream registration on a missing path
|
||||
// silently fails to deliver events).
|
||||
try? FileManager.default.createDirectory(
|
||||
atPath: bridgeInboxDirectory, withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(
|
||||
atPath: bridgeOutboxDirectory, withIntermediateDirectories: true)
|
||||
cleanQueueDirectory(bridgeInboxDirectory)
|
||||
cleanQueueDirectory(bridgeOutboxDirectory)
|
||||
|
||||
try launchWithInjection()
|
||||
try waitForReady(timeout: 15.0)
|
||||
}
|
||||
|
||||
private func cleanQueueDirectory(_ path: String) {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: path)
|
||||
else { return }
|
||||
for entry in entries {
|
||||
try? FileManager.default.removeItem(atPath: (path as NSString).appendingPathComponent(entry))
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill Messages.app if running.
|
||||
public func killMessages() {
|
||||
let task = Process()
|
||||
task.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
|
||||
task.arguments = ["Messages"]
|
||||
task.standardOutput = FileHandle.nullDevice
|
||||
task.standardError = FileHandle.nullDevice
|
||||
try? task.run()
|
||||
task.waitUntilExit()
|
||||
}
|
||||
|
||||
/// Send a command asynchronously.
|
||||
public func sendCommand(
|
||||
action: String, params: [String: Any]
|
||||
) async throws -> [String: Any] {
|
||||
try ensureRunning()
|
||||
// Serialize params to JSON data to cross the Sendable boundary safely
|
||||
let paramsData = try JSONSerialization.data(withJSONObject: params, options: [])
|
||||
return try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<[String: Any], Error>) in
|
||||
queue.async {
|
||||
do {
|
||||
let deserializedParams =
|
||||
(try? JSONSerialization.jsonObject(with: paramsData, options: []))
|
||||
as? [String: Any] ?? [:]
|
||||
let response = try self.sendCommandSync(action: action, params: deserializedParams)
|
||||
continuation.resume(returning: response)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@ -38,43 +38,36 @@ enum TypedStreamParser {
|
||||
}
|
||||
|
||||
/// Strips a typedstream length prefix from `segment` and returns the longest valid UTF-8 decoding.
|
||||
/// Length prefix forms (BER-style): single byte (< 0x80), `0x81 NN`, or `0x82 NN NN`.
|
||||
/// 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.
|
||||
/// Length prefix forms (BER-style): single byte (< 0x80), `0x81 NN`, or `0x82 NN NN`. The older
|
||||
/// implementation only handled the single-byte form, which silently dropped any message longer
|
||||
/// than 127 bytes because the unstripped 0x81/0x82 byte is invalid as a UTF-8 leading byte.
|
||||
private static func decodeSegment(_ segment: [UInt8]) -> String {
|
||||
guard let first = segment.first else { return "" }
|
||||
|
||||
var structuredPrefixes: [Int] = []
|
||||
var prefixLengths: Set<Int> = [0]
|
||||
if first < 0x80, Int(first) == segment.count - 1 {
|
||||
structuredPrefixes.append(1)
|
||||
prefixLengths.insert(1)
|
||||
}
|
||||
if first == 0x81, segment.count >= 2 {
|
||||
structuredPrefixes.append(2)
|
||||
prefixLengths.insert(2)
|
||||
}
|
||||
if first == 0x82, segment.count >= 3 {
|
||||
structuredPrefixes.append(3)
|
||||
prefixLengths.insert(3)
|
||||
}
|
||||
|
||||
var bestStructured = ""
|
||||
var anyStructuredValid = false
|
||||
for prefixLen in structuredPrefixes {
|
||||
var best = ""
|
||||
for prefixLen in prefixLengths {
|
||||
guard prefixLen <= segment.count else { continue }
|
||||
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 candidate.count > best.count {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
if anyStructuredValid {
|
||||
return bestStructured
|
||||
}
|
||||
|
||||
return String(bytes: segment, encoding: .utf8)?
|
||||
.trimmingLeadingControlCharacters() ?? ""
|
||||
return best
|
||||
}
|
||||
|
||||
private static func findSequence(_ needle: [UInt8], in haystack: [UInt8], from start: 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
@ -2,43 +2,6 @@ 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 {}
|
||||
@ -118,8 +81,7 @@ enum SendRichCommand {
|
||||
),
|
||||
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 'iMessage;-;+15551234567' --text 'BOOM' --effect com.apple.MobileSMS.expressivesend.impact",
|
||||
"imsg send-rich --chat ... --text 'hello world' --format '[{\"start\":0,\"length\":5,\"styles\":[\"bold\"]}]'",
|
||||
]
|
||||
) { values, runtime in
|
||||
@ -137,9 +99,7 @@ enum SendRichCommand {
|
||||
"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 effect = values.option("effect"), !effect.isEmpty { params["effectId"] = effect }
|
||||
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
|
||||
if let reply = values.option("replyTo"), !reply.isEmpty {
|
||||
params["selectedMessageGuid"] = reply
|
||||
@ -225,9 +185,7 @@ enum SendMultipartCommand {
|
||||
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 effect = values.option("effect"), !effect.isEmpty { params["effectId"] = effect }
|
||||
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
|
||||
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
|
||||
@ -60,8 +60,7 @@ enum StatusCommand {
|
||||
message: availability.message,
|
||||
bridgeVersion: bridgeVersion,
|
||||
v2Ready: v2Ready,
|
||||
selectors: selectors,
|
||||
rpcMethods: kSupportedRPCMethods
|
||||
selectors: selectors
|
||||
)
|
||||
try JSONLines.print(payload)
|
||||
} else {
|
||||
@ -138,7 +137,6 @@ private struct StatusPayload: Encodable {
|
||||
let bridgeVersion: Int
|
||||
let v2Ready: Bool
|
||||
let selectors: [String: Bool]
|
||||
let rpcMethods: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case basicFeatures = "basic_features"
|
||||
@ -150,6 +148,5 @@ private struct StatusPayload: Encodable {
|
||||
case bridgeVersion = "bridge_version"
|
||||
case v2Ready = "v2_ready"
|
||||
case selectors
|
||||
case rpcMethods = "rpc_methods"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.7.2</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.8.1</string>
|
||||
<string>0.7.2</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.7.2"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
@ -237,45 +184,6 @@ func typedStreamParserDecodesLongMessageWith0x82Prefix() {
|
||||
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDoesNotPrependPrintableAsciiLengthByte() {
|
||||
// 64-byte body of 'A' → length byte 0x40 ('@'), printable.
|
||||
// Without the structured-prefix-wins rule, the raw decode keeps the '@' and beats the stripped body by one character.
|
||||
let text = String(repeating: "A", count: 64)
|
||||
let bytes: [UInt8] =
|
||||
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
|
||||
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDecodes32ByteBodyAtLowerRegressionEdge() {
|
||||
// 32-byte body → length byte 0x20 (space). Lower edge of the 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).
|
||||
|
||||
@ -42,24 +42,6 @@ func bridgeMessagingCommandsExposeChatRequirement() async {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func bridgeAttachmentStagingUsesChatGuid() throws {
|
||||
let testFile = URL(fileURLWithPath: #filePath)
|
||||
let repoRoot =
|
||||
testFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let helper = repoRoot.appendingPathComponent("Sources/IMsgHelper/IMsgInjected.m")
|
||||
let source = try String(contentsOf: helper, encoding: .utf8)
|
||||
|
||||
#expect(source.contains("NSString *chatGuid, NSString **outErr)"))
|
||||
#expect(source.contains("[inv setArgument:&cg atIndex:5];"))
|
||||
#expect(
|
||||
source.contains("saveAttachmentsForTransfer:chatGUID:storeAtExternalLocation:completion:"))
|
||||
#expect(source.contains("prepareOutgoingTransfer(fileURL, filename, chatGuid, &prepErr)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatMarkRejectsConflictingFlags() async {
|
||||
let router = CommandRouter()
|
||||
@ -72,48 +54,6 @@ func chatMarkRejectsConflictingFlags() async {
|
||||
#expect(output.contains("Invalid value for option: --read"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expressiveSendEffectExpandsShortNames() {
|
||||
// Bubble effects map to MobileSMS.expressivesend.<name>.
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("invisibleink")
|
||||
== "com.apple.MobileSMS.expressivesend.invisibleink")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("impact")
|
||||
== "com.apple.MobileSMS.expressivesend.impact")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("loud")
|
||||
== "com.apple.MobileSMS.expressivesend.loud")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("gentle")
|
||||
== "com.apple.MobileSMS.expressivesend.gentle")
|
||||
|
||||
// Screen effects map to messages.effect.CK<TitleCase>Effect.
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("confetti")
|
||||
== "com.apple.messages.effect.CKConfettiEffect")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("lasers")
|
||||
== "com.apple.messages.effect.CKLasersEffect")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("celebration")
|
||||
== "com.apple.messages.effect.CKCelebrationEffect")
|
||||
|
||||
// Case-insensitive on the short form.
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("InvisibleInk")
|
||||
== "com.apple.MobileSMS.expressivesend.invisibleink")
|
||||
|
||||
// Already-expanded ids pass through untouched.
|
||||
let expanded = "com.apple.MobileSMS.expressivesend.impact"
|
||||
#expect(ExpressiveSendEffect.expand(expanded) == expanded)
|
||||
let screenExpanded = "com.apple.messages.effect.CKHeartEffect"
|
||||
#expect(ExpressiveSendEffect.expand(screenExpanded) == screenExpanded)
|
||||
|
||||
// Unknown short names pass through so the dylib can return its own error.
|
||||
#expect(ExpressiveSendEffect.expand("totally-not-real") == "totally-not-real")
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatCreateRejectsUnsupportedServiceBeforeBridgeLaunch() async {
|
||||
let values = ParsedValues(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -5,27 +5,21 @@ description: "Cutting an imsg release: changelog, version bump, signed/notarized
|
||||
|
||||
## Release notes source
|
||||
- GitHub Release notes come from `CHANGELOG.md` for the matching version section (`## X.Y.Z - YYYY-MM-DD`).
|
||||
- Keep 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 +29,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 +37,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.
|
||||
|
||||
@ -36,12 +36,10 @@ imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
|
||||
- **Built for agents.** Stable JSON-RPC over stdio, deterministic JSON schemas, and `imsg completions llm` for in-context CLI help.
|
||||
- **Contacts integration.** Resolves names from your Address Book when permission is granted, while keeping raw handles in the output.
|
||||
- **Attachment-aware.** Reports filenames, UTIs, byte counts, and resolved paths. Optional `--convert-attachments` exposes model-friendly CAF→M4A and GIF→PNG variants.
|
||||
- **Linux read-only preview.** Linux builds can inspect an existing Messages database copied from macOS. They do not send, mutate, or connect to Messages.app.
|
||||
|
||||
## Pick your path
|
||||
|
||||
- **Trying it.** [Install](install.md) → [Quickstart](quickstart.md). Five minutes from `brew install` to a streaming watch.
|
||||
- **Reading on Linux.** [Linux read-only preview](linux.md) covers copying an existing database from macOS and running read-only commands.
|
||||
- **Wiring up an agent.** [JSON output](json.md) and [JSON-RPC](rpc.md) cover the stable contracts; [completions](completions.md) shows how to feed the CLI reference into an LLM.
|
||||
- **Sending messages.** [Send](send.md) and [React](send.md#standard-tapbacks) explain text/file/group sends and how the Tahoe ghost-row check works.
|
||||
- **Diagnosing access.** [Permissions](permissions.md) and [Troubleshooting](troubleshooting.md).
|
||||
|
||||
@ -5,9 +5,6 @@ description: "Install imsg with Homebrew, build it from source, or pin a specifi
|
||||
|
||||
`imsg` ships as a signed, notarized universal macOS binary. It runs on macOS 14 (Sonoma) and newer, including macOS 26 (Tahoe).
|
||||
|
||||
0.8.0 and newer releases also publish Linux builds as a read-only preview for
|
||||
existing Messages databases copied from macOS. See [Linux read-only preview](linux.md).
|
||||
|
||||
## Homebrew
|
||||
|
||||
```bash
|
||||
@ -42,18 +39,6 @@ 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
|
||||
|
||||
@ -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
|
||||
```
|
||||
@ -14,10 +14,6 @@ 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.
|
||||
@ -97,5 +93,4 @@ imsg send --chat-id 42 --text "same thread"
|
||||
- [JSON output](json.md) — the stable schema agents should consume.
|
||||
- [JSON-RPC](rpc.md) — same surfaces, but over stdio with a single long-running process.
|
||||
- [Attachments](attachments.md) — metadata, original paths, and CAF/GIF conversion.
|
||||
- [Linux read-only preview](linux.md) — inspect a copied macOS Messages database on Linux.
|
||||
- [Troubleshooting](troubleshooting.md) — when reads silently return nothing.
|
||||
|
||||
@ -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}"
|
||||
@ -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.7.2
|
||||
|
||||
Loading…
Reference in New Issue
Block a user