Compare commits
86 Commits
fix/watch-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa998e39f | ||
|
|
d5038414b2 | ||
|
|
23c5892688 | ||
|
|
03be1d9483 | ||
|
|
53b4ada222 | ||
|
|
9b0d341535 | ||
|
|
98fd924a7f | ||
|
|
0d1ca83815 | ||
|
|
f6de1c6fd5 | ||
|
|
e833e0c898 | ||
|
|
788f9f2a4b | ||
|
|
b2a4931016 | ||
|
|
c48ee7294b | ||
|
|
3695bfb96e | ||
|
|
311cc41a7f | ||
|
|
243226951f | ||
|
|
2d7b506d17 | ||
|
|
020e3de20e | ||
|
|
0ac10bbb85 | ||
|
|
e18a6431be | ||
|
|
34ff986c78 | ||
|
|
e0a2e972b8 | ||
|
|
672c0b7eb7 | ||
|
|
f017773382 | ||
|
|
c56c24d488 | ||
|
|
bbd6b93a1e | ||
|
|
c16daed4b4 | ||
|
|
a4ca952ea2 | ||
|
|
6c30d94e62 | ||
|
|
6a05484ae6 | ||
|
|
63b55b04d6 | ||
|
|
e982084640 | ||
|
|
edad072a41 | ||
|
|
327829a819 | ||
|
|
df2d928ff0 | ||
|
|
715a75fb4e | ||
|
|
9ec34e69fb | ||
|
|
8dcb9d087b | ||
|
|
5203f7461c | ||
|
|
7725473729 | ||
|
|
f8f0c5d712 | ||
|
|
6253bdd135 | ||
|
|
f40487bd67 | ||
|
|
55203c5960 | ||
|
|
810d62b529 | ||
|
|
abddb40268 | ||
|
|
19c4cd3083 | ||
|
|
af6fd822c1 | ||
|
|
60ed8a9f02 | ||
|
|
f0cd725c70 | ||
|
|
9d92837726 | ||
|
|
90b7d84cb9 | ||
|
|
79224803ac | ||
|
|
518144ee5b | ||
|
|
d825174537 | ||
|
|
13b4bff08a | ||
|
|
6b08ee1d4f | ||
|
|
d8c9f70f63 | ||
|
|
fb7b847531 | ||
|
|
f2fff0bdcf | ||
|
|
0a4388dd68 | ||
|
|
4ee8f6cd32 | ||
|
|
be6c0d3110 | ||
|
|
bf2d02e5e7 | ||
|
|
c9fa1c2003 | ||
|
|
a2c16b3cac | ||
|
|
5591b2e99e | ||
|
|
9c328a247d | ||
|
|
b43fc32f98 | ||
|
|
38fa96a3ee | ||
|
|
a2c0865a54 | ||
|
|
7fa01f182b | ||
|
|
69499d891f | ||
|
|
c608db6db7 | ||
|
|
5a9f5441b9 | ||
|
|
ba9a1ff079 | ||
|
|
3406429299 | ||
|
|
f849fcab14 | ||
|
|
deb01a0ef7 | ||
|
|
b78027b251 | ||
|
|
9321a77efa | ||
|
|
1b27e3b12c | ||
|
|
ee3c085070 | ||
|
|
7d51f301e0 | ||
|
|
1c7352f706 | ||
|
|
a03e81c8b4 |
64
.agents/skills/imsg/SKILL.md
Normal file
64
.agents/skills/imsg/SKILL.md
Normal file
@ -0,0 +1,64 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## Sources
|
||||
|
||||
- DB: `~/Library/Messages/chat.db`
|
||||
- Repo: `~/Projects/imsg`
|
||||
- CLI: `imsg`
|
||||
- JSON output is NDJSON; pipe to `jq -s` for arrays.
|
||||
|
||||
## Read Workflow
|
||||
|
||||
Check DB access:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```bash
|
||||
imsg chats --limit 200 --json | jq -s '.[] | select((.contact_name // .display_name // .name // .identifier // "" | ascii_downcase) | contains("beatrix"))'
|
||||
```
|
||||
|
||||
Then read the chat by id:
|
||||
|
||||
```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.
|
||||
|
||||
## Sends
|
||||
|
||||
Only send, react, mark read, or show typing when the user explicitly asks. Prefer dry wording in the final confirmation: recipient, service, and what was sent.
|
||||
|
||||
Common send command:
|
||||
|
||||
```bash
|
||||
imsg send --to "+15551234567" --text "message" --service auto
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
For repo edits:
|
||||
|
||||
```bash
|
||||
make test
|
||||
make build
|
||||
```
|
||||
|
||||
For live read proof:
|
||||
|
||||
```bash
|
||||
imsg chats --limit 3 --json | jq -s
|
||||
```
|
||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@ -6,10 +6,10 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Swift version
|
||||
run: swift --version
|
||||
- name: Install SwiftLint
|
||||
@ -19,4 +19,26 @@ jobs:
|
||||
- name: Test
|
||||
run: make test
|
||||
- name: Build
|
||||
run: make build ARCHES=$(uname -m)
|
||||
run: make build ARCHES="$(uname -m)"
|
||||
|
||||
linux-read-core:
|
||||
runs-on: ubuntu-latest
|
||||
container: swift:6.2.4-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Swift version
|
||||
run: swift --version
|
||||
- name: Install Python
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3
|
||||
- name: Generate version
|
||||
run: scripts/generate-version.sh
|
||||
- name: Resolve dependencies
|
||||
run: swift package resolve
|
||||
- name: Patch dependencies
|
||||
run: scripts/patch-deps.sh
|
||||
- name: Test Linux read core
|
||||
run: swift test
|
||||
- name: Build CLI
|
||||
run: swift build --product imsg
|
||||
|
||||
54
.github/workflows/pages.yml
vendored
Normal file
54
.github/workflows/pages.yml
vendored
Normal file
@ -0,0 +1,54 @@
|
||||
name: pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "scripts/build-docs-site.mjs"
|
||||
- "scripts/docs-site-assets.mjs"
|
||||
- "Makefile"
|
||||
- ".github/workflows/pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy docs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Build docs site
|
||||
run: make docs-site
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: dist/docs-site
|
||||
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
142
.github/workflows/release.yml
vendored
142
.github/workflows/release.yml
vendored
@ -7,16 +7,22 @@ on:
|
||||
description: "Tag to (re)release (e.g. v0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
include_macos:
|
||||
description: "Also rebuild and upload the macOS archive"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
macos-release:
|
||||
if: ${{ inputs.include_macos }}
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -32,7 +38,9 @@ jobs:
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: git checkout ${{ inputs.tag }}
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
git checkout ${{ steps.tag.outputs.tag }}
|
||||
|
||||
- name: Resolve packages
|
||||
run: swift package resolve
|
||||
@ -44,20 +52,12 @@ jobs:
|
||||
run: scripts/generate-version.sh
|
||||
|
||||
- name: Build
|
||||
run: swift build -c release --product imsg
|
||||
|
||||
- name: Codesign
|
||||
run: codesign --force --sign - --entitlements Resources/imsg.entitlements --identifier com.steipete.imsg .build/release/imsg
|
||||
run: |
|
||||
rm -rf dist
|
||||
OUTPUT_DIR=dist scripts/build-universal.sh
|
||||
|
||||
- name: Package artifact
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp .build/release/imsg dist/imsg
|
||||
for bundle in .build/release/*.bundle; do
|
||||
if [ -e "$bundle" ]; then
|
||||
cp -R "$bundle" dist/
|
||||
fi
|
||||
done
|
||||
(
|
||||
cd dist
|
||||
shopt -s nullglob
|
||||
@ -70,9 +70,12 @@ jobs:
|
||||
)
|
||||
|
||||
- name: Publish release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
name: ${{ steps.tag.outputs.tag }}
|
||||
files: dist/imsg-macos.zip
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@ -97,3 +100,112 @@ jobs:
|
||||
fi
|
||||
|
||||
gh release edit "$TAG" --notes-file "$notes_file"
|
||||
|
||||
linux-release:
|
||||
runs-on: ubuntu-latest
|
||||
container: swift:6.2.4-noble
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
git checkout ${{ steps.tag.outputs.tag }}
|
||||
|
||||
- name: Install Python
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3
|
||||
|
||||
- name: Resolve packages
|
||||
run: swift package resolve
|
||||
|
||||
- name: Patch dependencies
|
||||
run: scripts/patch-deps.sh
|
||||
|
||||
- name: Sync version
|
||||
run: scripts/generate-version.sh
|
||||
|
||||
- name: Build Linux archive
|
||||
run: |
|
||||
rm -rf dist
|
||||
OUTPUT_DIR=dist scripts/build-linux.sh
|
||||
|
||||
- name: Publish Linux release asset
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
name: ${{ steps.tag.outputs.tag }}
|
||||
files: dist/imsg-linux-x86_64.tar.gz
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-homebrew-tap:
|
||||
if: ${{ inputs.include_macos }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: macos-release
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
run: echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Dispatch tap formula update
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
request_id="imsg-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
expected_title="Update imsg for ${RELEASE_TAG} (${request_id})"
|
||||
|
||||
gh workflow run update-formula.yml \
|
||||
--repo steipete/homebrew-tap \
|
||||
--ref main \
|
||||
-f formula=imsg \
|
||||
-f tag="$RELEASE_TAG" \
|
||||
-f repository=openclaw/imsg \
|
||||
-f macos_artifact=imsg-macos.zip \
|
||||
-f request_id="$request_id"
|
||||
|
||||
run_id=""
|
||||
for _ in {1..30}; do
|
||||
run_id=$(gh run list \
|
||||
--repo steipete/homebrew-tap \
|
||||
--workflow update-formula.yml \
|
||||
--branch main \
|
||||
--event workflow_dispatch \
|
||||
--limit 20 \
|
||||
--json databaseId,displayTitle \
|
||||
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
|
||||
if [ -n "$run_id" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::Could not find tap workflow run with title: $expected_title"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh run watch "$run_id" \
|
||||
--repo steipete/homebrew-tap \
|
||||
--exit-status \
|
||||
--interval 10
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -39,6 +39,7 @@ Package.resolved
|
||||
|
||||
# Build artifacts
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# Node.js / pnpm
|
||||
pnpm-lock.yaml
|
||||
|
||||
152
CHANGELOG.md
152
CHANGELOG.md
@ -1,11 +1,128 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.1 - Unreleased
|
||||
## 0.8.1 - Unreleased
|
||||
|
||||
- fix: prefer handle sends when chat identifier is a direct handle
|
||||
## 0.8.0 - 2026-05-08
|
||||
|
||||
### Linux Read-Only Preview
|
||||
- feat: add a Linux read-only core build with fixture-backed tests and GitHub
|
||||
CI coverage for copied Messages databases.
|
||||
- build: add Linux release archive packaging for `imsg-linux-x86_64.tar.gz`.
|
||||
- docs: document Linux as read-only support for existing copied Messages
|
||||
databases.
|
||||
|
||||
### Message Decoding
|
||||
- fix: strip printable typedstream length bytes from recovered `attributedBody`
|
||||
text for 32-126 byte messages (#107, thanks @SagarSDagdu).
|
||||
|
||||
## 0.7.3 - 2026-05-06
|
||||
|
||||
### Private API Bridge
|
||||
- fix: restore macOS 26 bridge sends, replies, tapbacks, typing/read RPC, and
|
||||
chat/group lifecycle RPC methods after the BlueBubbles-inspired bridge port
|
||||
regressed on Tahoe (#101, thanks @omarshahine).
|
||||
- fix: stage bridge attachments with the target chat GUID and fall back to the
|
||||
modern IMDPersistence save API when the legacy persistent-path API returns
|
||||
nil (#102, #103, thanks @omarshahine).
|
||||
|
||||
### Security
|
||||
- fix: harden bridge IPC queue directories and attachment paths against
|
||||
symlink traversal while preserving trusted macOS system aliases like `/tmp`
|
||||
(#105, thanks @omarshahine).
|
||||
|
||||
## 0.7.2 - 2026-05-06
|
||||
|
||||
### Release Packaging
|
||||
- fix: publish a fresh signed and notarized macOS patch archive with matching
|
||||
Homebrew metadata.
|
||||
|
||||
## 0.7.1 - 2026-05-06
|
||||
|
||||
### Release Packaging
|
||||
- fix: ship a signed and notarized macOS release archive and refresh the
|
||||
Homebrew checksum for the patch release.
|
||||
|
||||
## 0.7.0 - 2026-05-06
|
||||
|
||||
### Private API Bridge
|
||||
- feat: port the BlueBubbles-inspired private-API bridge surface for rich sends,
|
||||
message mutation, chat management, account/nickname introspection, and live
|
||||
bridge events; add local DB search and v2 concurrent bridge IPC (#100, thanks
|
||||
@omarshahine).
|
||||
- fix: route default bridge calls over v2 IPC when available and reject
|
||||
unsupported `chat-create --service SMS` requests instead of reporting a
|
||||
service that was not applied.
|
||||
- fix: decode typedstream attributed bodies with `0x81`/`0x82` length prefixes
|
||||
so long fallback message text is preserved in history and watch output (#99,
|
||||
thanks @SagarSDagdu).
|
||||
|
||||
### Docs And CI
|
||||
- docs: publish the per-feature docs site at `imsg.sh` and add
|
||||
syntax-highlighted code examples.
|
||||
- ci: update GitHub Actions for the Node 24 runtime and quote workflow
|
||||
architecture lookup.
|
||||
|
||||
## 0.6.0 - 2026-05-05
|
||||
|
||||
### More Reliable Live Streams And History
|
||||
- fix: keep `imsg watch` streams alive with a lightweight polling fallback when macOS misses filesystem events (#78).
|
||||
- fix: dedupe URL preview balloon messages in `watch` without dropping similar messages from other chats or older database schemas (#64, thanks @lesaai).
|
||||
- fix: decode UTF-16LE BOM attributed bodies so plain-text history output recovers messages whose `text` column is empty (#91, thanks @clawbunny).
|
||||
- fix: speed up JSON history output by batching attachment and reaction metadata lookups (#81, thanks @kacy).
|
||||
- fix: speed up chat listing by using `chat_message_join.message_date` when Messages provides it (#76, thanks @tmad4000).
|
||||
- docs: clarify stale Full Disk Access grants, Terminal.app permissions, and watch fallback polling requirements (#28, #32, #33, #46, #83, thanks @wangran870414).
|
||||
|
||||
### Better Chat, Group, And Account Diagnostics
|
||||
- feat: add `imsg group --chat-id <id>` to inspect a chat's identifier, GUID, service, participants, account metadata, and group/direct status (#88, thanks @mryanb).
|
||||
- feat: resolve Contacts names in `chats`, `history`, `watch`, and direct sends while preserving raw handles for automation (#75, #77, thanks @regaw-leinad and @jsindy).
|
||||
- feat: expose read-only account routing hints (`account_id`, `account_login`, `last_addressed_handle`) for multi-number diagnostics (#18).
|
||||
- fix: include group metadata in CLI JSON history/watch output, not just RPC payloads (#57, thanks @clawbunny).
|
||||
|
||||
### Sending, RPC, And Automation Fixes
|
||||
- fix: return best-effort sent message `id` and `guid` from RPC `send` responses when the row can be observed after Messages accepts the send (#85).
|
||||
- fix: expose RPC watch debounce and default it to 500ms to reduce outbound echo races (#72, #80).
|
||||
- fix: gate RPC watch reaction metadata on `include_reactions`, not `attachments` (#82).
|
||||
- fix: confirm standard tapback reaction selection in Messages automation before reporting success (#53, thanks @PeterRosdahl).
|
||||
- fix: reject unsupported custom emoji reaction sends instead of taking a no-op AppleScript path (#55).
|
||||
- fix: detect Tahoe group-send ghost rows and fail instead of reporting false success (#90, thanks @loop).
|
||||
- docs: document standard tapback sending and watch reaction events (#66, thanks @safaaleigh).
|
||||
|
||||
### Attachments, Completions, And Install Polish
|
||||
- feat: optionally report model-compatible converted receive-side attachment files for CAF audio and GIF images (#73, thanks @mfzeidan).
|
||||
- feat: add shell completions and an LLM-oriented command reference generator (`imsg completions bash|zsh|fish|llm`) (#21, thanks @bdmorin).
|
||||
- fix: publish universal macOS release binaries for Homebrew installs (#68, #79).
|
||||
- docs: document the Homebrew install path in the README (#61, thanks @joshuayoes).
|
||||
- docs: clarify that `send --file` supports regular file and audio attachments through Messages.app (#35, thanks @rock19).
|
||||
- docs: add a local release helper for dispatching Homebrew tap updates (#97, thanks @dinakars777).
|
||||
|
||||
### Advanced IMCore / Tahoe Notes
|
||||
- feat: add advanced IMCore controls for `status`, `launch`, `read`, and typing diagnostics.
|
||||
- fix: normalize IMCore typing chat lookup across `iMessage`, `SMS`, and `any` prefixes (#51, #54, #56, #58).
|
||||
- fix: report macOS 26/Tahoe IMCore typing entitlement failures as advanced-feature setup errors instead of misleading chat lookup failures (#60).
|
||||
- docs: document macOS 26 advanced IMCore injection, library-validation, and private-entitlement limits (#60).
|
||||
|
||||
### Internal Safety
|
||||
- refactor: centralize Messages schema detection, row decoding, query assembly, typed row IDs, and attachment/reaction query paths behind smaller `MessageStore` extensions.
|
||||
- test: expand release packaging, CLI metadata, schema-compatibility, JSON newline, stdout capture, and live-read coverage.
|
||||
|
||||
## 0.5.0 - 2026-02-16
|
||||
|
||||
- feat: add typing indicator command + RPC methods with stricter validation (#41, thanks @kohoj)
|
||||
- feat: `--reactions` flag for `watch` command to include tapback events in stream (#26)
|
||||
- feat: `imsg react` command to send tapback reactions via UI automation (#24)
|
||||
- feat: reaction events include `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, `reacted_to_guid` fields
|
||||
- feat: add `include_reactions` toggle to `watch.subscribe` RPC and extend RPC reaction metadata fields
|
||||
- feat: include `thread_originator_guid` in message output (#39, thanks @ruthmade)
|
||||
- feat: expose `destination_caller_id` in message output (#29, thanks @commander-alexander)
|
||||
- fix: apply history filters before limit (#20, thanks @tommybananas)
|
||||
- fix: flush watch output immediately when stdout is buffered (#43, thanks @ccaum)
|
||||
- feat: include `thread_originator_guid` in message output (#39, thanks @ruthmade)
|
||||
- fix: prefer handle sends when chat identifier is a direct handle
|
||||
- fix: detect groups from `;+;` prefix in guid/identifier for RPC payloads (#42, thanks @shivshil)
|
||||
- fix: harden `react` AppleScript execution and tighten group-handle detection paths
|
||||
- refactor: consolidate schema detection, stdout writing, and message/RPC payload mapping paths
|
||||
- test: split command test suites by domain and align group-handle expectations
|
||||
- docs: update changelog entries as typing/reaction work landed
|
||||
- chore: bump version marker to `0.5.0`
|
||||
|
||||
## 0.4.0 - 2026-01-07
|
||||
- feat: surface audio message transcriptions (thanks @antons)
|
||||
@ -16,8 +133,13 @@
|
||||
- ci: switch to make-based lint/test/build
|
||||
- docs: update build/test/release instructions
|
||||
- chore: replace pnpm scripts with make targets
|
||||
- refactor: split message-store query paths for clearer message retrieval internals
|
||||
- test: keep attachment tests isolated from user attachment directories
|
||||
- fix: address attachment upload error handling regressions
|
||||
- docs: refine changelog ordering/notes for patch-deps and 0.4.0 prep
|
||||
- chore: version housekeeping for the 0.3.1 -> 0.4.0 release transition
|
||||
|
||||
## 0.3.0 - 2026-01-02
|
||||
## 0.3.0 - 2026-01-03
|
||||
- feat: JSON-RPC server over stdin/stdout (`imsg rpc`) with chats, history, watch, and send
|
||||
- feat: group chat metadata in JSON/RPC output (participants, chat identifiers, is_group)
|
||||
- feat: tapback + emoji reaction support in JSON output (#8) — thanks @tylerwince
|
||||
@ -29,10 +151,19 @@
|
||||
- docs: add RPC + group chat notes
|
||||
- test: expand RPC/command coverage, add reaction fixtures, drop unused stdout helper
|
||||
- test: add coverage for sender fallback
|
||||
- feat: add IMCore send mode and IMCore-based reaction send path
|
||||
- fix: stabilize IMCore send and sender fallback behavior
|
||||
- change: remove private API send mode in favor of IMCore path
|
||||
- build: add/harden notarized release script checks
|
||||
- chore: update copyright year to 2026
|
||||
- test: split message-store fixtures for more isolated reaction/sender coverage
|
||||
- docs: maintain unreleased/release changelog staging for 0.2.2/0.3.0
|
||||
- chore: release/prepare metadata updates for 0.3.0 and 0.3.1
|
||||
|
||||
## 0.2.1 - 2025-12-30
|
||||
- fix: avoid crash parsing long attributed bodies (>256 bytes) (thanks @tommybananas)
|
||||
- docs: prepare/backfill changelog notes for 0.2.1
|
||||
- chore: bump release version metadata to 0.2.1
|
||||
|
||||
## 0.2.0 - 2025-12-28
|
||||
- feat: Swift 6 rewrite with reusable IMsgCore library target
|
||||
@ -40,17 +171,25 @@
|
||||
- feat: event-driven watch using filesystem events (no polling)
|
||||
- feat: SQLite.swift + PhoneNumberKit + NSAppleScript integration
|
||||
- fix: ship PhoneNumberKit resource bundle for CLI installs
|
||||
- fix: patch/avoid PhoneNumberKit bundle lookup crashes across install layouts
|
||||
- fix: embed Info.plist + AppleEvents entitlement for automation prompts
|
||||
- fix: fall back to osascript when AppleEvents permission is missing
|
||||
- fix: retry osascript on transient unknown AppleScript errors
|
||||
- fix: decode length-prefixed attributed bodies for sent messages
|
||||
- fix: resolve CLI version detection for symlinked/bundle installs
|
||||
- chore: SwiftLint + swift-format linting
|
||||
- change: JSON attachment keys now snake_case
|
||||
- deprecation note: `--interval` replaced by `--debounce` (no compatibility)
|
||||
- docs: add release process documentation
|
||||
- ci: publish release notes from changelog and harden extraction
|
||||
- chore: reset release versioning during Swift rewrite stabilization
|
||||
- chore: version.env + generated version source for `--version`
|
||||
|
||||
## 0.1.1 - 2025-12-27
|
||||
- feat: `imsg chats --json`
|
||||
- fix: drop sqlite `immutable` flag so new messages/replies show up (thanks @zleman1593)
|
||||
- test: add/stabilize live update regression coverage
|
||||
- docs: add unreleased entry and backfill/prepare changelog history
|
||||
- chore: update go dependencies
|
||||
|
||||
## 0.1.0 - 2025-12-20
|
||||
@ -60,3 +199,8 @@
|
||||
- feat: `imsg send` text and/or one attachment (`--service imessage|sms|auto`, `--region`)
|
||||
- feat: attachment metadata output (`--attachments`) incl. resolved path + missing flag
|
||||
- fix: clearer Full Disk Access error for `~/Library/Messages/chat.db`
|
||||
- fix: coerce attachment aliasing in message parsing
|
||||
- build: add GoReleaser workflow and tag backfill support
|
||||
- ci: harden Go/lint environment setup and align toolchain/linter installation
|
||||
- docs: add repository guidelines/package docs and initial README polish
|
||||
- chore: bootstrap initial project/release scaffolding and dependency baseline
|
||||
|
||||
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
\g<1>2026 Peter Steinberger
|
||||
Copyright (c) 2026 Peter Steinberger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
38
Makefile
38
Makefile
@ -1,21 +1,23 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: help format lint test build imsg clean
|
||||
.PHONY: help format lint test build imsg clean build-dylib docs-site
|
||||
|
||||
help:
|
||||
@printf "%s\n" \
|
||||
"make format - swift format in-place" \
|
||||
"make lint - swift format lint + swiftlint" \
|
||||
"make test - sync version, patch deps, run swift test" \
|
||||
"make build - universal release build into bin/" \
|
||||
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
|
||||
"make clean - swift package clean"
|
||||
"make format - swift format in-place" \
|
||||
"make lint - swift format lint + swiftlint" \
|
||||
"make test - sync version, patch deps, run swift test" \
|
||||
"make build - universal release build into bin/" \
|
||||
"make build-dylib - build injectable dylib for Messages.app" \
|
||||
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
|
||||
"make docs-site - build the imsg.sh docs site into dist/docs-site" \
|
||||
"make clean - swift package clean"
|
||||
|
||||
format:
|
||||
swift format --in-place --recursive Sources Tests
|
||||
swift format --in-place --recursive Sources Tests TestsLinux
|
||||
|
||||
lint:
|
||||
swift format lint --recursive Sources Tests
|
||||
swift format lint --recursive Sources Tests TestsLinux
|
||||
swiftlint
|
||||
|
||||
test:
|
||||
@ -30,6 +32,19 @@ build:
|
||||
scripts/patch-deps.sh
|
||||
scripts/build-universal.sh
|
||||
|
||||
# Build injectable dylib for Messages.app (DYLD_INSERT_LIBRARIES).
|
||||
# Uses arm64e architecture to match Messages.app on Apple Silicon.
|
||||
# Requires SIP disabled on the target machine to inject into system apps.
|
||||
build-dylib:
|
||||
@echo "Building imsg-bridge-helper.dylib (injectable)..."
|
||||
@mkdir -p .build/release
|
||||
@clang -dynamiclib -arch arm64e -fobjc-arc \
|
||||
-Wno-arc-performSelector-leaks \
|
||||
-framework Foundation \
|
||||
-o .build/release/imsg-bridge-helper.dylib \
|
||||
Sources/IMsgHelper/IMsgInjected.m
|
||||
@echo "Built .build/release/imsg-bridge-helper.dylib"
|
||||
|
||||
imsg:
|
||||
scripts/generate-version.sh
|
||||
swift package resolve
|
||||
@ -38,5 +53,10 @@ imsg:
|
||||
swift build -c debug --product imsg
|
||||
./.build/debug/imsg $(ARGS)
|
||||
|
||||
docs-site:
|
||||
node scripts/build-docs-site.mjs
|
||||
|
||||
clean:
|
||||
swift package clean
|
||||
@rm -f .build/release/imsg-bridge-helper.dylib
|
||||
@rm -rf dist/docs-site
|
||||
|
||||
125
Package.swift
125
Package.swift
@ -2,58 +2,85 @@
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "imsg",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "IMsgCore", targets: ["IMsgCore"]),
|
||||
.executable(name: "imsg", targets: ["imsg"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
|
||||
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.4"),
|
||||
.package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.2"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "IMsgCore",
|
||||
dependencies: [
|
||||
.product(name: "SQLite", package: "SQLite.swift"),
|
||||
.product(name: "PhoneNumberKit", package: "PhoneNumberKit"),
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedFramework("ScriptingBridge"),
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "imsg",
|
||||
name: "imsg",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "IMsgCore", targets: ["IMsgCore"]),
|
||||
.executable(name: "imsg", targets: ["imsg"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.1"),
|
||||
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.5"),
|
||||
.package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.5"),
|
||||
],
|
||||
targets: {
|
||||
var targets: [Target] = [
|
||||
.target(
|
||||
name: "IMsgCore",
|
||||
dependencies: [
|
||||
"IMsgCore",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist",
|
||||
.product(name: "SQLite", package: "SQLite.swift"),
|
||||
.product(name: "PhoneNumberKit", package: "PhoneNumberKit"),
|
||||
],
|
||||
linkerSettings: [
|
||||
.unsafeFlags([
|
||||
"-Xlinker", "-sectcreate",
|
||||
"-Xlinker", "__TEXT",
|
||||
"-Xlinker", "__info_plist",
|
||||
"-Xlinker", "Sources/imsg/Resources/Info.plist",
|
||||
])
|
||||
.linkedFramework("ScriptingBridge", .when(platforms: [.macOS])),
|
||||
.linkedFramework("Contacts", .when(platforms: [.macOS])),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "IMsgCoreTests",
|
||||
dependencies: [
|
||||
"IMsgCore",
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "imsgTests",
|
||||
dependencies: [
|
||||
"imsg",
|
||||
"IMsgCore",
|
||||
]
|
||||
),
|
||||
),
|
||||
.executableTarget(
|
||||
name: "imsg",
|
||||
dependencies: [
|
||||
"IMsgCore",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist"
|
||||
],
|
||||
linkerSettings: [
|
||||
.unsafeFlags(
|
||||
[
|
||||
"-Xlinker", "-sectcreate",
|
||||
"-Xlinker", "__TEXT",
|
||||
"-Xlinker", "__info_plist",
|
||||
"-Xlinker", "Sources/imsg/Resources/Info.plist",
|
||||
],
|
||||
.when(platforms: [.macOS])
|
||||
)
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
#if os(macOS)
|
||||
targets.append(contentsOf: [
|
||||
.testTarget(
|
||||
name: "IMsgCoreTests",
|
||||
dependencies: [
|
||||
"IMsgCore"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "imsgTests",
|
||||
dependencies: [
|
||||
"imsg",
|
||||
"IMsgCore",
|
||||
],
|
||||
exclude: [
|
||||
"README-live.md"
|
||||
]
|
||||
),
|
||||
])
|
||||
#else
|
||||
targets.append(
|
||||
.testTarget(
|
||||
name: "IMsgLinuxTests",
|
||||
dependencies: [
|
||||
"imsg",
|
||||
"IMsgCore",
|
||||
.product(name: "SQLite", package: "SQLite.swift"),
|
||||
],
|
||||
path: "TestsLinux"
|
||||
))
|
||||
#endif
|
||||
|
||||
return targets
|
||||
}()
|
||||
)
|
||||
|
||||
395
README.md
395
README.md
@ -1,82 +1,373 @@
|
||||
# 💬 imsg — Send, read, stream iMessage & SMS
|
||||
# imsg
|
||||
|
||||
A macOS Messages.app CLI to send, read, and stream iMessage/SMS (with attachment metadata). Read-only for receives; send uses AppleScript (no private APIs).
|
||||
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.
|
||||
|
||||
## Features
|
||||
- List chats, view history, or stream new messages (`watch`).
|
||||
- Send text and attachments via iMessage or SMS (AppleScript, no private APIs).
|
||||
- Phone normalization to E.164 for reliable buddy lookup (`--region`, default US).
|
||||
- Optional attachment metadata output (mime, name, path, missing flag).
|
||||
- Filters: participants, start/end time, JSON output for tooling.
|
||||
- Read-only DB access (`mode=ro`), no DB writes.
|
||||
- Event-driven watch via filesystem events.
|
||||
`imsg` reads `~/Library/Messages/chat.db` directly, streams new rows over
|
||||
filesystem events (with a polling fallback), and drives Messages.app through
|
||||
its public AppleScript automation surface. Advanced IMCore controls (read
|
||||
receipts, typing indicators, edit/unsend, group management, rich sends) are
|
||||
opt-in behind a SIP-disabled dylib injection. Linux builds are a read-only
|
||||
preview against a `chat.db` copied from macOS.
|
||||
|
||||
Full docs: **[imsg.sh](https://imsg.sh)**.
|
||||
[Quickstart](https://imsg.sh/quickstart) ·
|
||||
[JSON schema](https://imsg.sh/json) ·
|
||||
[JSON-RPC](https://imsg.sh/rpc) ·
|
||||
[Changelog](CHANGELOG.md)
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Local-first reads.** Chats, history, attachments, and search query
|
||||
`chat.db` directly — no daemon, no network round-trip.
|
||||
- **Live streams.** `imsg watch` follows filesystem events on `chat.db` and
|
||||
falls back to a lightweight poll when macOS drops the event.
|
||||
- **Send through Messages.app.** Text, files, and standard tapbacks ride the
|
||||
public AppleScript surface — no private send APIs required.
|
||||
- **Group-aware.** Direct chats, group threads, participants, GUIDs, and
|
||||
per-chat account routing hints all show up in JSON.
|
||||
- **Built for agents.** Stable JSON-RPC over stdio, deterministic JSON
|
||||
schemas, and `imsg completions llm` for in-context CLI help.
|
||||
- **Contacts integration.** Resolves names from Address Book when permission
|
||||
is granted, while keeping raw handles in the output.
|
||||
- **Attachment-aware.** Filenames, UTIs, byte counts, resolved paths, and
|
||||
optional CAF→M4A / GIF→PNG conversion for model consumers.
|
||||
- **Advanced IMCore (opt-in).** Edit, unsend, delete, rich-text formatting,
|
||||
effects, reply threading, group create/rename/photo, member add/remove,
|
||||
read receipts, typing indicators, and live event streams via the bridge.
|
||||
- **Linux read-only preview.** Inspect a copied Messages database from a Linux
|
||||
host. No sending, no Messages.app integration.
|
||||
|
||||
## Requirements
|
||||
- macOS 14+ with Messages.app signed in.
|
||||
- Full Disk Access for your terminal to read `~/Library/Messages/chat.db`.
|
||||
- Automation permission for your terminal to control Messages.app (for sending).
|
||||
- For SMS relay, enable “Text Message Forwarding” on your iPhone to this Mac.
|
||||
|
||||
- macOS 14 or newer (macOS 26 / Tahoe supported, with caveats noted below).
|
||||
- Messages.app signed in to iMessage and/or SMS relay.
|
||||
- Full Disk Access for the terminal or parent app that launches `imsg`.
|
||||
- Automation permission for Messages.app when using `send` or `react`.
|
||||
- Optional Contacts permission for name resolution.
|
||||
- Optional `ffmpeg` on `PATH` for receive-side attachment conversion.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
make build
|
||||
# binary at ./bin/imsg
|
||||
./bin/imsg --help
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
`--json` emits one JSON object per line. Pipe to `jq -s` to materialize an
|
||||
array, or stream it to whatever consumer you're wiring up. Human progress and
|
||||
warnings always go to stderr so pipes stay parseable.
|
||||
|
||||
## Commands
|
||||
- `imsg chats [--limit 20] [--json]` — list recent conversations.
|
||||
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]`
|
||||
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--debounce 250ms] [--attachments] [--participants …] [--start …] [--end …] [--json]`
|
||||
- `imsg send --to <handle> [--text "hi"] [--file /path/img.jpg] [--service imessage|sms|auto] [--region US]`
|
||||
|
||||
### Quick samples
|
||||
```
|
||||
# list 5 chats
|
||||
imsg chats --limit 5
|
||||
Read, watch, and send (no special permissions beyond Full Disk Access and
|
||||
Automation):
|
||||
|
||||
# list chats as JSON
|
||||
imsg chats --limit 5 --json
|
||||
- `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 rpc`
|
||||
- `imsg completions bash|zsh|fish|llm`
|
||||
|
||||
# last 10 messages in chat 1 with attachments
|
||||
imsg history --chat-id 1 --limit 10 --attachments
|
||||
Advanced IMCore (require `imsg launch` with SIP off — see
|
||||
[Advanced IMCore](#advanced-imcore-features)):
|
||||
|
||||
# filter by date and emit JSON
|
||||
imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json
|
||||
- `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`
|
||||
|
||||
# live stream a chat
|
||||
imsg watch --chat-id 1 --attachments --debounce 250ms
|
||||
`react` intentionally sends only the standard tapbacks Messages.app exposes
|
||||
reliably through automation. Custom emoji tapbacks can be read from
|
||||
history/watch output, but are sent through the bridge `tapback` command.
|
||||
|
||||
# send a picture
|
||||
imsg send --to "+14155551212" --text "hi" --file ~/Desktop/pic.jpg --service imessage
|
||||
```
|
||||
## JSON Output
|
||||
|
||||
## Attachment notes
|
||||
`--attachments` prints per-attachment lines with name, MIME, missing flag, and resolved path (tilde expanded). Only metadata is shown; files aren’t copied.
|
||||
`--json` emits one JSON object per line, so consumers can stream it directly
|
||||
or collect it with `jq -s`.
|
||||
|
||||
## JSON output
|
||||
`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`.
|
||||
`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `guid`, `reply_to_guid`, `sender`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata with `filename`, `transfer_name`, `uti`, `mime_type`, `total_bytes`, `is_sticker`, `original_path`, `missing`), `reactions`.
|
||||
Chat objects include:
|
||||
|
||||
Note: `reply_to_guid` and `reactions` are read-only metadata.
|
||||
- `id`, `name`, `identifier`, `guid`, `service`, `last_message_at`
|
||||
- `display_name`, `contact_name`
|
||||
- `is_group`, `participants`
|
||||
- `account_id`, `account_login`, `last_addressed_handle`
|
||||
|
||||
## Permissions troubleshooting
|
||||
If you see “unable to open database file” or empty output:
|
||||
1) Grant Full Disk Access: System Settings → Privacy & Security → Full Disk Access → add your terminal.
|
||||
2) Ensure Messages.app is signed in and `~/Library/Messages/chat.db` exists.
|
||||
3) For send, allow the terminal under System Settings → Privacy & Security → Automation → Messages.
|
||||
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`
|
||||
- `sender`, `sender_name`, `is_from_me`, `text`, `created_at`
|
||||
- `attachments`, `reactions`
|
||||
|
||||
When `watch --reactions --json` sees a tapback event, the message object also
|
||||
includes `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`,
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
Read methods: `chats.list`, `messages.history`, `watch.subscribe`,
|
||||
`watch.unsubscribe`. Mutating: `send`. See [docs/rpc.md](docs/rpc.md) for
|
||||
request and response shapes.
|
||||
|
||||
## Attachments
|
||||
|
||||
`--attachments` reports metadata only. It does not copy or upload files.
|
||||
|
||||
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
|
||||
variants:
|
||||
|
||||
- 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
|
||||
`converted_mime_type`.
|
||||
|
||||
`send --file` sends regular files, including audio, through Messages.app.
|
||||
Before handing the file to Messages, `imsg` stages it under
|
||||
`~/Library/Messages/Attachments/imsg/` so Messages can read it reliably.
|
||||
|
||||
## Watch Behavior
|
||||
|
||||
`imsg watch` starts at the newest message by default and streams messages
|
||||
written after it starts. Use `--since-rowid <id>` to resume from a stored
|
||||
cursor.
|
||||
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
4. Also add the built-in Terminal.app at
|
||||
`/System/Applications/Utilities/Terminal.app`; macOS can still consult the
|
||||
default terminal grant.
|
||||
5. Toggle stale Full Disk Access entries off and on after terminal, Homebrew,
|
||||
Node, or app updates.
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
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:
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
make test
|
||||
make build-dylib
|
||||
imsg launch
|
||||
imsg status
|
||||
```
|
||||
|
||||
Note: `make test` applies a small patch to SQLite.swift to silence a SwiftPM warning about `PrivacyInfo.xcprivacy`.
|
||||
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`
|
||||
private-entitlement checks.
|
||||
- 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
|
||||
`csrutil enable`.
|
||||
|
||||
### Bridge command surface
|
||||
|
||||
The bridge implements a manual port of the BlueBubbles private-API surface
|
||||
(inspired by their Apache-2.0 helper) into our own dylib — no third-party
|
||||
binary. Most commands take a `--chat` argument that is the chat GUID
|
||||
(e.g. `iMessage;-;+15551234567` for direct, `iMessage;+;chat0000` for
|
||||
groups). Get a chat GUID via `imsg chats --json`.
|
||||
|
||||
Messaging:
|
||||
|
||||
```bash
|
||||
# Rich send with effect + reply
|
||||
imsg send-rich --chat 'iMessage;-;+15551234567' --text "boom" \
|
||||
--effect com.apple.MobileSMS.expressivesend.impact \
|
||||
--reply-to <messageGuid>
|
||||
|
||||
# Text formatting (macOS 15+ Sequoia): bold/italic/underline/strikethrough
|
||||
# applied to specific ranges of the message body.
|
||||
imsg send-rich --chat ... --text 'hello world' \
|
||||
--format '[{"start":0,"length":5,"styles":["bold"]},
|
||||
{"start":6,"length":5,"styles":["italic","underline"]}]'
|
||||
|
||||
# Multipart send (text-only in v1; per-part textFormatting also supported)
|
||||
imsg send-multipart --chat 'iMessage;+;chat0000' \
|
||||
--parts '[{"text":"hi"},
|
||||
{"text":"there","textFormatting":[{"start":0,"length":5,"styles":["bold"]}]}]'
|
||||
|
||||
# Attachment (file or audio)
|
||||
imsg send-attachment --chat ... --file ~/Pictures/img.jpg
|
||||
imsg send-attachment --chat ... --file ~/audio.caf --audio
|
||||
|
||||
# Bridge tapback (custom emoji + remove supported here, unlike `imsg react`)
|
||||
imsg tapback --chat ... --message <guid> --kind love
|
||||
imsg tapback --chat ... --message <guid> --kind love --remove
|
||||
```
|
||||
|
||||
Mutate (macOS 13+ — selector availability surfaced in `imsg status`):
|
||||
|
||||
```bash
|
||||
imsg edit --chat ... --message <guid> --new-text "actually..."
|
||||
imsg unsend --chat ... --message <guid>
|
||||
imsg delete-message --chat ... --message <guid>
|
||||
imsg notify-anyways --chat ... --message <guid>
|
||||
```
|
||||
|
||||
Chat management:
|
||||
|
||||
```bash
|
||||
imsg chat-create --addresses '+15551111111,+15552222222' --name 'Crew' --text 'gm'
|
||||
imsg chat-name --chat ... --name 'Renamed'
|
||||
imsg chat-photo --chat ... --file ~/Downloads/g.jpg # set
|
||||
imsg chat-photo --chat ... # clear
|
||||
imsg chat-add-member --chat ... --address +15553333333
|
||||
imsg chat-remove-member --chat ... --address +15553333333
|
||||
imsg chat-leave --chat ...
|
||||
imsg chat-delete --chat ...
|
||||
imsg chat-mark --chat ... --read # or --unread
|
||||
```
|
||||
|
||||
`chat-create` currently creates iMessage chats only. SMS sending remains
|
||||
available through `imsg send --service sms`.
|
||||
|
||||
Introspection:
|
||||
|
||||
```bash
|
||||
imsg account # active iMessage account + aliases
|
||||
imsg whois --address +15551234567 --type phone
|
||||
imsg whois --address foo@bar.com --type email
|
||||
imsg nickname --address +15551234567
|
||||
```
|
||||
|
||||
Live events (typing indicators surfaced through the dylib):
|
||||
|
||||
```bash
|
||||
imsg watch --bb-events # merge dylib events into stdout
|
||||
imsg watch --bb-events --json # one JSON object per event
|
||||
```
|
||||
|
||||
### v2 IPC under the hood
|
||||
|
||||
The dylib v1 used a single overwriting `.imsg-command.json` polled at 100ms,
|
||||
which races when multiple CLI invocations run concurrently. v2 uses a
|
||||
per-request UUID-keyed queue:
|
||||
|
||||
```
|
||||
~/Library/Containers/com.apple.MobileSMS/Data/
|
||||
.imsg-bridge-ready PID lock — set when injection is live
|
||||
.imsg-rpc/in/<uuid>.json requests dropped here by the CLI (atomic rename)
|
||||
.imsg-rpc/out/<uuid>.json responses written by the dylib (atomic rename)
|
||||
.imsg-events.jsonl inbound async events (typing, alias-removed)
|
||||
```
|
||||
|
||||
Set `IMSG_BRIDGE_LEGACY_IPC=1` to force the legacy single-file path for
|
||||
debugging (existing v1 callers and un-rebuilt dylibs continue to work
|
||||
without this).
|
||||
|
||||
## Development
|
||||
|
||||
## Linting & formatting
|
||||
```bash
|
||||
make lint
|
||||
make format
|
||||
make test
|
||||
make build
|
||||
```
|
||||
|
||||
## Core library
|
||||
The reusable Swift core lives in `Sources/IMsgCore` and is consumed by the CLI target. Apps can depend on the `IMsgCore` library target directly.
|
||||
`make test` applies the repository's SQLite.swift patch before running Swift
|
||||
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.
|
||||
|
||||
@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.addressbook</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
import CryptoKit
|
||||
#endif
|
||||
|
||||
enum AttachmentResolver {
|
||||
private struct ConversionPlan {
|
||||
let targetExtension: String
|
||||
let mimeType: String
|
||||
let arguments: (_ input: String, _ output: String) -> [String]
|
||||
}
|
||||
|
||||
static func resolve(_ path: String) -> (resolved: String, missing: Bool) {
|
||||
guard !path.isEmpty else { return ("", true) }
|
||||
let expanded = (path as NSString).expandingTildeInPath
|
||||
@ -9,9 +19,174 @@ enum AttachmentResolver {
|
||||
return (expanded, !(exists && !isDir.boolValue))
|
||||
}
|
||||
|
||||
static func metadata(
|
||||
filename: String,
|
||||
transferName: String,
|
||||
uti: String,
|
||||
mimeType: String,
|
||||
totalBytes: Int64,
|
||||
isSticker: Bool,
|
||||
options: AttachmentQueryOptions = .default
|
||||
) -> AttachmentMeta {
|
||||
let resolved = resolve(filename)
|
||||
let converted =
|
||||
options.convertUnsupported && !resolved.missing
|
||||
? convertUnsupportedAttachment(path: resolved.resolved, uti: uti, mimeType: mimeType)
|
||||
: nil
|
||||
return AttachmentMeta(
|
||||
filename: filename,
|
||||
transferName: transferName,
|
||||
uti: uti,
|
||||
mimeType: mimeType,
|
||||
totalBytes: totalBytes,
|
||||
isSticker: isSticker,
|
||||
originalPath: resolved.resolved,
|
||||
convertedPath: converted?.path,
|
||||
convertedMimeType: converted?.mimeType,
|
||||
missing: resolved.missing
|
||||
)
|
||||
}
|
||||
|
||||
static func displayName(filename: String, transferName: String) -> String {
|
||||
if !transferName.isEmpty { return transferName }
|
||||
if !filename.isEmpty { return filename }
|
||||
return "(unknown)"
|
||||
}
|
||||
|
||||
static func convertedURL(for sourcePath: String, targetExtension: String) -> URL {
|
||||
let sourceURL = URL(fileURLWithPath: sourcePath)
|
||||
let values = try? sourceURL.resourceValues(forKeys: [
|
||||
.contentModificationDateKey, .fileSizeKey,
|
||||
])
|
||||
let modification = values?.contentModificationDate?.timeIntervalSince1970 ?? 0
|
||||
let size = values?.fileSize ?? 0
|
||||
let token = "\(sourceURL.path)|\(size)|\(modification)"
|
||||
let digest = cacheDigest(for: token)
|
||||
let base = sourceURL.deletingPathExtension().lastPathComponent
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "-")
|
||||
let prefix = base.isEmpty ? "attachment" : String(base.prefix(48))
|
||||
return conversionCacheDirectory()
|
||||
.appendingPathComponent("\(prefix)-\(digest.prefix(16)).\(targetExtension)")
|
||||
}
|
||||
|
||||
private static func convertUnsupportedAttachment(
|
||||
path: String,
|
||||
uti: String,
|
||||
mimeType: String
|
||||
) -> (path: String, mimeType: String)? {
|
||||
guard let plan = conversionPlan(path: path, uti: uti, mimeType: mimeType) else {
|
||||
return nil
|
||||
}
|
||||
let outputURL = convertedURL(for: path, targetExtension: plan.targetExtension)
|
||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||
return (outputURL.path, plan.mimeType)
|
||||
}
|
||||
guard let ffmpegURL = executableURL(named: "ffmpeg") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: outputURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let temporaryURL = outputURL.deletingLastPathComponent()
|
||||
.appendingPathComponent(".\(UUID().uuidString).\(plan.targetExtension)")
|
||||
let process = Process()
|
||||
process.executableURL = ffmpegURL
|
||||
process.arguments = plan.arguments(path, temporaryURL.path)
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0,
|
||||
FileManager.default.fileExists(atPath: temporaryURL.path)
|
||||
else {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
return nil
|
||||
}
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
try FileManager.default.moveItem(at: temporaryURL, to: outputURL)
|
||||
return (outputURL.path, plan.mimeType)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func conversionPlan(
|
||||
path: String,
|
||||
uti: String,
|
||||
mimeType: String
|
||||
) -> ConversionPlan? {
|
||||
let lowerPath = path.lowercased()
|
||||
let lowerUTI = uti.lowercased()
|
||||
let lowerMime = mimeType.lowercased()
|
||||
if lowerUTI == "com.apple.coreaudio-format"
|
||||
|| lowerPath.hasSuffix(".caf")
|
||||
|| lowerMime == "audio/x-caf"
|
||||
{
|
||||
return ConversionPlan(targetExtension: "m4a", mimeType: "audio/mp4") { input, output in
|
||||
["-nostdin", "-y", "-i", input, "-c:a", "aac", "-b:a", "128k", output]
|
||||
}
|
||||
}
|
||||
if lowerUTI == "com.compuserve.gif"
|
||||
|| lowerPath.hasSuffix(".gif")
|
||||
|| lowerMime == "image/gif"
|
||||
{
|
||||
return ConversionPlan(targetExtension: "png", mimeType: "image/png") { input, output in
|
||||
["-nostdin", "-y", "-i", input, "-vframes", "1", output]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func conversionCacheDirectory() -> URL {
|
||||
if let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||
return caches.appendingPathComponent("imsg/converted-attachments", isDirectory: true)
|
||||
}
|
||||
return FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"imsg/converted-attachments",
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
|
||||
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 =
|
||||
path.split(separator: ":").map(String.init)
|
||||
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
|
||||
for directory in candidates {
|
||||
let url = URL(fileURLWithPath: directory).appendingPathComponent(name)
|
||||
if FileManager.default.isExecutableFile(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
215
Sources/IMsgCore/ContactResolver.swift
Normal file
215
Sources/IMsgCore/ContactResolver.swift
Normal file
@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
@preconcurrency import Contacts
|
||||
#endif
|
||||
|
||||
public struct ContactMatch: Equatable, Sendable {
|
||||
public let name: String
|
||||
public let handle: String
|
||||
|
||||
public init(name: String, handle: String) {
|
||||
self.name = name
|
||||
self.handle = handle
|
||||
}
|
||||
}
|
||||
|
||||
public protocol ContactResolving: Sendable {
|
||||
var contactsUnavailable: Bool { get }
|
||||
|
||||
func displayName(for handle: String) -> String?
|
||||
func displayNames(for handles: [String]) -> [String: String]
|
||||
func searchByName(_ query: String) -> [ContactMatch]
|
||||
}
|
||||
|
||||
public final class NoOpContactResolver: ContactResolving, Sendable {
|
||||
public let contactsUnavailable: Bool
|
||||
|
||||
public init(contactsUnavailable: Bool = false) {
|
||||
self.contactsUnavailable = contactsUnavailable
|
||||
}
|
||||
|
||||
public func displayName(for handle: String) -> String? { nil }
|
||||
public func displayNames(for handles: [String]) -> [String: String] { [:] }
|
||||
public func searchByName(_ query: String) -> [ContactMatch] { [] }
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
return NoOpContactResolver(contactsUnavailable: true)
|
||||
#endif
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
#else
|
||||
_ = handles
|
||||
return [:]
|
||||
#endif
|
||||
}
|
||||
|
||||
public func searchByName(_ query: String) -> [ContactMatch] {
|
||||
#if os(macOS)
|
||||
let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !normalizedQuery.isEmpty else { return [] }
|
||||
|
||||
var matches: [ContactMatch] = []
|
||||
for contact in contacts where contact.name.lowercased().contains(normalizedQuery) {
|
||||
if let phone = contact.phones.first {
|
||||
matches.append(ContactMatch(name: contact.name, handle: phone))
|
||||
} else if let email = contact.emails.first {
|
||||
matches.append(ContactMatch(name: contact.name, handle: email))
|
||||
}
|
||||
}
|
||||
return matches
|
||||
#else
|
||||
_ = query
|
||||
return []
|
||||
#endif
|
||||
}
|
||||
|
||||
#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 load(store: CNContactStore, region: String) -> any ContactResolving {
|
||||
let keysToFetch: [CNKeyDescriptor] = [
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactNicknameKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
]
|
||||
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
|
||||
let normalizer = PhoneNumberNormalizer()
|
||||
var phoneToName: [String: String] = [:]
|
||||
var emailToName: [String: String] = [:]
|
||||
var contacts: [ContactRecord] = []
|
||||
|
||||
do {
|
||||
try store.enumerateContacts(with: request) { contact, _ in
|
||||
guard let name = displayName(for: contact) else { return }
|
||||
var phones: [String] = []
|
||||
var emails: [String] = []
|
||||
|
||||
for number in contact.phoneNumbers {
|
||||
let normalized = normalizer.normalize(number.value.stringValue, region: region)
|
||||
phones.append(normalized)
|
||||
phoneToName[normalized] = phoneToName[normalized] ?? name
|
||||
}
|
||||
for email in contact.emailAddresses {
|
||||
let normalized = String(email.value).lowercased()
|
||||
emails.append(normalized)
|
||||
emailToName[normalized] = emailToName[normalized] ?? name
|
||||
}
|
||||
|
||||
if !phones.isEmpty || !emails.isEmpty {
|
||||
contacts.append(ContactRecord(name: name, phones: phones, emails: emails))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return NoOpContactResolver(contactsUnavailable: true)
|
||||
}
|
||||
|
||||
return ContactResolver(
|
||||
phoneToName: phoneToName,
|
||||
emailToName: emailToName,
|
||||
contacts: contacts,
|
||||
region: region
|
||||
)
|
||||
}
|
||||
|
||||
private static func displayName(for contact: CNContact) -> String? {
|
||||
if !contact.nickname.isEmpty {
|
||||
return contact.nickname
|
||||
}
|
||||
let name = [contact.givenName, contact.familyName]
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
return name.isEmpty ? nil : name
|
||||
}
|
||||
|
||||
private func normalizedLookupHandle(_ handle: String) -> String {
|
||||
let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
for prefix in ["iMessage;-;", "iMessage;+;", "SMS;-;", "SMS;+;", "any;-;", "any;+;"]
|
||||
where trimmed.hasPrefix(prefix) {
|
||||
return String(trimmed.dropFirst(prefix.count))
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private struct ContactRecord: Sendable {
|
||||
let name: String
|
||||
let phones: [String]
|
||||
let emails: [String]
|
||||
}
|
||||
#endif
|
||||
11
Sources/IMsgCore/DatabaseIDs.swift
Normal file
11
Sources/IMsgCore/DatabaseIDs.swift
Normal file
@ -0,0 +1,11 @@
|
||||
struct MessageID: RawRepresentable, Hashable, Sendable {
|
||||
let rawValue: Int64
|
||||
}
|
||||
|
||||
struct ChatID: RawRepresentable, Hashable, Sendable {
|
||||
let rawValue: Int64
|
||||
}
|
||||
|
||||
struct HandleID: RawRepresentable, Hashable, Sendable {
|
||||
let rawValue: Int64
|
||||
}
|
||||
@ -1,11 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
public enum IMsgError: LocalizedError, Sendable {
|
||||
public enum IMsgError: LocalizedError, CustomStringConvertible, Sendable {
|
||||
case permissionDenied(path: String, underlying: Error)
|
||||
case invalidISODate(String)
|
||||
case invalidService(String)
|
||||
case unsupportedService(String)
|
||||
case invalidChatTarget(String)
|
||||
case appleScriptFailure(String)
|
||||
case typingIndicatorFailed(String)
|
||||
case invalidReaction(String)
|
||||
case unsupportedReaction(String)
|
||||
case chatNotFound(chatID: Int64)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@ -19,9 +24,10 @@ public enum IMsgError: LocalizedError, Sendable {
|
||||
|
||||
To fix:
|
||||
1. Open System Settings → Privacy & Security → Full Disk Access
|
||||
2. Add your terminal application (Terminal.app, iTerm, etc.)
|
||||
3. Restart your terminal
|
||||
4. Try again
|
||||
2. Add your terminal application and any parent launcher (VS Code, Node, gateway, etc.)
|
||||
3. Also add the built-in Terminal.app if you normally use another terminal
|
||||
4. Toggle stale entries off and on after terminal/Homebrew/app updates
|
||||
5. Restart the terminal or parent app, then try again
|
||||
|
||||
Note: This is required because macOS protects the Messages database.
|
||||
For more details, see: https://github.com/steipete/imsg#permissions-troubleshooting
|
||||
@ -30,10 +36,28 @@ public enum IMsgError: LocalizedError, Sendable {
|
||||
return "Invalid ISO8601 date: \(value)"
|
||||
case .invalidService(let value):
|
||||
return "Invalid service: \(value)"
|
||||
case .unsupportedService(let value):
|
||||
return "Unsupported service: \(value)"
|
||||
case .invalidChatTarget(let value):
|
||||
return "Invalid chat target: \(value)"
|
||||
case .appleScriptFailure(let message):
|
||||
return "AppleScript failed: \(message)"
|
||||
case .typingIndicatorFailed(let message):
|
||||
return "Typing indicator failed: \(message)"
|
||||
case .invalidReaction(let value):
|
||||
return """
|
||||
Invalid reaction: \(value)
|
||||
|
||||
Valid reactions: love, like, dislike, laugh, emphasis, question
|
||||
"""
|
||||
case .unsupportedReaction(let message):
|
||||
return "Unsupported reaction: \(message)"
|
||||
case .chatNotFound(let chatID):
|
||||
return "Chat not found: \(chatID)"
|
||||
}
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
errorDescription ?? "Unknown imsg error"
|
||||
}
|
||||
}
|
||||
|
||||
173
Sources/IMsgCore/IMCoreBridge.swift
Normal file
173
Sources/IMsgCore/IMCoreBridge.swift
Normal file
@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
|
||||
public enum IMCoreBridgeError: Error, CustomStringConvertible {
|
||||
case dylibNotFound
|
||||
case connectionFailed(String)
|
||||
case chatNotFound(String)
|
||||
case operationFailed(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .dylibNotFound:
|
||||
return "imsg-bridge-helper.dylib not found. Build with: make build-dylib"
|
||||
case .connectionFailed(let error):
|
||||
return "Connection to Messages.app failed: \(error)"
|
||||
case .chatNotFound(let id):
|
||||
return "Chat not found: \(id)"
|
||||
case .operationFailed(let reason):
|
||||
return "Operation failed: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge to IMCore via DYLD injection into Messages.app.
|
||||
///
|
||||
/// Communicates with an injected dylib inside Messages.app via file-based IPC.
|
||||
/// The dylib has access to IMCore when Messages.app accepts the injection.
|
||||
/// macOS 26/Tahoe can still block this path with library validation/private
|
||||
/// entitlement checks.
|
||||
///
|
||||
/// Requires:
|
||||
/// - SIP disabled (for `DYLD_INSERT_LIBRARIES` on system apps)
|
||||
/// - The `imsg-bridge-helper.dylib` built via `make build-dylib`
|
||||
public final class IMCoreBridge: @unchecked Sendable {
|
||||
public static let shared = IMCoreBridge()
|
||||
|
||||
private let launcher = MessagesLauncher.shared
|
||||
|
||||
/// Whether the dylib exists on disk (does not check if Messages.app is running).
|
||||
public var isAvailable: Bool {
|
||||
let possiblePaths = [
|
||||
"/usr/local/lib/imsg-bridge-helper.dylib",
|
||||
".build/release/imsg-bridge-helper.dylib",
|
||||
".build/debug/imsg-bridge-helper.dylib",
|
||||
]
|
||||
return possiblePaths.contains { FileManager.default.fileExists(atPath: $0) }
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Commands
|
||||
|
||||
/// Set typing indicator for a conversation.
|
||||
public func setTyping(for handle: String, typing: Bool) async throws {
|
||||
let params: [String: Any] = [
|
||||
"handle": handle,
|
||||
"typing": typing,
|
||||
]
|
||||
_ = try await invokeBridge(action: .typing, params: params)
|
||||
}
|
||||
|
||||
/// Mark all messages as read in a conversation.
|
||||
public func markAsRead(handle: String) async throws {
|
||||
_ = try await invokeBridge(action: .read, params: ["handle": handle])
|
||||
}
|
||||
|
||||
/// List all available chats (for debugging).
|
||||
public func listChats() async throws -> [[String: Any]] {
|
||||
let response = try await invokeBridge(action: .listChats, params: [:])
|
||||
return response["chats"] as? [[String: Any]] ?? []
|
||||
}
|
||||
|
||||
/// Get detailed status from the injected helper.
|
||||
public func getStatus() async throws -> [String: Any] {
|
||||
return try await invokeBridge(action: .status, params: [:])
|
||||
}
|
||||
|
||||
/// Check availability and return a diagnostic message.
|
||||
public func checkAvailability() -> (available: Bool, message: String) {
|
||||
let possiblePaths = [
|
||||
"/usr/local/lib/imsg-bridge-helper.dylib",
|
||||
".build/release/imsg-bridge-helper.dylib",
|
||||
".build/debug/imsg-bridge-helper.dylib",
|
||||
]
|
||||
|
||||
var dylibPath: String?
|
||||
for path in possiblePaths {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
dylibPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard dylibPath != nil else {
|
||||
return (
|
||||
false,
|
||||
"""
|
||||
imsg-bridge-helper.dylib not found. To build:
|
||||
1. make build-dylib
|
||||
2. Restart imsg
|
||||
|
||||
Note: Advanced features require:
|
||||
- SIP disabled (for DYLD injection)
|
||||
- Full Disk Access granted to Terminal
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
switch MessagesLauncher.currentSIPStatus() {
|
||||
case .enabled:
|
||||
return (
|
||||
false,
|
||||
"""
|
||||
System Integrity Protection (SIP) is enabled.
|
||||
Advanced IMCore features are intentionally disabled.
|
||||
|
||||
To enable advanced features:
|
||||
1. Disable SIP in Recovery mode (`csrutil disable`)
|
||||
2. Run `make build-dylib`
|
||||
3. Run `imsg launch`
|
||||
"""
|
||||
)
|
||||
case .unknown(let details):
|
||||
return (
|
||||
false,
|
||||
"""
|
||||
Unable to determine SIP status. Refusing to auto-inject Messages.app.
|
||||
Details: \(details)
|
||||
"""
|
||||
)
|
||||
case .disabled:
|
||||
break
|
||||
}
|
||||
|
||||
if launcher.hasReadyLockFile() {
|
||||
return (true, "Connected to Messages.app. IMCore features available.")
|
||||
}
|
||||
|
||||
return (
|
||||
false,
|
||||
"""
|
||||
SIP is disabled and the helper dylib is present, but Messages.app is not currently injected.
|
||||
Run `imsg launch` to enable advanced IMCore features.
|
||||
|
||||
Note: macOS 26/Tahoe can still block advanced IMCore features through
|
||||
library validation or imagent private entitlement checks. Basic send,
|
||||
history, and watch commands do not use this path.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func invokeBridge(
|
||||
action: BridgeAction, params: [String: Any]
|
||||
) async throws -> [String: Any] {
|
||||
do {
|
||||
return try await IMsgBridgeClient.shared.invoke(action: action, params: params)
|
||||
} catch let error as IMsgBridgeError {
|
||||
switch error {
|
||||
case .dylibReturnedError(let message):
|
||||
if message.contains("Chat not found") {
|
||||
let handle = params["handle"] as? String ?? "unknown"
|
||||
throw IMCoreBridgeError.chatNotFound(handle)
|
||||
}
|
||||
throw IMCoreBridgeError.operationFailed(message)
|
||||
default:
|
||||
throw IMCoreBridgeError.connectionFailed(error.description)
|
||||
}
|
||||
} catch let error as MessagesLauncherError {
|
||||
throw IMCoreBridgeError.connectionFailed(error.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
151
Sources/IMsgCore/IMsgBridgeClient.swift
Normal file
151
Sources/IMsgCore/IMsgBridgeClient.swift
Normal file
@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
|
||||
/// One-shot RPC client for the v2 bridge protocol.
|
||||
///
|
||||
/// Each call atomically drops a `<uuid>.json` request file into
|
||||
/// `~/Library/Containers/com.apple.MobileSMS/Data/.imsg-rpc/in/`, then polls
|
||||
/// `out/<uuid>.json` until the dylib responds (or `timeout` elapses).
|
||||
///
|
||||
/// The dylib is shared across CLI invocations: many concurrent `imsg`
|
||||
/// processes can drop requests at once and each gets routed back to the
|
||||
/// correct caller via the UUID. There is no global lock on the CLI side.
|
||||
public final class IMsgBridgeClient: @unchecked Sendable {
|
||||
public static let shared = IMsgBridgeClient(launcher: MessagesLauncher.shared)
|
||||
|
||||
private let launcher: MessagesLauncher
|
||||
private let useLegacyIPC: Bool
|
||||
|
||||
/// Polling cadence while waiting for a response file to appear.
|
||||
private let pollInterval: TimeInterval = 0.05
|
||||
|
||||
public init(launcher: MessagesLauncher, useLegacyIPC: Bool? = nil) {
|
||||
self.launcher = launcher
|
||||
if let override = useLegacyIPC {
|
||||
self.useLegacyIPC = override
|
||||
} else {
|
||||
let env = ProcessInfo.processInfo.environment["IMSG_BRIDGE_LEGACY_IPC"]
|
||||
self.useLegacyIPC = (env == "1" || env == "true")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the dylib is currently injected and has published its ready lock.
|
||||
public func isReady() -> Bool {
|
||||
launcher.hasReadyLockFile()
|
||||
}
|
||||
|
||||
// MARK: - High-level API
|
||||
|
||||
/// Invoke a v2 bridge action and return its `data` payload on success.
|
||||
/// Legacy single-file IPC is only used when explicitly requested through
|
||||
/// `IMSG_BRIDGE_LEGACY_IPC=1`.
|
||||
public func invoke(
|
||||
action: BridgeAction,
|
||||
params: [String: Any] = [:],
|
||||
timeout: TimeInterval = IMsgBridgeProtocol.defaultResponseTimeout
|
||||
) async throws -> [String: Any] {
|
||||
if useLegacyIPC {
|
||||
try launcher.ensureRunning()
|
||||
return try await invokeLegacy(action: action, params: params)
|
||||
}
|
||||
|
||||
try launcher.ensureLaunched()
|
||||
return try await invokeV2(action: action, params: params, timeout: timeout)
|
||||
}
|
||||
|
||||
// MARK: - v2 path
|
||||
|
||||
private func invokeV2(
|
||||
action: BridgeAction,
|
||||
params: [String: Any],
|
||||
timeout: TimeInterval
|
||||
) async throws -> [String: Any] {
|
||||
let id = UUID().uuidString
|
||||
let envelope: [String: Any] = [
|
||||
"v": IMsgBridgeProtocol.version,
|
||||
"id": id,
|
||||
"action": action.rawValue,
|
||||
"params": params,
|
||||
]
|
||||
|
||||
let inboxDir = launcher.bridgeInboxDirectory
|
||||
let outboxDir = launcher.bridgeOutboxDirectory
|
||||
try ensureDirectory(inboxDir)
|
||||
try ensureDirectory(outboxDir)
|
||||
|
||||
let tmp = (inboxDir as NSString).appendingPathComponent("\(id).tmp")
|
||||
let final = (inboxDir as NSString).appendingPathComponent("\(id).json")
|
||||
let outPath = (outboxDir as NSString).appendingPathComponent("\(id).json")
|
||||
|
||||
let payload = try JSONSerialization.data(withJSONObject: envelope, options: [])
|
||||
try payload.write(to: URL(fileURLWithPath: tmp))
|
||||
try FileManager.default.moveItem(atPath: tmp, toPath: final)
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000))
|
||||
guard
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: outPath)),
|
||||
data.count > 1
|
||||
else { continue }
|
||||
// Best-effort cleanup; ignore failures (dylib may also unlink).
|
||||
try? FileManager.default.removeItem(atPath: outPath)
|
||||
|
||||
guard
|
||||
let raw = try? JSONSerialization.jsonObject(with: data, options: [])
|
||||
as? [String: Any]
|
||||
else {
|
||||
throw IMsgBridgeError.malformedResponse("non-object body")
|
||||
}
|
||||
let response = try BridgeResponse.parse(raw)
|
||||
if response.success {
|
||||
return response.data
|
||||
}
|
||||
throw IMsgBridgeError.dylibReturnedError(response.error ?? "unknown")
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(atPath: final)
|
||||
throw IMsgBridgeError.timeout(action: action.rawValue)
|
||||
}
|
||||
|
||||
// MARK: - Legacy path
|
||||
|
||||
private func invokeLegacy(
|
||||
action: BridgeAction,
|
||||
params: [String: Any]
|
||||
) async throws -> [String: Any] {
|
||||
do {
|
||||
let raw = try await launcher.sendCommand(action: action.rawValue, params: params)
|
||||
let response = try BridgeResponse.parse(raw)
|
||||
if response.success {
|
||||
return response.data
|
||||
}
|
||||
throw IMsgBridgeError.dylibReturnedError(response.error ?? "unknown")
|
||||
} catch let error as MessagesLauncherError {
|
||||
throw IMsgBridgeError.bridgeNotReady(error.description)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDirectory(_ path: String) throws {
|
||||
if SecurePath.hasSymlinkComponent(path) {
|
||||
throw IMsgBridgeError.ioError("\(path) traverses a symlink")
|
||||
}
|
||||
var isDir: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: path, isDirectory: &isDir) {
|
||||
if isDir.boolValue { return }
|
||||
throw IMsgBridgeError.ioError("\(path) exists and is not a directory")
|
||||
}
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: path,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
if SecurePath.hasSymlinkComponent(path) {
|
||||
throw IMsgBridgeError.ioError("\(path) traverses a symlink (post-mkdir)")
|
||||
}
|
||||
} catch let error as IMsgBridgeError {
|
||||
throw error
|
||||
} catch {
|
||||
throw IMsgBridgeError.ioError("mkdir \(path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
181
Sources/IMsgCore/IMsgBridgeProtocol.swift
Normal file
181
Sources/IMsgCore/IMsgBridgeProtocol.swift
Normal file
@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
|
||||
/// Wire-level constants and helpers for the v2 imsg ↔ dylib bridge protocol.
|
||||
///
|
||||
/// v1 (legacy) used a single overwriting `.imsg-command.json` file with a 100ms
|
||||
/// polling loop in the dylib. That model races when two CLI invocations write
|
||||
/// concurrently. v2 uses a per-request queue directory: callers atomically
|
||||
/// rename `<uuid>.tmp` → `<uuid>.json` into `.imsg-rpc/in/`, the dylib
|
||||
/// processes each file once and writes the matching response into
|
||||
/// `.imsg-rpc/out/<uuid>.json`.
|
||||
public enum IMsgBridgeProtocol {
|
||||
/// Current envelope version. Bump when the on-wire shape changes.
|
||||
public static let version: Int = 2
|
||||
|
||||
/// Subdirectory under the Messages.app sandbox container holding RPC files.
|
||||
public static let rpcDirectoryName: String = ".imsg-rpc"
|
||||
public static let inboxDirectoryName: String = "in"
|
||||
public static let outboxDirectoryName: String = "out"
|
||||
|
||||
/// Inbound async event log written by the dylib (typing, alias-changes, …).
|
||||
public static let eventsFileName: String = ".imsg-events.jsonl"
|
||||
public static let rotatedEventsFileName: String = ".imsg-events.jsonl.1"
|
||||
public static let eventsRotationBytes: Int = 1 * 1024 * 1024
|
||||
|
||||
/// Default per-request timeout for synchronous RPC waits.
|
||||
public static let defaultResponseTimeout: TimeInterval = 10.0
|
||||
}
|
||||
|
||||
/// All action verbs exposed by the v2 bridge. Names match the BlueBubbles
|
||||
/// reference vocabulary so traffic shape stays familiar, but each handler is a
|
||||
/// local rewrite inside `Sources/IMsgHelper/IMsgInjected.m`.
|
||||
public enum BridgeAction: String, Sendable, CaseIterable {
|
||||
// Liveness
|
||||
case ping
|
||||
case status
|
||||
case listChats = "list_chats"
|
||||
|
||||
// Typing
|
||||
case typing // legacy compound: { handle, typing: bool }
|
||||
case startTyping = "start-typing"
|
||||
case stopTyping = "stop-typing"
|
||||
case checkTypingStatus = "check-typing-status"
|
||||
|
||||
// Read
|
||||
case read // legacy
|
||||
case markChatRead = "mark-chat-read"
|
||||
case markChatUnread = "mark-chat-unread"
|
||||
|
||||
// Send
|
||||
case sendMessage = "send-message"
|
||||
case sendMultipart = "send-multipart"
|
||||
case sendAttachment = "send-attachment"
|
||||
case sendReaction = "send-reaction"
|
||||
case notifyAnyways = "notify-anyways"
|
||||
|
||||
// Mutate
|
||||
case editMessage = "edit-message"
|
||||
case unsendMessage = "unsend-message"
|
||||
case deleteMessage = "delete-message"
|
||||
|
||||
// Chat management
|
||||
case addParticipant = "add-participant"
|
||||
case removeParticipant = "remove-participant"
|
||||
case setDisplayName = "set-display-name"
|
||||
case updateGroupPhoto = "update-group-photo"
|
||||
case leaveChat = "leave-chat"
|
||||
case deleteChat = "delete-chat"
|
||||
case createChat = "create-chat"
|
||||
|
||||
// Introspection
|
||||
case searchMessages = "search-messages"
|
||||
case getAccountInfo = "get-account-info"
|
||||
case getNicknameInfo = "get-nickname-info"
|
||||
case checkImessageAvailability = "check-imessage-availability"
|
||||
case downloadPurgedAttachment = "download-purged-attachment"
|
||||
}
|
||||
|
||||
/// Reaction kinds (BlueBubbles vocabulary) → IMAssociatedMessageType integers.
|
||||
///
|
||||
/// Constants are stable across macOS 11–15. Add 1000 to the kind id to send a
|
||||
/// removal (e.g. `love` → 2000, `remove-love` → 3000).
|
||||
public enum BridgeReactionKind: String, Sendable, CaseIterable {
|
||||
case love
|
||||
case like
|
||||
case dislike
|
||||
case laugh
|
||||
case emphasize
|
||||
case question
|
||||
case removeLove = "remove-love"
|
||||
case removeLike = "remove-like"
|
||||
case removeDislike = "remove-dislike"
|
||||
case removeLaugh = "remove-laugh"
|
||||
case removeEmphasize = "remove-emphasize"
|
||||
case removeQuestion = "remove-question"
|
||||
|
||||
public var associatedMessageType: Int {
|
||||
switch self {
|
||||
case .love: return 2000
|
||||
case .like: return 2001
|
||||
case .dislike: return 2002
|
||||
case .laugh: return 2003
|
||||
case .emphasize: return 2004
|
||||
case .question: return 2005
|
||||
case .removeLove: return 3000
|
||||
case .removeLike: return 3001
|
||||
case .removeDislike: return 3002
|
||||
case .removeLaugh: return 3003
|
||||
case .removeEmphasize: return 3004
|
||||
case .removeQuestion: return 3005
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced by `IMsgBridgeClient` and adjacent helpers.
|
||||
public enum IMsgBridgeError: Error, CustomStringConvertible, Equatable {
|
||||
case bridgeNotReady(String)
|
||||
case timeout(action: String)
|
||||
case malformedResponse(String)
|
||||
case dylibReturnedError(String)
|
||||
case ioError(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .bridgeNotReady(let detail): return "imsg bridge not ready: \(detail)"
|
||||
case .timeout(let action): return "Timed out waiting for response to '\(action)'"
|
||||
case .malformedResponse(let detail): return "Malformed bridge response: \(detail)"
|
||||
case .dylibReturnedError(let msg): return "Dylib error: \(msg)"
|
||||
case .ioError(let detail): return "Bridge IO error: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded shape of a v2 bridge response.
|
||||
///
|
||||
/// The dylib always writes `{"v":2,"id":"<uuid>","success":<bool>,...}`. On
|
||||
/// success, action-specific fields land under `data` (or directly at the top
|
||||
/// level for handlers that haven't been migrated yet). On failure, `error`
|
||||
/// holds a human-readable string.
|
||||
public struct BridgeResponse {
|
||||
public let id: String
|
||||
public let success: Bool
|
||||
public let data: [String: Any]
|
||||
public let error: String?
|
||||
|
||||
public init(id: String, success: Bool, data: [String: Any], error: String?) {
|
||||
self.id = id
|
||||
self.success = success
|
||||
self.data = data
|
||||
self.error = error
|
||||
}
|
||||
|
||||
/// Parse a JSON response object into a `BridgeResponse`. Tolerates v1 shape
|
||||
/// (no `v` field, integer `id`) so the legacy single-file IPC keeps working.
|
||||
public static func parse(_ raw: [String: Any]) throws -> BridgeResponse {
|
||||
let id: String
|
||||
if let s = raw["id"] as? String {
|
||||
id = s
|
||||
} else if let i = raw["id"] as? Int {
|
||||
id = String(i)
|
||||
} else if let d = raw["id"] as? Double {
|
||||
id = String(Int(d))
|
||||
} else {
|
||||
id = ""
|
||||
}
|
||||
|
||||
let success = (raw["success"] as? Bool) ?? false
|
||||
let error = raw["error"] as? String
|
||||
|
||||
var data: [String: Any]
|
||||
if let d = raw["data"] as? [String: Any] {
|
||||
data = d
|
||||
} else {
|
||||
data = raw
|
||||
for stripped in ["v", "id", "success", "error", "timestamp"] {
|
||||
data.removeValue(forKey: stripped)
|
||||
}
|
||||
}
|
||||
|
||||
return BridgeResponse(id: id, success: success, data: data, error: error)
|
||||
}
|
||||
}
|
||||
175
Sources/IMsgCore/IMsgEventTailer.swift
Normal file
175
Sources/IMsgCore/IMsgEventTailer.swift
Normal file
@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
/// Live tailer for `.imsg-events.jsonl` written by the injected dylib.
|
||||
///
|
||||
/// Uses `DispatchSource.makeFileSystemObjectSource` watching `.write`,
|
||||
/// `.extend`, and `.rename`. On rename (file rotation by the dylib at 1 MiB)
|
||||
/// the source closes and reopens. Each newly-written full line is decoded as
|
||||
/// a JSON object and surfaced via the `events` AsyncStream.
|
||||
///
|
||||
/// Designed to be co-resident with `MessageWatcher` inside `imsg watch`.
|
||||
public final class IMsgEventTailer: @unchecked Sendable {
|
||||
/// One decoded event line. `payloadJSON` is the raw JSON-encoded `data`
|
||||
/// object (UTF-8 bytes); decode lazily on the consumer side via
|
||||
/// `JSONSerialization` if you need typed access. Holding raw Data keeps the
|
||||
/// type Sendable across actor boundaries under Swift 6 strict concurrency.
|
||||
public struct Event: Sendable {
|
||||
public let timestamp: String?
|
||||
public let name: String
|
||||
public let payloadJSON: Data
|
||||
|
||||
public init(timestamp: String?, name: String, payloadJSON: Data) {
|
||||
self.timestamp = timestamp
|
||||
self.name = name
|
||||
self.payloadJSON = payloadJSON
|
||||
}
|
||||
|
||||
/// Decode `payloadJSON` to a dictionary. Returns `[:]` on any error.
|
||||
public func decodedPayload() -> [String: Any] {
|
||||
guard
|
||||
let obj = try? JSONSerialization.jsonObject(with: payloadJSON, options: [])
|
||||
as? [String: Any]
|
||||
else { return [:] }
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
private let path: String
|
||||
private let replayExisting: Bool
|
||||
#if os(macOS)
|
||||
private var source: DispatchSourceFileSystemObject?
|
||||
private var fd: Int32 = -1
|
||||
private var pending = Data()
|
||||
#endif
|
||||
private var continuation: AsyncStream<Event>.Continuation?
|
||||
private let queue = DispatchQueue(label: "imsg.event.tailer")
|
||||
|
||||
public init(path: String, replayExisting: Bool = false) {
|
||||
self.path = path
|
||||
self.replayExisting = replayExisting
|
||||
}
|
||||
|
||||
/// Start tailing and return an AsyncStream of decoded events. Starts at EOF
|
||||
/// by default so `watch --bb-events` only emits live events.
|
||||
public func events() -> AsyncStream<Event> {
|
||||
return AsyncStream { continuation in
|
||||
self.continuation = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
self.stop()
|
||||
}
|
||||
#if os(macOS)
|
||||
self.queue.async {
|
||||
self.openAndStart()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
#if os(macOS)
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.source?.cancel()
|
||||
self.source = nil
|
||||
if self.fd >= 0 {
|
||||
close(self.fd)
|
||||
self.fd = -1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
#if os(macOS)
|
||||
private func openAndStart() {
|
||||
if !FileManager.default.fileExists(atPath: path) {
|
||||
// Create empty file so we can watch it. The dylib appends; missing
|
||||
// file means injection isn't active yet — caller can retry later.
|
||||
FileManager.default.createFile(atPath: path, contents: Data(), attributes: nil)
|
||||
}
|
||||
let fd = open(path, O_RDONLY)
|
||||
if fd < 0 { return }
|
||||
self.fd = fd
|
||||
if replayExisting {
|
||||
drainAvailable()
|
||||
} else {
|
||||
lseek(fd, 0, SEEK_END)
|
||||
}
|
||||
|
||||
let src = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
eventMask: [.extend, .write, .rename, .delete],
|
||||
queue: queue
|
||||
)
|
||||
src.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
let mask = src.data
|
||||
if mask.contains(.rename) || mask.contains(.delete) {
|
||||
// File rotated by the dylib — close and reopen the new file.
|
||||
self.reopen()
|
||||
return
|
||||
}
|
||||
self.drainAvailable()
|
||||
}
|
||||
src.setCancelHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.fd >= 0 {
|
||||
close(self.fd)
|
||||
self.fd = -1
|
||||
}
|
||||
}
|
||||
src.resume()
|
||||
self.source = src
|
||||
}
|
||||
|
||||
private func reopen() {
|
||||
source?.cancel()
|
||||
source = nil
|
||||
if fd >= 0 {
|
||||
close(fd)
|
||||
fd = -1
|
||||
}
|
||||
pending.removeAll(keepingCapacity: true)
|
||||
// Small delay lets the dylib finish the rename; then start fresh.
|
||||
queue.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.openAndStart()
|
||||
}
|
||||
}
|
||||
|
||||
private func drainAvailable() {
|
||||
guard fd >= 0 else { return }
|
||||
var buffer = Data(count: 8192)
|
||||
while true {
|
||||
let n = buffer.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) -> Int in
|
||||
guard let base = raw.baseAddress else { return -1 }
|
||||
return read(fd, base, raw.count)
|
||||
}
|
||||
if n <= 0 { break }
|
||||
pending.append(buffer.prefix(n))
|
||||
processPending()
|
||||
}
|
||||
}
|
||||
|
||||
private func processPending() {
|
||||
while let nl = pending.firstIndex(of: 0x0A) {
|
||||
let line = pending[..<nl]
|
||||
pending.removeSubrange(...nl)
|
||||
guard !line.isEmpty else { continue }
|
||||
guard
|
||||
let obj = try? JSONSerialization.jsonObject(with: line, options: [])
|
||||
as? [String: Any]
|
||||
else { continue }
|
||||
let name = (obj["event"] as? String) ?? "unknown"
|
||||
let ts = obj["ts"] as? String
|
||||
let data = (obj["data"] as? [String: Any]) ?? [:]
|
||||
let payloadData = (try? JSONSerialization.data(withJSONObject: data, options: [])) ?? Data()
|
||||
continuation?.yield(Event(timestamp: ts, name: name, payloadJSON: payloadData))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import Carbon
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import Carbon
|
||||
#endif
|
||||
|
||||
public enum MessageService: String, Sendable, CaseIterable {
|
||||
case auto
|
||||
case imessage
|
||||
@ -62,20 +65,26 @@ public struct MessageSender {
|
||||
}
|
||||
|
||||
public func send(_ options: MessageSendOptions) throws {
|
||||
var resolved = options
|
||||
let chatTarget = resolveChatTarget(&resolved)
|
||||
let useChat = !chatTarget.isEmpty
|
||||
if useChat == false {
|
||||
if resolved.region.isEmpty { resolved.region = "US" }
|
||||
resolved.recipient = normalizer.normalize(resolved.recipient, region: resolved.region)
|
||||
if resolved.service == .auto { resolved.service = .imessage }
|
||||
}
|
||||
#if !os(macOS)
|
||||
_ = options
|
||||
throw IMsgError.appleScriptFailure(
|
||||
"Sending requires Messages.app automation and is only supported on macOS.")
|
||||
#else
|
||||
var resolved = options
|
||||
let chatTarget = resolveChatTarget(&resolved)
|
||||
let useChat = !chatTarget.isEmpty
|
||||
if useChat == false {
|
||||
if resolved.region.isEmpty { resolved.region = "US" }
|
||||
resolved.recipient = normalizer.normalize(resolved.recipient, region: resolved.region)
|
||||
if resolved.service == .auto { resolved.service = .imessage }
|
||||
}
|
||||
|
||||
if resolved.attachmentPath.isEmpty == false {
|
||||
resolved.attachmentPath = try stageAttachment(at: resolved.attachmentPath)
|
||||
}
|
||||
if resolved.attachmentPath.isEmpty == false {
|
||||
resolved.attachmentPath = try stageAttachment(at: resolved.attachmentPath)
|
||||
}
|
||||
|
||||
try sendViaAppleScript(resolved, chatTarget: chatTarget, useChat: useChat)
|
||||
try sendViaAppleScript(resolved, chatTarget: chatTarget, useChat: useChat)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func stageAttachment(at path: String) throws -> String {
|
||||
@ -202,48 +211,60 @@ public struct MessageSender {
|
||||
}
|
||||
|
||||
private static func runAppleScript(source: String, arguments: [String]) throws {
|
||||
guard let script = NSAppleScript(source: source) else {
|
||||
throw IMsgError.appleScriptFailure("Unable to compile AppleScript")
|
||||
}
|
||||
var errorInfo: NSDictionary?
|
||||
let event = NSAppleEventDescriptor(
|
||||
eventClass: AEEventClass(kASAppleScriptSuite),
|
||||
eventID: AEEventID(kASSubroutineEvent),
|
||||
targetDescriptor: nil,
|
||||
returnID: AEReturnID(kAutoGenerateReturnID),
|
||||
transactionID: AETransactionID(kAnyTransactionID)
|
||||
)
|
||||
event.setParam(
|
||||
NSAppleEventDescriptor(string: "run"), forKeyword: AEKeyword(keyASSubroutineName))
|
||||
let list = NSAppleEventDescriptor.list()
|
||||
for (index, value) in arguments.enumerated() {
|
||||
list.insert(NSAppleEventDescriptor(string: value), at: index + 1)
|
||||
}
|
||||
event.setParam(list, forKeyword: keyDirectObject)
|
||||
script.executeAppleEvent(event, error: &errorInfo)
|
||||
if let errorInfo {
|
||||
if shouldFallbackToOsascript(errorInfo: errorInfo) {
|
||||
try runOsascript(source: source, arguments: arguments)
|
||||
return
|
||||
#if os(macOS)
|
||||
guard let script = NSAppleScript(source: source) else {
|
||||
throw IMsgError.appleScriptFailure("Unable to compile AppleScript")
|
||||
}
|
||||
let message =
|
||||
(errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown AppleScript error"
|
||||
throw IMsgError.appleScriptFailure(message)
|
||||
}
|
||||
var errorInfo: NSDictionary?
|
||||
let event = NSAppleEventDescriptor(
|
||||
eventClass: AEEventClass(kASAppleScriptSuite),
|
||||
eventID: AEEventID(kASSubroutineEvent),
|
||||
targetDescriptor: nil,
|
||||
returnID: AEReturnID(kAutoGenerateReturnID),
|
||||
transactionID: AETransactionID(kAnyTransactionID)
|
||||
)
|
||||
event.setParam(
|
||||
NSAppleEventDescriptor(string: "run"), forKeyword: AEKeyword(keyASSubroutineName))
|
||||
let list = NSAppleEventDescriptor.list()
|
||||
for (index, value) in arguments.enumerated() {
|
||||
list.insert(NSAppleEventDescriptor(string: value), at: index + 1)
|
||||
}
|
||||
event.setParam(list, forKeyword: keyDirectObject)
|
||||
script.executeAppleEvent(event, error: &errorInfo)
|
||||
if let errorInfo {
|
||||
if shouldFallbackToOsascript(errorInfo: errorInfo) {
|
||||
try runOsascript(source: source, arguments: arguments)
|
||||
return
|
||||
}
|
||||
let message =
|
||||
(errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown AppleScript error"
|
||||
throw IMsgError.appleScriptFailure(message)
|
||||
}
|
||||
#else
|
||||
_ = source
|
||||
_ = arguments
|
||||
throw IMsgError.appleScriptFailure(
|
||||
"Sending requires Messages.app automation and is only supported on macOS.")
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func shouldFallbackToOsascript(errorInfo: NSDictionary) -> Bool {
|
||||
if let errorNumber = errorInfo[NSAppleScript.errorNumber] as? Int, errorNumber == -1743 {
|
||||
return true
|
||||
}
|
||||
if errorInfo[NSAppleScript.errorMessage] == nil {
|
||||
return true
|
||||
}
|
||||
if let message = errorInfo[NSAppleScript.errorMessage] as? String {
|
||||
let lower = message.lowercased()
|
||||
return lower.contains("not authorized") || lower.contains("not authorised")
|
||||
}
|
||||
return false
|
||||
#if os(macOS)
|
||||
if let errorNumber = errorInfo[NSAppleScript.errorNumber] as? Int, errorNumber == -1743 {
|
||||
return true
|
||||
}
|
||||
if errorInfo[NSAppleScript.errorMessage] == nil {
|
||||
return true
|
||||
}
|
||||
if let message = errorInfo[NSAppleScript.errorMessage] as? String {
|
||||
let lower = message.lowercased()
|
||||
return lower.contains("not authorized") || lower.contains("not authorised")
|
||||
}
|
||||
return false
|
||||
#else
|
||||
_ = errorInfo
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func runOsascript(source: String, arguments: [String]) throws {
|
||||
|
||||
102
Sources/IMsgCore/MessageStore+Attachments.swift
Normal file
102
Sources/IMsgCore/MessageStore+Attachments.swift
Normal file
@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
private struct AttachmentQuery {
|
||||
let sql: String
|
||||
let bindings: [Binding?]
|
||||
|
||||
init(messageID: MessageID) {
|
||||
self.sql = """
|
||||
SELECT a.filename AS filename, a.transfer_name AS transfer_name, a.uti AS uti,
|
||||
a.mime_type AS mime_type, a.total_bytes AS total_bytes, a.is_sticker AS is_sticker
|
||||
FROM message_attachment_join maj
|
||||
JOIN attachment a ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?
|
||||
"""
|
||||
self.bindings = [messageID.rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
private struct AudioTranscriptionQuery {
|
||||
let sql: String
|
||||
let bindings: [Binding?]
|
||||
|
||||
init(messageID: MessageID) {
|
||||
self.sql = """
|
||||
SELECT a.user_info
|
||||
FROM message_attachment_join maj
|
||||
JOIN attachment a ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?
|
||||
LIMIT 1
|
||||
"""
|
||||
self.bindings = [messageID.rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageStore {
|
||||
public func attachments(
|
||||
for messageID: Int64,
|
||||
options: AttachmentQueryOptions = .default
|
||||
) throws -> [AttachmentMeta] {
|
||||
let query = AttachmentQuery(messageID: MessageID(rawValue: messageID))
|
||||
return try withConnection { db in
|
||||
var metas: [AttachmentMeta] = []
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let filename = try stringValue(row, "filename")
|
||||
let transferName = try stringValue(row, "transfer_name")
|
||||
let uti = try stringValue(row, "uti")
|
||||
let mimeType = try stringValue(row, "mime_type")
|
||||
let totalBytes = try int64Value(row, "total_bytes") ?? 0
|
||||
let isSticker = try boolValue(row, "is_sticker")
|
||||
metas.append(
|
||||
AttachmentResolver.metadata(
|
||||
filename: filename,
|
||||
transferName: transferName,
|
||||
uti: uti,
|
||||
mimeType: mimeType,
|
||||
totalBytes: totalBytes,
|
||||
isSticker: isSticker,
|
||||
options: options
|
||||
))
|
||||
}
|
||||
return metas
|
||||
}
|
||||
}
|
||||
|
||||
func audioTranscription(for messageID: Int64) throws -> String? {
|
||||
guard schema.hasAttachmentUserInfo else { return nil }
|
||||
let query = AudioTranscriptionQuery(messageID: MessageID(rawValue: messageID))
|
||||
return try withConnection { db in
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let info = try dataValue(row, "user_info")
|
||||
guard !info.isEmpty else { continue }
|
||||
if let transcription = parseAudioTranscription(from: info) {
|
||||
return transcription
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func parseAudioTranscription(from data: Data) -> String? {
|
||||
do {
|
||||
let plist = try PropertyListSerialization.propertyList(
|
||||
from: data,
|
||||
options: [],
|
||||
format: nil
|
||||
)
|
||||
guard
|
||||
let dict = plist as? [String: Any],
|
||||
let transcription = dict["audio-transcription"] as? String,
|
||||
!transcription.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return transcription
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
152
Sources/IMsgCore/MessageStore+Chats.swift
Normal file
152
Sources/IMsgCore/MessageStore+Chats.swift
Normal file
@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
private struct ChatRoutingSelection {
|
||||
let accountIDColumn: String
|
||||
let accountLoginColumn: String
|
||||
let lastAddressedHandleColumn: String
|
||||
|
||||
init(schema: MessageStoreSchema) {
|
||||
self.accountIDColumn = schema.hasChatAccountIDColumn ? "IFNULL(c.account_id, '')" : "''"
|
||||
self.accountLoginColumn =
|
||||
schema.hasChatAccountLoginColumn ? "IFNULL(c.account_login, '')" : "''"
|
||||
self.lastAddressedHandleColumn =
|
||||
schema.hasChatLastAddressedHandleColumn ? "IFNULL(c.last_addressed_handle, '')" : "''"
|
||||
}
|
||||
}
|
||||
|
||||
private struct ListChatsQuery {
|
||||
let sql: String
|
||||
let bindings: [Binding?]
|
||||
|
||||
init(limit: Int, schema: MessageStoreSchema) {
|
||||
let routing = ChatRoutingSelection(schema: schema)
|
||||
if schema.hasChatMessageJoinMessageDateColumn {
|
||||
self.sql = """
|
||||
SELECT c.ROWID AS chat_rowid, IFNULL(c.display_name, c.chat_identifier) AS name,
|
||||
c.chat_identifier AS chat_identifier, c.service_name AS service_name,
|
||||
MAX(cmj.message_date) AS last_date,
|
||||
\(routing.accountIDColumn) AS account_id,
|
||||
\(routing.accountLoginColumn) AS account_login,
|
||||
\(routing.lastAddressedHandleColumn) AS last_addressed_handle
|
||||
FROM chat c
|
||||
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
|
||||
GROUP BY c.ROWID
|
||||
ORDER BY last_date DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
} else {
|
||||
self.sql = """
|
||||
SELECT c.ROWID AS chat_rowid, IFNULL(c.display_name, c.chat_identifier) AS name,
|
||||
c.chat_identifier AS chat_identifier, c.service_name AS service_name,
|
||||
MAX(m.date) AS last_date,
|
||||
\(routing.accountIDColumn) AS account_id,
|
||||
\(routing.accountLoginColumn) AS account_login,
|
||||
\(routing.lastAddressedHandleColumn) AS last_addressed_handle
|
||||
FROM chat c
|
||||
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
|
||||
JOIN message m ON m.ROWID = cmj.message_id
|
||||
GROUP BY c.ROWID
|
||||
ORDER BY last_date DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
}
|
||||
self.bindings = [limit]
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatInfoQuery {
|
||||
let sql: String
|
||||
let bindings: [Binding?]
|
||||
|
||||
init(chatID: ChatID, schema: MessageStoreSchema) {
|
||||
let routing = ChatRoutingSelection(schema: schema)
|
||||
self.sql = """
|
||||
SELECT c.ROWID AS chat_rowid, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid,
|
||||
IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service,
|
||||
\(routing.accountIDColumn) AS account_id,
|
||||
\(routing.accountLoginColumn) AS account_login,
|
||||
\(routing.lastAddressedHandleColumn) AS last_addressed_handle
|
||||
FROM chat c
|
||||
WHERE c.ROWID = ?
|
||||
LIMIT 1
|
||||
"""
|
||||
self.bindings = [chatID.rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
private struct ParticipantsQuery {
|
||||
let sql = """
|
||||
SELECT h.id
|
||||
FROM chat_handle_join chj
|
||||
JOIN handle h ON h.ROWID = chj.handle_id
|
||||
WHERE chj.chat_id = ?
|
||||
ORDER BY h.id ASC
|
||||
"""
|
||||
let bindings: [Binding?]
|
||||
|
||||
init(chatID: ChatID) {
|
||||
self.bindings = [chatID.rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageStore {
|
||||
public func listChats(limit: Int) throws -> [Chat] {
|
||||
let query = ListChatsQuery(limit: limit, schema: schema)
|
||||
return try withConnection { db in
|
||||
var chats: [Chat] = []
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
chats.append(
|
||||
Chat(
|
||||
id: try int64Value(row, "chat_rowid") ?? 0,
|
||||
identifier: try stringValue(row, "chat_identifier"),
|
||||
name: try stringValue(row, "name"),
|
||||
service: try stringValue(row, "service_name"),
|
||||
lastMessageAt: try appleDate(from: int64Value(row, "last_date")),
|
||||
accountID: try stringValue(row, "account_id").nilIfEmpty,
|
||||
accountLogin: try stringValue(row, "account_login").nilIfEmpty,
|
||||
lastAddressedHandle: try stringValue(row, "last_addressed_handle").nilIfEmpty
|
||||
))
|
||||
}
|
||||
return chats
|
||||
}
|
||||
}
|
||||
|
||||
public func chatInfo(chatID: Int64) throws -> ChatInfo? {
|
||||
let query = ChatInfoQuery(chatID: ChatID(rawValue: chatID), schema: schema)
|
||||
return try withConnection { db in
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
return ChatInfo(
|
||||
id: try int64Value(row, "chat_rowid") ?? 0,
|
||||
identifier: try stringValue(row, "identifier"),
|
||||
guid: try stringValue(row, "guid"),
|
||||
name: try stringValue(row, "name"),
|
||||
service: try stringValue(row, "service"),
|
||||
accountID: try stringValue(row, "account_id").nilIfEmpty,
|
||||
accountLogin: try stringValue(row, "account_login").nilIfEmpty,
|
||||
lastAddressedHandle: try stringValue(row, "last_addressed_handle").nilIfEmpty
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func participants(chatID: Int64) throws -> [String] {
|
||||
let query = ParticipantsQuery(chatID: ChatID(rawValue: chatID))
|
||||
return try withConnection { db in
|
||||
var results: [String] = []
|
||||
var seen = Set<String>()
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let handle = try stringValue(row, "id")
|
||||
if handle.isEmpty { continue }
|
||||
if seen.insert(handle).inserted {
|
||||
results.append(handle)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,12 +2,19 @@ import Foundation
|
||||
import SQLite
|
||||
|
||||
extension MessageStore {
|
||||
struct DecodedReaction: Sendable {
|
||||
let isReaction: Bool
|
||||
let reactionType: ReactionType?
|
||||
let isReactionAdd: Bool?
|
||||
let reactedToGUID: String?
|
||||
}
|
||||
|
||||
static func tableColumns(connection: Connection, table: String) -> Set<String> {
|
||||
do {
|
||||
let rows = try connection.prepare("PRAGMA table_info(\(table))")
|
||||
let rows = try connection.prepareRowIterator("PRAGMA table_info(\(table))")
|
||||
var columns = Set<String>()
|
||||
for row in rows {
|
||||
if let name = row[1] as? String {
|
||||
while let row = try rows.failableNext() {
|
||||
if let name = try row.get(Expression<String?>("name")) {
|
||||
columns.insert(name.lowercased())
|
||||
}
|
||||
}
|
||||
@ -116,4 +123,38 @@ extension MessageStore {
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func decodeReaction(
|
||||
associatedType: Int?,
|
||||
associatedGUID: String,
|
||||
text: String
|
||||
) -> DecodedReaction {
|
||||
guard let typeValue = associatedType, ReactionType.isReaction(typeValue) else {
|
||||
return DecodedReaction(
|
||||
isReaction: false,
|
||||
reactionType: nil,
|
||||
isReactionAdd: nil,
|
||||
reactedToGUID: nil
|
||||
)
|
||||
}
|
||||
|
||||
let isAdd = ReactionType.isReactionAdd(typeValue)
|
||||
let rawType = isAdd ? typeValue : typeValue - 1000
|
||||
let customEmoji = (rawType == 2006) ? extractCustomEmoji(from: text) : nil
|
||||
guard let reactionType = ReactionType(rawValue: rawType, customEmoji: customEmoji) else {
|
||||
return DecodedReaction(
|
||||
isReaction: true,
|
||||
reactionType: nil,
|
||||
isReactionAdd: isAdd,
|
||||
reactedToGUID: normalizeAssociatedGUID(associatedGUID)
|
||||
)
|
||||
}
|
||||
|
||||
return DecodedReaction(
|
||||
isReaction: true,
|
||||
reactionType: reactionType,
|
||||
isReactionAdd: isAdd,
|
||||
reactedToGUID: normalizeAssociatedGUID(associatedGUID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
233
Sources/IMsgCore/MessageStore+HistoryMetadata.swift
Normal file
233
Sources/IMsgCore/MessageStore+HistoryMetadata.swift
Normal file
@ -0,0 +1,233 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension MessageStore {
|
||||
private static let bulkAttachmentBatchSize = 500
|
||||
private static let bulkReactionBatchSize = 200
|
||||
|
||||
public func attachments(
|
||||
for messageIDs: [Int64],
|
||||
options: AttachmentQueryOptions = .default
|
||||
) throws -> [Int64: [AttachmentMeta]] {
|
||||
let uniqueIDs = Array(Set(messageIDs)).sorted()
|
||||
guard !uniqueIDs.isEmpty else { return [:] }
|
||||
|
||||
var metasByMessageID: [Int64: [AttachmentMeta]] = [:]
|
||||
for start in stride(from: 0, to: uniqueIDs.count, by: Self.bulkAttachmentBatchSize) {
|
||||
let end = min(start + Self.bulkAttachmentBatchSize, uniqueIDs.count)
|
||||
let batch = Array(uniqueIDs[start..<end])
|
||||
let placeholders = Array(repeating: "?", count: batch.count).joined(separator: ",")
|
||||
let sql = """
|
||||
SELECT maj.message_id AS message_id, a.filename AS filename,
|
||||
a.transfer_name AS transfer_name, a.uti AS uti, a.mime_type AS mime_type,
|
||||
a.total_bytes AS total_bytes, a.is_sticker AS is_sticker
|
||||
FROM message_attachment_join maj
|
||||
JOIN attachment a ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id IN (\(placeholders))
|
||||
ORDER BY maj.message_id ASC
|
||||
"""
|
||||
let bindings: [Binding?] = batch.map { $0 }
|
||||
try withConnection { db in
|
||||
let rows = try db.prepareRowIterator(sql, bindings: bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let messageID = try int64Value(row, "message_id") ?? 0
|
||||
let filename = try stringValue(row, "filename")
|
||||
let transferName = try stringValue(row, "transfer_name")
|
||||
let uti = try stringValue(row, "uti")
|
||||
let mimeType = try stringValue(row, "mime_type")
|
||||
let totalBytes = try int64Value(row, "total_bytes") ?? 0
|
||||
let isSticker = try boolValue(row, "is_sticker")
|
||||
metasByMessageID[messageID, default: []].append(
|
||||
AttachmentResolver.metadata(
|
||||
filename: filename,
|
||||
transferName: transferName,
|
||||
uti: uti,
|
||||
mimeType: mimeType,
|
||||
totalBytes: totalBytes,
|
||||
isSticker: isSticker,
|
||||
options: options
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return metasByMessageID
|
||||
}
|
||||
|
||||
public func reactions(for messages: [Message]) throws -> [Int64: [Reaction]] {
|
||||
guard schema.hasReactionColumns else { return [:] }
|
||||
|
||||
var messageIDByGUID: [String: Int64] = [:]
|
||||
for message in messages where !message.guid.isEmpty {
|
||||
messageIDByGUID[message.guid] = message.rowID
|
||||
}
|
||||
let guids = Array(messageIDByGUID.keys).sorted()
|
||||
guard !guids.isEmpty else { return [:] }
|
||||
|
||||
var reactionsByMessageID: [Int64: [Reaction]] = [:]
|
||||
var reactionIndexByMessageID: [Int64: [BulkReactionKey: Int]] = [:]
|
||||
for start in stride(from: 0, to: guids.count, by: Self.bulkReactionBatchSize) {
|
||||
let end = min(start + Self.bulkReactionBatchSize, guids.count)
|
||||
let batch = Array(guids[start..<end])
|
||||
try appendReactions(
|
||||
matching: batch,
|
||||
messageIDByGUID: messageIDByGUID,
|
||||
reactionsByMessageID: &reactionsByMessageID,
|
||||
reactionIndexByMessageID: &reactionIndexByMessageID
|
||||
)
|
||||
}
|
||||
return reactionsByMessageID
|
||||
}
|
||||
|
||||
private func appendReactions(
|
||||
matching guids: [String],
|
||||
messageIDByGUID: [String: Int64],
|
||||
reactionsByMessageID: inout [Int64: [Reaction]],
|
||||
reactionIndexByMessageID: inout [Int64: [BulkReactionKey: Int]]
|
||||
) throws {
|
||||
let exactPlaceholders = Array(repeating: "?", count: guids.count).joined(separator: ",")
|
||||
let suffixConditions = Array(
|
||||
repeating: "r.associated_message_guid LIKE ?",
|
||||
count: guids.count
|
||||
).joined(separator: " OR ")
|
||||
let bodyColumn = schema.hasAttributedBody ? "r.attributedBody" : "NULL"
|
||||
let sql = """
|
||||
SELECT r.ROWID AS reaction_rowid, r.associated_message_guid AS associated_message_guid,
|
||||
r.associated_message_type AS associated_message_type, h.id AS sender,
|
||||
r.is_from_me AS is_from_me, r.date AS date, IFNULL(r.text, '') AS text,
|
||||
\(bodyColumn) AS body
|
||||
FROM message r
|
||||
LEFT JOIN handle h ON r.handle_id = h.ROWID
|
||||
WHERE r.associated_message_guid IS NOT NULL
|
||||
AND r.associated_message_guid != ''
|
||||
AND r.associated_message_type >= 2000
|
||||
AND r.associated_message_type <= 3006
|
||||
AND (
|
||||
r.associated_message_guid IN (\(exactPlaceholders))
|
||||
OR \(suffixConditions)
|
||||
)
|
||||
ORDER BY r.date ASC
|
||||
"""
|
||||
let bindings: [Binding?] = guids.map { $0 } + guids.map { "%/\($0)" }
|
||||
|
||||
try withConnection { db in
|
||||
let rows = try db.prepareRowIterator(sql, bindings: bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let associatedGUID = try stringValue(row, "associated_message_guid")
|
||||
let baseGUID = baseAssociatedMessageGUID(from: associatedGUID)
|
||||
guard let messageID = messageIDByGUID[baseGUID] else { continue }
|
||||
|
||||
let rowID = try int64Value(row, "reaction_rowid") ?? 0
|
||||
let typeValue = try intValue(row, "associated_message_type") ?? 0
|
||||
let sender = try stringValue(row, "sender")
|
||||
let isFromMe = try boolValue(row, "is_from_me")
|
||||
let date = try appleDate(from: int64Value(row, "date"))
|
||||
let text = try stringValue(row, "text")
|
||||
let body = try dataValue(row, "body")
|
||||
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
|
||||
|
||||
var reactions = reactionsByMessageID[messageID, default: []]
|
||||
var reactionIndex = reactionIndexByMessageID[messageID] ?? [:]
|
||||
applyBulkReactionRow(
|
||||
rowID: rowID,
|
||||
typeValue: typeValue,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
resolvedText: resolvedText,
|
||||
messageID: messageID,
|
||||
reactions: &reactions,
|
||||
reactionIndex: &reactionIndex
|
||||
)
|
||||
reactionsByMessageID[messageID] = reactions
|
||||
reactionIndexByMessageID[messageID] = reactionIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func baseAssociatedMessageGUID(from associatedGUID: String) -> String {
|
||||
guard let slashIndex = associatedGUID.lastIndex(of: "/") else { return associatedGUID }
|
||||
let guidStart = associatedGUID.index(after: slashIndex)
|
||||
return String(associatedGUID[guidStart...])
|
||||
}
|
||||
|
||||
private func applyBulkReactionRow(
|
||||
rowID: Int64,
|
||||
typeValue: Int,
|
||||
sender: String,
|
||||
isFromMe: Bool,
|
||||
date: Date,
|
||||
resolvedText: String,
|
||||
messageID: Int64,
|
||||
reactions: inout [Reaction],
|
||||
reactionIndex: inout [BulkReactionKey: Int]
|
||||
) {
|
||||
if ReactionType.isReactionRemove(typeValue) {
|
||||
let customEmoji = typeValue == 3006 ? extractCustomEmoji(from: resolvedText) : nil
|
||||
let reactionType = ReactionType.fromRemoval(typeValue, customEmoji: customEmoji)
|
||||
if let reactionType {
|
||||
let key = BulkReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
|
||||
if let index = reactionIndex.removeValue(forKey: key) {
|
||||
reactions.remove(at: index)
|
||||
reactionIndex = BulkReactionKey.reindex(reactions: reactions)
|
||||
}
|
||||
return
|
||||
}
|
||||
if typeValue == 3006 {
|
||||
if let index = reactions.firstIndex(where: {
|
||||
$0.sender == sender && $0.isFromMe == isFromMe && $0.reactionType.isCustom
|
||||
}) {
|
||||
reactions.remove(at: index)
|
||||
reactionIndex = BulkReactionKey.reindex(reactions: reactions)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let customEmoji = typeValue == 2006 ? extractCustomEmoji(from: resolvedText) : nil
|
||||
guard let reactionType = ReactionType(rawValue: typeValue, customEmoji: customEmoji) else {
|
||||
return
|
||||
}
|
||||
|
||||
let key = BulkReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
|
||||
if let index = reactionIndex[key] {
|
||||
reactions[index] = Reaction(
|
||||
rowID: rowID,
|
||||
reactionType: reactionType,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
associatedMessageID: messageID
|
||||
)
|
||||
} else {
|
||||
reactionIndex[key] = reactions.count
|
||||
reactions.append(
|
||||
Reaction(
|
||||
rowID: rowID,
|
||||
reactionType: reactionType,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
associatedMessageID: messageID
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private struct BulkReactionKey: Hashable {
|
||||
let sender: String
|
||||
let isFromMe: Bool
|
||||
let reactionType: ReactionType
|
||||
|
||||
static func reindex(reactions: [Reaction]) -> [BulkReactionKey: Int] {
|
||||
var index: [BulkReactionKey: Int] = [:]
|
||||
for (offset, reaction) in reactions.enumerated() {
|
||||
let key = BulkReactionKey(
|
||||
sender: reaction.sender,
|
||||
isFromMe: reaction.isFromMe,
|
||||
reactionType: reaction.reactionType
|
||||
)
|
||||
index[key] = offset
|
||||
}
|
||||
return index
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,37 +1,128 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension MessageStore {
|
||||
public func messages(chatID: Int64, limit: Int) throws -> [Message] {
|
||||
return try messages(chatID: chatID, limit: limit, filter: nil)
|
||||
}
|
||||
struct MessageRowColumns {
|
||||
static let balloonBundleID = "balloon_bundle_id"
|
||||
|
||||
public func messages(chatID: Int64, limit: Int, filter: MessageFilter?) throws -> [Message] {
|
||||
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
|
||||
let guidColumn = hasReactionColumns ? "m.guid" : "NULL"
|
||||
let associatedGuidColumn = hasReactionColumns ? "m.associated_message_guid" : "NULL"
|
||||
let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL"
|
||||
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
|
||||
let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0"
|
||||
let rowID: String
|
||||
let chatID: String?
|
||||
let handleID: String
|
||||
let sender: String
|
||||
let text: String
|
||||
let date: String
|
||||
let isFromMe: String
|
||||
let service: String
|
||||
let isAudioMessage: String
|
||||
let destinationCallerID: String
|
||||
let guid: String
|
||||
let associatedGUID: String
|
||||
let associatedType: String
|
||||
let attachments: String
|
||||
let body: String
|
||||
let threadOriginatorGUID: String
|
||||
|
||||
static func message(chatID: String?) -> MessageRowColumns {
|
||||
MessageRowColumns(
|
||||
rowID: "message_rowid",
|
||||
chatID: chatID,
|
||||
handleID: "handle_id",
|
||||
sender: "sender",
|
||||
text: "text",
|
||||
date: "date",
|
||||
isFromMe: "is_from_me",
|
||||
service: "service",
|
||||
isAudioMessage: "is_audio_message",
|
||||
destinationCallerID: "destination_caller_id",
|
||||
guid: "guid",
|
||||
associatedGUID: "associated_guid",
|
||||
associatedType: "associated_type",
|
||||
attachments: "attachments",
|
||||
body: "body",
|
||||
threadOriginatorGUID: "thread_originator_guid"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct DecodedMessageRow {
|
||||
let rowID: Int64
|
||||
let chatID: Int64
|
||||
let handleID: Int64?
|
||||
let sender: String
|
||||
let text: String
|
||||
let date: Date
|
||||
let isFromMe: Bool
|
||||
let service: String
|
||||
let destinationCallerID: String
|
||||
let guid: String
|
||||
let associatedGUID: String
|
||||
let associatedType: Int?
|
||||
let attachments: Int
|
||||
let threadOriginatorGUID: String
|
||||
}
|
||||
|
||||
struct MessageRowSelection {
|
||||
let selectList: String
|
||||
let columns: MessageRowColumns
|
||||
|
||||
init(store: MessageStore, includeChatID: Bool, includeBalloonBundleID: Bool = false) {
|
||||
let columns = MessageRowColumns.message(chatID: includeChatID ? "chat_id" : nil)
|
||||
let schema = store.schema
|
||||
let bodyColumn = schema.hasAttributedBody ? "m.attributedBody" : "NULL"
|
||||
let guidColumn = schema.hasReactionColumns ? "m.guid" : "NULL"
|
||||
let associatedGuidColumn = schema.hasReactionColumns ? "m.associated_message_guid" : "NULL"
|
||||
let associatedTypeColumn = schema.hasReactionColumns ? "m.associated_message_type" : "NULL"
|
||||
let destinationCallerColumn =
|
||||
schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
|
||||
let audioMessageColumn = schema.hasAudioMessageColumn ? "m.is_audio_message" : "0"
|
||||
let threadOriginatorColumn =
|
||||
hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL"
|
||||
schema.hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL"
|
||||
let chatColumn = includeChatID ? ", cmj.chat_id AS \(columns.chatID!)" : ""
|
||||
|
||||
var selectList = """
|
||||
m.ROWID AS \(columns.rowID)\(chatColumn), m.handle_id AS \(columns.handleID),
|
||||
h.id AS \(columns.sender), IFNULL(m.text, '') AS \(columns.text),
|
||||
m.date AS \(columns.date), m.is_from_me AS \(columns.isFromMe),
|
||||
m.service AS \(columns.service),
|
||||
\(audioMessageColumn) AS \(columns.isAudioMessage),
|
||||
\(destinationCallerColumn) AS \(columns.destinationCallerID),
|
||||
\(guidColumn) AS \(columns.guid), \(associatedGuidColumn) AS \(columns.associatedGUID),
|
||||
\(associatedTypeColumn) AS \(columns.associatedType),
|
||||
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS \(columns.attachments),
|
||||
\(bodyColumn) AS \(columns.body),
|
||||
\(threadOriginatorColumn) AS \(columns.threadOriginatorGUID)
|
||||
"""
|
||||
if includeBalloonBundleID {
|
||||
let balloonColumn = schema.hasBalloonBundleIDColumn ? "m.balloon_bundle_id" : "NULL"
|
||||
selectList += ",\n \(balloonColumn) AS \(MessageRowColumns.balloonBundleID)"
|
||||
}
|
||||
|
||||
self.selectList = selectList
|
||||
self.columns = columns
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatMessagesQuery {
|
||||
let sql: String
|
||||
let bindings: [Binding?]
|
||||
let selection: MessageRowSelection
|
||||
let fallbackChatID: Int64
|
||||
|
||||
init(store: MessageStore, chatID: ChatID, limit: Int, filter: MessageFilter?) {
|
||||
self.selection = MessageRowSelection(store: store, includeChatID: false)
|
||||
let destinationCallerColumn =
|
||||
store.schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
|
||||
let reactionFilter =
|
||||
hasReactionColumns
|
||||
store.schema.hasReactionColumns
|
||||
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
|
||||
: ""
|
||||
var sql = """
|
||||
SELECT m.ROWID, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
|
||||
\(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id,
|
||||
\(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type,
|
||||
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
|
||||
\(bodyColumn) AS body,
|
||||
\(threadOriginatorColumn) AS thread_originator_guid
|
||||
SELECT \(selection.selectList)
|
||||
FROM message m
|
||||
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
WHERE cmj.chat_id = ?\(reactionFilter)
|
||||
"""
|
||||
var bindings: [Binding?] = [chatID]
|
||||
var bindings: [Binding?] = [chatID.rawValue]
|
||||
|
||||
if let filter {
|
||||
if let startDate = filter.startDate {
|
||||
@ -45,7 +136,6 @@ extension MessageStore {
|
||||
if !filter.participants.isEmpty {
|
||||
let placeholders = Array(repeating: "?", count: filter.participants.count).joined(
|
||||
separator: ",")
|
||||
// Match current in-memory behavior: Message.sender is either handle.id or destination_caller_id.
|
||||
sql +=
|
||||
" AND COALESCE(NULLIF(h.id,''), \(destinationCallerColumn)) COLLATE NOCASE IN (\(placeholders))"
|
||||
for participant in filter.participants {
|
||||
@ -57,65 +147,140 @@ extension MessageStore {
|
||||
sql += " ORDER BY m.date DESC LIMIT ?"
|
||||
bindings.append(limit)
|
||||
|
||||
self.sql = sql
|
||||
self.bindings = bindings
|
||||
self.fallbackChatID = chatID.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessagesAfterQuery {
|
||||
let sql: String
|
||||
let bindings: [Binding?]
|
||||
let selection: MessageRowSelection
|
||||
let fallbackChatID: Int64?
|
||||
|
||||
init(
|
||||
store: MessageStore,
|
||||
afterRowID: MessageID,
|
||||
chatID: ChatID?,
|
||||
limit: Int,
|
||||
includeReactions: Bool
|
||||
) {
|
||||
self.selection = MessageRowSelection(
|
||||
store: store,
|
||||
includeChatID: true,
|
||||
includeBalloonBundleID: true
|
||||
)
|
||||
let reactionFilter: String
|
||||
if includeReactions || !store.schema.hasReactionColumns {
|
||||
reactionFilter = ""
|
||||
} else {
|
||||
reactionFilter =
|
||||
" AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
|
||||
}
|
||||
var sql = """
|
||||
SELECT \(selection.selectList)
|
||||
FROM message m
|
||||
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
WHERE m.ROWID > ?\(reactionFilter)
|
||||
"""
|
||||
var bindings: [Binding?] = [afterRowID.rawValue]
|
||||
if let chatID {
|
||||
sql += " AND cmj.chat_id = ?"
|
||||
bindings.append(chatID.rawValue)
|
||||
}
|
||||
sql += " ORDER BY m.ROWID ASC LIMIT ?"
|
||||
bindings.append(limit)
|
||||
|
||||
self.sql = sql
|
||||
self.bindings = bindings
|
||||
self.fallbackChatID = chatID?.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct LatestSentMessageQuery {
|
||||
let sql: String
|
||||
let bindings: [Binding?]
|
||||
let selection: MessageRowSelection
|
||||
let fallbackChatID: Int64?
|
||||
|
||||
init(store: MessageStore, text: String, chatID: ChatID?, since date: Date) {
|
||||
self.selection = MessageRowSelection(store: store, includeChatID: true)
|
||||
var sql = """
|
||||
SELECT \(selection.selectList)
|
||||
FROM message m
|
||||
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
WHERE m.is_from_me = 1
|
||||
AND IFNULL(m.text, '') = ?
|
||||
AND m.date >= ?
|
||||
"""
|
||||
var bindings: [Binding?] = [text, MessageStore.appleEpoch(date)]
|
||||
if let chatID {
|
||||
sql += " AND cmj.chat_id = ?"
|
||||
bindings.append(chatID.rawValue)
|
||||
}
|
||||
sql += " ORDER BY m.date DESC, m.ROWID DESC LIMIT 1"
|
||||
|
||||
self.sql = sql
|
||||
self.bindings = bindings
|
||||
self.fallbackChatID = chatID?.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageStore {
|
||||
public func maxRowID() throws -> Int64 {
|
||||
return try withConnection { db in
|
||||
let value = try db.scalar("SELECT MAX(ROWID) FROM message")
|
||||
return int64Value(value) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
public func messages(chatID: Int64, limit: Int) throws -> [Message] {
|
||||
return try messages(chatID: chatID, limit: limit, filter: nil)
|
||||
}
|
||||
|
||||
public func messages(chatID: Int64, limit: Int, filter: MessageFilter?) throws -> [Message] {
|
||||
let query = ChatMessagesQuery(
|
||||
store: self,
|
||||
chatID: ChatID(rawValue: chatID),
|
||||
limit: limit,
|
||||
filter: filter
|
||||
)
|
||||
|
||||
return try withConnection { db in
|
||||
var messages: [Message] = []
|
||||
for row in try db.prepare(sql, bindings) {
|
||||
let colRowID = 0
|
||||
let colHandleID = 1
|
||||
let colSender = 2
|
||||
let colText = 3
|
||||
let colDate = 4
|
||||
let colIsFromMe = 5
|
||||
let colService = 6
|
||||
let colIsAudioMessage = 7
|
||||
let colDestinationCallerID = 8
|
||||
let colGUID = 9
|
||||
let colAssociatedGUID = 10
|
||||
let colAssociatedType = 11
|
||||
let colAttachments = 12
|
||||
let colBody = 13
|
||||
let colThreadOriginatorGUID = 14
|
||||
|
||||
let rowID = int64Value(row[colRowID]) ?? 0
|
||||
let handleID = int64Value(row[colHandleID])
|
||||
var sender = stringValue(row[colSender])
|
||||
let text = stringValue(row[colText])
|
||||
let date = appleDate(from: int64Value(row[colDate]))
|
||||
let isFromMe = boolValue(row[colIsFromMe])
|
||||
let service = stringValue(row[colService])
|
||||
let isAudioMessage = boolValue(row[colIsAudioMessage])
|
||||
let destinationCallerID = stringValue(row[colDestinationCallerID])
|
||||
if sender.isEmpty && !destinationCallerID.isEmpty {
|
||||
sender = destinationCallerID
|
||||
}
|
||||
let guid = stringValue(row[colGUID])
|
||||
let associatedGuid = stringValue(row[colAssociatedGUID])
|
||||
let associatedType = intValue(row[colAssociatedType])
|
||||
let attachments = intValue(row[colAttachments]) ?? 0
|
||||
let body = dataValue(row[colBody])
|
||||
let threadOriginatorGUID = stringValue(row[colThreadOriginatorGUID])
|
||||
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
|
||||
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
|
||||
resolvedText = transcription
|
||||
}
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let decoded = try decodeMessageRow(
|
||||
row,
|
||||
columns: query.selection.columns,
|
||||
fallbackChatID: query.fallbackChatID
|
||||
)
|
||||
let replyToGUID = replyToGUID(
|
||||
associatedGuid: associatedGuid,
|
||||
associatedType: associatedType
|
||||
associatedGuid: decoded.associatedGUID,
|
||||
associatedType: decoded.associatedType
|
||||
)
|
||||
messages.append(
|
||||
Message(
|
||||
rowID: rowID,
|
||||
chatID: chatID,
|
||||
sender: sender,
|
||||
text: resolvedText,
|
||||
date: date,
|
||||
isFromMe: isFromMe,
|
||||
service: service,
|
||||
handleID: handleID,
|
||||
attachmentsCount: attachments,
|
||||
guid: guid,
|
||||
replyToGUID: replyToGUID,
|
||||
threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID
|
||||
rowID: decoded.rowID,
|
||||
chatID: decoded.chatID,
|
||||
sender: decoded.sender,
|
||||
text: decoded.text,
|
||||
date: decoded.date,
|
||||
isFromMe: decoded.isFromMe,
|
||||
service: decoded.service,
|
||||
handleID: decoded.handleID,
|
||||
attachmentsCount: decoded.attachments,
|
||||
guid: decoded.guid,
|
||||
routing: Message.RoutingMetadata(
|
||||
replyToGUID: replyToGUID,
|
||||
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
|
||||
? nil : decoded.threadOriginatorGUID,
|
||||
destinationCallerID: decoded.destinationCallerID.isEmpty
|
||||
? nil : decoded.destinationCallerID
|
||||
)
|
||||
))
|
||||
}
|
||||
return messages
|
||||
@ -123,102 +288,188 @@ extension MessageStore {
|
||||
}
|
||||
|
||||
public func messagesAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [Message] {
|
||||
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
|
||||
let guidColumn = hasReactionColumns ? "m.guid" : "NULL"
|
||||
let associatedGuidColumn = hasReactionColumns ? "m.associated_message_guid" : "NULL"
|
||||
let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL"
|
||||
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
|
||||
let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0"
|
||||
let threadOriginatorColumn =
|
||||
hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL"
|
||||
let reactionFilter =
|
||||
hasReactionColumns
|
||||
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
|
||||
: ""
|
||||
var sql = """
|
||||
SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
|
||||
\(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id,
|
||||
\(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type,
|
||||
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
|
||||
\(bodyColumn) AS body,
|
||||
\(threadOriginatorColumn) AS thread_originator_guid
|
||||
FROM message m
|
||||
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
WHERE m.ROWID > ?\(reactionFilter)
|
||||
"""
|
||||
var bindings: [Binding?] = [afterRowID]
|
||||
if let chatID {
|
||||
sql += " AND cmj.chat_id = ?"
|
||||
bindings.append(chatID)
|
||||
}
|
||||
sql += " ORDER BY m.ROWID ASC LIMIT ?"
|
||||
bindings.append(limit)
|
||||
return try messagesAfter(
|
||||
afterRowID: afterRowID,
|
||||
chatID: chatID,
|
||||
limit: limit,
|
||||
includeReactions: false
|
||||
)
|
||||
}
|
||||
|
||||
public func messagesAfter(
|
||||
afterRowID: Int64,
|
||||
chatID: Int64?,
|
||||
limit: Int,
|
||||
includeReactions: Bool
|
||||
) throws -> [Message] {
|
||||
let query = MessagesAfterQuery(
|
||||
store: self,
|
||||
afterRowID: MessageID(rawValue: afterRowID),
|
||||
chatID: chatID.map { ChatID(rawValue: $0) },
|
||||
limit: limit,
|
||||
includeReactions: includeReactions
|
||||
)
|
||||
|
||||
return try withConnection { db in
|
||||
var messages: [Message] = []
|
||||
for row in try db.prepare(sql, bindings) {
|
||||
let colRowID = 0
|
||||
let colChatID = 1
|
||||
let colHandleID = 2
|
||||
let colSender = 3
|
||||
let colText = 4
|
||||
let colDate = 5
|
||||
let colIsFromMe = 6
|
||||
let colService = 7
|
||||
let colIsAudioMessage = 8
|
||||
let colDestinationCallerID = 9
|
||||
let colGUID = 10
|
||||
let colAssociatedGUID = 11
|
||||
let colAssociatedType = 12
|
||||
let colAttachments = 13
|
||||
let colBody = 14
|
||||
let colThreadOriginatorGUID = 15
|
||||
let urlBalloonProvider = "com.apple.messages.URLBalloonProvider"
|
||||
|
||||
let rowID = int64Value(row[colRowID]) ?? 0
|
||||
let resolvedChatID = int64Value(row[colChatID]) ?? chatID ?? 0
|
||||
let handleID = int64Value(row[colHandleID])
|
||||
var sender = stringValue(row[colSender])
|
||||
let text = stringValue(row[colText])
|
||||
let date = appleDate(from: int64Value(row[colDate]))
|
||||
let isFromMe = boolValue(row[colIsFromMe])
|
||||
let service = stringValue(row[colService])
|
||||
let isAudioMessage = boolValue(row[colIsAudioMessage])
|
||||
let destinationCallerID = stringValue(row[colDestinationCallerID])
|
||||
if sender.isEmpty && !destinationCallerID.isEmpty {
|
||||
sender = destinationCallerID
|
||||
}
|
||||
let guid = stringValue(row[colGUID])
|
||||
let associatedGuid = stringValue(row[colAssociatedGUID])
|
||||
let associatedType = intValue(row[colAssociatedType])
|
||||
let attachments = intValue(row[colAttachments]) ?? 0
|
||||
let body = dataValue(row[colBody])
|
||||
let threadOriginatorGUID = stringValue(row[colThreadOriginatorGUID])
|
||||
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
|
||||
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
|
||||
resolvedText = transcription
|
||||
}
|
||||
let replyToGUID = replyToGUID(
|
||||
associatedGuid: associatedGuid,
|
||||
associatedType: associatedType
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let decoded = try decodeMessageRow(
|
||||
row,
|
||||
columns: query.selection.columns,
|
||||
fallbackChatID: query.fallbackChatID
|
||||
)
|
||||
let balloonBundleID = try stringValue(row, MessageRowColumns.balloonBundleID)
|
||||
if balloonBundleID == urlBalloonProvider,
|
||||
shouldSkipURLBalloonDuplicate(
|
||||
chatID: decoded.chatID,
|
||||
sender: decoded.sender,
|
||||
text: decoded.text,
|
||||
isFromMe: decoded.isFromMe,
|
||||
date: decoded.date,
|
||||
rowID: decoded.rowID
|
||||
)
|
||||
{
|
||||
continue
|
||||
}
|
||||
|
||||
let replyToGUID = replyToGUID(
|
||||
associatedGuid: decoded.associatedGUID,
|
||||
associatedType: decoded.associatedType
|
||||
)
|
||||
let reaction = decodeReaction(
|
||||
associatedType: decoded.associatedType,
|
||||
associatedGUID: decoded.associatedGUID,
|
||||
text: decoded.text
|
||||
)
|
||||
|
||||
messages.append(
|
||||
Message(
|
||||
rowID: rowID,
|
||||
chatID: resolvedChatID,
|
||||
sender: sender,
|
||||
text: resolvedText,
|
||||
date: date,
|
||||
isFromMe: isFromMe,
|
||||
service: service,
|
||||
handleID: handleID,
|
||||
attachmentsCount: attachments,
|
||||
guid: guid,
|
||||
replyToGUID: replyToGUID,
|
||||
threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID
|
||||
rowID: decoded.rowID,
|
||||
chatID: decoded.chatID,
|
||||
sender: decoded.sender,
|
||||
text: decoded.text,
|
||||
date: decoded.date,
|
||||
isFromMe: decoded.isFromMe,
|
||||
service: decoded.service,
|
||||
handleID: decoded.handleID,
|
||||
attachmentsCount: decoded.attachments,
|
||||
guid: decoded.guid,
|
||||
routing: Message.RoutingMetadata(
|
||||
replyToGUID: replyToGUID,
|
||||
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
|
||||
? nil : decoded.threadOriginatorGUID,
|
||||
destinationCallerID: decoded.destinationCallerID.isEmpty
|
||||
? nil : decoded.destinationCallerID
|
||||
),
|
||||
reaction: Message.ReactionMetadata(
|
||||
isReaction: reaction.isReaction,
|
||||
reactionType: reaction.reactionType,
|
||||
isReactionAdd: reaction.isReactionAdd,
|
||||
reactedToGUID: reaction.reactedToGUID
|
||||
)
|
||||
))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
public func latestSentMessage(matchingText text: String, chatID: Int64?, since date: Date)
|
||||
throws -> Message?
|
||||
{
|
||||
guard !text.isEmpty else { return nil }
|
||||
|
||||
let query = LatestSentMessageQuery(
|
||||
store: self,
|
||||
text: text,
|
||||
chatID: chatID.map { ChatID(rawValue: $0) },
|
||||
since: date
|
||||
)
|
||||
|
||||
return try withConnection { db in
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
guard let row = try rows.failableNext() else { return nil }
|
||||
let decoded = try decodeMessageRow(
|
||||
row,
|
||||
columns: query.selection.columns,
|
||||
fallbackChatID: query.fallbackChatID
|
||||
)
|
||||
let replyToGUID = replyToGUID(
|
||||
associatedGuid: decoded.associatedGUID,
|
||||
associatedType: decoded.associatedType
|
||||
)
|
||||
return Message(
|
||||
rowID: decoded.rowID,
|
||||
chatID: decoded.chatID,
|
||||
sender: decoded.sender,
|
||||
text: decoded.text,
|
||||
date: decoded.date,
|
||||
isFromMe: decoded.isFromMe,
|
||||
service: decoded.service,
|
||||
handleID: decoded.handleID,
|
||||
attachmentsCount: decoded.attachments,
|
||||
guid: decoded.guid,
|
||||
routing: Message.RoutingMetadata(
|
||||
replyToGUID: replyToGUID,
|
||||
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
|
||||
? nil : decoded.threadOriginatorGUID,
|
||||
destinationCallerID: decoded.destinationCallerID.isEmpty
|
||||
? nil : decoded.destinationCallerID
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMessageRow(
|
||||
_ row: Row,
|
||||
columns: MessageRowColumns,
|
||||
fallbackChatID: Int64?
|
||||
) throws -> DecodedMessageRow {
|
||||
let rowID = try int64Value(row, columns.rowID) ?? 0
|
||||
let resolvedChatID =
|
||||
try columns.chatID.flatMap { try int64Value(row, $0) } ?? fallbackChatID ?? 0
|
||||
let handleID = try int64Value(row, columns.handleID)
|
||||
let sender = try stringValue(row, columns.sender)
|
||||
let text = try stringValue(row, columns.text)
|
||||
let date = try appleDate(from: int64Value(row, columns.date))
|
||||
let isFromMe = try boolValue(row, columns.isFromMe)
|
||||
let service = try stringValue(row, columns.service)
|
||||
let isAudioMessage = try boolValue(row, columns.isAudioMessage)
|
||||
let destinationCallerID = try stringValue(row, columns.destinationCallerID)
|
||||
let guid = try stringValue(row, columns.guid)
|
||||
let associatedGUID = try stringValue(row, columns.associatedGUID)
|
||||
let associatedType = try intValue(row, columns.associatedType)
|
||||
let attachments = try intValue(row, columns.attachments) ?? 0
|
||||
let body = try dataValue(row, columns.body)
|
||||
let threadOriginatorGUID = try stringValue(row, columns.threadOriginatorGUID)
|
||||
|
||||
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
|
||||
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
|
||||
resolvedText = transcription
|
||||
}
|
||||
|
||||
var resolvedSender = sender
|
||||
if resolvedSender.isEmpty && !destinationCallerID.isEmpty {
|
||||
resolvedSender = destinationCallerID
|
||||
}
|
||||
|
||||
return DecodedMessageRow(
|
||||
rowID: rowID,
|
||||
chatID: resolvedChatID,
|
||||
handleID: handleID,
|
||||
sender: resolvedSender,
|
||||
text: resolvedText,
|
||||
date: date,
|
||||
isFromMe: isFromMe,
|
||||
service: service,
|
||||
destinationCallerID: destinationCallerID,
|
||||
guid: guid,
|
||||
associatedGUID: associatedGUID,
|
||||
associatedType: associatedType,
|
||||
attachments: attachments,
|
||||
threadOriginatorGUID: threadOriginatorGUID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
140
Sources/IMsgCore/MessageStore+ReactionEvents.swift
Normal file
140
Sources/IMsgCore/MessageStore+ReactionEvents.swift
Normal file
@ -0,0 +1,140 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
/// A reaction event represents when someone adds or removes a reaction to a message.
|
||||
/// Unlike `Reaction` which represents the current state, this captures the event itself.
|
||||
public struct ReactionEvent: Sendable, Equatable {
|
||||
/// The ROWID of the reaction message in the database
|
||||
public let rowID: Int64
|
||||
/// The chat ID where the reaction occurred
|
||||
public let chatID: Int64
|
||||
/// The type of reaction
|
||||
public let reactionType: ReactionType
|
||||
/// Whether this is adding (true) or removing (false) a reaction
|
||||
public let isAdd: Bool
|
||||
/// The sender of the reaction (phone number or email)
|
||||
public let sender: String
|
||||
/// Whether the reaction was sent by the current user
|
||||
public let isFromMe: Bool
|
||||
/// When the reaction event occurred
|
||||
public let date: Date
|
||||
/// The GUID of the message being reacted to
|
||||
public let reactedToGUID: String
|
||||
/// The ROWID of the message being reacted to (if available)
|
||||
public let reactedToID: Int64?
|
||||
/// The original text of the reaction message (e.g., "Liked \"hello\"")
|
||||
public let text: String
|
||||
|
||||
public init(
|
||||
rowID: Int64,
|
||||
chatID: Int64,
|
||||
reactionType: ReactionType,
|
||||
isAdd: Bool,
|
||||
sender: String,
|
||||
isFromMe: Bool,
|
||||
date: Date,
|
||||
reactedToGUID: String,
|
||||
reactedToID: Int64?,
|
||||
text: String
|
||||
) {
|
||||
self.rowID = rowID
|
||||
self.chatID = chatID
|
||||
self.reactionType = reactionType
|
||||
self.isAdd = isAdd
|
||||
self.sender = sender
|
||||
self.isFromMe = isFromMe
|
||||
self.date = date
|
||||
self.reactedToGUID = reactedToGUID
|
||||
self.reactedToID = reactedToID
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageStore {
|
||||
/// Fetch reaction events (add/remove) after a given rowID.
|
||||
/// These are the reaction messages themselves, useful for streaming reaction events in watch mode.
|
||||
public func reactionEventsAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws
|
||||
-> [ReactionEvent]
|
||||
{
|
||||
guard schema.hasReactionColumns else { return [] }
|
||||
|
||||
let bodyColumn = schema.hasAttributedBody ? "m.attributedBody" : "NULL"
|
||||
let destinationCallerColumn =
|
||||
schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
|
||||
|
||||
var sql = """
|
||||
SELECT m.ROWID AS reaction_rowid, cmj.chat_id AS chat_id,
|
||||
m.associated_message_type AS associated_message_type,
|
||||
m.associated_message_guid AS associated_message_guid,
|
||||
m.handle_id AS handle_id, h.id AS sender, m.is_from_me AS is_from_me,
|
||||
m.date AS date, IFNULL(m.text, '') AS text,
|
||||
\(destinationCallerColumn) AS destination_caller_id,
|
||||
\(bodyColumn) AS body,
|
||||
orig.ROWID AS orig_rowid
|
||||
FROM message m
|
||||
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
LEFT JOIN message orig ON (orig.guid = m.associated_message_guid
|
||||
OR m.associated_message_guid LIKE '%/' || orig.guid)
|
||||
WHERE m.ROWID > ?
|
||||
AND m.associated_message_type >= 2000
|
||||
AND m.associated_message_type <= 3006
|
||||
"""
|
||||
var bindings: [Binding?] = [afterRowID]
|
||||
|
||||
if let chatID {
|
||||
sql += " AND cmj.chat_id = ?"
|
||||
bindings.append(chatID)
|
||||
}
|
||||
sql += " ORDER BY m.ROWID ASC LIMIT ?"
|
||||
bindings.append(limit)
|
||||
|
||||
return try withConnection { db in
|
||||
var events: [ReactionEvent] = []
|
||||
let rows = try db.prepareRowIterator(sql, bindings: bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let rowID = try int64Value(row, "reaction_rowid") ?? 0
|
||||
let resolvedChatID = try int64Value(row, "chat_id") ?? chatID ?? 0
|
||||
let typeValue = try intValue(row, "associated_message_type") ?? 0
|
||||
let associatedGUID = try stringValue(row, "associated_message_guid")
|
||||
var sender = try stringValue(row, "sender")
|
||||
let isFromMe = try boolValue(row, "is_from_me")
|
||||
let date = try appleDate(from: int64Value(row, "date"))
|
||||
let text = try stringValue(row, "text")
|
||||
let destinationCallerID = try stringValue(row, "destination_caller_id")
|
||||
let body = try dataValue(row, "body")
|
||||
let origRowID = try int64Value(row, "orig_rowid")
|
||||
|
||||
if sender.isEmpty && !destinationCallerID.isEmpty {
|
||||
sender = destinationCallerID
|
||||
}
|
||||
|
||||
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
|
||||
let decoded = decodeReaction(
|
||||
associatedType: typeValue,
|
||||
associatedGUID: associatedGUID,
|
||||
text: resolvedText
|
||||
)
|
||||
guard let reactionType = decoded.reactionType, let isAdd = decoded.isReactionAdd else {
|
||||
continue
|
||||
}
|
||||
|
||||
events.append(
|
||||
ReactionEvent(
|
||||
rowID: rowID,
|
||||
chatID: resolvedChatID,
|
||||
reactionType: reactionType,
|
||||
isAdd: isAdd,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
reactedToGUID: decoded.reactedToGUID ?? "",
|
||||
reactedToID: origRowID,
|
||||
text: resolvedText
|
||||
))
|
||||
}
|
||||
return events
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
145
Sources/IMsgCore/MessageStore+Reactions.swift
Normal file
145
Sources/IMsgCore/MessageStore+Reactions.swift
Normal file
@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
private struct CurrentReactionsQuery {
|
||||
let sql: String
|
||||
let bindings: [Binding?]
|
||||
|
||||
init(messageID: MessageID, schema: MessageStoreSchema) {
|
||||
let bodyColumn = schema.hasAttributedBody ? "r.attributedBody" : "NULL"
|
||||
self.sql = """
|
||||
SELECT r.ROWID AS reaction_rowid, r.associated_message_type AS associated_message_type,
|
||||
h.id AS sender, r.is_from_me AS is_from_me, r.date AS date, IFNULL(r.text, '') AS text,
|
||||
\(bodyColumn) AS body
|
||||
FROM message m
|
||||
JOIN message r ON r.associated_message_guid = m.guid
|
||||
OR r.associated_message_guid LIKE '%/' || m.guid
|
||||
LEFT JOIN handle h ON r.handle_id = h.ROWID
|
||||
WHERE m.ROWID = ?
|
||||
AND m.guid IS NOT NULL
|
||||
AND m.guid != ''
|
||||
AND r.associated_message_type >= 2000
|
||||
AND r.associated_message_type <= 3006
|
||||
ORDER BY r.date ASC
|
||||
"""
|
||||
self.bindings = [messageID.rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageStore {
|
||||
public func reactions(for messageID: Int64) throws -> [Reaction] {
|
||||
guard schema.hasReactionColumns else { return [] }
|
||||
let query = CurrentReactionsQuery(
|
||||
messageID: MessageID(rawValue: messageID),
|
||||
schema: schema
|
||||
)
|
||||
return try withConnection { db in
|
||||
var reactions: [Reaction] = []
|
||||
var reactionIndex: [ReactionKey: Int] = [:]
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let rowID = try int64Value(row, "reaction_rowid") ?? 0
|
||||
let typeValue = try intValue(row, "associated_message_type") ?? 0
|
||||
let sender = try stringValue(row, "sender")
|
||||
let isFromMe = try boolValue(row, "is_from_me")
|
||||
let date = try appleDate(from: int64Value(row, "date"))
|
||||
let text = try stringValue(row, "text")
|
||||
let body = try dataValue(row, "body")
|
||||
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
|
||||
|
||||
if ReactionType.isReactionRemove(typeValue) {
|
||||
let customEmoji = typeValue == 3006 ? extractCustomEmoji(from: resolvedText) : nil
|
||||
let reactionType = ReactionType.fromRemoval(typeValue, customEmoji: customEmoji)
|
||||
if let reactionType {
|
||||
let key = ReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
|
||||
if let index = reactionIndex.removeValue(forKey: key) {
|
||||
reactions.remove(at: index)
|
||||
reactionIndex = ReactionKey.reindex(reactions: reactions)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if typeValue == 3006 {
|
||||
if let index = reactions.firstIndex(where: {
|
||||
$0.sender == sender && $0.isFromMe == isFromMe && $0.reactionType.isCustom
|
||||
}) {
|
||||
reactions.remove(at: index)
|
||||
reactionIndex = ReactionKey.reindex(reactions: reactions)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let customEmoji: String? = typeValue == 2006 ? extractCustomEmoji(from: resolvedText) : nil
|
||||
guard let reactionType = ReactionType(rawValue: typeValue, customEmoji: customEmoji) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let key = ReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
|
||||
if let index = reactionIndex[key] {
|
||||
reactions[index] = Reaction(
|
||||
rowID: rowID,
|
||||
reactionType: reactionType,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
associatedMessageID: messageID
|
||||
)
|
||||
} else {
|
||||
reactionIndex[key] = reactions.count
|
||||
reactions.append(
|
||||
Reaction(
|
||||
rowID: rowID,
|
||||
reactionType: reactionType,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
associatedMessageID: messageID
|
||||
))
|
||||
}
|
||||
}
|
||||
return reactions
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract custom emoji from reaction message text like "Reacted 🎉 to "original message""
|
||||
func extractCustomEmoji(from text: String) -> String? {
|
||||
guard
|
||||
let reactedRange = text.range(of: "Reacted "),
|
||||
let toRange = text.range(of: " to ", range: reactedRange.upperBound..<text.endIndex)
|
||||
else {
|
||||
return extractFirstEmoji(from: text)
|
||||
}
|
||||
let emoji = String(text[reactedRange.upperBound..<toRange.lowerBound])
|
||||
return emoji.isEmpty ? extractFirstEmoji(from: text) : emoji
|
||||
}
|
||||
|
||||
private func extractFirstEmoji(from text: String) -> String? {
|
||||
for character in text {
|
||||
if character.unicodeScalars.contains(where: {
|
||||
$0.properties.isEmojiPresentation || $0.properties.isEmoji
|
||||
}) {
|
||||
return String(character)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private struct ReactionKey: Hashable {
|
||||
let sender: String
|
||||
let isFromMe: Bool
|
||||
let reactionType: ReactionType
|
||||
|
||||
static func reindex(reactions: [Reaction]) -> [ReactionKey: Int] {
|
||||
var index: [ReactionKey: Int] = [:]
|
||||
for (offset, reaction) in reactions.enumerated() {
|
||||
let key = ReactionKey(
|
||||
sender: reaction.sender,
|
||||
isFromMe: reaction.isFromMe,
|
||||
reactionType: reaction.reactionType
|
||||
)
|
||||
index[key] = offset
|
||||
}
|
||||
return index
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Sources/IMsgCore/MessageStore+SQLRow.swift
Normal file
26
Sources/IMsgCore/MessageStore+SQLRow.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension MessageStore {
|
||||
func stringValue(_ row: Row, _ column: String) throws -> String {
|
||||
try row.get(Expression<String?>(column)) ?? ""
|
||||
}
|
||||
|
||||
func int64Value(_ row: Row, _ column: String) throws -> Int64? {
|
||||
try row.get(Expression<Int64?>(column))
|
||||
}
|
||||
|
||||
func intValue(_ row: Row, _ column: String) throws -> Int? {
|
||||
guard let value = try int64Value(row, column) else { return nil }
|
||||
return Int(value)
|
||||
}
|
||||
|
||||
func boolValue(_ row: Row, _ column: String) throws -> Bool {
|
||||
try row.get(Expression<Bool?>(column)) ?? false
|
||||
}
|
||||
|
||||
func dataValue(_ row: Row, _ column: String) throws -> Data {
|
||||
guard let blob = try row.get(Expression<Blob?>(column)) else { return Data() }
|
||||
return Data(blob.bytes)
|
||||
}
|
||||
}
|
||||
94
Sources/IMsgCore/MessageStore+Search.swift
Normal file
94
Sources/IMsgCore/MessageStore+Search.swift
Normal file
@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
private struct SearchMessagesQuery {
|
||||
let sql: String
|
||||
let bindings: [Binding?]
|
||||
let selection: MessageRowSelection
|
||||
let fallbackChatID: Int64? = nil
|
||||
|
||||
init(store: MessageStore, text: String, exact: Bool, limit: Int) {
|
||||
self.selection = MessageRowSelection(store: store, includeChatID: true)
|
||||
let reactionFilter =
|
||||
store.schema.hasReactionColumns
|
||||
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
|
||||
: ""
|
||||
let predicate =
|
||||
exact
|
||||
? "IFNULL(m.text, '') = ? COLLATE NOCASE"
|
||||
: "IFNULL(m.text, '') LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
let textBinding = exact ? text : SearchMessagesQuery.likePattern(for: text)
|
||||
self.sql = """
|
||||
SELECT \(selection.selectList)
|
||||
FROM message m
|
||||
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
WHERE \(predicate)\(reactionFilter)
|
||||
ORDER BY m.date DESC, m.ROWID DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
self.bindings = [textBinding, limit]
|
||||
}
|
||||
|
||||
private static func likePattern(for text: String) -> String {
|
||||
var escaped = ""
|
||||
for char in text {
|
||||
if char == "\\" || char == "%" || char == "_" {
|
||||
escaped.append("\\")
|
||||
}
|
||||
escaped.append(char)
|
||||
}
|
||||
return "%\(escaped)%"
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageStore {
|
||||
public func searchMessages(query text: String, match: String, limit: Int) throws -> [Message] {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
let exact = match.lowercased() == "exact"
|
||||
let query = SearchMessagesQuery(
|
||||
store: self,
|
||||
text: trimmed,
|
||||
exact: exact,
|
||||
limit: limit
|
||||
)
|
||||
|
||||
return try withConnection { db in
|
||||
var messages: [Message] = []
|
||||
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
|
||||
while let row = try rows.failableNext() {
|
||||
let decoded = try decodeMessageRow(
|
||||
row,
|
||||
columns: query.selection.columns,
|
||||
fallbackChatID: query.fallbackChatID
|
||||
)
|
||||
let replyToGUID = replyToGUID(
|
||||
associatedGuid: decoded.associatedGUID,
|
||||
associatedType: decoded.associatedType
|
||||
)
|
||||
messages.append(
|
||||
Message(
|
||||
rowID: decoded.rowID,
|
||||
chatID: decoded.chatID,
|
||||
sender: decoded.sender,
|
||||
text: decoded.text,
|
||||
date: decoded.date,
|
||||
isFromMe: decoded.isFromMe,
|
||||
service: decoded.service,
|
||||
handleID: decoded.handleID,
|
||||
attachmentsCount: decoded.attachments,
|
||||
guid: decoded.guid,
|
||||
routing: Message.RoutingMetadata(
|
||||
replyToGUID: replyToGUID,
|
||||
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
|
||||
? nil : decoded.threadOriginatorGUID,
|
||||
destinationCallerID: decoded.destinationCallerID.isEmpty
|
||||
? nil : decoded.destinationCallerID
|
||||
)
|
||||
))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
}
|
||||
123
Sources/IMsgCore/MessageStore+SentMessages.swift
Normal file
123
Sources/IMsgCore/MessageStore+SentMessages.swift
Normal file
@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension MessageStore {
|
||||
public func chatInfo(matchingTarget target: String) throws -> ChatInfo? {
|
||||
let candidates = Self.chatTargetCandidates(target)
|
||||
guard !candidates.isEmpty else { return nil }
|
||||
|
||||
let placeholders = Array(repeating: "?", count: candidates.count).joined(separator: ",")
|
||||
let accountIDColumn = schema.hasChatAccountIDColumn ? "IFNULL(c.account_id, '')" : "''"
|
||||
let accountLoginColumn = schema.hasChatAccountLoginColumn ? "IFNULL(c.account_login, '')" : "''"
|
||||
let lastAddressedHandleColumn =
|
||||
schema.hasChatLastAddressedHandleColumn ? "IFNULL(c.last_addressed_handle, '')" : "''"
|
||||
let sql = """
|
||||
SELECT c.ROWID AS chat_rowid, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid,
|
||||
IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service,
|
||||
\(accountIDColumn) AS account_id,
|
||||
\(accountLoginColumn) AS account_login,
|
||||
\(lastAddressedHandleColumn) AS last_addressed_handle
|
||||
FROM chat c
|
||||
WHERE c.chat_identifier IN (\(placeholders))
|
||||
OR c.guid IN (\(placeholders))
|
||||
LIMIT 1
|
||||
"""
|
||||
let bindings: [Binding?] = candidates + candidates
|
||||
return try withConnection { db in
|
||||
let rows = try db.prepareRowIterator(sql, bindings: bindings)
|
||||
guard let row = try rows.failableNext() else { return nil }
|
||||
return ChatInfo(
|
||||
id: try int64Value(row, "chat_rowid") ?? 0,
|
||||
identifier: try stringValue(row, "identifier"),
|
||||
guid: try stringValue(row, "guid"),
|
||||
name: try stringValue(row, "name"),
|
||||
service: try stringValue(row, "service"),
|
||||
accountID: try stringValue(row, "account_id").nilIfEmpty,
|
||||
accountLogin: try stringValue(row, "account_login").nilIfEmpty,
|
||||
lastAddressedHandle: try stringValue(row, "last_addressed_handle").nilIfEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func latestUnjoinedSentMessageRowID(
|
||||
matchingTargetHandles handles: [String],
|
||||
since date: Date
|
||||
) throws -> Int64? {
|
||||
let candidates = Self.chatTargetHandleCandidates(handles)
|
||||
guard !candidates.isEmpty else { return nil }
|
||||
|
||||
let placeholders = Array(repeating: "?", count: candidates.count).joined(separator: ",")
|
||||
let sql = """
|
||||
SELECT m.ROWID AS message_rowid
|
||||
FROM message m
|
||||
LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
|
||||
LEFT JOIN handle h ON h.ROWID = m.handle_id
|
||||
WHERE m.is_from_me = 1
|
||||
AND m.date >= ?
|
||||
AND IFNULL(m.text, '') = ''
|
||||
AND cmj.message_id IS NULL
|
||||
AND IFNULL(h.id, '') IN (\(placeholders))
|
||||
ORDER BY m.date DESC, m.ROWID DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
let bindings: [Binding?] = [MessageStore.appleEpoch(date)] + candidates
|
||||
return try withConnection { db in
|
||||
let rows = try db.prepareRowIterator(sql, bindings: bindings)
|
||||
guard let row = try rows.failableNext() else { return nil }
|
||||
return try int64Value(row, "message_rowid")
|
||||
}
|
||||
}
|
||||
|
||||
private static func chatTargetHandleCandidates(_ handles: [String]) -> [String] {
|
||||
var candidates: [String] = []
|
||||
for handle in handles {
|
||||
candidates.append(contentsOf: chatTargetCandidates(handle))
|
||||
}
|
||||
return dedupe(candidates)
|
||||
}
|
||||
|
||||
private static func chatTargetCandidates(_ target: String) -> [String] {
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
|
||||
var candidates = [trimmed]
|
||||
if let toggled = toggledAnyGroupPolarity(trimmed) {
|
||||
candidates.append(toggled)
|
||||
}
|
||||
if let bare = bareAnyGroupIdentifier(trimmed) {
|
||||
candidates.append(bare)
|
||||
}
|
||||
return dedupe(candidates)
|
||||
}
|
||||
|
||||
private static func toggledAnyGroupPolarity(_ value: String) -> String? {
|
||||
if value.hasPrefix("any;+;") {
|
||||
return "any;-;" + value.dropFirst("any;+;".count)
|
||||
}
|
||||
if value.hasPrefix("any;-;") {
|
||||
return "any;+;" + value.dropFirst("any;-;".count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func bareAnyGroupIdentifier(_ value: String) -> String? {
|
||||
if value.hasPrefix("any;+;") {
|
||||
return String(value.dropFirst("any;+;".count))
|
||||
}
|
||||
if value.hasPrefix("any;-;") {
|
||||
return String(value.dropFirst("any;-;".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
|
||||
}
|
||||
}
|
||||
@ -14,12 +14,17 @@ public final class MessageStore: @unchecked Sendable {
|
||||
private let connection: Connection
|
||||
private let queue: DispatchQueue
|
||||
private let queueKey = DispatchSpecificKey<Void>()
|
||||
let hasAttributedBody: Bool
|
||||
let hasReactionColumns: Bool
|
||||
let hasThreadOriginatorGUIDColumn: Bool
|
||||
let hasDestinationCallerID: Bool
|
||||
let hasAudioMessageColumn: Bool
|
||||
let hasAttachmentUserInfo: Bool
|
||||
let schema: MessageStoreSchema
|
||||
|
||||
private struct URLBalloonDedupeEntry: Sendable {
|
||||
let rowID: Int64
|
||||
let date: Date
|
||||
}
|
||||
|
||||
private static let urlBalloonDedupeWindow: TimeInterval = 90
|
||||
private static let urlBalloonDedupeRetention: TimeInterval = 10 * 60
|
||||
|
||||
private var urlBalloonDedupe: [String: URLBalloonDedupeEntry] = [:]
|
||||
|
||||
public init(path: String = MessageStore.defaultPath) throws {
|
||||
let normalized = NSString(string: path).expandingTildeInPath
|
||||
@ -31,17 +36,7 @@ public final class MessageStore: @unchecked Sendable {
|
||||
let location = Connection.Location.uri(uri, parameters: [.mode(.readOnly)])
|
||||
self.connection = try Connection(location, readonly: true)
|
||||
self.connection.busyTimeout = 5
|
||||
let messageColumns = MessageStore.tableColumns(connection: self.connection, table: "message")
|
||||
let attachmentColumns = MessageStore.tableColumns(
|
||||
connection: self.connection,
|
||||
table: "attachment"
|
||||
)
|
||||
self.hasAttributedBody = messageColumns.contains("attributedbody")
|
||||
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
|
||||
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
|
||||
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
|
||||
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
|
||||
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
|
||||
self.schema = MessageStoreSchema(connection: self.connection)
|
||||
} catch {
|
||||
throw MessageStore.enhance(error: error, path: normalized)
|
||||
}
|
||||
@ -55,121 +50,32 @@ public final class MessageStore: @unchecked Sendable {
|
||||
hasThreadOriginatorGUIDColumn: Bool? = nil,
|
||||
hasDestinationCallerID: Bool? = nil,
|
||||
hasAudioMessageColumn: Bool? = nil,
|
||||
hasAttachmentUserInfo: Bool? = nil
|
||||
hasAttachmentUserInfo: Bool? = nil,
|
||||
hasBalloonBundleIDColumn: Bool? = nil,
|
||||
hasChatMessageJoinMessageDateColumn: Bool? = nil,
|
||||
hasChatAccountIDColumn: Bool? = nil,
|
||||
hasChatAccountLoginColumn: Bool? = nil,
|
||||
hasChatLastAddressedHandleColumn: Bool? = nil
|
||||
) throws {
|
||||
self.path = path
|
||||
self.queue = DispatchQueue(label: "imsg.db.test", qos: .userInitiated)
|
||||
self.queue.setSpecific(key: queueKey, value: ())
|
||||
self.connection = connection
|
||||
self.connection.busyTimeout = 5
|
||||
let messageColumns = MessageStore.tableColumns(connection: connection, table: "message")
|
||||
let attachmentColumns = MessageStore.tableColumns(connection: connection, table: "attachment")
|
||||
if let hasAttributedBody {
|
||||
self.hasAttributedBody = hasAttributedBody
|
||||
} else {
|
||||
self.hasAttributedBody = messageColumns.contains("attributedbody")
|
||||
}
|
||||
if let hasReactionColumns {
|
||||
self.hasReactionColumns = hasReactionColumns
|
||||
} else {
|
||||
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
|
||||
}
|
||||
if let hasThreadOriginatorGUIDColumn {
|
||||
self.hasThreadOriginatorGUIDColumn = hasThreadOriginatorGUIDColumn
|
||||
} else {
|
||||
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
|
||||
}
|
||||
if let hasDestinationCallerID {
|
||||
self.hasDestinationCallerID = hasDestinationCallerID
|
||||
} else {
|
||||
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
|
||||
}
|
||||
if let hasAudioMessageColumn {
|
||||
self.hasAudioMessageColumn = hasAudioMessageColumn
|
||||
} else {
|
||||
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
|
||||
}
|
||||
if let hasAttachmentUserInfo {
|
||||
self.hasAttachmentUserInfo = hasAttachmentUserInfo
|
||||
} else {
|
||||
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
|
||||
}
|
||||
}
|
||||
|
||||
public func listChats(limit: Int) throws -> [Chat] {
|
||||
let sql = """
|
||||
SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, c.service_name,
|
||||
MAX(m.date) AS last_date
|
||||
FROM chat c
|
||||
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
|
||||
JOIN message m ON m.ROWID = cmj.message_id
|
||||
GROUP BY c.ROWID
|
||||
ORDER BY last_date DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
return try withConnection { db in
|
||||
var chats: [Chat] = []
|
||||
for row in try db.prepare(sql, limit) {
|
||||
let id = int64Value(row[0]) ?? 0
|
||||
let name = stringValue(row[1])
|
||||
let identifier = stringValue(row[2])
|
||||
let service = stringValue(row[3])
|
||||
let lastDate = appleDate(from: int64Value(row[4]))
|
||||
chats.append(
|
||||
Chat(
|
||||
id: id, identifier: identifier, name: name, service: service, lastMessageAt: lastDate))
|
||||
}
|
||||
return chats
|
||||
}
|
||||
}
|
||||
|
||||
public func chatInfo(chatID: Int64) throws -> ChatInfo? {
|
||||
let sql = """
|
||||
SELECT c.ROWID, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid,
|
||||
IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service
|
||||
FROM chat c
|
||||
WHERE c.ROWID = ?
|
||||
LIMIT 1
|
||||
"""
|
||||
return try withConnection { db in
|
||||
for row in try db.prepare(sql, chatID) {
|
||||
let id = int64Value(row[0]) ?? 0
|
||||
let identifier = stringValue(row[1])
|
||||
let guid = stringValue(row[2])
|
||||
let name = stringValue(row[3])
|
||||
let service = stringValue(row[4])
|
||||
return ChatInfo(
|
||||
id: id,
|
||||
identifier: identifier,
|
||||
guid: guid,
|
||||
name: name,
|
||||
service: service
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func participants(chatID: Int64) throws -> [String] {
|
||||
let sql = """
|
||||
SELECT h.id
|
||||
FROM chat_handle_join chj
|
||||
JOIN handle h ON h.ROWID = chj.handle_id
|
||||
WHERE chj.chat_id = ?
|
||||
ORDER BY h.id ASC
|
||||
"""
|
||||
return try withConnection { db in
|
||||
var results: [String] = []
|
||||
var seen = Set<String>()
|
||||
for row in try db.prepare(sql, chatID) {
|
||||
let handle = stringValue(row[0])
|
||||
if handle.isEmpty { continue }
|
||||
if seen.insert(handle).inserted {
|
||||
results.append(handle)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
self.schema = MessageStoreSchema(
|
||||
base: MessageStoreSchema(connection: connection),
|
||||
hasAttributedBody: hasAttributedBody,
|
||||
hasReactionColumns: hasReactionColumns,
|
||||
hasThreadOriginatorGUIDColumn: hasThreadOriginatorGUIDColumn,
|
||||
hasDestinationCallerID: hasDestinationCallerID,
|
||||
hasAudioMessageColumn: hasAudioMessageColumn,
|
||||
hasAttachmentUserInfo: hasAttachmentUserInfo,
|
||||
hasBalloonBundleIDColumn: hasBalloonBundleIDColumn,
|
||||
hasChatMessageJoinMessageDateColumn: hasChatMessageJoinMessageDateColumn,
|
||||
hasChatAccountIDColumn: hasChatAccountIDColumn,
|
||||
hasChatAccountLoginColumn: hasChatAccountLoginColumn,
|
||||
hasChatLastAddressedHandleColumn: hasChatLastAddressedHandleColumn
|
||||
)
|
||||
}
|
||||
|
||||
func withConnection<T>(_ block: (Connection) throws -> T) throws -> T {
|
||||
@ -180,218 +86,42 @@ public final class MessageStore: @unchecked Sendable {
|
||||
try block(connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageStore {
|
||||
public func attachments(for messageID: Int64) throws -> [AttachmentMeta] {
|
||||
let sql = """
|
||||
SELECT a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker
|
||||
FROM message_attachment_join maj
|
||||
JOIN attachment a ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?
|
||||
"""
|
||||
return try withConnection { db in
|
||||
var metas: [AttachmentMeta] = []
|
||||
for row in try db.prepare(sql, messageID) {
|
||||
let filename = stringValue(row[0])
|
||||
let transferName = stringValue(row[1])
|
||||
let uti = stringValue(row[2])
|
||||
let mimeType = stringValue(row[3])
|
||||
let totalBytes = int64Value(row[4]) ?? 0
|
||||
let isSticker = boolValue(row[5])
|
||||
let resolved = AttachmentResolver.resolve(filename)
|
||||
metas.append(
|
||||
AttachmentMeta(
|
||||
filename: filename,
|
||||
transferName: transferName,
|
||||
uti: uti,
|
||||
mimeType: mimeType,
|
||||
totalBytes: totalBytes,
|
||||
isSticker: isSticker,
|
||||
originalPath: resolved.resolved,
|
||||
missing: resolved.missing
|
||||
))
|
||||
}
|
||||
return metas
|
||||
func shouldSkipURLBalloonDuplicate(
|
||||
chatID: Int64,
|
||||
sender: String,
|
||||
text: String,
|
||||
isFromMe: Bool,
|
||||
date: Date,
|
||||
rowID: Int64
|
||||
) -> Bool {
|
||||
guard !text.isEmpty else { return false }
|
||||
|
||||
pruneURLBalloonDedupe(referenceDate: date)
|
||||
|
||||
let key = "\(chatID)|\(isFromMe ? 1 : 0)|\(sender)|\(text)"
|
||||
let current = URLBalloonDedupeEntry(rowID: rowID, date: date)
|
||||
guard let previous = urlBalloonDedupe[key] else {
|
||||
urlBalloonDedupe[key] = current
|
||||
return false
|
||||
}
|
||||
|
||||
urlBalloonDedupe[key] = current
|
||||
if rowID <= previous.rowID {
|
||||
return true
|
||||
}
|
||||
return date.timeIntervalSince(previous.date) <= MessageStore.urlBalloonDedupeWindow
|
||||
}
|
||||
|
||||
func audioTranscription(for messageID: Int64) throws -> String? {
|
||||
guard hasAttachmentUserInfo else { return nil }
|
||||
let sql = """
|
||||
SELECT a.user_info
|
||||
FROM message_attachment_join maj
|
||||
JOIN attachment a ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?
|
||||
LIMIT 1
|
||||
"""
|
||||
return try withConnection { db in
|
||||
for row in try db.prepare(sql, messageID) {
|
||||
let info = dataValue(row[0])
|
||||
guard !info.isEmpty else { continue }
|
||||
if let transcription = parseAudioTranscription(from: info) {
|
||||
return transcription
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func parseAudioTranscription(from data: Data) -> String? {
|
||||
do {
|
||||
let plist = try PropertyListSerialization.propertyList(
|
||||
from: data,
|
||||
options: [],
|
||||
format: nil
|
||||
)
|
||||
guard
|
||||
let dict = plist as? [String: Any],
|
||||
let transcription = dict["audio-transcription"] as? String,
|
||||
!transcription.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return transcription
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func maxRowID() throws -> Int64 {
|
||||
return try withConnection { db in
|
||||
let value = try db.scalar("SELECT MAX(ROWID) FROM message")
|
||||
return int64Value(value) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
public func reactions(for messageID: Int64) throws -> [Reaction] {
|
||||
guard hasReactionColumns else { return [] }
|
||||
// Reactions are stored as messages with associated_message_type in range 2000-2006
|
||||
// 2000-2005 are standard tapbacks, 2006 is custom emoji reactions
|
||||
// They reference the original message via associated_message_guid which has format "p:X/GUID"
|
||||
// where X is the part index (0 for single-part messages) and GUID matches the original message's guid
|
||||
let bodyColumn = hasAttributedBody ? "r.attributedBody" : "NULL"
|
||||
let sql = """
|
||||
SELECT r.ROWID, r.associated_message_type, h.id, r.is_from_me, r.date, IFNULL(r.text, '') as text,
|
||||
\(bodyColumn) AS body
|
||||
FROM message m
|
||||
JOIN message r ON r.associated_message_guid = m.guid
|
||||
OR r.associated_message_guid LIKE '%/' || m.guid
|
||||
LEFT JOIN handle h ON r.handle_id = h.ROWID
|
||||
WHERE m.ROWID = ?
|
||||
AND m.guid IS NOT NULL
|
||||
AND m.guid != ''
|
||||
AND r.associated_message_type >= 2000
|
||||
AND r.associated_message_type <= 3006
|
||||
ORDER BY r.date ASC
|
||||
"""
|
||||
return try withConnection { db in
|
||||
var reactions: [Reaction] = []
|
||||
var reactionIndex: [ReactionKey: Int] = [:]
|
||||
for row in try db.prepare(sql, messageID) {
|
||||
let rowID = int64Value(row[0]) ?? 0
|
||||
let typeValue = intValue(row[1]) ?? 0
|
||||
let sender = stringValue(row[2])
|
||||
let isFromMe = boolValue(row[3])
|
||||
let date = appleDate(from: int64Value(row[4]))
|
||||
let text = stringValue(row[5])
|
||||
let body = dataValue(row[6])
|
||||
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
|
||||
|
||||
if ReactionType.isReactionRemove(typeValue) {
|
||||
let customEmoji = typeValue == 3006 ? extractCustomEmoji(from: resolvedText) : nil
|
||||
let reactionType = ReactionType.fromRemoval(typeValue, customEmoji: customEmoji)
|
||||
if let reactionType {
|
||||
let key = ReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
|
||||
if let index = reactionIndex.removeValue(forKey: key) {
|
||||
reactions.remove(at: index)
|
||||
reactionIndex = ReactionKey.reindex(reactions: reactions)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if typeValue == 3006 {
|
||||
if let index = reactions.firstIndex(where: {
|
||||
$0.sender == sender && $0.isFromMe == isFromMe && $0.reactionType.isCustom
|
||||
}) {
|
||||
reactions.remove(at: index)
|
||||
reactionIndex = ReactionKey.reindex(reactions: reactions)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let customEmoji: String? = typeValue == 2006 ? extractCustomEmoji(from: resolvedText) : nil
|
||||
guard let reactionType = ReactionType(rawValue: typeValue, customEmoji: customEmoji) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let key = ReactionKey(sender: sender, isFromMe: isFromMe, reactionType: reactionType)
|
||||
if let index = reactionIndex[key] {
|
||||
reactions[index] = Reaction(
|
||||
rowID: rowID,
|
||||
reactionType: reactionType,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
associatedMessageID: messageID
|
||||
)
|
||||
} else {
|
||||
reactionIndex[key] = reactions.count
|
||||
reactions.append(
|
||||
Reaction(
|
||||
rowID: rowID,
|
||||
reactionType: reactionType,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
associatedMessageID: messageID
|
||||
))
|
||||
}
|
||||
}
|
||||
return reactions
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract custom emoji from reaction message text like "Reacted 🎉 to "original message""
|
||||
private func extractCustomEmoji(from text: String) -> String? {
|
||||
// Format: "Reacted X to "..." where X is the emoji. Fallback to first emoji in text.
|
||||
guard
|
||||
let reactedRange = text.range(of: "Reacted "),
|
||||
let toRange = text.range(of: " to ", range: reactedRange.upperBound..<text.endIndex)
|
||||
else {
|
||||
return extractFirstEmoji(from: text)
|
||||
}
|
||||
let emoji = String(text[reactedRange.upperBound..<toRange.lowerBound])
|
||||
return emoji.isEmpty ? extractFirstEmoji(from: text) : emoji
|
||||
}
|
||||
|
||||
private func extractFirstEmoji(from text: String) -> String? {
|
||||
for character in text {
|
||||
if character.unicodeScalars.contains(where: {
|
||||
$0.properties.isEmojiPresentation || $0.properties.isEmoji
|
||||
}) {
|
||||
return String(character)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private struct ReactionKey: Hashable {
|
||||
let sender: String
|
||||
let isFromMe: Bool
|
||||
let reactionType: ReactionType
|
||||
|
||||
static func reindex(reactions: [Reaction]) -> [ReactionKey: Int] {
|
||||
var index: [ReactionKey: Int] = [:]
|
||||
for (offset, reaction) in reactions.enumerated() {
|
||||
let key = ReactionKey(
|
||||
sender: reaction.sender,
|
||||
isFromMe: reaction.isFromMe,
|
||||
reactionType: reaction.reactionType
|
||||
)
|
||||
index[key] = offset
|
||||
}
|
||||
return index
|
||||
}
|
||||
private func pruneURLBalloonDedupe(referenceDate: Date) {
|
||||
guard !urlBalloonDedupe.isEmpty else { return }
|
||||
let cutoff = referenceDate.addingTimeInterval(-MessageStore.urlBalloonDedupeRetention)
|
||||
urlBalloonDedupe = urlBalloonDedupe.filter { $0.value.date >= cutoff }
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var nilIfEmpty: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
|
||||
67
Sources/IMsgCore/MessageStoreSchema.swift
Normal file
67
Sources/IMsgCore/MessageStoreSchema.swift
Normal file
@ -0,0 +1,67 @@
|
||||
import SQLite
|
||||
|
||||
struct MessageStoreSchema: Sendable {
|
||||
let hasAttributedBody: Bool
|
||||
let hasReactionColumns: Bool
|
||||
let hasThreadOriginatorGUIDColumn: Bool
|
||||
let hasDestinationCallerID: Bool
|
||||
let hasAudioMessageColumn: Bool
|
||||
let hasAttachmentUserInfo: Bool
|
||||
let hasBalloonBundleIDColumn: Bool
|
||||
let hasChatMessageJoinMessageDateColumn: Bool
|
||||
let hasChatAccountIDColumn: Bool
|
||||
let hasChatAccountLoginColumn: Bool
|
||||
let hasChatLastAddressedHandleColumn: Bool
|
||||
|
||||
init(connection: Connection) {
|
||||
let messageColumns = MessageStore.tableColumns(connection: connection, table: "message")
|
||||
let attachmentColumns = MessageStore.tableColumns(connection: connection, table: "attachment")
|
||||
let chatMessageJoinColumns = MessageStore.tableColumns(
|
||||
connection: connection,
|
||||
table: "chat_message_join"
|
||||
)
|
||||
let chatColumns = MessageStore.tableColumns(connection: connection, table: "chat")
|
||||
|
||||
self.hasAttributedBody = messageColumns.contains("attributedbody")
|
||||
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
|
||||
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
|
||||
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
|
||||
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
|
||||
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
|
||||
self.hasBalloonBundleIDColumn = messageColumns.contains("balloon_bundle_id")
|
||||
self.hasChatMessageJoinMessageDateColumn = chatMessageJoinColumns.contains("message_date")
|
||||
self.hasChatAccountIDColumn = chatColumns.contains("account_id")
|
||||
self.hasChatAccountLoginColumn = chatColumns.contains("account_login")
|
||||
self.hasChatLastAddressedHandleColumn = chatColumns.contains("last_addressed_handle")
|
||||
}
|
||||
|
||||
init(
|
||||
base: MessageStoreSchema,
|
||||
hasAttributedBody: Bool? = nil,
|
||||
hasReactionColumns: Bool? = nil,
|
||||
hasThreadOriginatorGUIDColumn: Bool? = nil,
|
||||
hasDestinationCallerID: Bool? = nil,
|
||||
hasAudioMessageColumn: Bool? = nil,
|
||||
hasAttachmentUserInfo: Bool? = nil,
|
||||
hasBalloonBundleIDColumn: Bool? = nil,
|
||||
hasChatMessageJoinMessageDateColumn: Bool? = nil,
|
||||
hasChatAccountIDColumn: Bool? = nil,
|
||||
hasChatAccountLoginColumn: Bool? = nil,
|
||||
hasChatLastAddressedHandleColumn: Bool? = nil
|
||||
) {
|
||||
self.hasAttributedBody = hasAttributedBody ?? base.hasAttributedBody
|
||||
self.hasReactionColumns = hasReactionColumns ?? base.hasReactionColumns
|
||||
self.hasThreadOriginatorGUIDColumn =
|
||||
hasThreadOriginatorGUIDColumn ?? base.hasThreadOriginatorGUIDColumn
|
||||
self.hasDestinationCallerID = hasDestinationCallerID ?? base.hasDestinationCallerID
|
||||
self.hasAudioMessageColumn = hasAudioMessageColumn ?? base.hasAudioMessageColumn
|
||||
self.hasAttachmentUserInfo = hasAttachmentUserInfo ?? base.hasAttachmentUserInfo
|
||||
self.hasBalloonBundleIDColumn = hasBalloonBundleIDColumn ?? base.hasBalloonBundleIDColumn
|
||||
self.hasChatMessageJoinMessageDateColumn =
|
||||
hasChatMessageJoinMessageDateColumn ?? base.hasChatMessageJoinMessageDateColumn
|
||||
self.hasChatAccountIDColumn = hasChatAccountIDColumn ?? base.hasChatAccountIDColumn
|
||||
self.hasChatAccountLoginColumn = hasChatAccountLoginColumn ?? base.hasChatAccountLoginColumn
|
||||
self.hasChatLastAddressedHandleColumn =
|
||||
hasChatLastAddressedHandleColumn ?? base.hasChatLastAddressedHandleColumn
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,26 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
public struct MessageWatcherConfiguration: Sendable, Equatable {
|
||||
public var debounceInterval: TimeInterval
|
||||
public var fallbackPollInterval: TimeInterval?
|
||||
public var batchLimit: Int
|
||||
/// When true, reaction events (tapback add/remove) are included in the stream
|
||||
public var includeReactions: Bool
|
||||
|
||||
public init(debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100) {
|
||||
public init(
|
||||
debounceInterval: TimeInterval = 0.25,
|
||||
fallbackPollInterval: TimeInterval? = 5,
|
||||
batchLimit: Int = 100,
|
||||
includeReactions: Bool = false
|
||||
) {
|
||||
self.debounceInterval = debounceInterval
|
||||
self.fallbackPollInterval = fallbackPollInterval
|
||||
self.batchLimit = batchLimit
|
||||
self.includeReactions = includeReactions
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,8 +60,11 @@ private final class WatchState: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "imsg.watch", qos: .userInitiated)
|
||||
|
||||
private var cursor: Int64
|
||||
private var sources: [DispatchSourceFileSystemObject] = []
|
||||
#if os(macOS)
|
||||
private var sources: [DispatchSourceFileSystemObject] = []
|
||||
#endif
|
||||
private var pending = false
|
||||
private var stopped = false
|
||||
|
||||
init(
|
||||
store: MessageStore,
|
||||
@ -76,59 +92,82 @@ private final class WatchState: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
let paths = [store.path, store.path + "-wal", store.path + "-shm"]
|
||||
for path in paths {
|
||||
if let source = makeSource(path: path) {
|
||||
sources.append(source)
|
||||
#if os(macOS)
|
||||
let paths = [store.path, store.path + "-wal", store.path + "-shm"]
|
||||
for path in paths {
|
||||
if let source = makeSource(path: path) {
|
||||
sources.append(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
queue.async {
|
||||
self.scheduleFallbackPoll()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.async {
|
||||
for source in self.sources {
|
||||
source.cancel()
|
||||
}
|
||||
self.sources.removeAll()
|
||||
self.stopped = true
|
||||
#if os(macOS)
|
||||
for source in self.sources {
|
||||
source.cancel()
|
||||
}
|
||||
self.sources.removeAll()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSource(path: String) -> DispatchSourceFileSystemObject? {
|
||||
let fd = open(path, O_EVTONLY)
|
||||
guard fd >= 0 else { return nil }
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
eventMask: [.write, .extend, .rename, .delete],
|
||||
queue: queue
|
||||
)
|
||||
source.setEventHandler { [weak self] in
|
||||
self?.schedulePoll()
|
||||
#if os(macOS)
|
||||
private func makeSource(path: String) -> DispatchSourceFileSystemObject? {
|
||||
let fd = open(path, O_EVTONLY)
|
||||
guard fd >= 0 else { return nil }
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
eventMask: [.write, .extend, .rename, .delete],
|
||||
queue: queue
|
||||
)
|
||||
source.setEventHandler { [weak self] in
|
||||
self?.schedulePoll()
|
||||
}
|
||||
source.setCancelHandler {
|
||||
close(fd)
|
||||
}
|
||||
source.resume()
|
||||
return source
|
||||
}
|
||||
source.setCancelHandler {
|
||||
close(fd)
|
||||
}
|
||||
source.resume()
|
||||
return source
|
||||
}
|
||||
#endif
|
||||
|
||||
private func schedulePoll() {
|
||||
if stopped { return }
|
||||
if pending { return }
|
||||
pending = true
|
||||
let delay = configuration.debounceInterval
|
||||
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.stopped { return }
|
||||
self.pending = false
|
||||
self.poll()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleFallbackPoll() {
|
||||
guard let interval = configuration.fallbackPollInterval, interval > 0 else { return }
|
||||
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
|
||||
guard let self, !self.stopped else { return }
|
||||
self.poll()
|
||||
self.scheduleFallbackPoll()
|
||||
}
|
||||
}
|
||||
|
||||
private func poll() {
|
||||
if stopped { return }
|
||||
do {
|
||||
let messages = try store.messagesAfter(
|
||||
afterRowID: cursor,
|
||||
chatID: chatID,
|
||||
limit: configuration.batchLimit
|
||||
limit: configuration.batchLimit,
|
||||
includeReactions: configuration.includeReactions
|
||||
)
|
||||
for message in messages {
|
||||
continuation.yield(message)
|
||||
|
||||
405
Sources/IMsgCore/MessagesLauncher.swift
Normal file
405
Sources/IMsgCore/MessagesLauncher.swift
Normal file
@ -0,0 +1,405 @@
|
||||
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()
|
||||
|
||||
// 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 lockFile: String {
|
||||
containerPath + "/.imsg-bridge-ready"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
private let messagesAppPath =
|
||||
"/System/Applications/Messages.app/Contents/MacOS/Messages"
|
||||
private let queue = DispatchQueue(label: "imsg.messages.launcher")
|
||||
private let lock = NSLock()
|
||||
|
||||
/// Path to the dylib to inject.
|
||||
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
|
||||
|
||||
private init() {
|
||||
let possiblePaths = [
|
||||
"/usr/local/lib/imsg-bridge-helper.dylib",
|
||||
".build/release/imsg-bridge-helper.dylib",
|
||||
".build/debug/imsg-bridge-helper.dylib",
|
||||
]
|
||||
for path in possiblePaths {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
self.dylibPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Messages.app has published the bridge-ready lock file.
|
||||
public func hasReadyLockFile() -> Bool {
|
||||
FileManager.default.fileExists(atPath: lockFile)
|
||||
}
|
||||
|
||||
/// Check if Messages.app is running with our dylib (lock file exists and responds to ping).
|
||||
public func isInjectedAndReady() -> Bool {
|
||||
guard hasReadyLockFile() else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let response = try sendCommandSync(action: "ping", params: [:])
|
||||
return response["success"] as? Bool == true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure Messages.app is running with our dylib injected.
|
||||
public func ensureRunning() throws {
|
||||
if isInjectedAndReady() { return }
|
||||
try launchInjectedMessages()
|
||||
}
|
||||
|
||||
/// Ensure Messages.app is launched with the helper without touching legacy IPC.
|
||||
public func ensureLaunched() throws {
|
||||
if hasReadyLockFile() { return }
|
||||
try launchInjectedMessages()
|
||||
}
|
||||
|
||||
private func launchInjectedMessages() throws {
|
||||
switch Self.currentSIPStatus() {
|
||||
case .disabled:
|
||||
break
|
||||
case .enabled:
|
||||
throw MessagesLauncherError.sipEnabled
|
||||
case .unknown(let details):
|
||||
throw MessagesLauncherError.sipStatusUnknown(details)
|
||||
}
|
||||
|
||||
guard FileManager.default.fileExists(atPath: dylibPath) else {
|
||||
throw MessagesLauncherError.dylibNotFound(dylibPath)
|
||||
}
|
||||
|
||||
killMessages()
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
|
||||
// Clean up stale IPC files
|
||||
try? FileManager.default.removeItem(atPath: commandFile)
|
||||
try? FileManager.default.removeItem(atPath: responseFile)
|
||||
try? FileManager.default.removeItem(atPath: lockFile)
|
||||
|
||||
// Pre-create v2 RPC queue directories so the dylib can FSEvent-watch them
|
||||
// immediately on startup (FSEventStream registration on a missing path
|
||||
// silently fails to deliver events).
|
||||
try ensureSecureQueueDirectory(bridgeInboxDirectory)
|
||||
try ensureSecureQueueDirectory(bridgeOutboxDirectory)
|
||||
try cleanQueueDirectory(bridgeInboxDirectory)
|
||||
try cleanQueueDirectory(bridgeOutboxDirectory)
|
||||
|
||||
try launchWithInjection()
|
||||
try waitForReady(timeout: 15.0)
|
||||
}
|
||||
|
||||
private func ensureSecureQueueDirectory(_ path: String) throws {
|
||||
if SecurePath.hasSymlinkComponent(path) {
|
||||
throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)")
|
||||
}
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: path,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
if SecurePath.hasSymlinkComponent(path) {
|
||||
throw MessagesLauncherError.socketError(
|
||||
"RPC queue path traverses a symlink (post-mkdir): \(path)")
|
||||
}
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o700], ofItemAtPath: path)
|
||||
} catch let error as MessagesLauncherError {
|
||||
throw error
|
||||
} catch {
|
||||
throw MessagesLauncherError.socketError("mkdir \(path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
let task = Process()
|
||||
task.executableURL = URL(fileURLWithPath: messagesAppPath)
|
||||
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["DYLD_INSERT_LIBRARIES"] = absoluteDylibPath
|
||||
task.environment = environment
|
||||
|
||||
task.standardOutput = FileHandle.nullDevice
|
||||
task.standardError = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try task.run()
|
||||
} catch {
|
||||
throw MessagesLauncherError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForReady(timeout: TimeInterval) throws {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
while Date() < deadline {
|
||||
if FileManager.default.fileExists(atPath: lockFile) {
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
return
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
|
||||
throw MessagesLauncherError.socketTimeout
|
||||
}
|
||||
|
||||
private func sendCommandSync(
|
||||
action: String, params: [String: Any]
|
||||
) throws -> [String: Any] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let command: [String: Any] = [
|
||||
"id": Int(Date().timeIntervalSince1970 * 1000),
|
||||
"action": action,
|
||||
"params": params,
|
||||
]
|
||||
|
||||
let 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 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")
|
||||
}
|
||||
}
|
||||
#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)
|
||||
case launchFailed(String)
|
||||
case sipEnabled
|
||||
case sipStatusUnknown(String)
|
||||
case socketTimeout
|
||||
case socketError(String)
|
||||
case invalidResponse
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .dylibNotFound(let path):
|
||||
return "imsg-bridge-helper.dylib not found at \(path). Build with: make build-dylib"
|
||||
case .launchFailed(let reason):
|
||||
return "Failed to launch Messages.app: \(reason)"
|
||||
case .sipEnabled:
|
||||
return
|
||||
"System Integrity Protection (SIP) is enabled. "
|
||||
+ "Refusing to inject into Messages.app. "
|
||||
+ "Disable SIP in Recovery mode before using `imsg launch`."
|
||||
case .sipStatusUnknown(let details):
|
||||
return
|
||||
"Unable to determine SIP status. "
|
||||
+ "Refusing to inject into Messages.app. "
|
||||
+ "Details: \(details)"
|
||||
case .socketTimeout:
|
||||
return
|
||||
"Timeout waiting for Messages.app to initialize. "
|
||||
+ "Ensure SIP is disabled and Messages.app has necessary permissions."
|
||||
case .socketError(let reason):
|
||||
return "IPC error: \(reason)"
|
||||
case .invalidResponse:
|
||||
return "Invalid response from Messages.app helper"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -190,13 +190,28 @@ public struct Chat: Sendable, Equatable {
|
||||
public let name: String
|
||||
public let service: String
|
||||
public let lastMessageAt: Date
|
||||
public let accountID: String?
|
||||
public let accountLogin: String?
|
||||
public let lastAddressedHandle: String?
|
||||
|
||||
public init(id: Int64, identifier: String, name: String, service: String, lastMessageAt: Date) {
|
||||
public init(
|
||||
id: Int64,
|
||||
identifier: String,
|
||||
name: String,
|
||||
service: String,
|
||||
lastMessageAt: Date,
|
||||
accountID: String? = nil,
|
||||
accountLogin: String? = nil,
|
||||
lastAddressedHandle: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.identifier = identifier
|
||||
self.name = name
|
||||
self.service = service
|
||||
self.lastMessageAt = lastMessageAt
|
||||
self.accountID = accountID
|
||||
self.accountLogin = accountLogin
|
||||
self.lastAddressedHandle = lastAddressedHandle
|
||||
}
|
||||
}
|
||||
|
||||
@ -206,17 +221,67 @@ public struct ChatInfo: Sendable, Equatable {
|
||||
public let guid: String
|
||||
public let name: String
|
||||
public let service: String
|
||||
public let accountID: String?
|
||||
public let accountLogin: String?
|
||||
public let lastAddressedHandle: String?
|
||||
|
||||
public init(id: Int64, identifier: String, guid: String, name: String, service: String) {
|
||||
public init(
|
||||
id: Int64,
|
||||
identifier: String,
|
||||
guid: String,
|
||||
name: String,
|
||||
service: String,
|
||||
accountID: String? = nil,
|
||||
accountLogin: String? = nil,
|
||||
lastAddressedHandle: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.identifier = identifier
|
||||
self.guid = guid
|
||||
self.name = name
|
||||
self.service = service
|
||||
self.accountID = accountID
|
||||
self.accountLogin = accountLogin
|
||||
self.lastAddressedHandle = lastAddressedHandle
|
||||
}
|
||||
}
|
||||
|
||||
public struct Message: Sendable, Equatable {
|
||||
public struct RoutingMetadata: Sendable, Equatable {
|
||||
public let replyToGUID: String?
|
||||
public let threadOriginatorGUID: String?
|
||||
public let destinationCallerID: String?
|
||||
|
||||
public init(
|
||||
replyToGUID: String? = nil,
|
||||
threadOriginatorGUID: String? = nil,
|
||||
destinationCallerID: String? = nil
|
||||
) {
|
||||
self.replyToGUID = replyToGUID
|
||||
self.threadOriginatorGUID = threadOriginatorGUID
|
||||
self.destinationCallerID = destinationCallerID
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReactionMetadata: Sendable, Equatable {
|
||||
public let isReaction: Bool
|
||||
public let reactionType: ReactionType?
|
||||
public let isReactionAdd: Bool?
|
||||
public let reactedToGUID: String?
|
||||
|
||||
public init(
|
||||
isReaction: Bool = false,
|
||||
reactionType: ReactionType? = nil,
|
||||
isReactionAdd: Bool? = nil,
|
||||
reactedToGUID: String? = nil
|
||||
) {
|
||||
self.isReaction = isReaction
|
||||
self.reactionType = reactionType
|
||||
self.isReactionAdd = isReactionAdd
|
||||
self.reactedToGUID = reactedToGUID
|
||||
}
|
||||
}
|
||||
|
||||
public let rowID: Int64
|
||||
public let chatID: Int64
|
||||
public let guid: String
|
||||
@ -229,6 +294,53 @@ public struct Message: Sendable, Equatable {
|
||||
public let service: String
|
||||
public let handleID: Int64?
|
||||
public let attachmentsCount: Int
|
||||
/// The destination_caller_id from the database. For messages where is_from_me is true,
|
||||
/// this can help distinguish between messages actually sent by the local user vs
|
||||
/// messages received on a secondary phone number registered with the same Apple ID.
|
||||
public let destinationCallerID: String?
|
||||
|
||||
// Reaction metadata (populated when message is a reaction event)
|
||||
/// Whether this message is a reaction event (tapback add/remove)
|
||||
public let isReaction: Bool
|
||||
/// The type of reaction (only set when isReaction is true)
|
||||
public let reactionType: ReactionType?
|
||||
/// Whether this is adding (true) or removing (false) a reaction (only set when isReaction is true)
|
||||
public let isReactionAdd: Bool?
|
||||
/// The GUID of the message being reacted to (only set when isReaction is true)
|
||||
public let reactedToGUID: String?
|
||||
|
||||
public init(
|
||||
rowID: Int64,
|
||||
chatID: Int64,
|
||||
sender: String,
|
||||
text: String,
|
||||
date: Date,
|
||||
isFromMe: Bool,
|
||||
service: String,
|
||||
handleID: Int64?,
|
||||
attachmentsCount: Int,
|
||||
guid: String = "",
|
||||
routing: RoutingMetadata = RoutingMetadata(),
|
||||
reaction: ReactionMetadata = ReactionMetadata()
|
||||
) {
|
||||
self.rowID = rowID
|
||||
self.chatID = chatID
|
||||
self.guid = guid
|
||||
self.replyToGUID = routing.replyToGUID
|
||||
self.threadOriginatorGUID = routing.threadOriginatorGUID
|
||||
self.sender = sender
|
||||
self.text = text
|
||||
self.date = date
|
||||
self.isFromMe = isFromMe
|
||||
self.service = service
|
||||
self.handleID = handleID
|
||||
self.attachmentsCount = attachmentsCount
|
||||
self.destinationCallerID = routing.destinationCallerID
|
||||
self.isReaction = reaction.isReaction
|
||||
self.reactionType = reaction.reactionType
|
||||
self.isReactionAdd = reaction.isReactionAdd
|
||||
self.reactedToGUID = reaction.reactedToGUID
|
||||
}
|
||||
|
||||
public init(
|
||||
rowID: Int64,
|
||||
@ -242,20 +354,36 @@ public struct Message: Sendable, Equatable {
|
||||
attachmentsCount: Int,
|
||||
guid: String = "",
|
||||
replyToGUID: String? = nil,
|
||||
threadOriginatorGUID: String? = nil
|
||||
threadOriginatorGUID: String? = nil,
|
||||
destinationCallerID: String? = nil,
|
||||
isReaction: Bool = false,
|
||||
reactionType: ReactionType? = nil,
|
||||
isReactionAdd: Bool? = nil,
|
||||
reactedToGUID: String? = nil
|
||||
) {
|
||||
self.rowID = rowID
|
||||
self.chatID = chatID
|
||||
self.guid = guid
|
||||
self.replyToGUID = replyToGUID
|
||||
self.threadOriginatorGUID = threadOriginatorGUID
|
||||
self.sender = sender
|
||||
self.text = text
|
||||
self.date = date
|
||||
self.isFromMe = isFromMe
|
||||
self.service = service
|
||||
self.handleID = handleID
|
||||
self.attachmentsCount = attachmentsCount
|
||||
self.init(
|
||||
rowID: rowID,
|
||||
chatID: chatID,
|
||||
sender: sender,
|
||||
text: text,
|
||||
date: date,
|
||||
isFromMe: isFromMe,
|
||||
service: service,
|
||||
handleID: handleID,
|
||||
attachmentsCount: attachmentsCount,
|
||||
guid: guid,
|
||||
routing: RoutingMetadata(
|
||||
replyToGUID: replyToGUID,
|
||||
threadOriginatorGUID: threadOriginatorGUID,
|
||||
destinationCallerID: destinationCallerID
|
||||
),
|
||||
reaction: ReactionMetadata(
|
||||
isReaction: isReaction,
|
||||
reactionType: reactionType,
|
||||
isReactionAdd: isReactionAdd,
|
||||
reactedToGUID: reactedToGUID
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -267,6 +395,8 @@ public struct AttachmentMeta: Sendable, Equatable {
|
||||
public let totalBytes: Int64
|
||||
public let isSticker: Bool
|
||||
public let originalPath: String
|
||||
public let convertedPath: String?
|
||||
public let convertedMimeType: String?
|
||||
public let missing: Bool
|
||||
|
||||
public init(
|
||||
@ -277,6 +407,8 @@ public struct AttachmentMeta: Sendable, Equatable {
|
||||
totalBytes: Int64,
|
||||
isSticker: Bool,
|
||||
originalPath: String,
|
||||
convertedPath: String? = nil,
|
||||
convertedMimeType: String? = nil,
|
||||
missing: Bool
|
||||
) {
|
||||
self.filename = filename
|
||||
@ -286,6 +418,18 @@ public struct AttachmentMeta: Sendable, Equatable {
|
||||
self.totalBytes = totalBytes
|
||||
self.isSticker = isSticker
|
||||
self.originalPath = originalPath
|
||||
self.convertedPath = convertedPath
|
||||
self.convertedMimeType = convertedMimeType
|
||||
self.missing = missing
|
||||
}
|
||||
}
|
||||
|
||||
public struct AttachmentQueryOptions: Sendable, Equatable {
|
||||
public static let `default` = AttachmentQueryOptions()
|
||||
|
||||
public let convertUnsupported: Bool
|
||||
|
||||
public init(convertUnsupported: Bool = false) {
|
||||
self.convertUnsupported = convertUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
67
Sources/IMsgCore/SecurePath.swift
Normal file
67
Sources/IMsgCore/SecurePath.swift
Normal file
@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
/// Lexical-walk symlink detector. Used wherever we accept a filesystem path
|
||||
/// from outside the dylib (RPC inbox dir, attachment paths) and want to refuse
|
||||
/// any path that traverses a symbolic link, including parent components.
|
||||
///
|
||||
/// `realpath()` alone isn't sufficient: a same-UID attacker who can write to
|
||||
/// our RPC inbox could otherwise symlink an arbitrary file (a credential file,
|
||||
/// a password manager DB) into a location they control and have Messages.app
|
||||
/// exfiltrate it as an attachment. Comparing the resolved path against the
|
||||
/// lexical input is fragile too — macOS rewrites `/tmp` to `/private/tmp`,
|
||||
/// breaking that check for legitimate paths. Walking each component with
|
||||
/// `lstat()` and refusing the path on any `S_IFLNK` is the robust answer.
|
||||
public enum SecurePath {
|
||||
private static func normalizingTrustedSystemAliasPrefix(_ path: String) -> String {
|
||||
let aliases = [
|
||||
"/tmp": "/private/tmp",
|
||||
"/var": "/private/var",
|
||||
"/etc": "/private/etc",
|
||||
]
|
||||
for (alias, canonical) in aliases {
|
||||
if path == alias {
|
||||
return canonical
|
||||
}
|
||||
if path.hasPrefix(alias + "/") {
|
||||
return canonical + path.dropFirst(alias.count)
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/// Returns true if any component of `path` (after tilde expansion and CWD
|
||||
/// resolution for relative paths) is a symbolic link. Final component
|
||||
/// included.
|
||||
public static func hasSymlinkComponent(_ path: String) -> Bool {
|
||||
var lexicalPath = (path as NSString).expandingTildeInPath
|
||||
if !lexicalPath.hasPrefix("/") {
|
||||
lexicalPath =
|
||||
(FileManager.default.currentDirectoryPath as NSString)
|
||||
.appendingPathComponent(lexicalPath)
|
||||
}
|
||||
lexicalPath = normalizingTrustedSystemAliasPrefix(lexicalPath)
|
||||
|
||||
let components = (lexicalPath as NSString).pathComponents
|
||||
guard !components.isEmpty else { return false }
|
||||
|
||||
var cursor = components.first == "/" ? "/" : ""
|
||||
for component in components where component != "/" && !component.isEmpty {
|
||||
cursor = (cursor as NSString).appendingPathComponent(component)
|
||||
|
||||
var info = stat()
|
||||
if lstat(cursor, &info) != 0 {
|
||||
continue
|
||||
}
|
||||
if (info.st_mode & S_IFMT) == S_IFLNK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,12 @@ enum TypedStreamParser {
|
||||
static func parseAttributedBody(_ data: Data) -> String {
|
||||
guard !data.isEmpty else { return "" }
|
||||
let bytes = [UInt8](data)
|
||||
if bytes.count >= 2, bytes[0] == 0xff, bytes[1] == 0xfe {
|
||||
let payload = data.dropFirst(2)
|
||||
if let text = String(data: payload, encoding: .utf16LittleEndian) {
|
||||
return text.trimmingLeadingControlCharacters()
|
||||
}
|
||||
}
|
||||
let start = [UInt8(0x01), UInt8(0x2b)]
|
||||
let end = [UInt8(0x86), UInt8(0x84)]
|
||||
var best = ""
|
||||
@ -13,13 +19,8 @@ enum TypedStreamParser {
|
||||
if bytes[index] == start[0], bytes[index + 1] == start[1] {
|
||||
let sliceStart = index + 2
|
||||
if let sliceEnd = findSequence(end, in: bytes, from: sliceStart) {
|
||||
var segment = Array(bytes[sliceStart..<sliceEnd])
|
||||
// Check if first byte equals length prefix (convert byte to Int for comparison)
|
||||
if segment.count > 1, Int(segment[0]) == segment.count - 1 {
|
||||
segment.removeFirst()
|
||||
}
|
||||
let candidate = String(decoding: segment, as: UTF8.self)
|
||||
.trimmingLeadingControlCharacters()
|
||||
let segment = Array(bytes[sliceStart..<sliceEnd])
|
||||
let candidate = decodeSegment(segment)
|
||||
if candidate.count > best.count {
|
||||
best = candidate
|
||||
}
|
||||
@ -36,6 +37,46 @@ enum TypedStreamParser {
|
||||
return text.trimmingLeadingControlCharacters()
|
||||
}
|
||||
|
||||
/// Strips a typedstream length prefix from `segment` and returns the longest valid UTF-8 decoding.
|
||||
/// Length prefix forms (BER-style): single byte (< 0x80), `0x81 NN`, or `0x82 NN NN`.
|
||||
/// Structured prefixes always win over the raw `prefixLen = 0` decode: otherwise, when the
|
||||
/// length byte is itself a printable-ASCII character (body length 32–126), the unstripped decode
|
||||
/// produces an N+1 character string that beats the correct N-character body.
|
||||
private static func decodeSegment(_ segment: [UInt8]) -> String {
|
||||
guard let first = segment.first else { return "" }
|
||||
|
||||
var structuredPrefixes: [Int] = []
|
||||
if first < 0x80, Int(first) == segment.count - 1 {
|
||||
structuredPrefixes.append(1)
|
||||
}
|
||||
if first == 0x81, segment.count >= 2 {
|
||||
structuredPrefixes.append(2)
|
||||
}
|
||||
if first == 0x82, segment.count >= 3 {
|
||||
structuredPrefixes.append(3)
|
||||
}
|
||||
|
||||
var bestStructured = ""
|
||||
var anyStructuredValid = false
|
||||
for prefixLen in structuredPrefixes {
|
||||
let body = Array(segment[prefixLen...])
|
||||
guard
|
||||
let candidate = String(bytes: body, encoding: .utf8)?
|
||||
.trimmingLeadingControlCharacters()
|
||||
else { continue }
|
||||
anyStructuredValid = true
|
||||
if candidate.count > bestStructured.count {
|
||||
bestStructured = candidate
|
||||
}
|
||||
}
|
||||
if anyStructuredValid {
|
||||
return bestStructured
|
||||
}
|
||||
|
||||
return String(bytes: segment, encoding: .utf8)?
|
||||
.trimmingLeadingControlCharacters() ?? ""
|
||||
}
|
||||
|
||||
private static func findSequence(_ needle: [UInt8], in haystack: [UInt8], from start: Int)
|
||||
-> Int?
|
||||
{
|
||||
|
||||
345
Sources/IMsgCore/TypingIndicator.swift
Normal file
345
Sources/IMsgCore/TypingIndicator.swift
Normal file
@ -0,0 +1,345 @@
|
||||
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()
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
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 setTypingDirect(chatIdentifier: String, isTyping: Bool) throws {
|
||||
let frameworkPath = "/System/Library/PrivateFrameworks/IMCore.framework/IMCore"
|
||||
guard let handle = dlopen(frameworkPath, RTLD_LAZY) else {
|
||||
let error = String(cString: dlerror())
|
||||
throw IMsgError.typingIndicatorFailed(
|
||||
"Failed to load IMCore framework: \(error)")
|
||||
}
|
||||
defer { dlclose(handle) }
|
||||
|
||||
try ensureDaemonConnection()
|
||||
let chat = try lookupChat(identifier: chatIdentifier)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
try await sleep(UInt64(duration * 1_000_000_000))
|
||||
try stopTyping(chatIdentifier)
|
||||
stopped = true
|
||||
}
|
||||
|
||||
private static func ensureDaemonConnection() throws {
|
||||
guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else {
|
||||
throw IMsgError.typingIndicatorFailed("IMDaemonController class not found")
|
||||
}
|
||||
|
||||
let sharedSel = sel_registerName("sharedInstance")
|
||||
guard controllerClass.responds(to: sharedSel) else {
|
||||
throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available")
|
||||
}
|
||||
|
||||
guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else {
|
||||
throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance")
|
||||
}
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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 identSel = sel_registerName("existingChatWithChatIdentifier:")
|
||||
if registry.responds(to: identSel) {
|
||||
for candidate in candidates {
|
||||
if let chat = registry.perform(identSel, with: candidate)?.takeUnretainedValue()
|
||||
as? NSObject
|
||||
{
|
||||
return chat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
daemonConnectionTracker.lock.lock()
|
||||
let connectionKnownUnavailable = daemonConnectionTracker.connectionKnownUnavailable
|
||||
daemonConnectionTracker.lock.unlock()
|
||||
if connectionKnownUnavailable {
|
||||
throw IMsgError.typingIndicatorFailed(daemonUnavailableMessage())
|
||||
}
|
||||
|
||||
throw IMsgError.typingIndicatorFailed(
|
||||
"Chat not found for identifier: \(identifier). "
|
||||
+ "Make sure Messages.app has an active conversation with this contact.")
|
||||
}
|
||||
|
||||
static func daemonUnavailableMessage() -> String {
|
||||
"Failed to connect to imagent (Messages daemon) for IMCore typing indicators. "
|
||||
+ "On macOS 26/Tahoe, imagent can reject third-party clients without "
|
||||
+ "Apple-private entitlements, and Messages.app may also block the injected "
|
||||
+ "bridge via library validation. Run 'imsg status' and 'imsg launch' to "
|
||||
+ "verify advanced feature setup. Normal 'send', 'history', and 'watch' "
|
||||
+ "commands do not use this IMCore path."
|
||||
}
|
||||
|
||||
static func chatLookupCandidates(for identifier: String) -> [String] {
|
||||
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
|
||||
let bareIdentifier = stripKnownChatPrefix(trimmed) ?? trimmed
|
||||
var candidates = [trimmed]
|
||||
if bareIdentifier != trimmed {
|
||||
candidates.append(bareIdentifier)
|
||||
}
|
||||
for prefix in chatIdentifierPrefixes {
|
||||
candidates.append(prefix + bareIdentifier)
|
||||
}
|
||||
return dedupe(candidates)
|
||||
}
|
||||
|
||||
private static let chatIdentifierPrefixes = [
|
||||
"iMessage;-;",
|
||||
"iMessage;+;",
|
||||
"SMS;-;",
|
||||
"SMS;+;",
|
||||
"any;-;",
|
||||
"any;+;",
|
||||
]
|
||||
|
||||
private static func stripKnownChatPrefix(_ value: String) -> String? {
|
||||
for prefix in chatIdentifierPrefixes where value.hasPrefix(prefix) {
|
||||
return String(value.dropFirst(prefix.count))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func dedupe(_ values: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for value in values where !value.isEmpty {
|
||||
if seen.insert(value).inserted {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/// Thread-safe box for passing an error out of a Task back to the calling thread.
|
||||
private final class BridgeResultBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _error: Error?
|
||||
|
||||
var error: Error? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _error
|
||||
}
|
||||
|
||||
func setError(_ error: Error) {
|
||||
lock.lock()
|
||||
_error = error
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
3388
Sources/IMsgHelper/IMsgInjected.m
Normal file
3388
Sources/IMsgHelper/IMsgInjected.m
Normal file
File diff suppressed because it is too large
Load Diff
@ -9,3 +9,14 @@ func displayName(for meta: AttachmentMeta) -> String {
|
||||
if !meta.filename.isEmpty { return meta.filename }
|
||||
return "(unknown)"
|
||||
}
|
||||
|
||||
func attachmentMetadataLine(for meta: AttachmentMeta) -> String {
|
||||
let name = displayName(for: meta)
|
||||
var line =
|
||||
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
|
||||
if let convertedPath = meta.convertedPath {
|
||||
let convertedMime = meta.convertedMimeType ?? ""
|
||||
line += " converted_mime=\(convertedMime) converted_path=\(convertedPath)"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
107
Sources/imsg/ChatTargetResolver.swift
Normal file
107
Sources/imsg/ChatTargetResolver.swift
Normal file
@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
struct ChatTargetInput: Sendable {
|
||||
let recipient: String
|
||||
let chatID: Int64?
|
||||
let chatIdentifier: String
|
||||
let chatGUID: String
|
||||
|
||||
var hasChatTarget: Bool {
|
||||
chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedChatTarget: Sendable {
|
||||
let chatIdentifier: String
|
||||
let chatGUID: String
|
||||
|
||||
var preferredIdentifier: String? {
|
||||
if !chatGUID.isEmpty { return chatGUID }
|
||||
if !chatIdentifier.isEmpty { return chatIdentifier }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatTargetResolver {
|
||||
static func validateRecipientRequirements(
|
||||
input: ChatTargetInput,
|
||||
mixedTargetError: Error,
|
||||
missingRecipientError: Error
|
||||
) throws {
|
||||
if input.hasChatTarget && !input.recipient.isEmpty {
|
||||
throw mixedTargetError
|
||||
}
|
||||
if !input.hasChatTarget && input.recipient.isEmpty {
|
||||
throw missingRecipientError
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveChatTarget(
|
||||
input: ChatTargetInput,
|
||||
lookupChat: (Int64) async throws -> ChatInfo?,
|
||||
unknownChatError: (Int64) -> Error
|
||||
) async throws -> ResolvedChatTarget {
|
||||
var resolvedIdentifier = input.chatIdentifier
|
||||
var resolvedGUID = input.chatGUID
|
||||
|
||||
if let chatID = input.chatID {
|
||||
guard let info = try await lookupChat(chatID) else {
|
||||
throw unknownChatError(chatID)
|
||||
}
|
||||
resolvedIdentifier = info.identifier
|
||||
resolvedGUID = info.guid
|
||||
}
|
||||
|
||||
return ResolvedChatTarget(
|
||||
chatIdentifier: resolvedIdentifier,
|
||||
chatGUID: resolvedGUID
|
||||
)
|
||||
}
|
||||
|
||||
static func directTypingIdentifier(
|
||||
recipient: String,
|
||||
serviceRaw: String,
|
||||
invalidServiceError: (String) -> Error
|
||||
) throws -> String {
|
||||
guard let service = MessageService(rawValue: serviceRaw.lowercased()) else {
|
||||
throw invalidServiceError(serviceRaw)
|
||||
}
|
||||
let prefix = service == .sms ? "SMS" : "iMessage"
|
||||
return "\(prefix);-;\(recipient)"
|
||||
}
|
||||
|
||||
static func looksLikeContactName(_ recipient: String) -> Bool {
|
||||
let trimmed = recipient.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return false }
|
||||
if trimmed.contains("@") { return false }
|
||||
if trimmed.hasPrefix("+") { return false }
|
||||
let phoneCharacters = CharacterSet(charactersIn: "0123456789-(). ")
|
||||
if trimmed.unicodeScalars.allSatisfy({ phoneCharacters.contains($0) }) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
static func resolveRecipientName(
|
||||
_ recipient: String,
|
||||
contacts: any ContactResolving
|
||||
) throws -> String {
|
||||
guard looksLikeContactName(recipient) else { return recipient }
|
||||
let matches = contacts.searchByName(recipient)
|
||||
switch matches.count {
|
||||
case 0:
|
||||
return recipient
|
||||
case 1:
|
||||
return matches[0].handle
|
||||
default:
|
||||
let details =
|
||||
matches
|
||||
.map { " \($0.name): \($0.handle)" }
|
||||
.joined(separator: "\n")
|
||||
throw IMsgError.invalidChatTarget(
|
||||
"Multiple contacts match \"\(recipient)\":\n\(details)\nSpecify a phone number or email instead."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,10 +11,38 @@ struct CommandRouter {
|
||||
self.version = CommandRouter.resolveVersion()
|
||||
self.specs = [
|
||||
ChatsCommand.spec,
|
||||
GroupCommand.spec,
|
||||
HistoryCommand.spec,
|
||||
WatchCommand.spec,
|
||||
SendCommand.spec,
|
||||
ReactCommand.spec,
|
||||
ReadCommand.spec,
|
||||
TypingCommand.spec,
|
||||
LaunchCommand.spec,
|
||||
StatusCommand.spec,
|
||||
RpcCommand.spec,
|
||||
CompletionsCommand.spec,
|
||||
// Bridge-backed (require `imsg launch` + SIP off)
|
||||
SendRichCommand.spec,
|
||||
SendMultipartCommand.spec,
|
||||
SendAttachmentCommand.spec,
|
||||
BridgeReactCommand.spec,
|
||||
EditCommand.spec,
|
||||
UnsendCommand.spec,
|
||||
DeleteMessageCommand.spec,
|
||||
NotifyAnywaysCommand.spec,
|
||||
ChatCreateCommand.spec,
|
||||
ChatNameCommand.spec,
|
||||
ChatPhotoCommand.spec,
|
||||
ChatAddMemberCommand.spec,
|
||||
ChatRemoveMemberCommand.spec,
|
||||
ChatLeaveCommand.spec,
|
||||
ChatDeleteCommand.spec,
|
||||
ChatMarkCommand.spec,
|
||||
SearchCommand.spec,
|
||||
AccountCommand.spec,
|
||||
WhoisCommand.spec,
|
||||
NicknameCommand.spec,
|
||||
]
|
||||
let descriptor = CommandDescriptor(
|
||||
name: rootName,
|
||||
@ -33,7 +61,7 @@ struct CommandRouter {
|
||||
func run(argv: [String]) async -> Int32 {
|
||||
let argv = normalizeArguments(argv)
|
||||
if argv.contains("--version") || argv.contains("-V") {
|
||||
Swift.print(version)
|
||||
StdoutWriter.writeLine(version)
|
||||
return 0
|
||||
}
|
||||
if argv.count <= 1 || argv.contains("--help") || argv.contains("-h") {
|
||||
@ -46,7 +74,7 @@ struct CommandRouter {
|
||||
guard let commandName = invocation.path.last,
|
||||
let spec = specs.first(where: { $0.name == commandName })
|
||||
else {
|
||||
Swift.print("Unknown command")
|
||||
StdoutWriter.writeLine("Unknown command")
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
return 1
|
||||
}
|
||||
@ -54,18 +82,20 @@ struct CommandRouter {
|
||||
do {
|
||||
try await spec.run(invocation.parsedValues, runtime)
|
||||
return 0
|
||||
} catch is BridgeOutput.EmittedError {
|
||||
return 1
|
||||
} catch {
|
||||
Swift.print(error)
|
||||
StdoutWriter.writeLine(String(describing: error))
|
||||
return 1
|
||||
}
|
||||
} catch let error as CommanderProgramError {
|
||||
Swift.print(error.description)
|
||||
StdoutWriter.writeLine(error.description)
|
||||
if case .missingSubcommand = error {
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
}
|
||||
return 1
|
||||
} catch {
|
||||
Swift.print(error)
|
||||
StdoutWriter.writeLine(String(describing: error))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
302
Sources/imsg/Commands/BridgeChatCommands.swift
Normal file
302
Sources/imsg/Commands/BridgeChatCommands.swift
Normal file
@ -0,0 +1,302 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
// MARK: - chat-create
|
||||
|
||||
enum ChatCreateCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "chat-create",
|
||||
abstract: "Create a new chat (1:1 or group)",
|
||||
discussion: """
|
||||
Requires `imsg launch` (SIP-disabled, dylib injected). Vends handles for
|
||||
each address through Messages' private IMCore API and asks IMChatRegistry
|
||||
to materialize a chat. Optionally sets a display name and sends an
|
||||
initial message. Chat creation is currently iMessage-only; use
|
||||
`imsg send --service sms` for SMS sends.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(
|
||||
label: "addresses", names: [.long("addresses")],
|
||||
help: "comma-separated handles (phone or email)"),
|
||||
.make(label: "name", names: [.long("name")], help: "group display name"),
|
||||
.make(label: "text", names: [.long("text")], help: "initial message body"),
|
||||
.make(
|
||||
label: "service", names: [.long("service")], help: "iMessage (default)"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg chat-create --addresses '+15551234567,+15559876543' --name 'Crew' --text 'gm'"
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let raw = values.option("addresses"), !raw.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("addresses")
|
||||
}
|
||||
let addresses = raw.split(separator: ",").map {
|
||||
String($0).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
.filter { !$0.isEmpty }
|
||||
guard !addresses.isEmpty else { throw ParsedValuesError.invalidOption("addresses") }
|
||||
|
||||
let service = values.option("service") ?? "iMessage"
|
||||
guard service.caseInsensitiveCompare("iMessage") == .orderedSame else {
|
||||
throw IMsgError.unsupportedService(service)
|
||||
}
|
||||
|
||||
var params: [String: Any] = [
|
||||
"addresses": addresses,
|
||||
"service": "iMessage",
|
||||
]
|
||||
if let text = values.option("text"), !text.isEmpty { params["message"] = text }
|
||||
if let name = values.option("name"), !name.isEmpty { params["displayName"] = name }
|
||||
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .createChat, params: params, runtime: runtime
|
||||
) { data in
|
||||
let guid = (data["chatGuid"] as? String) ?? ""
|
||||
return "chat-create: created (guid=\(guid))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - chat-name
|
||||
|
||||
enum ChatNameCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "chat-name",
|
||||
abstract: "Set a chat's display name",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "name", names: [.long("name")], help: "new display name"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg chat-name --chat 'iMessage;+;chat0000' --name 'New Name'"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
guard let name = values.option("name") else {
|
||||
throw ParsedValuesError.missingOption("name")
|
||||
}
|
||||
let params: [String: Any] = ["chatGuid": chat, "newName": name]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .setDisplayName, params: params, runtime: runtime
|
||||
) { _ in "chat-name: set" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - chat-photo
|
||||
|
||||
enum ChatPhotoCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "chat-photo",
|
||||
abstract: "Set or clear a group chat photo",
|
||||
discussion: "Omit --file to clear the existing photo.",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "file", names: [.long("file")], help: "path to image (omit to clear)"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg chat-photo --chat 'iMessage;+;chat0000' --file ~/Downloads/g.jpg"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
var params: [String: Any] = ["chatGuid": chat]
|
||||
if let file = values.option("file"), !file.isEmpty {
|
||||
params["filePath"] = (file as NSString).expandingTildeInPath
|
||||
}
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .updateGroupPhoto, params: params, runtime: runtime
|
||||
) { _ in "chat-photo: updated" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - chat-add-member / chat-remove-member
|
||||
|
||||
enum ChatAddMemberCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "chat-add-member",
|
||||
abstract: "Add a participant to a group chat",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "address", names: [.long("address")], help: "phone or email to add"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg chat-add-member --chat 'iMessage;+;chat0000' --address +15551234567"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
guard let addr = values.option("address"), !addr.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("address")
|
||||
}
|
||||
let params: [String: Any] = ["chatGuid": chat, "address": addr]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .addParticipant, params: params, runtime: runtime
|
||||
) { _ in "chat-add-member: added" }
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatRemoveMemberCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "chat-remove-member",
|
||||
abstract: "Remove a participant from a group chat",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "address", names: [.long("address")], help: "phone or email to remove"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg chat-remove-member --chat 'iMessage;+;chat0000' --address +15551234567"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
guard let addr = values.option("address"), !addr.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("address")
|
||||
}
|
||||
let params: [String: Any] = ["chatGuid": chat, "address": addr]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .removeParticipant, params: params, runtime: runtime
|
||||
) { _ in "chat-remove-member: removed" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - chat-leave / chat-delete
|
||||
|
||||
enum ChatLeaveCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "chat-leave",
|
||||
abstract: "Leave a group chat",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg chat-leave --chat 'iMessage;+;chat0000'"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
let params: [String: Any] = ["chatGuid": chat]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .leaveChat, params: params, runtime: runtime
|
||||
) { _ in "chat-leave: left" }
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatDeleteCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "chat-delete",
|
||||
abstract: "Delete a chat from Messages.app",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg chat-delete --chat 'iMessage;-;+15551234567'"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
let params: [String: Any] = ["chatGuid": chat]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .deleteChat, params: params, runtime: runtime
|
||||
) { _ in "chat-delete: deleted" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - chat-mark
|
||||
|
||||
enum ChatMarkCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "chat-mark",
|
||||
abstract: "Mark a chat as read or unread",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid")
|
||||
],
|
||||
flags: [
|
||||
.make(label: "read", names: [.long("read")], help: "mark as read"),
|
||||
.make(label: "unread", names: [.long("unread")], help: "mark as unread"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg chat-mark --chat ... --read",
|
||||
"imsg chat-mark --chat ... --unread",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
let read = values.flag("read")
|
||||
let unread = values.flag("unread")
|
||||
if read && unread {
|
||||
throw ParsedValuesError.invalidOption("read")
|
||||
}
|
||||
let action: BridgeAction = unread ? .markChatUnread : .markChatRead
|
||||
let params: [String: Any] = ["chatGuid": chat]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: action, params: params, runtime: runtime
|
||||
) { _ in "chat-mark: \(unread ? "unread" : "read")" }
|
||||
}
|
||||
}
|
||||
177
Sources/imsg/Commands/BridgeIntroCommands.swift
Normal file
177
Sources/imsg/Commands/BridgeIntroCommands.swift
Normal file
@ -0,0 +1,177 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
// MARK: - search
|
||||
|
||||
enum SearchCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "search",
|
||||
abstract: "Search local Messages history",
|
||||
discussion: """
|
||||
Searches the local chat.db, not the injected bridge. Use --match exact
|
||||
for case-insensitive exact text matches; the default is contains.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "query", names: [.long("query")], help: "search query (required)"),
|
||||
.make(label: "match", names: [.long("match")], help: "exact|contains (default contains)"),
|
||||
.make(label: "limit", names: [.long("limit")], help: "maximum results (default 50)"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg search --query 'pizza tonight' --match contains"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
contactResolverFactory: @escaping () async -> any ContactResolving = {
|
||||
await ContactResolver.create()
|
||||
}
|
||||
) async throws {
|
||||
guard let q = values.option("query"), !q.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("query")
|
||||
}
|
||||
let match = values.option("match") ?? "contains"
|
||||
guard match == "contains" || match == "exact" else {
|
||||
throw ParsedValuesError.invalidOption("match")
|
||||
}
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let limit = values.optionInt("limit") ?? 50
|
||||
let store = try MessageStore(path: dbPath)
|
||||
let messages = try store.searchMessages(query: q, match: match, limit: limit)
|
||||
let contacts = await contactResolverFactory()
|
||||
|
||||
if runtime.jsonOutput {
|
||||
let cache = ChatCache(store: store)
|
||||
for message in messages {
|
||||
let payload = try await buildMessagePayload(
|
||||
store: store,
|
||||
cache: cache,
|
||||
message: message,
|
||||
includeAttachments: false,
|
||||
includeReactions: false,
|
||||
contactResolver: contacts
|
||||
)
|
||||
try JSONLines.printObject(payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for message in messages {
|
||||
let direction = message.isFromMe ? "sent" : "recv"
|
||||
let timestamp = CLIISO8601.format(message.date)
|
||||
let sender =
|
||||
message.isFromMe
|
||||
? message.sender : (contacts.displayName(for: message.sender) ?? message.sender)
|
||||
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(sender): \(message.text)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - account
|
||||
|
||||
enum AccountCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "account",
|
||||
abstract: "Show the active iMessage account, login, and aliases",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions()
|
||||
)),
|
||||
usageExamples: ["imsg account"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .getAccountInfo, params: [:], runtime: runtime
|
||||
) { data in
|
||||
let login = (data["login"] as? String) ?? ""
|
||||
let aliases = (data["vetted_aliases"] as? [String]) ?? []
|
||||
return "account: \(login)\n aliases: \(aliases.joined(separator: ", "))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - whois
|
||||
|
||||
enum WhoisCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "whois",
|
||||
abstract: "Check whether a handle is reachable on iMessage",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "address", names: [.long("address")], help: "phone or email to check"),
|
||||
.make(label: "type", names: [.long("type")], help: "phone|email"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg whois --address +15551234567 --type phone",
|
||||
"imsg whois --address foo@bar.com --type email",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let addr = values.option("address"), !addr.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("address")
|
||||
}
|
||||
let aliasType = values.option("type") ?? (addr.contains("@") ? "email" : "phone")
|
||||
let params: [String: Any] = [
|
||||
"address": addr,
|
||||
"aliasType": aliasType,
|
||||
]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .checkImessageAvailability, params: params, runtime: runtime
|
||||
) { data in
|
||||
let avail = (data["available"] as? Bool) ?? false
|
||||
let status = (data["id_status"] as? Int) ?? 0
|
||||
return "whois \(addr): \(avail ? "available" : "unavailable") (id_status=\(status))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - nickname
|
||||
|
||||
enum NicknameCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "nickname",
|
||||
abstract: "Show contact-card / nickname info for a handle",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "address", names: [.long("address")], help: "phone or email")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg nickname --address +15551234567"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let addr = values.option("address"), !addr.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("address")
|
||||
}
|
||||
let params: [String: Any] = ["address": addr]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .getNicknameInfo, params: params, runtime: runtime
|
||||
) { data in
|
||||
let has = (data["has_nickname"] as? Bool) ?? false
|
||||
let desc = (data["description"] as? String) ?? ""
|
||||
return "nickname: \(has ? desc : "(none)")"
|
||||
}
|
||||
}
|
||||
}
|
||||
505
Sources/imsg/Commands/BridgeMessagingCommands.swift
Normal file
505
Sources/imsg/Commands/BridgeMessagingCommands.swift
Normal file
@ -0,0 +1,505 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
/// Expand short expressive-send names (e.g. `invisibleink`, `confetti`) to the
|
||||
/// full bundle identifiers Messages.app expects on `expressiveSendStyleID`.
|
||||
/// Already-prefixed strings (anything starting with `com.apple.`) and unknown
|
||||
/// names pass through untouched so the dylib can return its own error.
|
||||
enum ExpressiveSendEffect {
|
||||
/// Bubble effects render on the message bubble itself.
|
||||
static let bubbleNames: Set<String> = ["impact", "loud", "gentle", "invisibleink"]
|
||||
|
||||
/// Screen effects play a full-screen animation. Map the short name to the
|
||||
/// `CK<TitleCase>Effect` token used in the bundle id.
|
||||
static let screenNames: [String: String] = [
|
||||
"confetti": "Confetti",
|
||||
"lasers": "Lasers",
|
||||
"fireworks": "Fireworks",
|
||||
"balloons": "Balloons",
|
||||
"sparkles": "Sparkles",
|
||||
"spotlight": "Spotlight",
|
||||
"echo": "Echo",
|
||||
"love": "Love",
|
||||
"celebration": "Celebration",
|
||||
]
|
||||
|
||||
static func expand(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return raw }
|
||||
if trimmed.hasPrefix("com.apple.") { return trimmed }
|
||||
let key = trimmed.lowercased()
|
||||
if bubbleNames.contains(key) {
|
||||
return "com.apple.MobileSMS.expressivesend.\(key)"
|
||||
}
|
||||
if let token = screenNames[key] {
|
||||
return "com.apple.messages.effect.CK\(token)Effect"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers shared by all bridge-backed commands.
|
||||
enum BridgeOutput {
|
||||
struct EmittedError: Error {}
|
||||
|
||||
static func emit(_ data: [String: Any], runtime: RuntimeOptions, summary: String) {
|
||||
if runtime.jsonOutput {
|
||||
try? JSONLines.printObject(data)
|
||||
} else {
|
||||
StdoutWriter.writeLine(summary)
|
||||
}
|
||||
}
|
||||
|
||||
static func emitError(_ message: String, runtime: RuntimeOptions) {
|
||||
if runtime.jsonOutput {
|
||||
try? JSONLines.printObject(["success": false, "error": message])
|
||||
} else {
|
||||
StdoutWriter.writeLine("error: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Invoke a bridge action and emit the result. Returns the data dict on
|
||||
/// success or nil on failure (after emitting an error message).
|
||||
static func invokeAndEmit(
|
||||
action: BridgeAction,
|
||||
params: [String: Any],
|
||||
runtime: RuntimeOptions,
|
||||
summary: (([String: Any]) -> String)
|
||||
) async throws -> [String: Any] {
|
||||
do {
|
||||
let data = try await IMsgBridgeClient.shared.invoke(action: action, params: params)
|
||||
emit(data, runtime: runtime, summary: summary(data))
|
||||
return data
|
||||
} catch {
|
||||
emitError(String(describing: error), runtime: runtime)
|
||||
throw EmittedError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - send-rich
|
||||
|
||||
enum SendRichCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "send-rich",
|
||||
abstract: "Send a message via the IMCore bridge (effects, replies, subjects)",
|
||||
discussion: """
|
||||
Requires `imsg launch` (SIP-disabled, dylib injected). Unlike `imsg send`
|
||||
which uses AppleScript, this routes through Messages' private API for
|
||||
richer features: expressive-send effects, reply targets, subject lines.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(
|
||||
label: "chat", names: [.long("chat")], help: "chat guid (e.g. iMessage;-;+15551234567)"),
|
||||
.make(label: "text", names: [.long("text")], help: "message body"),
|
||||
.make(
|
||||
label: "effect", names: [.long("effect")],
|
||||
help: "expressive send id (impact, loud, gentle, invisibleink, confetti, …)"),
|
||||
.make(label: "subject", names: [.long("subject")], help: "subject line"),
|
||||
.make(label: "replyTo", names: [.long("reply-to")], help: "guid of message to reply to"),
|
||||
.make(label: "part", names: [.long("part")], help: "part index (default 0)"),
|
||||
.make(
|
||||
label: "format",
|
||||
names: [.long("format")],
|
||||
help: "JSON array of {start,length,styles:[...]} ranges (macOS 15+)"),
|
||||
.make(
|
||||
label: "formatFile", names: [.long("format-file")],
|
||||
help: "path to JSON file containing the format ranges array"),
|
||||
],
|
||||
flags: [
|
||||
.make(
|
||||
label: "noDDScan", names: [.long("no-dd-scan")],
|
||||
help: "disable data-detector scan deferral")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi'",
|
||||
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'BOOM' --effect impact",
|
||||
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'pew pew' --effect lasers",
|
||||
"imsg send-rich --chat ... --text 'hello world' --format '[{\"start\":0,\"length\":5,\"styles\":[\"bold\"]}]'",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
let text = values.option("text") ?? ""
|
||||
var params: [String: Any] = [
|
||||
"chatGuid": chat,
|
||||
"message": text,
|
||||
"partIndex": Int(values.option("part") ?? "0") ?? 0,
|
||||
"ddScan": !values.flag("noDDScan"),
|
||||
]
|
||||
if let effect = values.option("effect"), !effect.isEmpty {
|
||||
params["effectId"] = ExpressiveSendEffect.expand(effect)
|
||||
}
|
||||
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
|
||||
if let reply = values.option("replyTo"), !reply.isEmpty {
|
||||
params["selectedMessageGuid"] = reply
|
||||
}
|
||||
|
||||
// Optional text formatting (macOS 15+ — Sequoia and later). Pass either
|
||||
// inline JSON via --format or a file path via --format-file. Format:
|
||||
// [{"start":0,"length":5,"styles":["bold","italic"]}, ...]
|
||||
let formatRaw: String?
|
||||
if let inline = values.option("format"), !inline.isEmpty {
|
||||
formatRaw = inline
|
||||
} else if let path = values.option("formatFile"), !path.isEmpty {
|
||||
formatRaw = try String(contentsOfFile: path, encoding: .utf8)
|
||||
} else {
|
||||
formatRaw = nil
|
||||
}
|
||||
if let raw = formatRaw {
|
||||
guard
|
||||
let bytes = raw.data(using: .utf8),
|
||||
let ranges = try JSONSerialization.jsonObject(with: bytes) as? [[String: Any]]
|
||||
else {
|
||||
throw ParsedValuesError.invalidOption("format")
|
||||
}
|
||||
params["textFormatting"] = ranges
|
||||
}
|
||||
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .sendMessage, params: params, runtime: runtime
|
||||
) { data in
|
||||
let guid = (data["messageGuid"] as? String) ?? ""
|
||||
return guid.isEmpty ? "send-rich: queued" : "send-rich: sent (guid=\(guid))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - send-multipart
|
||||
|
||||
enum SendMultipartCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "send-multipart",
|
||||
abstract: "Send a multi-part message",
|
||||
discussion: """
|
||||
Pass --parts as a JSON array (e.g., '[{"text":"hi"},{"text":"there"}]')
|
||||
or via --parts-file pointing at a .json file. v1 supports text-only
|
||||
parts; mention/file parts are a future enhancement.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "parts", names: [.long("parts")], help: "JSON array of parts"),
|
||||
.make(
|
||||
label: "partsFile", names: [.long("parts-file")],
|
||||
help: "path to JSON file containing parts array"),
|
||||
.make(label: "effect", names: [.long("effect")], help: "expressive send id"),
|
||||
.make(label: "subject", names: [.long("subject")], help: "subject line"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg send-multipart --chat 'iMessage;+;chat0000' --parts '[{\"text\":\"hi\"},{\"text\":\"world\"}]'"
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
let partsRaw: String
|
||||
if let inline = values.option("parts"), !inline.isEmpty {
|
||||
partsRaw = inline
|
||||
} else if let path = values.option("partsFile"), !path.isEmpty {
|
||||
partsRaw = try String(contentsOfFile: path, encoding: .utf8)
|
||||
} else {
|
||||
throw ParsedValuesError.missingOption("parts")
|
||||
}
|
||||
guard
|
||||
let data = partsRaw.data(using: .utf8),
|
||||
let parts = try JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||
else {
|
||||
throw ParsedValuesError.invalidOption("parts")
|
||||
}
|
||||
var params: [String: Any] = ["chatGuid": chat, "parts": parts]
|
||||
if let effect = values.option("effect"), !effect.isEmpty {
|
||||
params["effectId"] = ExpressiveSendEffect.expand(effect)
|
||||
}
|
||||
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
|
||||
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .sendMultipart, params: params, runtime: runtime
|
||||
) { data in
|
||||
let guid = (data["messageGuid"] as? String) ?? ""
|
||||
let count = (data["parts_count"] as? Int) ?? 0
|
||||
return "send-multipart: \(count) parts queued (guid=\(guid))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - send-attachment
|
||||
|
||||
enum SendAttachmentCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "send-attachment",
|
||||
abstract: "Send a file attachment via the IMCore bridge",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "file", names: [.long("file")], help: "absolute path to file"),
|
||||
],
|
||||
flags: [
|
||||
.make(label: "audio", names: [.long("audio")], help: "send as audio message")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg send-attachment --chat 'iMessage;-;+15551234567' --file ~/Pictures/me.jpg"
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
guard let file = values.option("file"), !file.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("file")
|
||||
}
|
||||
let expanded = (file as NSString).expandingTildeInPath
|
||||
let params: [String: Any] = [
|
||||
"chatGuid": chat,
|
||||
"filePath": expanded,
|
||||
"isAudioMessage": values.flag("audio"),
|
||||
]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .sendAttachment, params: params, runtime: runtime
|
||||
) { data in
|
||||
let guid = (data["messageGuid"] as? String) ?? ""
|
||||
return "send-attachment: queued (guid=\(guid))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - react (BB-style; complements existing AS-backed `react`)
|
||||
|
||||
enum BridgeReactCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "tapback",
|
||||
abstract: "Send a tapback reaction via the IMCore bridge",
|
||||
discussion: """
|
||||
`imsg tapback` uses the bridge for reliability across macOS versions.
|
||||
`imsg react` (AppleScript) remains for SIP-on machines.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "message", names: [.long("message")], help: "target message guid"),
|
||||
.make(
|
||||
label: "kind", names: [.long("kind")],
|
||||
help: "love|like|dislike|laugh|emphasize|question"),
|
||||
.make(label: "part", names: [.long("part")], help: "part index"),
|
||||
],
|
||||
flags: [
|
||||
.make(
|
||||
label: "remove", names: [.long("remove")],
|
||||
help: "remove this reaction instead of adding")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg tapback --chat 'iMessage;-;+15551234567' --message ABCD-EFGH --kind love"
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
guard let message = values.option("message"), !message.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("message")
|
||||
}
|
||||
guard let kind = values.option("kind"), !kind.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("kind")
|
||||
}
|
||||
let normalized = kind.lowercased()
|
||||
let prefixed = values.flag("remove") ? "remove-\(normalized)" : normalized
|
||||
let params: [String: Any] = [
|
||||
"chatGuid": chat,
|
||||
"selectedMessageGuid": message,
|
||||
"reactionType": prefixed,
|
||||
"partIndex": Int(values.option("part") ?? "0") ?? 0,
|
||||
]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .sendReaction, params: params, runtime: runtime
|
||||
) { _ in "tapback: \(prefixed) sent" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - edit
|
||||
|
||||
enum EditCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "edit",
|
||||
abstract: "Edit a sent message",
|
||||
discussion: "Requires macOS 13+ (selector-probed at startup).",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "message", names: [.long("message")], help: "target message guid"),
|
||||
.make(label: "newText", names: [.long("new-text")], help: "replacement text"),
|
||||
.make(
|
||||
label: "bcText",
|
||||
names: [.long("bc-text")],
|
||||
help: "backwards-compat text shown to older clients (default: same as new-text)"),
|
||||
.make(label: "part", names: [.long("part")], help: "part index"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg edit --chat ... --message <guid> --new-text 'updated'"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
guard let message = values.option("message"), !message.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("message")
|
||||
}
|
||||
guard let newText = values.option("newText"), !newText.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("new-text")
|
||||
}
|
||||
let params: [String: Any] = [
|
||||
"chatGuid": chat,
|
||||
"messageGuid": message,
|
||||
"editedMessage": newText,
|
||||
"backwardsCompatibilityMessage": values.option("bcText") ?? newText,
|
||||
"partIndex": Int(values.option("part") ?? "0") ?? 0,
|
||||
]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .editMessage, params: params, runtime: runtime
|
||||
) { _ in "edit: queued" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - unsend
|
||||
|
||||
enum UnsendCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "unsend",
|
||||
abstract: "Retract a sent message",
|
||||
discussion: "Requires macOS 13+ (selector-probed at startup).",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "message", names: [.long("message")], help: "target message guid"),
|
||||
.make(label: "part", names: [.long("part")], help: "part index"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg unsend --chat ... --message <guid>"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
guard let message = values.option("message"), !message.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("message")
|
||||
}
|
||||
let params: [String: Any] = [
|
||||
"chatGuid": chat,
|
||||
"messageGuid": message,
|
||||
"partIndex": Int(values.option("part") ?? "0") ?? 0,
|
||||
]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .unsendMessage, params: params, runtime: runtime
|
||||
) { _ in "unsend: queued" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - delete-message
|
||||
|
||||
enum DeleteMessageCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "delete-message",
|
||||
abstract: "Delete a single message from a chat",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "message", names: [.long("message")], help: "target message guid"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg delete-message --chat ... --message <guid>"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
guard let message = values.option("message"), !message.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("message")
|
||||
}
|
||||
let params: [String: Any] = [
|
||||
"chatGuid": chat,
|
||||
"messageGuid": message,
|
||||
]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .deleteMessage, params: params, runtime: runtime
|
||||
) { _ in "delete-message: queued" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - notify-anyways
|
||||
|
||||
enum NotifyAnywaysCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "notify-anyways",
|
||||
abstract: "Force a notification for a message that was filtered/suppressed",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
|
||||
.make(label: "message", names: [.long("message")], help: "target message guid"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: ["imsg notify-anyways --chat ... --message <guid>"]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
guard let chat = values.option("chat"), !chat.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("chat")
|
||||
}
|
||||
guard let message = values.option("message"), !message.isEmpty else {
|
||||
throw ParsedValuesError.missingOption("message")
|
||||
}
|
||||
let params: [String: Any] = ["chatGuid": chat, "messageGuid": message]
|
||||
_ = try await BridgeOutput.invokeAndEmit(
|
||||
action: .notifyAnyways, params: params, runtime: runtime
|
||||
) { _ in "notify-anyways: queued" }
|
||||
}
|
||||
}
|
||||
@ -19,21 +19,72 @@ enum ChatsCommand {
|
||||
"imsg chats --limit 5 --json",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
contactResolverFactory: @escaping () async -> any ContactResolving = {
|
||||
await ContactResolver.create()
|
||||
}
|
||||
) async throws {
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let limit = values.optionInt("limit") ?? 20
|
||||
let store = try MessageStore(path: dbPath)
|
||||
let chats = try store.listChats(limit: limit)
|
||||
let contacts = await contactResolverFactory()
|
||||
|
||||
if runtime.jsonOutput {
|
||||
for chat in chats {
|
||||
try JSONLines.print(ChatPayload(chat: chat))
|
||||
let chatInfo = try store.chatInfo(chatID: chat.id)
|
||||
let participants = try store.participants(chatID: chat.id)
|
||||
let contactName = contactNameForChat(
|
||||
chat: chat,
|
||||
chatInfo: chatInfo,
|
||||
participants: participants,
|
||||
contacts: contacts
|
||||
)
|
||||
try StdoutWriter.writeJSONLine(
|
||||
ChatPayload(
|
||||
chat: chat,
|
||||
chatInfo: chatInfo,
|
||||
participants: participants,
|
||||
contactName: contactName
|
||||
))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for chat in chats {
|
||||
let last = CLIISO8601.format(chat.lastMessageAt)
|
||||
Swift.print("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)")
|
||||
let participants = try store.participants(chatID: chat.id)
|
||||
let contactName = contactNameForChat(
|
||||
chat: chat,
|
||||
chatInfo: nil,
|
||||
participants: participants,
|
||||
contacts: contacts
|
||||
)
|
||||
let displayName = contactName ?? chat.name
|
||||
StdoutWriter.writeLine("[\(chat.id)] \(displayName) (\(chat.identifier)) last=\(last)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func contactNameForChat(
|
||||
chat: Chat,
|
||||
chatInfo: ChatInfo?,
|
||||
participants: [String],
|
||||
contacts: any ContactResolving
|
||||
) -> String? {
|
||||
let identifier = chatInfo?.identifier ?? chat.identifier
|
||||
let guid = chatInfo?.guid ?? ""
|
||||
guard !isGroupHandle(identifier: identifier, guid: guid) else { return nil }
|
||||
if let name = contacts.displayName(for: identifier) {
|
||||
return name
|
||||
}
|
||||
if participants.count == 1 {
|
||||
return contacts.displayName(for: participants[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
461
Sources/imsg/Commands/CompletionsCommand.swift
Normal file
461
Sources/imsg/Commands/CompletionsCommand.swift
Normal file
@ -0,0 +1,461 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum CompletionsCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "completions",
|
||||
abstract: "Generate shell completions or LLM context",
|
||||
discussion: "Outputs completion scripts for bash, zsh, fish, or a Markdown CLI reference.",
|
||||
signature: CommandSignature(
|
||||
arguments: [
|
||||
.make(label: "shell", help: "bash, zsh, fish, or llm", isOptional: true)
|
||||
]
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg completions bash > ~/.bash_completion.d/imsg",
|
||||
"imsg completions zsh > ~/.zsh/completions/_imsg",
|
||||
"imsg completions fish > ~/.config/fish/completions/imsg.fish",
|
||||
"imsg completions llm",
|
||||
]
|
||||
) { values, _ in
|
||||
try await run(shell: values.argument(0), specs: CommandRouter().specs)
|
||||
}
|
||||
|
||||
static func run(shell: String?, specs: [CommandSpec]) async throws {
|
||||
let output = try CompletionGenerator.generate(shell: shell, rootName: "imsg", specs: specs)
|
||||
StdoutWriter.writeLine(output)
|
||||
}
|
||||
}
|
||||
|
||||
enum CompletionError: Error, CustomStringConvertible, Sendable {
|
||||
case missingShell
|
||||
case unknownShell(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .missingShell:
|
||||
return "Missing shell argument. Use: bash, zsh, fish, or llm"
|
||||
case .unknownShell(let shell):
|
||||
return "Unknown shell '\(shell)'. Use: bash, zsh, fish, or llm"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CompletionGenerator {
|
||||
static func generate(shell: String?, rootName: String, specs: [CommandSpec]) throws -> String {
|
||||
guard let shell, !shell.isEmpty else {
|
||||
throw CompletionError.missingShell
|
||||
}
|
||||
switch shell.lowercased() {
|
||||
case "bash":
|
||||
return BashCompletionGenerator.generate(rootName: rootName, specs: specs)
|
||||
case "zsh":
|
||||
return ZshCompletionGenerator.generate(rootName: rootName, specs: specs)
|
||||
case "fish":
|
||||
return FishCompletionGenerator.generate(rootName: rootName, specs: specs)
|
||||
case "llm":
|
||||
return LLMCompletionGenerator.generate(rootName: rootName, specs: specs)
|
||||
default:
|
||||
throw CompletionError.unknownShell(shell)
|
||||
}
|
||||
}
|
||||
|
||||
static let serviceChoices = MessageService.allCases.map(\.rawValue).joined(separator: " ")
|
||||
static let reactionChoices = "love like dislike laugh emphasis question"
|
||||
static let logLevelChoices = "trace verbose debug info warning error critical"
|
||||
|
||||
static func optionNames(for spec: CommandSpec) -> [String] {
|
||||
let signature = spec.signature.flattened()
|
||||
return
|
||||
(signature.options.flatMap { names($0.names) } + signature.flags.flatMap { names($0.names) })
|
||||
.sorted()
|
||||
}
|
||||
|
||||
static func zshOptions(for spec: CommandSpec) -> [String] {
|
||||
let signature = spec.signature.flattened()
|
||||
var result = signature.options.map { option in
|
||||
let names = zshNameGroup(option.names)
|
||||
let help = escapeZsh(option.help ?? "")
|
||||
let longName = primaryLongName(option.names) ?? option.label
|
||||
let choices = choicesForOption(longName)
|
||||
let value =
|
||||
choices.map { ":value:(\($0))" }
|
||||
?? ":value:"
|
||||
return "'\(names)[\(help)]\(value)'"
|
||||
}
|
||||
result += signature.flags.map { flag in
|
||||
"'\(zshNameGroup(flag.names))[\(escapeZsh(flag.help ?? ""))]'"
|
||||
}
|
||||
if spec.name == "completions" {
|
||||
result.append("'1:shell:(bash zsh fish llm)'")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func fishOption(
|
||||
rootName: String,
|
||||
command: String,
|
||||
option: OptionDefinition
|
||||
) -> String {
|
||||
var line = "complete -c \(rootName) -n '__\(rootName)_using_command \(command)'"
|
||||
for name in option.names where !name.isAlias {
|
||||
line += fishName(name)
|
||||
}
|
||||
line += " -d \(shellQuote(option.help ?? ""))"
|
||||
if let choices = choicesForOption(primaryLongName(option.names) ?? option.label) {
|
||||
line += " -xa \(shellQuote(choices))"
|
||||
} else if optionWantsFiles(option) {
|
||||
line += " -r -F"
|
||||
} else {
|
||||
line += " -x"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
static func fishFlag(rootName: String, command: String, flag: FlagDefinition) -> String {
|
||||
var line = "complete -c \(rootName) -n '__\(rootName)_using_command \(command)'"
|
||||
for name in flag.names where !name.isAlias {
|
||||
line += fishName(name)
|
||||
}
|
||||
line += " -d \(shellQuote(flag.help ?? ""))"
|
||||
return line
|
||||
}
|
||||
|
||||
static func usageFragment(for signature: CommandSignature) -> String {
|
||||
var parts: [String] = []
|
||||
for argument in signature.arguments {
|
||||
parts.append(argument.isOptional ? "[\(argument.label)]" : "<\(argument.label)>")
|
||||
}
|
||||
if !signature.options.isEmpty || !signature.flags.isEmpty {
|
||||
parts.append("[options]")
|
||||
}
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func names(_ names: [CommanderName]) -> [String] {
|
||||
names.map { name in
|
||||
switch name {
|
||||
case .short(let value), .aliasShort(let value):
|
||||
return "-\(value)"
|
||||
case .long(let value), .aliasLong(let value):
|
||||
return "--\(value)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func formatNames(_ commandNames: [CommanderName], expectsValue: Bool) -> String {
|
||||
names(commandNames).joined(separator: ", ") + (expectsValue ? " <value>" : "")
|
||||
}
|
||||
|
||||
static func primaryLongName(_ names: [CommanderName]) -> String? {
|
||||
for name in names {
|
||||
if case .long(let value) = name {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func choicesForOption(_ name: String) -> String? {
|
||||
switch name {
|
||||
case "service":
|
||||
return serviceChoices
|
||||
case "reaction":
|
||||
return reactionChoices
|
||||
case "log-level", "logLevel":
|
||||
return logLevelChoices
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func optionWantsFiles(_ option: OptionDefinition) -> Bool {
|
||||
let longName = primaryLongName(option.names) ?? option.label
|
||||
return longName == "db" || longName == "file"
|
||||
|| option.help?.localizedCaseInsensitiveContains("path") == true
|
||||
}
|
||||
|
||||
static func zshNameGroup(_ names: [CommanderName]) -> String {
|
||||
let visible = names.filter { !$0.isAlias }
|
||||
return visible.map { name in
|
||||
switch name {
|
||||
case .short(let value):
|
||||
return "-\(value)"
|
||||
case .long(let value):
|
||||
return "--\(value)"
|
||||
case .aliasShort(let value):
|
||||
return "-\(value)"
|
||||
case .aliasLong(let value):
|
||||
return "--\(value)"
|
||||
}
|
||||
}.joined(separator: ",")
|
||||
}
|
||||
|
||||
static func fishName(_ name: CommanderName) -> String {
|
||||
switch name {
|
||||
case .short(let value), .aliasShort(let value):
|
||||
return " -s \(value)"
|
||||
case .long(let value), .aliasLong(let value):
|
||||
return " -l \(value)"
|
||||
}
|
||||
}
|
||||
|
||||
static func shellQuote(_ value: String) -> String {
|
||||
"'\(value.replacingOccurrences(of: "'", with: "\\'"))'"
|
||||
}
|
||||
|
||||
static func escapeZsh(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "]", with: "\\]")
|
||||
.replacingOccurrences(of: "'", with: "'\\''")
|
||||
}
|
||||
}
|
||||
|
||||
private enum BashCompletionGenerator {
|
||||
static func generate(rootName: String, specs: [CommandSpec]) -> String {
|
||||
let commands = specs.map(\.name).joined(separator: " ")
|
||||
let commandCases = specs.map { spec in
|
||||
let options = CompletionGenerator.optionNames(for: spec).joined(separator: " ")
|
||||
return """
|
||||
\(spec.name))
|
||||
COMPREPLY=($(compgen -W "\(options)" -- "$cur"))
|
||||
;;
|
||||
"""
|
||||
}.joined(separator: "\n")
|
||||
|
||||
return """
|
||||
# Bash completion for \(rootName)
|
||||
# Generated by: \(rootName) completions bash
|
||||
|
||||
_\(rootName)() {
|
||||
local cur prev words cword
|
||||
if type _init_completion >/dev/null 2>&1; then
|
||||
_init_completion || return
|
||||
else
|
||||
COMPREPLY=()
|
||||
words=("${COMP_WORDS[@]}")
|
||||
cword=$COMP_CWORD
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
fi
|
||||
|
||||
local commands="\(commands)"
|
||||
case "$prev" in
|
||||
--db|--file)
|
||||
COMPREPLY=($(compgen -f -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--service)
|
||||
COMPREPLY=($(compgen -W "\(CompletionGenerator.serviceChoices)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--reaction|-r)
|
||||
COMPREPLY=($(compgen -W "\(CompletionGenerator.reactionChoices)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--log-level|--logLevel)
|
||||
COMPREPLY=($(compgen -W "\(CompletionGenerator.logLevelChoices)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
completions)
|
||||
COMPREPLY=($(compgen -W "bash zsh fish llm" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
local cmd=""
|
||||
local word
|
||||
for word in "${words[@]:1:cword-1}"; do
|
||||
case "$word" in
|
||||
-*) ;;
|
||||
*)
|
||||
if [[ " $commands " == *" $word "* ]]; then
|
||||
cmd="$word"
|
||||
break
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$cmd" ]]; then
|
||||
COMPREPLY=($(compgen -W "$commands --help -h --version -V" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case "$cmd" in
|
||||
\(commandCases)
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _\(rootName) \(rootName)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
private enum ZshCompletionGenerator {
|
||||
static func generate(rootName: String, specs: [CommandSpec]) -> String {
|
||||
let commandDescriptions =
|
||||
specs
|
||||
.map { " '\($0.name):\(CompletionGenerator.escapeZsh($0.abstract))'" }
|
||||
.joined(separator: "\n")
|
||||
let commandCases = specs.map { spec in
|
||||
let optionSpecs = CompletionGenerator.zshOptions(for: spec).map { " \($0) \\" }
|
||||
.joined(separator: "\n")
|
||||
return """
|
||||
\(spec.name))
|
||||
_arguments \\
|
||||
\(optionSpecs)
|
||||
&& return 0
|
||||
;;
|
||||
"""
|
||||
}.joined(separator: "\n")
|
||||
|
||||
return """
|
||||
#compdef \(rootName)
|
||||
# Zsh completion for \(rootName)
|
||||
# Generated by: \(rootName) completions zsh
|
||||
|
||||
_\(rootName)() {
|
||||
local context state line
|
||||
typeset -A opt_args
|
||||
|
||||
local -a commands
|
||||
commands=(
|
||||
\(commandDescriptions)
|
||||
)
|
||||
|
||||
_arguments -C \\
|
||||
'(- *)'{-h,--help}'[Show help]' \\
|
||||
'(- *)'{-V,--version}'[Show version]' \\
|
||||
'1:command:->command' \\
|
||||
'*::arg:->args' \\
|
||||
&& return 0
|
||||
|
||||
case $state in
|
||||
command)
|
||||
_describe -t commands '\(rootName) commands' commands
|
||||
;;
|
||||
args)
|
||||
case $words[2] in
|
||||
\(commandCases)
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_\(rootName) "$@"
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
private enum FishCompletionGenerator {
|
||||
static func generate(rootName: String, specs: [CommandSpec]) -> String {
|
||||
var lines: [String] = [
|
||||
"# Fish completion for \(rootName)",
|
||||
"# Generated by: \(rootName) completions fish",
|
||||
"",
|
||||
"complete -c \(rootName) -f",
|
||||
"",
|
||||
"function __\(rootName)_needs_command",
|
||||
" set -l cmd (commandline -opc)",
|
||||
" test (count $cmd) -eq 1",
|
||||
"end",
|
||||
"",
|
||||
"function __\(rootName)_using_command",
|
||||
" set -l cmd (commandline -opc)",
|
||||
" test (count $cmd) -gt 1; and contains -- $cmd[2] $argv",
|
||||
"end",
|
||||
"",
|
||||
]
|
||||
|
||||
for spec in specs {
|
||||
let commandName = CompletionGenerator.shellQuote(spec.name)
|
||||
let abstract = CompletionGenerator.shellQuote(spec.abstract)
|
||||
lines.append(
|
||||
"complete -c \(rootName) -n __\(rootName)_needs_command -a \(commandName) -d \(abstract)"
|
||||
)
|
||||
}
|
||||
lines.append("")
|
||||
|
||||
for spec in specs {
|
||||
for option in spec.signature.flattened().options {
|
||||
lines.append(
|
||||
CompletionGenerator.fishOption(rootName: rootName, command: spec.name, option: option))
|
||||
}
|
||||
for flag in spec.signature.flattened().flags {
|
||||
lines.append(
|
||||
CompletionGenerator.fishFlag(rootName: rootName, command: spec.name, flag: flag))
|
||||
}
|
||||
if spec.name == "completions" {
|
||||
lines.append(
|
||||
"complete -c \(rootName) -n '__\(rootName)_using_command completions' -a 'bash zsh fish llm'"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
private enum LLMCompletionGenerator {
|
||||
static func generate(rootName: String, specs: [CommandSpec]) -> String {
|
||||
var lines: [String] = [
|
||||
"# \(rootName) CLI Reference",
|
||||
"",
|
||||
"macOS Messages.app CLI to send, read, and stream iMessage/SMS.",
|
||||
"",
|
||||
"## Commands",
|
||||
"",
|
||||
]
|
||||
|
||||
for spec in specs {
|
||||
lines.append("### \(spec.name)")
|
||||
lines.append("")
|
||||
lines.append(spec.abstract)
|
||||
if let discussion = spec.discussion, !discussion.isEmpty {
|
||||
lines.append("")
|
||||
lines.append(discussion)
|
||||
}
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Usage: `\(rootName) \(spec.name) \(CompletionGenerator.usageFragment(for: spec.signature))`"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
let signature = spec.signature.flattened()
|
||||
if !signature.arguments.isEmpty {
|
||||
lines.append("Arguments:")
|
||||
for argument in signature.arguments {
|
||||
let optional = argument.isOptional ? " optional" : ""
|
||||
lines.append("- `\(argument.label)`\(optional): \(argument.help ?? "")")
|
||||
}
|
||||
lines.append("")
|
||||
}
|
||||
if !signature.options.isEmpty || !signature.flags.isEmpty {
|
||||
lines.append("Options:")
|
||||
for option in signature.options {
|
||||
lines.append(
|
||||
"- `\(CompletionGenerator.formatNames(option.names, expectsValue: true))`: \(option.help ?? "")"
|
||||
)
|
||||
}
|
||||
for flag in signature.flags {
|
||||
lines.append(
|
||||
"- `\(CompletionGenerator.formatNames(flag.names, expectsValue: false))`: \(flag.help ?? "")"
|
||||
)
|
||||
}
|
||||
lines.append("")
|
||||
}
|
||||
if !spec.usageExamples.isEmpty {
|
||||
lines.append("Examples:")
|
||||
for example in spec.usageExamples {
|
||||
lines.append("- `\(example)`")
|
||||
}
|
||||
lines.append("")
|
||||
}
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
63
Sources/imsg/Commands/GroupCommand.swift
Normal file
63
Sources/imsg/Commands/GroupCommand.swift
Normal file
@ -0,0 +1,63 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum GroupCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "group",
|
||||
abstract: "Show chat identity and participants for a chat id",
|
||||
discussion: "Prints chat identifier, guid, display name, service, group flag, "
|
||||
+ "and participants for a given chat rowid. Works for direct chats too.",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid from 'imsg chats'")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg group --chat-id 1",
|
||||
"imsg group --chat-id 1 --json",
|
||||
]
|
||||
) { values, runtime in
|
||||
guard let chatID = values.optionInt64("chatID") else {
|
||||
throw ParsedValuesError.missingOption("chat-id")
|
||||
}
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let store = try MessageStore(path: dbPath)
|
||||
guard let info = try store.chatInfo(chatID: chatID) else {
|
||||
throw IMsgError.chatNotFound(chatID: chatID)
|
||||
}
|
||||
let participants = try store.participants(chatID: chatID)
|
||||
|
||||
if runtime.jsonOutput {
|
||||
try StdoutWriter.writeJSONLine(GroupPayload(chatInfo: info, participants: participants))
|
||||
return
|
||||
}
|
||||
|
||||
StdoutWriter.writeLine("id: \(info.id)")
|
||||
StdoutWriter.writeLine("identifier: \(info.identifier)")
|
||||
StdoutWriter.writeLine("guid: \(info.guid)")
|
||||
StdoutWriter.writeLine("name: \(info.name)")
|
||||
StdoutWriter.writeLine("service: \(info.service)")
|
||||
if let accountID = info.accountID {
|
||||
StdoutWriter.writeLine("account_id: \(accountID)")
|
||||
}
|
||||
if let accountLogin = info.accountLogin {
|
||||
StdoutWriter.writeLine("account_login: \(accountLogin)")
|
||||
}
|
||||
if let lastAddressedHandle = info.lastAddressedHandle {
|
||||
StdoutWriter.writeLine("last_addressed_handle: \(lastAddressedHandle)")
|
||||
}
|
||||
let isGroup = isGroupHandle(identifier: info.identifier, guid: info.guid)
|
||||
StdoutWriter.writeLine("is_group: \(isGroup)")
|
||||
if participants.isEmpty {
|
||||
StdoutWriter.writeLine("participants: (none)")
|
||||
} else {
|
||||
StdoutWriter.writeLine("participants:")
|
||||
for handle in participants {
|
||||
StdoutWriter.writeLine(" - \(handle)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,11 @@ enum HistoryCommand {
|
||||
flags: [
|
||||
.make(
|
||||
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
|
||||
)
|
||||
),
|
||||
.make(
|
||||
label: "convertAttachments", names: [.long("convert-attachments")],
|
||||
help: "convert CAF/GIF attachments to model-compatible cached files"
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
@ -30,12 +34,24 @@ enum HistoryCommand {
|
||||
"imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
contactResolverFactory: @escaping () async -> any ContactResolving = {
|
||||
await ContactResolver.create()
|
||||
}
|
||||
) async throws {
|
||||
guard let chatID = values.optionInt64("chatID") else {
|
||||
throw ParsedValuesError.missingOption("chat-id")
|
||||
}
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let limit = values.optionInt("limit") ?? 50
|
||||
let showAttachments = values.flag("attachments")
|
||||
let attachmentOptions = AttachmentQueryOptions(
|
||||
convertUnsupported: values.flag("convertAttachments"))
|
||||
let participants = values.optionValues("participants")
|
||||
.flatMap { $0.split(separator: ",").map { String($0) } }
|
||||
.filter { !$0.isEmpty }
|
||||
@ -47,17 +63,28 @@ enum HistoryCommand {
|
||||
|
||||
let store = try MessageStore(path: dbPath)
|
||||
let filtered = try store.messages(chatID: chatID, limit: limit, filter: filter)
|
||||
let contacts = await contactResolverFactory()
|
||||
|
||||
if runtime.jsonOutput {
|
||||
let cache = ChatCache(store: store)
|
||||
let attachmentsByMessageID = try store.attachments(
|
||||
for: filtered.map(\.rowID),
|
||||
options: attachmentOptions
|
||||
)
|
||||
let reactionsByMessageID = try store.reactions(for: filtered)
|
||||
for message in filtered {
|
||||
let attachments = try store.attachments(for: message.rowID)
|
||||
let reactions = try store.reactions(for: message.rowID)
|
||||
let payload = MessagePayload(
|
||||
let payload = try await buildMessagePayload(
|
||||
store: store,
|
||||
cache: cache,
|
||||
message: message,
|
||||
attachments: attachments,
|
||||
reactions: reactions
|
||||
includeAttachments: true,
|
||||
includeReactions: true,
|
||||
prefetchedAttachments: attachmentsByMessageID[message.rowID] ?? [],
|
||||
prefetchedReactions: reactionsByMessageID[message.rowID] ?? [],
|
||||
attachmentOptions: attachmentOptions,
|
||||
contactResolver: contacts
|
||||
)
|
||||
try JSONLines.print(payload)
|
||||
try JSONLines.printObject(payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -65,18 +92,18 @@ enum HistoryCommand {
|
||||
for message in filtered {
|
||||
let direction = message.isFromMe ? "sent" : "recv"
|
||||
let timestamp = CLIISO8601.format(message.date)
|
||||
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
|
||||
let sender =
|
||||
message.isFromMe
|
||||
? message.sender : (contacts.displayName(for: message.sender) ?? message.sender)
|
||||
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(sender): \(message.text)")
|
||||
if message.attachmentsCount > 0 {
|
||||
if showAttachments {
|
||||
let metas = try store.attachments(for: message.rowID)
|
||||
let metas = try store.attachments(for: message.rowID, options: attachmentOptions)
|
||||
for meta in metas {
|
||||
let name = displayName(for: meta)
|
||||
Swift.print(
|
||||
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
|
||||
)
|
||||
StdoutWriter.writeLine(attachmentMetadataLine(for: meta))
|
||||
}
|
||||
} else {
|
||||
Swift.print(
|
||||
StdoutWriter.writeLine(
|
||||
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
|
||||
)
|
||||
}
|
||||
|
||||
155
Sources/imsg/Commands/LaunchCommand.swift
Normal file
155
Sources/imsg/Commands/LaunchCommand.swift
Normal file
@ -0,0 +1,155 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum LaunchCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "launch",
|
||||
abstract: "Launch Messages.app with dylib injection",
|
||||
discussion: """
|
||||
Kills any running Messages.app instance, then relaunches it with
|
||||
DYLD_INSERT_LIBRARIES set to inject the imsg bridge helper dylib.
|
||||
This enables advanced features like typing indicators and read receipts
|
||||
that require IMCore framework access.
|
||||
|
||||
Requires SIP (System Integrity Protection) to be disabled.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: [
|
||||
.make(
|
||||
label: "dylib", names: [.long("dylib")],
|
||||
help: "Custom path to imsg-bridge-helper.dylib")
|
||||
],
|
||||
flags: [
|
||||
.make(
|
||||
label: "killOnly", names: [.long("kill-only")],
|
||||
help: "Only kill Messages.app, don't relaunch")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg launch",
|
||||
"imsg launch --kill-only",
|
||||
"imsg launch --dylib /path/to/dylib",
|
||||
"imsg launch --json",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
let killOnly = values.flags.contains("killOnly")
|
||||
let customDylib = values.option("dylib")
|
||||
|
||||
let launcher = MessagesLauncher.shared
|
||||
|
||||
if killOnly {
|
||||
if !runtime.jsonOutput {
|
||||
StdoutWriter.writeLine("Killing Messages.app...")
|
||||
}
|
||||
launcher.killMessages()
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "killed", "message": "Messages.app terminated"])
|
||||
} else {
|
||||
StdoutWriter.writeLine("Messages.app terminated")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch MessagesLauncher.currentSIPStatus() {
|
||||
case .enabled:
|
||||
let message =
|
||||
"SIP is enabled. Refusing to inject into Messages.app. "
|
||||
+ "Disable SIP in Recovery mode (`csrutil disable`) before running `imsg launch`."
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "error", "error": "sip_enabled", "message": message])
|
||||
} else {
|
||||
StdoutWriter.writeLine(message)
|
||||
}
|
||||
throw IMsgError.typingIndicatorFailed(message)
|
||||
case .unknown(let details):
|
||||
let message =
|
||||
"Unable to determine SIP status. Refusing to inject into Messages.app. Details: \(details)"
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "error", "error": "sip_unknown", "message": message])
|
||||
} else {
|
||||
StdoutWriter.writeLine(message)
|
||||
}
|
||||
throw IMsgError.typingIndicatorFailed(message)
|
||||
case .disabled:
|
||||
break
|
||||
}
|
||||
|
||||
let dylibPath = resolveDylibPath(custom: customDylib)
|
||||
|
||||
guard let resolvedPath = dylibPath else {
|
||||
let error =
|
||||
"imsg-bridge-helper.dylib not found. Searched:\n"
|
||||
+ " - /usr/local/lib/imsg-bridge-helper.dylib\n"
|
||||
+ " - .build/release/imsg-bridge-helper.dylib\n"
|
||||
+ "Run 'make build-dylib' or specify --dylib <path>"
|
||||
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "error", "error": "dylib_not_found", "message": error])
|
||||
} else {
|
||||
StdoutWriter.writeLine(error)
|
||||
}
|
||||
throw IMsgError.typingIndicatorFailed("dylib not found")
|
||||
}
|
||||
|
||||
launcher.dylibPath = resolvedPath
|
||||
|
||||
if !runtime.jsonOutput {
|
||||
StdoutWriter.writeLine("Using dylib: \(resolvedPath)")
|
||||
StdoutWriter.writeLine("Launching Messages.app with injection...")
|
||||
}
|
||||
|
||||
do {
|
||||
try launcher.ensureRunning()
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print([
|
||||
"status": "launched",
|
||||
"dylib": resolvedPath,
|
||||
"message": "Messages.app launched with dylib injection",
|
||||
])
|
||||
} else {
|
||||
StdoutWriter.writeLine("Messages.app launched with dylib injection")
|
||||
}
|
||||
} catch {
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print([
|
||||
"status": "error",
|
||||
"dylib": resolvedPath,
|
||||
"error": "\(error)",
|
||||
])
|
||||
} else {
|
||||
StdoutWriter.writeLine("Failed to launch: \(error)")
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveDylibPath(custom: String?) -> String? {
|
||||
if let custom = custom {
|
||||
if FileManager.default.fileExists(atPath: custom) {
|
||||
return custom
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
let searchPaths = [
|
||||
"/usr/local/lib/imsg-bridge-helper.dylib",
|
||||
".build/release/imsg-bridge-helper.dylib",
|
||||
]
|
||||
|
||||
for path in searchPaths {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
210
Sources/imsg/Commands/ReactCommand.swift
Normal file
210
Sources/imsg/Commands/ReactCommand.swift
Normal file
@ -0,0 +1,210 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum ReactCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "react",
|
||||
abstract: "Send a tapback reaction to the most recent message",
|
||||
discussion: """
|
||||
Sends a tapback reaction to the most recent incoming message in the specified chat.
|
||||
|
||||
IMPORTANT LIMITATIONS:
|
||||
- Only reacts to the MOST RECENT incoming message in the conversation
|
||||
- Requires Messages.app to be running
|
||||
- Uses UI automation (System Events) which requires accessibility permissions
|
||||
|
||||
Reaction types:
|
||||
love (❤️), like (👍), dislike (👎), laugh (😂), emphasis (‼️), question (❓)
|
||||
|
||||
Custom emoji tapbacks can be read from history/watch output, but cannot be
|
||||
sent reliably through Messages.app AppleScript automation.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid to react in"),
|
||||
.make(
|
||||
label: "reaction", names: [.long("reaction"), .short("r")],
|
||||
help: "reaction type: love, like, dislike, laugh, emphasis, question"),
|
||||
],
|
||||
flags: []
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg react --chat-id 1 --reaction like",
|
||||
"imsg react --chat-id 1 -r love",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
|
||||
appleScriptRunner: @escaping (String, [String]) throws -> Void = { source, arguments in
|
||||
try runAppleScript(source, arguments: arguments)
|
||||
}
|
||||
) async throws {
|
||||
guard let chatID = values.optionInt64("chatID") else {
|
||||
throw ParsedValuesError.missingOption("chat-id")
|
||||
}
|
||||
guard let reactionString = values.option("reaction") else {
|
||||
throw ParsedValuesError.missingOption("reaction")
|
||||
}
|
||||
guard let reactionType = ReactionType.parse(reactionString) else {
|
||||
throw IMsgError.invalidReaction(reactionString)
|
||||
}
|
||||
if case .custom(let emoji) = reactionType, !isSingleEmoji(emoji) {
|
||||
throw IMsgError.invalidReaction(reactionString)
|
||||
}
|
||||
if case .custom(let emoji) = reactionType {
|
||||
throw IMsgError.unsupportedReaction(
|
||||
"custom emoji tapback '\(emoji)' cannot be sent by Messages.app "
|
||||
+ "AppleScript automation; use love, like, dislike, laugh, emphasis, or question."
|
||||
)
|
||||
}
|
||||
|
||||
// Get chat info for the GUID
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let store = try storeFactory(dbPath)
|
||||
guard let chatInfo = try store.chatInfo(chatID: chatID) else {
|
||||
throw IMsgError.chatNotFound(chatID: chatID)
|
||||
}
|
||||
|
||||
let chatLookup = preferredChatLookup(chatInfo: chatInfo)
|
||||
|
||||
// Send the reaction via AppleScript + System Events
|
||||
try sendReaction(
|
||||
reactionType: reactionType,
|
||||
chatGUID: chatInfo.guid,
|
||||
chatLookup: chatLookup,
|
||||
appleScriptRunner: appleScriptRunner
|
||||
)
|
||||
|
||||
if runtime.jsonOutput {
|
||||
let result = ReactResult(
|
||||
success: true,
|
||||
chatID: chatID,
|
||||
reactionType: reactionType.name,
|
||||
reactionEmoji: reactionType.emoji
|
||||
)
|
||||
try JSONLines.print(result)
|
||||
} else {
|
||||
print("Sent \(reactionType.emoji) reaction to chat \(chatID)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func sendReaction(
|
||||
reactionType: ReactionType,
|
||||
chatGUID: String,
|
||||
chatLookup: String,
|
||||
appleScriptRunner: @escaping (String, [String]) throws -> Void
|
||||
) throws {
|
||||
let keyNumber: Int
|
||||
switch reactionType {
|
||||
case .love: keyNumber = 1
|
||||
case .like: keyNumber = 2
|
||||
case .dislike: keyNumber = 3
|
||||
case .laugh: keyNumber = 4
|
||||
case .emphasis: keyNumber = 5
|
||||
case .question: keyNumber = 6
|
||||
case .custom(let emoji):
|
||||
throw IMsgError.unsupportedReaction(
|
||||
"custom emoji tapback '\(emoji)' cannot be sent by Messages.app "
|
||||
+ "AppleScript automation; use love, like, dislike, laugh, emphasis, or question."
|
||||
)
|
||||
}
|
||||
|
||||
let script = """
|
||||
on run argv
|
||||
set chatGUID to item 1 of argv
|
||||
set chatLookup to item 2 of argv
|
||||
set reactionKey to item 3 of argv
|
||||
|
||||
tell application "Messages"
|
||||
activate
|
||||
set targetChat to chat id chatGUID
|
||||
end tell
|
||||
|
||||
delay 0.3
|
||||
|
||||
tell application "System Events"
|
||||
tell process "Messages"
|
||||
keystroke "f" using command down
|
||||
delay 0.15
|
||||
keystroke "a" using command down
|
||||
keystroke chatLookup
|
||||
delay 0.25
|
||||
key code 36
|
||||
delay 0.35
|
||||
keystroke "t" using command down
|
||||
delay 0.2
|
||||
keystroke reactionKey
|
||||
delay 0.1
|
||||
key code 36
|
||||
end tell
|
||||
end tell
|
||||
end run
|
||||
"""
|
||||
try appleScriptRunner(script, [chatGUID, chatLookup, "\(keyNumber)"])
|
||||
}
|
||||
|
||||
private static func preferredChatLookup(chatInfo: ChatInfo) -> String {
|
||||
let preferred = chatInfo.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !preferred.isEmpty {
|
||||
return preferred
|
||||
}
|
||||
let identifier = chatInfo.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identifier.isEmpty {
|
||||
return identifier
|
||||
}
|
||||
return chatInfo.guid
|
||||
}
|
||||
|
||||
private static func isSingleEmoji(_ value: String) -> Bool {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count == 1 else { return false }
|
||||
guard let scalar = trimmed.unicodeScalars.first else { return false }
|
||||
return scalar.properties.isEmoji || scalar.properties.isEmojiPresentation
|
||||
}
|
||||
|
||||
private static func runAppleScript(_ source: String, arguments: [String]) throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
process.arguments = ["-l", "AppleScript", "-"] + arguments
|
||||
|
||||
let stdinPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardInput = stdinPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
try process.run()
|
||||
if let data = source.data(using: .utf8) {
|
||||
stdinPipe.fileHandleForWriting.write(data)
|
||||
}
|
||||
stdinPipe.fileHandleForWriting.closeFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
let data = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let message = String(data: data, encoding: .utf8) ?? "Unknown AppleScript error"
|
||||
throw IMsgError.appleScriptFailure(message.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReactResult: Codable {
|
||||
let success: Bool
|
||||
let chatID: Int64
|
||||
let reactionType: String
|
||||
let reactionEmoji: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case success
|
||||
case chatID = "chat_id"
|
||||
case reactionType = "reaction_type"
|
||||
case reactionEmoji = "reaction_emoji"
|
||||
}
|
||||
}
|
||||
99
Sources/imsg/Commands/ReadCommand.swift
Normal file
99
Sources/imsg/Commands/ReadCommand.swift
Normal file
@ -0,0 +1,99 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum ReadCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "read",
|
||||
abstract: "Mark messages as read for a chat",
|
||||
discussion: """
|
||||
Marks messages as read via IMCore advanced features.
|
||||
Requires SIP disabled and Messages launched with `imsg launch`.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(
|
||||
label: "to",
|
||||
names: [.long("to"), .aliasLong("handle")],
|
||||
help: "phone number or email"),
|
||||
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid"),
|
||||
.make(
|
||||
label: "chatIdentifier", names: [.long("chat-identifier")],
|
||||
help: "chat identifier (e.g. iMessage;-;+14155551212)"),
|
||||
.make(label: "chatGUID", names: [.long("chat-guid")], help: "chat guid"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg read --to +14155551212",
|
||||
"imsg read --handle steipete@gmail.com",
|
||||
"imsg read --chat-id 1",
|
||||
"imsg read --chat-identifier \"iMessage;-;+14155551212\"",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
|
||||
markAsRead: @escaping (String) async throws -> Void = {
|
||||
try await IMCoreBridge.shared.markAsRead(handle: $0)
|
||||
}
|
||||
) async throws {
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let input = ChatTargetInput(
|
||||
recipient: values.option("to") ?? "",
|
||||
chatID: values.optionInt64("chatID"),
|
||||
chatIdentifier: values.option("chatIdentifier") ?? "",
|
||||
chatGUID: values.option("chatGUID") ?? ""
|
||||
)
|
||||
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: ParsedValuesError.invalidOption("to"),
|
||||
missingRecipientError: ParsedValuesError.missingOption("to")
|
||||
)
|
||||
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in
|
||||
let store = try storeFactory(dbPath)
|
||||
return try store.chatInfo(chatID: chatID)
|
||||
},
|
||||
unknownChatError: { chatID in
|
||||
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
}
|
||||
)
|
||||
let resolvedIdentifier: String
|
||||
if let preferred = resolvedTarget.preferredIdentifier {
|
||||
resolvedIdentifier = preferred
|
||||
} else if input.hasChatTarget {
|
||||
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
|
||||
} else {
|
||||
resolvedIdentifier = input.recipient
|
||||
}
|
||||
|
||||
try await markAsRead(resolvedIdentifier)
|
||||
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(ReadResult(success: true, handle: resolvedIdentifier, markedAsRead: true))
|
||||
} else {
|
||||
Swift.print("marked as read: \(resolvedIdentifier)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadResult: Codable {
|
||||
let success: Bool
|
||||
let handle: String
|
||||
let markedAsRead: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case success
|
||||
case handle
|
||||
case markedAsRead = "marked_as_read"
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,8 @@ enum RpcCommand {
|
||||
) { values, runtime in
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let store = try MessageStore(path: dbPath)
|
||||
let server = RPCServer(store: store, verbose: runtime.verbose)
|
||||
let contacts = await ContactResolver.create()
|
||||
let server = RPCServer(store: store, verbose: runtime.verbose, contactResolver: contacts)
|
||||
try await server.run()
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,20 +39,46 @@ enum SendCommand {
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) },
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) }
|
||||
resolveSentMessage:
|
||||
@escaping (
|
||||
MessageStore,
|
||||
MessageSendOptions,
|
||||
Int64?,
|
||||
Date
|
||||
) async throws -> Message? = SentMessageVerifier.resolveSentMessage,
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
|
||||
contactResolverFactory: @escaping (String) async -> any ContactResolving = { region in
|
||||
await ContactResolver.create(region: region)
|
||||
}
|
||||
) async throws {
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let recipient = values.option("to") ?? ""
|
||||
let chatID = values.optionInt64("chatID")
|
||||
let chatIdentifier = values.option("chatIdentifier") ?? ""
|
||||
let chatGUID = values.option("chatGUID") ?? ""
|
||||
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
|
||||
if hasChatTarget && !recipient.isEmpty {
|
||||
throw ParsedValuesError.invalidOption("to")
|
||||
}
|
||||
if !hasChatTarget && recipient.isEmpty {
|
||||
throw ParsedValuesError.missingOption("to")
|
||||
let store = try storeFactory(dbPath)
|
||||
let region = values.option("region") ?? "US"
|
||||
let rawRecipient = values.option("to") ?? ""
|
||||
let rawInput = ChatTargetInput(
|
||||
recipient: rawRecipient,
|
||||
chatID: values.optionInt64("chatID"),
|
||||
chatIdentifier: values.option("chatIdentifier") ?? "",
|
||||
chatGUID: values.option("chatGUID") ?? ""
|
||||
)
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: rawInput,
|
||||
mixedTargetError: ParsedValuesError.invalidOption("to"),
|
||||
missingRecipientError: ParsedValuesError.missingOption("to")
|
||||
)
|
||||
let recipient: String
|
||||
if !rawInput.hasChatTarget && ChatTargetResolver.looksLikeContactName(rawRecipient) {
|
||||
let contacts = await contactResolverFactory(region)
|
||||
recipient = try ChatTargetResolver.resolveRecipientName(rawRecipient, contacts: contacts)
|
||||
} else {
|
||||
recipient = rawRecipient
|
||||
}
|
||||
let input = ChatTargetInput(
|
||||
recipient: recipient,
|
||||
chatID: rawInput.chatID,
|
||||
chatIdentifier: rawInput.chatIdentifier,
|
||||
chatGUID: rawInput.chatGUID
|
||||
)
|
||||
|
||||
let text = values.option("text") ?? ""
|
||||
let file = values.option("file") ?? ""
|
||||
@ -63,37 +89,52 @@ enum SendCommand {
|
||||
guard let service = MessageService(rawValue: serviceRaw) else {
|
||||
throw IMsgError.invalidService(serviceRaw)
|
||||
}
|
||||
let region = values.option("region") ?? "US"
|
||||
|
||||
var resolvedChatIdentifier = chatIdentifier
|
||||
var resolvedChatGUID = chatGUID
|
||||
if let chatID {
|
||||
let store = try storeFactory(dbPath)
|
||||
guard let info = try store.chatInfo(chatID: chatID) else {
|
||||
throw IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in
|
||||
return try store.chatInfo(chatID: chatID)
|
||||
},
|
||||
unknownChatError: { chatID in
|
||||
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
}
|
||||
resolvedChatIdentifier = info.identifier
|
||||
resolvedChatGUID = info.guid
|
||||
}
|
||||
if hasChatTarget && resolvedChatIdentifier.isEmpty && resolvedChatGUID.isEmpty {
|
||||
)
|
||||
if input.hasChatTarget && resolvedTarget.preferredIdentifier == nil {
|
||||
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
|
||||
}
|
||||
|
||||
try sendMessage(
|
||||
MessageSendOptions(
|
||||
recipient: recipient,
|
||||
text: text,
|
||||
attachmentPath: file,
|
||||
service: service,
|
||||
region: region,
|
||||
chatIdentifier: resolvedChatIdentifier,
|
||||
chatGUID: resolvedChatGUID
|
||||
))
|
||||
let options = MessageSendOptions(
|
||||
recipient: input.recipient,
|
||||
text: text,
|
||||
attachmentPath: file,
|
||||
service: service,
|
||||
region: region,
|
||||
chatIdentifier: resolvedTarget.chatIdentifier,
|
||||
chatGUID: resolvedTarget.chatGUID
|
||||
)
|
||||
let sentAt = Date()
|
||||
try sendMessage(options)
|
||||
|
||||
if input.hasChatTarget {
|
||||
let verificationChatID =
|
||||
input.chatID
|
||||
?? resolvedTarget.preferredIdentifier.flatMap {
|
||||
try? store.chatInfo(matchingTarget: $0)?.id
|
||||
}
|
||||
let sentMessage = try? await resolveSentMessage(store, options, verificationChatID, sentAt)
|
||||
if sentMessage == nil {
|
||||
try SentMessageVerifier.throwIfMisroutedChatSend(
|
||||
store: store,
|
||||
options: options,
|
||||
sentAt: sentAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "sent"])
|
||||
try StdoutWriter.writeJSONLine(["status": "sent"])
|
||||
} else {
|
||||
Swift.print("sent")
|
||||
StdoutWriter.writeLine("sent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
155
Sources/imsg/Commands/StatusCommand.swift
Normal file
155
Sources/imsg/Commands/StatusCommand.swift
Normal file
@ -0,0 +1,155 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum StatusCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "status",
|
||||
abstract: "Check availability of imsg advanced features",
|
||||
discussion: """
|
||||
Display the current status of imsg features and permissions.
|
||||
Shows which advanced features (typing indicators, read receipts) are
|
||||
available and provides setup instructions if needed.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(CommandSignature()),
|
||||
usageExamples: [
|
||||
"imsg status",
|
||||
"imsg status --json",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
let bridge = IMCoreBridge.shared
|
||||
let availability = bridge.checkAvailability()
|
||||
let sipStatus: String = {
|
||||
switch MessagesLauncher.currentSIPStatus() {
|
||||
case .enabled:
|
||||
return "enabled"
|
||||
case .disabled:
|
||||
return "disabled"
|
||||
case .unknown:
|
||||
return "unknown"
|
||||
}
|
||||
}()
|
||||
|
||||
// Probe the bridge for v2 readiness + selector availability.
|
||||
var bridgeVersion: Int = 0
|
||||
var v2Ready: Bool = false
|
||||
var selectors: [String: Bool] = [:]
|
||||
if availability.available {
|
||||
do {
|
||||
let data = try await IMsgBridgeClient.shared.invoke(
|
||||
action: .status, params: [:], timeout: 3.0)
|
||||
bridgeVersion = (data["bridge_version"] as? Int) ?? 0
|
||||
v2Ready = (data["v2_ready"] as? Bool) ?? false
|
||||
if let raw = data["selectors"] as? [String: Bool] { selectors = raw }
|
||||
} catch {
|
||||
// Bridge probe failure is non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.jsonOutput {
|
||||
let payload = StatusPayload(
|
||||
basicFeatures: true,
|
||||
advancedFeatures: availability.available,
|
||||
typingIndicators: availability.available,
|
||||
readReceipts: availability.available,
|
||||
sip: sipStatus,
|
||||
message: availability.message,
|
||||
bridgeVersion: bridgeVersion,
|
||||
v2Ready: v2Ready,
|
||||
selectors: selectors,
|
||||
rpcMethods: kSupportedRPCMethods
|
||||
)
|
||||
try JSONLines.print(payload)
|
||||
} else {
|
||||
StdoutWriter.writeLine("imsg Status Report")
|
||||
StdoutWriter.writeLine("==================")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("Basic features (send, receive, history):")
|
||||
StdoutWriter.writeLine(" Available")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("System Integrity Protection (SIP):")
|
||||
StdoutWriter.writeLine(" \(sipStatus)")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("Advanced features (typing, read receipts):")
|
||||
if availability.available {
|
||||
StdoutWriter.writeLine(" Available - IMCore bridge connected")
|
||||
StdoutWriter.writeLine(
|
||||
" bridge version: v\(bridgeVersion)\(v2Ready ? " (v2 inbox active)" : "")")
|
||||
if !selectors.isEmpty {
|
||||
StdoutWriter.writeLine(" selectors:")
|
||||
for key in selectors.keys.sorted() {
|
||||
let ok = selectors[key] ?? false
|
||||
StdoutWriter.writeLine(" \(key): \(ok ? "✓" : "✗")")
|
||||
}
|
||||
}
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("Available bridge commands:")
|
||||
StdoutWriter.writeLine(" Send: imsg send-rich, send-multipart, send-attachment, tapback")
|
||||
StdoutWriter.writeLine(" Mutate: imsg edit, unsend, delete-message, notify-anyways")
|
||||
StdoutWriter.writeLine(
|
||||
" Chat: imsg chat-create, chat-name, chat-photo, chat-add/remove-member, chat-leave/delete, chat-mark"
|
||||
)
|
||||
StdoutWriter.writeLine(" Introspect: imsg account, whois, nickname")
|
||||
StdoutWriter.writeLine(" Local DB: imsg search")
|
||||
StdoutWriter.writeLine(" Watch with events: imsg watch --bb-events")
|
||||
} else {
|
||||
StdoutWriter.writeLine(" Not available")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("To enable advanced features:")
|
||||
StdoutWriter.writeLine(" 1. Disable System Integrity Protection (SIP)")
|
||||
StdoutWriter.writeLine(" - Restart Mac holding Cmd+R")
|
||||
StdoutWriter.writeLine(" - Open Terminal from Utilities menu")
|
||||
StdoutWriter.writeLine(" - Run: csrutil disable")
|
||||
StdoutWriter.writeLine(" - Restart normally")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine(" 2. Grant Full Disk Access")
|
||||
StdoutWriter.writeLine(" - System Settings > Privacy & Security > Full Disk Access")
|
||||
StdoutWriter.writeLine(" - Add Terminal or your terminal app")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine(" 3. Build and launch:")
|
||||
StdoutWriter.writeLine(" make build-dylib")
|
||||
StdoutWriter.writeLine(" imsg launch")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("macOS 26/Tahoe note:")
|
||||
StdoutWriter.writeLine(
|
||||
" Advanced IMCore features may still be blocked by library validation"
|
||||
)
|
||||
StdoutWriter.writeLine(
|
||||
" or imagent private entitlement checks. Basic commands still work."
|
||||
)
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("Note: Basic messaging features work without these steps.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusPayload: Encodable {
|
||||
let basicFeatures: Bool
|
||||
let advancedFeatures: Bool
|
||||
let typingIndicators: Bool
|
||||
let readReceipts: Bool
|
||||
let sip: String
|
||||
let message: String
|
||||
let bridgeVersion: Int
|
||||
let v2Ready: Bool
|
||||
let selectors: [String: Bool]
|
||||
let rpcMethods: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case basicFeatures = "basic_features"
|
||||
case advancedFeatures = "advanced_features"
|
||||
case typingIndicators = "typing_indicators"
|
||||
case readReceipts = "read_receipts"
|
||||
case sip
|
||||
case message
|
||||
case bridgeVersion = "bridge_version"
|
||||
case v2Ready = "v2_ready"
|
||||
case selectors
|
||||
case rpcMethods = "rpc_methods"
|
||||
}
|
||||
}
|
||||
141
Sources/imsg/Commands/TypingCommand.swift
Normal file
141
Sources/imsg/Commands/TypingCommand.swift
Normal file
@ -0,0 +1,141 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum TypingCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "typing",
|
||||
abstract: "Send typing indicator to a chat",
|
||||
discussion: """
|
||||
Sends typing indicators via IMCore advanced features.
|
||||
Requires SIP disabled and Messages launched with `imsg launch`.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "to", names: [.long("to")], help: "phone number or email"),
|
||||
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid"),
|
||||
.make(
|
||||
label: "chatIdentifier", names: [.long("chat-identifier")],
|
||||
help: "chat identifier (e.g. iMessage;-;+14155551212)"),
|
||||
.make(label: "chatGUID", names: [.long("chat-guid")], help: "chat guid"),
|
||||
.make(
|
||||
label: "duration", names: [.long("duration")],
|
||||
help: "how long to show typing (e.g. 5s, 3000ms); omit for start-only"),
|
||||
.make(
|
||||
label: "stop", names: [.long("stop")],
|
||||
help: "stop typing indicator instead of starting"),
|
||||
.make(
|
||||
label: "service", names: [.long("service")],
|
||||
help: "service to use: imessage|sms|auto"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg typing --to +14155551212",
|
||||
"imsg typing --to +14155551212 --duration 5s",
|
||||
"imsg typing --to +14155551212 --stop true",
|
||||
"imsg typing --chat-identifier \"iMessage;-;+14155551212\"",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
|
||||
startTyping: @escaping (String) throws -> Void = {
|
||||
try TypingIndicator.startTyping(chatIdentifier: $0)
|
||||
},
|
||||
stopTyping: @escaping (String) throws -> Void = {
|
||||
try TypingIndicator.stopTyping(chatIdentifier: $0)
|
||||
},
|
||||
typeForDuration: @escaping (String, TimeInterval) async throws -> Void = {
|
||||
try await TypingIndicator.typeForDuration(chatIdentifier: $0, duration: $1)
|
||||
}
|
||||
) async throws {
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let input = ChatTargetInput(
|
||||
recipient: values.option("to") ?? "",
|
||||
chatID: values.optionInt64("chatID"),
|
||||
chatIdentifier: values.option("chatIdentifier") ?? "",
|
||||
chatGUID: values.option("chatGUID") ?? ""
|
||||
)
|
||||
let stopFlag = try parseStopFlag(values.option("stop"))
|
||||
let durationRaw = values.option("duration") ?? ""
|
||||
let serviceRaw = values.option("service") ?? "imessage"
|
||||
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: ParsedValuesError.invalidOption("to"),
|
||||
missingRecipientError: ParsedValuesError.missingOption("to")
|
||||
)
|
||||
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in
|
||||
let store = try storeFactory(dbPath)
|
||||
return try store.chatInfo(chatID: chatID)
|
||||
},
|
||||
unknownChatError: { chatID in
|
||||
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
}
|
||||
)
|
||||
let resolvedIdentifier: String
|
||||
if let preferred = resolvedTarget.preferredIdentifier {
|
||||
resolvedIdentifier = preferred
|
||||
} else if input.hasChatTarget {
|
||||
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
|
||||
} else {
|
||||
resolvedIdentifier = try ChatTargetResolver.directTypingIdentifier(
|
||||
recipient: input.recipient,
|
||||
serviceRaw: serviceRaw,
|
||||
invalidServiceError: { IMsgError.invalidService($0) }
|
||||
)
|
||||
}
|
||||
|
||||
if stopFlag {
|
||||
try stopTyping(resolvedIdentifier)
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "stopped"])
|
||||
} else {
|
||||
Swift.print("typing indicator stopped")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !durationRaw.isEmpty {
|
||||
let seconds = try parseDurationToSeconds(durationRaw)
|
||||
try await typeForDuration(resolvedIdentifier, seconds)
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "completed", "duration_s": "\(seconds)"])
|
||||
} else {
|
||||
Swift.print("typing indicator shown for \(durationRaw)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try startTyping(resolvedIdentifier)
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "started"])
|
||||
} else {
|
||||
Swift.print("typing indicator started")
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseStopFlag(_ raw: String?) throws -> Bool {
|
||||
guard let raw else { return false }
|
||||
if raw == "true" { return true }
|
||||
if raw == "false" { return false }
|
||||
throw ParsedValuesError.invalidOption("stop")
|
||||
}
|
||||
|
||||
private static func parseDurationToSeconds(_ raw: String) throws -> TimeInterval {
|
||||
guard let seconds = DurationParser.parse(raw), seconds > 0 else {
|
||||
throw IMsgError.typingIndicatorFailed(
|
||||
"Invalid duration: \(raw). Use e.g. 5s, 3000ms, 1m, or 1h")
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import Commander
|
||||
import Darwin
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
@ -27,7 +26,19 @@ enum WatchCommand {
|
||||
flags: [
|
||||
.make(
|
||||
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
|
||||
)
|
||||
),
|
||||
.make(
|
||||
label: "convertAttachments", names: [.long("convert-attachments")],
|
||||
help: "convert CAF/GIF attachments to model-compatible cached files"
|
||||
),
|
||||
.make(
|
||||
label: "reactions", names: [.long("reactions")],
|
||||
help: "include reaction events (tapback add/remove) in the stream"
|
||||
),
|
||||
.make(
|
||||
label: "bbEvents", names: [.long("bb-events")],
|
||||
help: "include dylib-pushed events (typing, alias-removed) when injection is active"
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
@ -43,6 +54,9 @@ enum WatchCommand {
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
|
||||
contactResolverFactory: @escaping () async -> any ContactResolving = {
|
||||
await ContactResolver.create()
|
||||
},
|
||||
streamProvider:
|
||||
@escaping (
|
||||
MessageWatcher,
|
||||
@ -61,6 +75,9 @@ enum WatchCommand {
|
||||
}
|
||||
let sinceRowID = values.optionInt64("sinceRowID")
|
||||
let showAttachments = values.flag("attachments")
|
||||
let attachmentOptions = AttachmentQueryOptions(
|
||||
convertUnsupported: values.flag("convertAttachments"))
|
||||
let includeReactions = values.flag("reactions")
|
||||
let participants = values.optionValues("participants")
|
||||
.flatMap { $0.split(separator: ",").map { String($0) } }
|
||||
.filter { !$0.isEmpty }
|
||||
@ -72,47 +89,80 @@ enum WatchCommand {
|
||||
|
||||
let store = try storeFactory(dbPath)
|
||||
let watcher = MessageWatcher(store: store)
|
||||
let cache = ChatCache(store: store)
|
||||
let contacts = await contactResolverFactory()
|
||||
let config = MessageWatcherConfiguration(
|
||||
debounceInterval: debounceInterval,
|
||||
batchLimit: 100
|
||||
batchLimit: 100,
|
||||
includeReactions: includeReactions
|
||||
)
|
||||
|
||||
let bbEvents = values.flag("bbEvents")
|
||||
if bbEvents {
|
||||
let path = MessagesLauncher.shared.bridgeEventsFile
|
||||
let tailer = IMsgEventTailer(path: path)
|
||||
Task {
|
||||
for await event in tailer.events() {
|
||||
if runtime.jsonOutput {
|
||||
var obj: [String: Any] = [
|
||||
"kind": "bridge-event",
|
||||
"event": event.name,
|
||||
]
|
||||
if let ts = event.timestamp { obj["ts"] = ts }
|
||||
obj["data"] = event.decodedPayload()
|
||||
try? JSONLines.printObject(obj)
|
||||
} else {
|
||||
let stamp = event.timestamp ?? CLIISO8601.format(Date())
|
||||
StdoutWriter.writeLine("\(stamp) [bridge] \(event.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stream = streamProvider(watcher, chatID, sinceRowID, config)
|
||||
for try await message in stream {
|
||||
if !filter.allows(message) {
|
||||
continue
|
||||
}
|
||||
if runtime.jsonOutput {
|
||||
let attachments = try store.attachments(for: message.rowID)
|
||||
let reactions = try store.reactions(for: message.rowID)
|
||||
let payload = MessagePayload(
|
||||
let payload = try await buildMessagePayload(
|
||||
store: store,
|
||||
cache: cache,
|
||||
message: message,
|
||||
attachments: attachments,
|
||||
reactions: reactions
|
||||
includeAttachments: true,
|
||||
includeReactions: true,
|
||||
attachmentOptions: attachmentOptions,
|
||||
contactResolver: contacts
|
||||
)
|
||||
try JSONLines.print(payload)
|
||||
fflush(stdout)
|
||||
try JSONLines.printObject(payload)
|
||||
continue
|
||||
}
|
||||
let direction = message.isFromMe ? "sent" : "recv"
|
||||
let timestamp = CLIISO8601.format(message.date)
|
||||
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
|
||||
let sender =
|
||||
message.isFromMe
|
||||
? message.sender : (contacts.displayName(for: message.sender) ?? message.sender)
|
||||
if message.isReaction, let reactionType = message.reactionType {
|
||||
let action = (message.isReactionAdd ?? true) ? "added" : "removed"
|
||||
let targetGUID = message.reactedToGUID ?? "unknown"
|
||||
StdoutWriter.writeLine(
|
||||
"\(timestamp) [\(direction)] \(sender) \(action) \(reactionType.emoji) reaction to \(targetGUID)"
|
||||
)
|
||||
continue
|
||||
}
|
||||
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(sender): \(message.text)")
|
||||
if message.attachmentsCount > 0 {
|
||||
if showAttachments {
|
||||
let metas = try store.attachments(for: message.rowID)
|
||||
let metas = try store.attachments(for: message.rowID, options: attachmentOptions)
|
||||
for meta in metas {
|
||||
let name = displayName(for: meta)
|
||||
Swift.print(
|
||||
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
|
||||
)
|
||||
StdoutWriter.writeLine(attachmentMetadataLine(for: meta))
|
||||
}
|
||||
} else {
|
||||
Swift.print(
|
||||
StdoutWriter.writeLine(
|
||||
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
|
||||
)
|
||||
}
|
||||
}
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,13 +4,13 @@ import Foundation
|
||||
struct HelpPrinter {
|
||||
static func printRoot(version: String, rootName: String, commands: [CommandSpec]) {
|
||||
for line in renderRoot(version: version, rootName: rootName, commands: commands) {
|
||||
Swift.print(line)
|
||||
StdoutWriter.writeLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
static func printCommand(rootName: String, spec: CommandSpec) {
|
||||
for line in renderCommand(rootName: rootName, spec: spec) {
|
||||
Swift.print(line)
|
||||
StdoutWriter.writeLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,13 @@ enum JSONLines {
|
||||
static func print<T: Encodable>(_ value: T) throws {
|
||||
let line = try encode(value)
|
||||
if !line.isEmpty {
|
||||
Swift.print(line)
|
||||
StdoutWriter.writeLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
static func printObject(_ value: Any) throws {
|
||||
let data = try JSONSerialization.data(withJSONObject: value, options: [])
|
||||
guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return }
|
||||
StdoutWriter.writeLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,13 +7,33 @@ struct ChatPayload: Codable {
|
||||
let identifier: String
|
||||
let service: String
|
||||
let lastMessageAt: String
|
||||
let guid: String?
|
||||
let displayName: String?
|
||||
let contactName: String?
|
||||
let isGroup: Bool
|
||||
let participants: [String]?
|
||||
let accountID: String?
|
||||
let accountLogin: String?
|
||||
let lastAddressedHandle: String?
|
||||
|
||||
init(chat: Chat) {
|
||||
init(
|
||||
chat: Chat, chatInfo: ChatInfo? = nil, participants: [String]? = nil, contactName: String? = nil
|
||||
) {
|
||||
let identifier = chatInfo?.identifier ?? chat.identifier
|
||||
let guid = chatInfo?.guid ?? ""
|
||||
self.id = chat.id
|
||||
self.name = chat.name
|
||||
self.identifier = chat.identifier
|
||||
self.service = chat.service
|
||||
self.identifier = identifier
|
||||
self.service = chatInfo?.service ?? chat.service
|
||||
self.lastMessageAt = CLIISO8601.format(chat.lastMessageAt)
|
||||
self.guid = guid.isEmpty ? nil : guid
|
||||
self.displayName = chatInfo?.name
|
||||
self.contactName = contactName
|
||||
self.isGroup = isGroupHandle(identifier: identifier, guid: guid)
|
||||
self.participants = participants
|
||||
self.accountID = chatInfo?.accountID ?? chat.accountID
|
||||
self.accountLogin = chatInfo?.accountLogin ?? chat.accountLogin
|
||||
self.lastAddressedHandle = chatInfo?.lastAddressedHandle ?? chat.lastAddressedHandle
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@ -22,6 +42,14 @@ struct ChatPayload: Codable {
|
||||
case identifier
|
||||
case service
|
||||
case lastMessageAt = "last_message_at"
|
||||
case guid
|
||||
case displayName = "display_name"
|
||||
case contactName = "contact_name"
|
||||
case isGroup = "is_group"
|
||||
case participants
|
||||
case accountID = "account_id"
|
||||
case accountLogin = "account_login"
|
||||
case lastAddressedHandle = "last_addressed_handle"
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,24 +60,61 @@ struct MessagePayload: Codable {
|
||||
let replyToGUID: String?
|
||||
let threadOriginatorGUID: String?
|
||||
let sender: String
|
||||
let senderName: String?
|
||||
let isFromMe: Bool
|
||||
let text: String
|
||||
let createdAt: String
|
||||
let attachments: [AttachmentPayload]
|
||||
let reactions: [ReactionPayload]
|
||||
/// The destination_caller_id from the database. For messages where is_from_me is true,
|
||||
/// this can help distinguish between messages actually sent by the local user vs
|
||||
/// messages received on a secondary phone number registered with the same Apple ID.
|
||||
let destinationCallerID: String?
|
||||
|
||||
init(message: Message, attachments: [AttachmentMeta], reactions: [Reaction] = []) {
|
||||
// Reaction event metadata (populated when this message is a reaction event)
|
||||
let isReaction: Bool?
|
||||
let reactionType: String?
|
||||
let reactionEmoji: String?
|
||||
let isReactionAdd: Bool?
|
||||
let reactedToGUID: String?
|
||||
|
||||
init(
|
||||
message: Message,
|
||||
attachments: [AttachmentMeta],
|
||||
reactions: [Reaction] = [],
|
||||
senderName: String? = nil,
|
||||
reactionSenderNames: [Int64: String] = [:]
|
||||
) {
|
||||
self.id = message.rowID
|
||||
self.chatID = message.chatID
|
||||
self.guid = message.guid
|
||||
self.replyToGUID = message.replyToGUID
|
||||
self.threadOriginatorGUID = message.threadOriginatorGUID
|
||||
self.sender = message.sender
|
||||
self.senderName = senderName
|
||||
self.isFromMe = message.isFromMe
|
||||
self.text = message.text
|
||||
self.createdAt = CLIISO8601.format(message.date)
|
||||
self.attachments = attachments.map { AttachmentPayload(meta: $0) }
|
||||
self.reactions = reactions.map { ReactionPayload(reaction: $0) }
|
||||
self.reactions = reactions.map {
|
||||
ReactionPayload(reaction: $0, senderName: reactionSenderNames[$0.rowID])
|
||||
}
|
||||
self.destinationCallerID = message.destinationCallerID
|
||||
|
||||
// Reaction event metadata
|
||||
if message.isReaction {
|
||||
self.isReaction = true
|
||||
self.reactionType = message.reactionType?.name
|
||||
self.reactionEmoji = message.reactionType?.emoji
|
||||
self.isReactionAdd = message.isReactionAdd
|
||||
self.reactedToGUID = message.reactedToGUID
|
||||
} else {
|
||||
self.isReaction = nil
|
||||
self.reactionType = nil
|
||||
self.reactionEmoji = nil
|
||||
self.isReactionAdd = nil
|
||||
self.reactedToGUID = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@ -59,27 +124,48 @@ struct MessagePayload: Codable {
|
||||
case replyToGUID = "reply_to_guid"
|
||||
case threadOriginatorGUID = "thread_originator_guid"
|
||||
case sender
|
||||
case senderName = "sender_name"
|
||||
case isFromMe = "is_from_me"
|
||||
case text
|
||||
case createdAt = "created_at"
|
||||
case attachments
|
||||
case reactions
|
||||
case destinationCallerID = "destination_caller_id"
|
||||
case isReaction = "is_reaction"
|
||||
case reactionType = "reaction_type"
|
||||
case reactionEmoji = "reaction_emoji"
|
||||
case isReactionAdd = "is_reaction_add"
|
||||
case reactedToGUID = "reacted_to_guid"
|
||||
}
|
||||
}
|
||||
|
||||
extension MessagePayload {
|
||||
func asDictionary() throws -> [String: Any] {
|
||||
let data = try MessagePayload.encoder.encode(self)
|
||||
let json = try JSONSerialization.jsonObject(with: data)
|
||||
return (json as? [String: Any]) ?? [:]
|
||||
}
|
||||
|
||||
private static let encoder: JSONEncoder = {
|
||||
JSONEncoder()
|
||||
}()
|
||||
}
|
||||
|
||||
struct ReactionPayload: Codable {
|
||||
let id: Int64
|
||||
let type: String
|
||||
let emoji: String
|
||||
let sender: String
|
||||
let senderName: String?
|
||||
let isFromMe: Bool
|
||||
let createdAt: String
|
||||
|
||||
init(reaction: Reaction) {
|
||||
init(reaction: Reaction, senderName: String? = nil) {
|
||||
self.id = reaction.rowID
|
||||
self.type = reaction.reactionType.name
|
||||
self.emoji = reaction.reactionType.emoji
|
||||
self.sender = reaction.sender
|
||||
self.senderName = senderName
|
||||
self.isFromMe = reaction.isFromMe
|
||||
self.createdAt = CLIISO8601.format(reaction.date)
|
||||
}
|
||||
@ -89,11 +175,51 @@ struct ReactionPayload: Codable {
|
||||
case type
|
||||
case emoji
|
||||
case sender
|
||||
case senderName = "sender_name"
|
||||
case isFromMe = "is_from_me"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupPayload: Codable {
|
||||
let id: Int64
|
||||
let identifier: String
|
||||
let guid: String
|
||||
let name: String
|
||||
let service: String
|
||||
let isGroup: Bool
|
||||
let participants: [String]
|
||||
let accountID: String?
|
||||
let accountLogin: String?
|
||||
let lastAddressedHandle: String?
|
||||
|
||||
init(chatInfo: ChatInfo, participants: [String]) {
|
||||
self.id = chatInfo.id
|
||||
self.identifier = chatInfo.identifier
|
||||
self.guid = chatInfo.guid
|
||||
self.name = chatInfo.name
|
||||
self.service = chatInfo.service
|
||||
self.isGroup = isGroupHandle(identifier: chatInfo.identifier, guid: chatInfo.guid)
|
||||
self.participants = participants
|
||||
self.accountID = chatInfo.accountID
|
||||
self.accountLogin = chatInfo.accountLogin
|
||||
self.lastAddressedHandle = chatInfo.lastAddressedHandle
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case identifier
|
||||
case guid
|
||||
case name
|
||||
case service
|
||||
case isGroup = "is_group"
|
||||
case participants
|
||||
case accountID = "account_id"
|
||||
case accountLogin = "account_login"
|
||||
case lastAddressedHandle = "last_addressed_handle"
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentPayload: Codable {
|
||||
let filename: String
|
||||
let transferName: String
|
||||
@ -102,6 +228,8 @@ struct AttachmentPayload: Codable {
|
||||
let totalBytes: Int64
|
||||
let isSticker: Bool
|
||||
let originalPath: String
|
||||
let convertedPath: String?
|
||||
let convertedMimeType: String?
|
||||
let missing: Bool
|
||||
|
||||
init(meta: AttachmentMeta) {
|
||||
@ -112,6 +240,8 @@ struct AttachmentPayload: Codable {
|
||||
self.totalBytes = meta.totalBytes
|
||||
self.isSticker = meta.isSticker
|
||||
self.originalPath = meta.originalPath
|
||||
self.convertedPath = meta.convertedPath
|
||||
self.convertedMimeType = meta.convertedMimeType
|
||||
self.missing = meta.missing
|
||||
}
|
||||
|
||||
@ -123,6 +253,8 @@ struct AttachmentPayload: Codable {
|
||||
case totalBytes = "total_bytes"
|
||||
case isSticker = "is_sticker"
|
||||
case originalPath = "original_path"
|
||||
case convertedPath = "converted_path"
|
||||
case convertedMimeType = "converted_mime_type"
|
||||
case missing = "missing"
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,9 +8,10 @@ func chatPayload(
|
||||
name: String,
|
||||
service: String,
|
||||
lastMessageAt: Date,
|
||||
participants: [String]
|
||||
participants: [String],
|
||||
contactName: String? = nil
|
||||
) -> [String: Any] {
|
||||
return [
|
||||
var payload: [String: Any] = [
|
||||
"id": id,
|
||||
"identifier": identifier,
|
||||
"guid": guid,
|
||||
@ -20,6 +21,10 @@ func chatPayload(
|
||||
"participants": participants,
|
||||
"is_group": isGroupHandle(identifier: identifier, guid: guid),
|
||||
]
|
||||
if let contactName {
|
||||
payload["contact_name"] = contactName
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func messagePayload(
|
||||
@ -27,38 +32,31 @@ func messagePayload(
|
||||
chatInfo: ChatInfo?,
|
||||
participants: [String],
|
||||
attachments: [AttachmentMeta],
|
||||
reactions: [Reaction]
|
||||
) -> [String: Any] {
|
||||
reactions: [Reaction],
|
||||
senderName: String? = nil,
|
||||
reactionSenderNames: [Int64: String] = [:]
|
||||
) throws -> [String: Any] {
|
||||
let identifier = chatInfo?.identifier ?? ""
|
||||
let guid = chatInfo?.guid ?? ""
|
||||
let name = chatInfo?.name ?? ""
|
||||
var payload: [String: Any] = [
|
||||
"id": message.rowID,
|
||||
"chat_id": message.chatID,
|
||||
"guid": message.guid,
|
||||
"sender": message.sender,
|
||||
"is_from_me": message.isFromMe,
|
||||
"text": message.text,
|
||||
"created_at": CLIISO8601.format(message.date),
|
||||
"attachments": attachments.map { attachmentPayload($0) },
|
||||
"reactions": reactions.map { reactionPayload($0) },
|
||||
"chat_identifier": identifier,
|
||||
"chat_guid": guid,
|
||||
"chat_name": name,
|
||||
"participants": participants,
|
||||
"is_group": isGroupHandle(identifier: identifier, guid: guid),
|
||||
]
|
||||
if let replyToGUID = message.replyToGUID, !replyToGUID.isEmpty {
|
||||
payload["reply_to_guid"] = replyToGUID
|
||||
}
|
||||
if let threadOriginatorGUID = message.threadOriginatorGUID, !threadOriginatorGUID.isEmpty {
|
||||
payload["thread_originator_guid"] = threadOriginatorGUID
|
||||
}
|
||||
let core = MessagePayload(
|
||||
message: message,
|
||||
attachments: attachments,
|
||||
reactions: reactions,
|
||||
senderName: senderName,
|
||||
reactionSenderNames: reactionSenderNames
|
||||
)
|
||||
var payload = try core.asDictionary()
|
||||
payload["chat_identifier"] = identifier
|
||||
payload["chat_guid"] = guid
|
||||
payload["chat_name"] = name
|
||||
payload["participants"] = participants
|
||||
payload["is_group"] = isGroupHandle(identifier: identifier, guid: guid)
|
||||
return payload
|
||||
}
|
||||
|
||||
func attachmentPayload(_ meta: AttachmentMeta) -> [String: Any] {
|
||||
return [
|
||||
var payload: [String: Any] = [
|
||||
"filename": meta.filename,
|
||||
"transfer_name": meta.transferName,
|
||||
"uti": meta.uti,
|
||||
@ -68,10 +66,17 @@ func attachmentPayload(_ meta: AttachmentMeta) -> [String: Any] {
|
||||
"original_path": meta.originalPath,
|
||||
"missing": meta.missing,
|
||||
]
|
||||
if let convertedPath = meta.convertedPath {
|
||||
payload["converted_path"] = convertedPath
|
||||
}
|
||||
if let convertedMimeType = meta.convertedMimeType {
|
||||
payload["converted_mime_type"] = convertedMimeType
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func reactionPayload(_ reaction: Reaction) -> [String: Any] {
|
||||
return [
|
||||
func reactionPayload(_ reaction: Reaction, senderName: String? = nil) -> [String: Any] {
|
||||
var payload: [String: Any] = [
|
||||
"id": reaction.rowID,
|
||||
"type": reaction.reactionType.name,
|
||||
"emoji": reaction.reactionType.emoji,
|
||||
@ -79,11 +84,14 @@ func reactionPayload(_ reaction: Reaction) -> [String: Any] {
|
||||
"is_from_me": reaction.isFromMe,
|
||||
"created_at": CLIISO8601.format(reaction.date),
|
||||
]
|
||||
if let senderName {
|
||||
payload["sender_name"] = senderName
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func isGroupHandle(identifier: String, guid: String) -> Bool {
|
||||
let handle = identifier.isEmpty ? guid : identifier
|
||||
return handle.contains(";+;") || handle.contains(";-;")
|
||||
return guid.contains(";+;") || identifier.contains(";+;")
|
||||
}
|
||||
|
||||
func stringParam(_ value: Any?) -> String? {
|
||||
@ -131,3 +139,14 @@ func stringArrayParam(_ value: Any?) -> [String] {
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
let defaultRPCWatchDebounceInterval: TimeInterval = 0.5
|
||||
|
||||
func watchDebounceIntervalParam(_ params: [String: Any]) throws -> TimeInterval {
|
||||
let raw = params["debounce_ms"] ?? params["debounceMs"]
|
||||
guard let raw else { return defaultRPCWatchDebounceInterval }
|
||||
guard let milliseconds = intParam(raw), milliseconds >= 0 else {
|
||||
throw RPCError.invalidParams("debounce_ms must be a non-negative integer")
|
||||
}
|
||||
return Double(milliseconds) / 1000
|
||||
}
|
||||
|
||||
136
Sources/imsg/RPCServer+ChatHandlers.swift
Normal file
136
Sources/imsg/RPCServer+ChatHandlers.swift
Normal file
@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
/// Chat/group lifecycle and management methods. Each handler resolves the
|
||||
/// caller's chat target (`chat_guid` / `chat_identifier` / `chat_id`) into a
|
||||
/// chat GUID and then dispatches into the v2 bridge action that the dylib
|
||||
/// already implements.
|
||||
extension RPCServer {
|
||||
func handleChatsCreate(id: Any?, params: [String: Any]) async throws {
|
||||
let addresses = stringArrayParam(params["addresses"])
|
||||
guard !addresses.isEmpty else {
|
||||
throw RPCError.invalidParams("addresses is required (non-empty array of phone/email)")
|
||||
}
|
||||
let service = stringParam(params["service"]) ?? "iMessage"
|
||||
var bridgeParams: [String: Any] = [
|
||||
"addresses": addresses,
|
||||
"service": service,
|
||||
]
|
||||
if let name = stringParam(params["name"]), !name.isEmpty {
|
||||
bridgeParams["displayName"] = name
|
||||
}
|
||||
if let text = stringParam(params["text"]), !text.isEmpty {
|
||||
bridgeParams["message"] = text
|
||||
}
|
||||
let data = try await invokeBridge(action: .createChat, params: bridgeParams)
|
||||
var result: [String: Any] = ["ok": true]
|
||||
if let guid = data["chatGuid"] as? String, !guid.isEmpty {
|
||||
result["chat_guid"] = guid
|
||||
}
|
||||
respond(id: id, result: result)
|
||||
}
|
||||
|
||||
func handleChatsDelete(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
_ = try await invokeBridge(action: .deleteChat, params: ["chatGuid": chatGUID])
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleChatsMarkUnread(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
_ = try await invokeBridge(action: .markChatUnread, params: ["chatGuid": chatGUID])
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupRename(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
guard let name = stringParam(params["name"]) else {
|
||||
throw RPCError.invalidParams("name is required")
|
||||
}
|
||||
_ = try await invokeBridge(
|
||||
action: .setDisplayName,
|
||||
params: ["chatGuid": chatGUID, "newName": name]
|
||||
)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupSetIcon(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
var bridgeParams: [String: Any] = ["chatGuid": chatGUID]
|
||||
if let file = stringParam(params["file"]), !file.isEmpty {
|
||||
bridgeParams["filePath"] = (file as NSString).expandingTildeInPath
|
||||
}
|
||||
_ = try await invokeBridge(action: .updateGroupPhoto, params: bridgeParams)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupAddParticipant(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
guard let address = stringParam(params["address"]), !address.isEmpty else {
|
||||
throw RPCError.invalidParams("address is required")
|
||||
}
|
||||
_ = try await invokeBridge(
|
||||
action: .addParticipant,
|
||||
params: ["chatGuid": chatGUID, "address": address]
|
||||
)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupRemoveParticipant(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
guard let address = stringParam(params["address"]), !address.isEmpty else {
|
||||
throw RPCError.invalidParams("address is required")
|
||||
}
|
||||
_ = try await invokeBridge(
|
||||
action: .removeParticipant,
|
||||
params: ["chatGuid": chatGUID, "address": address]
|
||||
)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleGroupLeave(id: Any?, params: [String: Any]) async throws {
|
||||
let chatGUID = try await resolveChatGUIDParam(params)
|
||||
_ = try await invokeBridge(action: .leaveChat, params: ["chatGuid": chatGUID])
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Resolve a chat GUID from `chat_guid`, `chat_identifier`, or `chat_id`.
|
||||
/// Bridge management actions (rename/leave/etc.) require a real chat GUID;
|
||||
/// rejecting up-front gives callers a clearer error than the dylib's
|
||||
/// downstream "chat not found".
|
||||
private func resolveChatGUIDParam(_ params: [String: Any]) async throws -> String {
|
||||
let input = ChatTargetInput(
|
||||
recipient: "",
|
||||
chatID: int64Param(params["chat_id"]),
|
||||
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
|
||||
chatGUID: stringParam(params["chat_guid"]) ?? ""
|
||||
)
|
||||
if !input.hasChatTarget {
|
||||
throw RPCError.invalidParams("chat_guid, chat_identifier, or chat_id is required")
|
||||
}
|
||||
let resolved = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in try await cache.info(chatID: chatID) },
|
||||
unknownChatError: { chatID in RPCError.invalidParams("unknown chat_id \(chatID)") }
|
||||
)
|
||||
if !resolved.chatGUID.isEmpty {
|
||||
return resolved.chatGUID
|
||||
}
|
||||
if !resolved.chatIdentifier.isEmpty {
|
||||
return resolved.chatIdentifier
|
||||
}
|
||||
throw RPCError.invalidParams("could not resolve chat GUID for chat target")
|
||||
}
|
||||
|
||||
private func invokeBridge(
|
||||
action: BridgeAction, params: [String: Any]
|
||||
) async throws -> [String: Any] {
|
||||
do {
|
||||
return try await IMsgBridgeClient.shared.invoke(action: action, params: params)
|
||||
} catch {
|
||||
throw RPCError.internalError(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
361
Sources/imsg/RPCServer+Handlers.swift
Normal file
361
Sources/imsg/RPCServer+Handlers.swift
Normal file
@ -0,0 +1,361 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
extension RPCServer {
|
||||
func handleChatsList(id: Any?, params: [String: Any]) async throws {
|
||||
let limit = intParam(params["limit"]) ?? 20
|
||||
let chats = try store.listChats(limit: max(limit, 1))
|
||||
var payloads: [[String: Any]] = []
|
||||
payloads.reserveCapacity(chats.count)
|
||||
|
||||
for chat in chats {
|
||||
let info = try await cache.info(chatID: chat.id)
|
||||
let participants = try await cache.participants(chatID: chat.id)
|
||||
let identifier = info?.identifier ?? chat.identifier
|
||||
let guid = info?.guid ?? ""
|
||||
let name = (info?.name.isEmpty == false ? info?.name : nil) ?? chat.name
|
||||
let service = info?.service ?? chat.service
|
||||
let contactName =
|
||||
isGroupHandle(identifier: identifier, guid: guid)
|
||||
? nil : contactResolver.displayName(for: identifier)
|
||||
payloads.append(
|
||||
chatPayload(
|
||||
id: chat.id,
|
||||
identifier: identifier,
|
||||
guid: guid,
|
||||
name: name,
|
||||
service: service,
|
||||
lastMessageAt: chat.lastMessageAt,
|
||||
participants: participants,
|
||||
contactName: contactName
|
||||
))
|
||||
}
|
||||
|
||||
respond(id: id, result: ["chats": payloads])
|
||||
}
|
||||
|
||||
func handleMessagesHistory(id: Any?, params: [String: Any]) async throws {
|
||||
guard let chatID = int64Param(params["chat_id"]) else {
|
||||
throw RPCError.invalidParams("chat_id is required")
|
||||
}
|
||||
let limit = intParam(params["limit"]) ?? 50
|
||||
let participants = stringArrayParam(params["participants"])
|
||||
let startISO = stringParam(params["start"])
|
||||
let endISO = stringParam(params["end"])
|
||||
let includeAttachments = boolParam(params["attachments"]) ?? false
|
||||
let attachmentOptions = AttachmentQueryOptions(
|
||||
convertUnsupported: boolParam(params["convert_attachments"]) ?? false)
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: startISO,
|
||||
endISO: endISO
|
||||
)
|
||||
let filtered = try store.messages(chatID: chatID, limit: max(limit, 1), filter: filter)
|
||||
|
||||
var payloads: [[String: Any]] = []
|
||||
payloads.reserveCapacity(filtered.count)
|
||||
for message in filtered {
|
||||
let payload = try await buildMessagePayload(
|
||||
store: store,
|
||||
cache: cache,
|
||||
message: message,
|
||||
includeAttachments: includeAttachments,
|
||||
includeReactions: true,
|
||||
attachmentOptions: attachmentOptions,
|
||||
contactResolver: contactResolver
|
||||
)
|
||||
payloads.append(payload)
|
||||
}
|
||||
|
||||
respond(id: id, result: ["messages": payloads])
|
||||
}
|
||||
|
||||
func handleWatchSubscribe(id: Any?, params: [String: Any]) async throws {
|
||||
let chatID = int64Param(params["chat_id"])
|
||||
let sinceRowID = int64Param(params["since_rowid"])
|
||||
let participants = stringArrayParam(params["participants"])
|
||||
let startISO = stringParam(params["start"])
|
||||
let endISO = stringParam(params["end"])
|
||||
let includeAttachments = boolParam(params["attachments"]) ?? false
|
||||
let attachmentOptions = AttachmentQueryOptions(
|
||||
convertUnsupported: boolParam(params["convert_attachments"]) ?? false)
|
||||
let includeReactions = boolParam(params["include_reactions"]) ?? false
|
||||
let debounceInterval = try watchDebounceIntervalParam(params)
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: startISO,
|
||||
endISO: endISO
|
||||
)
|
||||
let config = MessageWatcherConfiguration(
|
||||
debounceInterval: debounceInterval,
|
||||
includeReactions: includeReactions
|
||||
)
|
||||
let subID = await subscriptions.allocateID()
|
||||
let localStore = store
|
||||
let localWatcher = watcher
|
||||
let localCache = cache
|
||||
let localWriter = output
|
||||
let localFilter = filter
|
||||
let localChatID = chatID
|
||||
let localSinceRowID = sinceRowID
|
||||
let localConfig = config
|
||||
let localIncludeAttachments = includeAttachments
|
||||
let localAttachmentOptions = attachmentOptions
|
||||
let localIncludeReactions = includeReactions
|
||||
let localContactResolver = contactResolver
|
||||
let task = Task {
|
||||
do {
|
||||
for try await message in localWatcher.stream(
|
||||
chatID: localChatID,
|
||||
sinceRowID: localSinceRowID,
|
||||
configuration: localConfig
|
||||
) {
|
||||
if Task.isCancelled { return }
|
||||
if !localFilter.allows(message) { continue }
|
||||
let payload = try await buildMessagePayload(
|
||||
store: localStore,
|
||||
cache: localCache,
|
||||
message: message,
|
||||
includeAttachments: localIncludeAttachments,
|
||||
includeReactions: localIncludeReactions,
|
||||
attachmentOptions: localAttachmentOptions,
|
||||
contactResolver: localContactResolver
|
||||
)
|
||||
localWriter.sendNotification(
|
||||
method: "message",
|
||||
params: ["subscription": subID, "message": payload]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
localWriter.sendNotification(
|
||||
method: "error",
|
||||
params: [
|
||||
"subscription": subID,
|
||||
"error": ["message": String(describing: error)],
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
await subscriptions.insert(task, for: subID)
|
||||
respond(id: id, result: ["subscription": subID])
|
||||
}
|
||||
|
||||
func handleWatchUnsubscribe(id: Any?, params: [String: Any]) async throws {
|
||||
guard let subID = intParam(params["subscription"]) else {
|
||||
throw RPCError.invalidParams("subscription is required")
|
||||
}
|
||||
if let task = await subscriptions.remove(subID) {
|
||||
task.cancel()
|
||||
}
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleSend(params: [String: Any], id: Any?) async throws {
|
||||
let text = stringParam(params["text"]) ?? ""
|
||||
let file = stringParam(params["file"]) ?? ""
|
||||
let serviceRaw = stringParam(params["service"]) ?? "auto"
|
||||
guard let service = MessageService(rawValue: serviceRaw) else {
|
||||
throw RPCError.invalidParams("invalid service")
|
||||
}
|
||||
let region = stringParam(params["region"]) ?? "US"
|
||||
let rawRecipient = stringParam(params["to"]) ?? ""
|
||||
let rawInput = ChatTargetInput(
|
||||
recipient: rawRecipient,
|
||||
chatID: int64Param(params["chat_id"]),
|
||||
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
|
||||
chatGUID: stringParam(params["chat_guid"]) ?? ""
|
||||
)
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: rawInput,
|
||||
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
|
||||
missingRecipientError: RPCError.invalidParams("to is required for direct sends")
|
||||
)
|
||||
let recipient: String
|
||||
do {
|
||||
recipient =
|
||||
rawInput.hasChatTarget || rawRecipient.isEmpty
|
||||
? rawRecipient
|
||||
: try ChatTargetResolver.resolveRecipientName(rawRecipient, contacts: contactResolver)
|
||||
} catch {
|
||||
throw RPCError.invalidParams(error.localizedDescription)
|
||||
}
|
||||
let input = ChatTargetInput(
|
||||
recipient: recipient,
|
||||
chatID: rawInput.chatID,
|
||||
chatIdentifier: rawInput.chatIdentifier,
|
||||
chatGUID: rawInput.chatGUID
|
||||
)
|
||||
|
||||
if text.isEmpty && file.isEmpty {
|
||||
throw RPCError.invalidParams("text or file 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)")
|
||||
}
|
||||
)
|
||||
if input.hasChatTarget && resolvedTarget.preferredIdentifier == nil {
|
||||
throw RPCError.invalidParams("missing chat identifier or guid")
|
||||
}
|
||||
|
||||
let options = MessageSendOptions(
|
||||
recipient: input.recipient,
|
||||
text: text,
|
||||
attachmentPath: file,
|
||||
service: service,
|
||||
region: region,
|
||||
chatIdentifier: resolvedTarget.chatIdentifier,
|
||||
chatGUID: resolvedTarget.chatGUID
|
||||
)
|
||||
let sentAt = Date()
|
||||
try sendMessage(options)
|
||||
|
||||
let verificationChatID =
|
||||
input.chatID
|
||||
?? resolvedTarget.preferredIdentifier.flatMap { try? store.chatInfo(matchingTarget: $0)?.id }
|
||||
let sentMessage = try? await resolveSentMessage(store, options, verificationChatID, sentAt)
|
||||
if sentMessage == nil {
|
||||
try SentMessageVerifier.throwIfMisroutedChatSend(
|
||||
store: store,
|
||||
options: options,
|
||||
sentAt: sentAt
|
||||
)
|
||||
}
|
||||
var result: [String: Any] = ["ok": true]
|
||||
if let sentMessage {
|
||||
result["id"] = sentMessage.rowID
|
||||
if !sentMessage.guid.isEmpty {
|
||||
result["guid"] = sentMessage.guid
|
||||
}
|
||||
}
|
||||
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(
|
||||
store: MessageStore,
|
||||
cache: ChatCache,
|
||||
message: Message,
|
||||
includeAttachments: Bool,
|
||||
includeReactions: Bool,
|
||||
prefetchedAttachments: [AttachmentMeta]? = nil,
|
||||
prefetchedReactions: [Reaction]? = nil,
|
||||
attachmentOptions: AttachmentQueryOptions = .default,
|
||||
contactResolver: any ContactResolving = NoOpContactResolver()
|
||||
) async throws -> [String: Any] {
|
||||
let chatInfo = try await cache.info(chatID: message.chatID)
|
||||
let participants = try await cache.participants(chatID: message.chatID)
|
||||
let attachments: [AttachmentMeta]
|
||||
if includeAttachments {
|
||||
attachments =
|
||||
try prefetchedAttachments ?? store.attachments(for: message.rowID, options: attachmentOptions)
|
||||
} else {
|
||||
attachments = []
|
||||
}
|
||||
let reactions: [Reaction]
|
||||
if includeReactions {
|
||||
reactions = try prefetchedReactions ?? store.reactions(for: message.rowID)
|
||||
} else {
|
||||
reactions = []
|
||||
}
|
||||
let senderName = message.isFromMe ? nil : contactResolver.displayName(for: message.sender)
|
||||
var reactionSenderNames: [Int64: String] = [:]
|
||||
for reaction in reactions where !reaction.isFromMe {
|
||||
if let name = contactResolver.displayName(for: reaction.sender) {
|
||||
reactionSenderNames[reaction.rowID] = name
|
||||
}
|
||||
}
|
||||
return try messagePayload(
|
||||
message: message,
|
||||
chatInfo: chatInfo,
|
||||
participants: participants,
|
||||
attachments: attachments,
|
||||
reactions: reactions,
|
||||
senderName: senderName,
|
||||
reactionSenderNames: reactionSenderNames
|
||||
)
|
||||
}
|
||||
123
Sources/imsg/RPCServer+Support.swift
Normal file
123
Sources/imsg/RPCServer+Support.swift
Normal file
@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
final class RPCWriter: RPCOutput, Sendable {
|
||||
func sendResponse(id: Any, result: Any) {
|
||||
send(["jsonrpc": "2.0", "id": id, "result": result])
|
||||
}
|
||||
|
||||
func sendError(id: Any?, error: RPCError) {
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"id": id ?? NSNull(),
|
||||
"error": error.asDictionary(),
|
||||
]
|
||||
send(payload)
|
||||
}
|
||||
|
||||
func sendNotification(method: String, params: Any) {
|
||||
send(["jsonrpc": "2.0", "method": method, "params": params])
|
||||
}
|
||||
|
||||
private func send(_ object: Any) {
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
||||
if let output = String(data: data, encoding: .utf8) {
|
||||
StdoutWriter.writeLine(output)
|
||||
}
|
||||
} catch {
|
||||
StdoutWriter.writeLine(
|
||||
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RPCError: Error {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: String?
|
||||
|
||||
static func parseError(_ message: String) -> RPCError {
|
||||
RPCError(code: -32700, message: "Parse error", data: message)
|
||||
}
|
||||
|
||||
static func invalidRequest(_ message: String) -> RPCError {
|
||||
RPCError(code: -32600, message: "Invalid Request", data: message)
|
||||
}
|
||||
|
||||
static func methodNotFound(_ method: String) -> RPCError {
|
||||
RPCError(code: -32601, message: "Method not found", data: method)
|
||||
}
|
||||
|
||||
static func invalidParams(_ message: String) -> RPCError {
|
||||
RPCError(code: -32602, message: "Invalid params", data: message)
|
||||
}
|
||||
|
||||
static func internalError(_ message: String) -> RPCError {
|
||||
RPCError(code: -32603, message: "Internal error", data: message)
|
||||
}
|
||||
|
||||
func asDictionary() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"code": code,
|
||||
"message": message,
|
||||
]
|
||||
if let data {
|
||||
dict["data"] = data
|
||||
}
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
actor SubscriptionStore {
|
||||
private var nextID = 1
|
||||
private var tasks: [Int: Task<Void, Never>] = [:]
|
||||
|
||||
func allocateID() -> Int {
|
||||
let id = nextID
|
||||
nextID += 1
|
||||
return id
|
||||
}
|
||||
|
||||
func insert(_ task: Task<Void, Never>, for id: Int) {
|
||||
tasks[id] = task
|
||||
}
|
||||
|
||||
func remove(_ id: Int) -> Task<Void, Never>? {
|
||||
tasks.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
for task in tasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
tasks.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
actor ChatCache {
|
||||
private let store: MessageStore
|
||||
private var infoCache: [Int64: ChatInfo] = [:]
|
||||
private var participantsCache: [Int64: [String]] = [:]
|
||||
|
||||
init(store: MessageStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func info(chatID: Int64) throws -> ChatInfo? {
|
||||
if let cached = infoCache[chatID] { return cached }
|
||||
if let info = try store.chatInfo(chatID: chatID) {
|
||||
infoCache[chatID] = info
|
||||
return info
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func participants(chatID: Int64) throws -> [String] {
|
||||
if let cached = participantsCache[chatID] { return cached }
|
||||
let participants = try store.participants(chatID: chatID)
|
||||
participantsCache[chatID] = participants
|
||||
return participants
|
||||
}
|
||||
}
|
||||
@ -1,27 +1,61 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
typealias SentMessageResolver = (
|
||||
_ store: MessageStore,
|
||||
_ options: MessageSendOptions,
|
||||
_ chatID: Int64?,
|
||||
_ sentAt: Date
|
||||
) async throws -> Message?
|
||||
|
||||
protocol RPCOutput: Sendable {
|
||||
func sendResponse(id: Any, result: Any)
|
||||
func sendError(id: Any?, error: RPCError)
|
||||
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 {
|
||||
private let store: MessageStore
|
||||
private let watcher: MessageWatcher
|
||||
private let output: RPCOutput
|
||||
private let cache: ChatCache
|
||||
private let verbose: Bool
|
||||
private let sendMessage: (MessageSendOptions) throws -> Void
|
||||
private var nextSubscriptionID = 1
|
||||
private var subscriptions: [Int: Task<Void, Never>] = [:]
|
||||
let store: MessageStore
|
||||
let watcher: MessageWatcher
|
||||
let output: RPCOutput
|
||||
let cache: ChatCache
|
||||
let subscriptions = SubscriptionStore()
|
||||
let verbose: Bool
|
||||
let sendMessage: (MessageSendOptions) throws -> Void
|
||||
let resolveSentMessage: SentMessageResolver
|
||||
let contactResolver: any ContactResolving
|
||||
|
||||
init(
|
||||
store: MessageStore,
|
||||
verbose: Bool,
|
||||
output: RPCOutput = RPCWriter(),
|
||||
sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) }
|
||||
sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) },
|
||||
resolveSentMessage: @escaping SentMessageResolver = RPCServer.resolveSentMessage,
|
||||
contactResolver: any ContactResolving = NoOpContactResolver()
|
||||
) {
|
||||
self.store = store
|
||||
self.watcher = MessageWatcher(store: store)
|
||||
@ -29,6 +63,8 @@ final class RPCServer {
|
||||
self.verbose = verbose
|
||||
self.output = output
|
||||
self.sendMessage = sendMessage
|
||||
self.resolveSentMessage = resolveSentMessage
|
||||
self.contactResolver = contactResolver
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
@ -37,15 +73,18 @@ final class RPCServer {
|
||||
if trimmed.isEmpty { continue }
|
||||
await handleLine(trimmed)
|
||||
}
|
||||
for task in subscriptions.values {
|
||||
task.cancel()
|
||||
}
|
||||
await subscriptions.cancelAll()
|
||||
}
|
||||
|
||||
func handleLineForTesting(_ line: String) async {
|
||||
await handleLine(line)
|
||||
}
|
||||
|
||||
func respond(id: Any?, result: Any) {
|
||||
guard let id else { return }
|
||||
output.sendResponse(id: id, result: result)
|
||||
}
|
||||
|
||||
private func handleLine(_ line: String) async {
|
||||
guard let data = line.data(using: .utf8) else {
|
||||
output.sendError(id: nil, error: RPCError.parseError("invalid utf8"))
|
||||
@ -77,116 +116,35 @@ final class RPCServer {
|
||||
do {
|
||||
switch method {
|
||||
case "chats.list":
|
||||
let limit = intParam(params["limit"]) ?? 20
|
||||
let chats = try store.listChats(limit: max(limit, 1))
|
||||
let payloads = try chats.map { chat in
|
||||
let info = try cache.info(chatID: chat.id)
|
||||
let participants = try cache.participants(chatID: chat.id)
|
||||
let identifier = info?.identifier ?? chat.identifier
|
||||
let guid = info?.guid ?? ""
|
||||
let name = (info?.name.isEmpty == false ? info?.name : nil) ?? chat.name
|
||||
let service = info?.service ?? chat.service
|
||||
return chatPayload(
|
||||
id: chat.id,
|
||||
identifier: identifier,
|
||||
guid: guid,
|
||||
name: name,
|
||||
service: service,
|
||||
lastMessageAt: chat.lastMessageAt,
|
||||
participants: participants
|
||||
)
|
||||
}
|
||||
respond(id: id, result: ["chats": payloads])
|
||||
try await handleChatsList(id: id, params: params)
|
||||
case "messages.history":
|
||||
guard let chatID = int64Param(params["chat_id"]) else {
|
||||
throw RPCError.invalidParams("chat_id is required")
|
||||
}
|
||||
let limit = intParam(params["limit"]) ?? 50
|
||||
let participants = stringArrayParam(params["participants"])
|
||||
let startISO = stringParam(params["start"])
|
||||
let endISO = stringParam(params["end"])
|
||||
let includeAttachments = boolParam(params["attachments"]) ?? false
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: startISO,
|
||||
endISO: endISO
|
||||
)
|
||||
let filtered = try store.messages(chatID: chatID, limit: max(limit, 1), filter: filter)
|
||||
let payloads = try filtered.map { message in
|
||||
try buildMessagePayload(
|
||||
store: store,
|
||||
cache: cache,
|
||||
message: message,
|
||||
includeAttachments: includeAttachments
|
||||
)
|
||||
}
|
||||
respond(id: id, result: ["messages": payloads])
|
||||
try await handleMessagesHistory(id: id, params: params)
|
||||
case "watch.subscribe":
|
||||
let chatID = int64Param(params["chat_id"])
|
||||
let sinceRowID = int64Param(params["since_rowid"])
|
||||
let participants = stringArrayParam(params["participants"])
|
||||
let startISO = stringParam(params["start"])
|
||||
let endISO = stringParam(params["end"])
|
||||
let includeAttachments = boolParam(params["attachments"]) ?? false
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: startISO,
|
||||
endISO: endISO
|
||||
)
|
||||
let config = MessageWatcherConfiguration()
|
||||
let subID = nextSubscriptionID
|
||||
nextSubscriptionID += 1
|
||||
let localStore = store
|
||||
let localWatcher = watcher
|
||||
let localCache = cache
|
||||
let localWriter = output
|
||||
let localFilter = filter
|
||||
let localChatID = chatID
|
||||
let localSinceRowID = sinceRowID
|
||||
let localConfig = config
|
||||
let localIncludeAttachments = includeAttachments
|
||||
let task = Task {
|
||||
do {
|
||||
for try await message in localWatcher.stream(
|
||||
chatID: localChatID,
|
||||
sinceRowID: localSinceRowID,
|
||||
configuration: localConfig
|
||||
) {
|
||||
if Task.isCancelled { return }
|
||||
if !localFilter.allows(message) { continue }
|
||||
let payload = try buildMessagePayload(
|
||||
store: localStore,
|
||||
cache: localCache,
|
||||
message: message,
|
||||
includeAttachments: localIncludeAttachments
|
||||
)
|
||||
localWriter.sendNotification(
|
||||
method: "message",
|
||||
params: ["subscription": subID, "message": payload]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
localWriter.sendNotification(
|
||||
method: "error",
|
||||
params: [
|
||||
"subscription": subID,
|
||||
"error": ["message": String(describing: error)],
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
subscriptions[subID] = task
|
||||
respond(id: id, result: ["subscription": subID])
|
||||
try await handleWatchSubscribe(id: id, params: params)
|
||||
case "watch.unsubscribe":
|
||||
guard let subID = intParam(params["subscription"]) else {
|
||||
throw RPCError.invalidParams("subscription is required")
|
||||
}
|
||||
if let task = subscriptions.removeValue(forKey: subID) {
|
||||
task.cancel()
|
||||
}
|
||||
respond(id: id, result: ["ok": true])
|
||||
try await handleWatchUnsubscribe(id: id, params: params)
|
||||
case "send":
|
||||
try handleSend(params: params, id: id)
|
||||
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))
|
||||
}
|
||||
@ -207,183 +165,17 @@ final class RPCServer {
|
||||
}
|
||||
}
|
||||
|
||||
private func respond(id: Any?, result: Any) {
|
||||
guard let id else { return }
|
||||
output.sendResponse(id: id, result: result)
|
||||
}
|
||||
|
||||
private func handleSend(params: [String: Any], id: Any?) throws {
|
||||
let text = stringParam(params["text"]) ?? ""
|
||||
let file = stringParam(params["file"]) ?? ""
|
||||
let serviceRaw = stringParam(params["service"]) ?? "auto"
|
||||
guard let service = MessageService(rawValue: serviceRaw) else {
|
||||
throw RPCError.invalidParams("invalid service")
|
||||
}
|
||||
let region = stringParam(params["region"]) ?? "US"
|
||||
|
||||
let chatID = int64Param(params["chat_id"])
|
||||
let chatIdentifier = stringParam(params["chat_identifier"]) ?? ""
|
||||
let chatGUID = stringParam(params["chat_guid"]) ?? ""
|
||||
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
|
||||
let recipient = stringParam(params["to"]) ?? ""
|
||||
if hasChatTarget && !recipient.isEmpty {
|
||||
throw RPCError.invalidParams("use to or chat_*; not both")
|
||||
}
|
||||
if !hasChatTarget && recipient.isEmpty {
|
||||
throw RPCError.invalidParams("to is required for direct sends")
|
||||
}
|
||||
|
||||
if text.isEmpty && file.isEmpty {
|
||||
throw RPCError.invalidParams("text or file is required")
|
||||
}
|
||||
|
||||
var resolvedChatIdentifier = chatIdentifier
|
||||
var resolvedChatGUID = chatGUID
|
||||
if let chatID {
|
||||
guard let info = try cache.info(chatID: chatID) else {
|
||||
throw RPCError.invalidParams("unknown chat_id \(chatID)")
|
||||
}
|
||||
resolvedChatIdentifier = info.identifier
|
||||
resolvedChatGUID = info.guid
|
||||
}
|
||||
if hasChatTarget && resolvedChatIdentifier.isEmpty && resolvedChatGUID.isEmpty {
|
||||
throw RPCError.invalidParams("missing chat identifier or guid")
|
||||
}
|
||||
|
||||
try sendMessage(
|
||||
MessageSendOptions(
|
||||
recipient: recipient,
|
||||
text: text,
|
||||
attachmentPath: file,
|
||||
service: service,
|
||||
region: region,
|
||||
chatIdentifier: resolvedChatIdentifier,
|
||||
chatGUID: resolvedChatGUID
|
||||
)
|
||||
static func resolveSentMessage(
|
||||
store: MessageStore,
|
||||
options: MessageSendOptions,
|
||||
chatID: Int64?,
|
||||
sentAt: Date
|
||||
) async throws -> Message? {
|
||||
try await SentMessageVerifier.resolveSentMessage(
|
||||
store: store,
|
||||
options: options,
|
||||
chatID: chatID,
|
||||
sentAt: sentAt
|
||||
)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func buildMessagePayload(
|
||||
store: MessageStore,
|
||||
cache: ChatCache,
|
||||
message: Message,
|
||||
includeAttachments: Bool
|
||||
) throws -> [String: Any] {
|
||||
let chatInfo = try cache.info(chatID: message.chatID)
|
||||
let participants = try cache.participants(chatID: message.chatID)
|
||||
let attachments = includeAttachments ? try store.attachments(for: message.rowID) : []
|
||||
let reactions = includeAttachments ? try store.reactions(for: message.rowID) : []
|
||||
return messagePayload(
|
||||
message: message,
|
||||
chatInfo: chatInfo,
|
||||
participants: participants,
|
||||
attachments: attachments,
|
||||
reactions: reactions
|
||||
)
|
||||
}
|
||||
|
||||
private final class RPCWriter: RPCOutput, @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "imsg.rpc.writer")
|
||||
|
||||
func sendResponse(id: Any, result: Any) {
|
||||
send(["jsonrpc": "2.0", "id": id, "result": result])
|
||||
}
|
||||
|
||||
func sendError(id: Any?, error: RPCError) {
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"id": id ?? NSNull(),
|
||||
"error": error.asDictionary(),
|
||||
]
|
||||
send(payload)
|
||||
}
|
||||
|
||||
func sendNotification(method: String, params: Any) {
|
||||
send(["jsonrpc": "2.0", "method": method, "params": params])
|
||||
}
|
||||
|
||||
private func send(_ object: Any) {
|
||||
queue.sync {
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
||||
if let output = String(data: data, encoding: .utf8) {
|
||||
FileHandle.standardOutput.write(Data(output.utf8))
|
||||
FileHandle.standardOutput.write(Data("\n".utf8))
|
||||
}
|
||||
} catch {
|
||||
if let fallback =
|
||||
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}\n"
|
||||
.data(using: .utf8)
|
||||
{
|
||||
FileHandle.standardOutput.write(fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RPCError: Error {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: String?
|
||||
|
||||
static func parseError(_ message: String) -> RPCError {
|
||||
RPCError(code: -32700, message: "Parse error", data: message)
|
||||
}
|
||||
|
||||
static func invalidRequest(_ message: String) -> RPCError {
|
||||
RPCError(code: -32600, message: "Invalid Request", data: message)
|
||||
}
|
||||
|
||||
static func methodNotFound(_ method: String) -> RPCError {
|
||||
RPCError(code: -32601, message: "Method not found", data: method)
|
||||
}
|
||||
|
||||
static func invalidParams(_ message: String) -> RPCError {
|
||||
RPCError(code: -32602, message: "Invalid params", data: message)
|
||||
}
|
||||
|
||||
static func internalError(_ message: String) -> RPCError {
|
||||
RPCError(code: -32603, message: "Internal error", data: message)
|
||||
}
|
||||
|
||||
func asDictionary() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"code": code,
|
||||
"message": message,
|
||||
]
|
||||
if let data {
|
||||
dict["data"] = data
|
||||
}
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
private final class ChatCache: @unchecked Sendable {
|
||||
private let store: MessageStore
|
||||
private var infoCache: [Int64: ChatInfo] = [:]
|
||||
private var participantsCache: [Int64: [String]] = [:]
|
||||
|
||||
init(store: MessageStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func info(chatID: Int64) throws -> ChatInfo? {
|
||||
if let cached = infoCache[chatID] { return cached }
|
||||
if let info = try store.chatInfo(chatID: chatID) {
|
||||
infoCache[chatID] = info
|
||||
return info
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func participants(chatID: Int64) throws -> [String] {
|
||||
if let cached = participantsCache[chatID] { return cached }
|
||||
let participants = try store.participants(chatID: chatID)
|
||||
participantsCache[chatID] = participants
|
||||
return participants
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.4.1</string>
|
||||
<string>0.8.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.4.1</string>
|
||||
<string>0.8.1</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Send messages via Messages.app.</string>
|
||||
</dict>
|
||||
|
||||
50
Sources/imsg/SentMessageVerifier.swift
Normal file
50
Sources/imsg/SentMessageVerifier.swift
Normal file
@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum SentMessageVerifier {
|
||||
static func resolveSentMessage(
|
||||
store: MessageStore,
|
||||
options: MessageSendOptions,
|
||||
chatID: Int64?,
|
||||
sentAt: Date
|
||||
) async throws -> Message? {
|
||||
guard !options.text.isEmpty else { return nil }
|
||||
|
||||
let lowerBound = sentAt.addingTimeInterval(-2)
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
repeat {
|
||||
if Task.isCancelled { return nil }
|
||||
if let message = try store.latestSentMessage(
|
||||
matchingText: options.text,
|
||||
chatID: chatID,
|
||||
since: lowerBound
|
||||
) {
|
||||
return message
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
} while Date() < deadline
|
||||
return nil
|
||||
}
|
||||
|
||||
static func throwIfMisroutedChatSend(
|
||||
store: MessageStore,
|
||||
options: MessageSendOptions,
|
||||
sentAt: Date
|
||||
) throws {
|
||||
let handles = [options.chatGUID, options.chatIdentifier].filter { !$0.isEmpty }
|
||||
guard !handles.isEmpty else { return }
|
||||
let lowerBound = sentAt.addingTimeInterval(-2)
|
||||
guard
|
||||
let rowID = try store.latestUnjoinedSentMessageRowID(
|
||||
matchingTargetHandles: handles,
|
||||
since: lowerBound
|
||||
)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
throw IMsgError.appleScriptFailure(
|
||||
"Messages accepted the chat send but wrote an unjoined empty outgoing row (\(rowID)); delivery to the target chat was not confirmed"
|
||||
)
|
||||
}
|
||||
}
|
||||
24
Sources/imsg/StdoutWriter.swift
Normal file
24
Sources/imsg/StdoutWriter.swift
Normal file
@ -0,0 +1,24 @@
|
||||
import Dispatch
|
||||
import Foundation
|
||||
|
||||
enum StdoutWriter {
|
||||
private static let queue = DispatchQueue(label: "imsg.stdout.writer")
|
||||
|
||||
private static let jsonEncoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.withoutEscapingSlashes]
|
||||
return encoder
|
||||
}()
|
||||
|
||||
static func writeLine(_ line: String) {
|
||||
queue.sync {
|
||||
FileHandle.standardOutput.write(Data((line + "\n").utf8))
|
||||
}
|
||||
}
|
||||
|
||||
static func writeJSONLine<T: Encodable>(_ value: T) throws {
|
||||
let data = try jsonEncoder.encode(value)
|
||||
guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return }
|
||||
writeLine(line)
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// Generated by scripts/generate-version.sh. Do not edit.
|
||||
enum IMsgVersion {
|
||||
static let current = "0.4.1"
|
||||
static let current = "0.8.1"
|
||||
}
|
||||
|
||||
18
Tests/IMsgCoreTests/ContactResolverTests.swift
Normal file
18
Tests/IMsgCoreTests/ContactResolverTests.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
@Test
|
||||
func noOpContactResolverReturnsNoMatches() {
|
||||
let resolver = NoOpContactResolver()
|
||||
#expect(resolver.contactsUnavailable == false)
|
||||
#expect(resolver.displayName(for: "+15551234567") == nil)
|
||||
#expect(resolver.displayNames(for: ["+15551234567"]).isEmpty)
|
||||
#expect(resolver.searchByName("John").isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func noOpContactResolverCanRepresentUnavailableContacts() {
|
||||
let resolver = NoOpContactResolver(contactsUnavailable: true)
|
||||
#expect(resolver.contactsUnavailable == true)
|
||||
}
|
||||
66
Tests/IMsgCoreTests/IMCoreBridgeTests.swift
Normal file
66
Tests/IMsgCoreTests/IMCoreBridgeTests.swift
Normal file
@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
@Test
|
||||
func imCoreBridgeIsNotAvailableWithoutDylib() {
|
||||
// In the test environment there's no dylib built, so isAvailable should be false
|
||||
// unless one happens to exist at a search path. We test the shared instance exists.
|
||||
let bridge = IMCoreBridge.shared
|
||||
// Just verify the API exists and doesn't crash
|
||||
_ = bridge.isAvailable
|
||||
}
|
||||
|
||||
@Test
|
||||
func imCoreBridgeCheckAvailabilityReturnsDiagnostic() {
|
||||
let bridge = IMCoreBridge.shared
|
||||
let (_, message) = bridge.checkAvailability()
|
||||
// Should return a non-empty diagnostic message regardless of availability
|
||||
#expect(!message.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesLauncherSharedInstanceExists() {
|
||||
let launcher = MessagesLauncher.shared
|
||||
// Verify the launcher can be accessed
|
||||
#expect(launcher.dylibPath.contains("imsg-bridge-helper.dylib"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesLauncherIsNotReadyWithoutInjection() {
|
||||
let launcher = MessagesLauncher.shared
|
||||
// Without actually launching Messages.app with injection, this should return false
|
||||
// (unless Messages happens to be running with our dylib, which is unlikely in CI)
|
||||
_ = launcher.isInjectedAndReady()
|
||||
// Just verify it doesn't crash
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesLauncherErrorDescriptions() {
|
||||
let errors: [MessagesLauncherError] = [
|
||||
.dylibNotFound("/fake/path"),
|
||||
.launchFailed("test reason"),
|
||||
.socketTimeout,
|
||||
.socketError("test error"),
|
||||
.invalidResponse,
|
||||
]
|
||||
|
||||
for error in errors {
|
||||
#expect(!error.description.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func imCoreBridgeErrorDescriptions() {
|
||||
let errors: [IMCoreBridgeError] = [
|
||||
.dylibNotFound,
|
||||
.connectionFailed("test"),
|
||||
.chatNotFound("test-handle"),
|
||||
.operationFailed("test reason"),
|
||||
]
|
||||
|
||||
for error in errors {
|
||||
#expect(!error.description.isEmpty)
|
||||
}
|
||||
}
|
||||
84
Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift
Normal file
84
Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift
Normal file
@ -0,0 +1,84 @@
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
@Suite("IMsgBridgeProtocol")
|
||||
struct IMsgBridgeProtocolTests {
|
||||
@Test
|
||||
func actionRawValuesMatchDylibVocabulary() {
|
||||
#expect(BridgeAction.sendMessage.rawValue == "send-message")
|
||||
#expect(BridgeAction.sendReaction.rawValue == "send-reaction")
|
||||
#expect(BridgeAction.editMessage.rawValue == "edit-message")
|
||||
#expect(BridgeAction.unsendMessage.rawValue == "unsend-message")
|
||||
#expect(BridgeAction.createChat.rawValue == "create-chat")
|
||||
#expect(BridgeAction.searchMessages.rawValue == "search-messages")
|
||||
#expect(BridgeAction.checkImessageAvailability.rawValue == "check-imessage-availability")
|
||||
// Legacy compat: the integer-id v1 protocol still uses these names.
|
||||
#expect(BridgeAction.typing.rawValue == "typing")
|
||||
#expect(BridgeAction.read.rawValue == "read")
|
||||
#expect(BridgeAction.listChats.rawValue == "list_chats")
|
||||
}
|
||||
|
||||
@Test
|
||||
func reactionKindMapsToStableAssociatedTypes() {
|
||||
#expect(BridgeReactionKind.love.associatedMessageType == 2000)
|
||||
#expect(BridgeReactionKind.like.associatedMessageType == 2001)
|
||||
#expect(BridgeReactionKind.dislike.associatedMessageType == 2002)
|
||||
#expect(BridgeReactionKind.laugh.associatedMessageType == 2003)
|
||||
#expect(BridgeReactionKind.emphasize.associatedMessageType == 2004)
|
||||
#expect(BridgeReactionKind.question.associatedMessageType == 2005)
|
||||
// Removal kinds are exactly +1000.
|
||||
for kind in BridgeReactionKind.allCases where !kind.rawValue.hasPrefix("remove-") {
|
||||
let removeName = "remove-\(kind.rawValue)"
|
||||
let remove = BridgeReactionKind(rawValue: removeName)
|
||||
#expect(remove != nil, "missing remove case for \(kind.rawValue)")
|
||||
#expect(remove?.associatedMessageType == kind.associatedMessageType + 1000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func parseAcceptsV2Envelope() throws {
|
||||
let raw: [String: Any] = [
|
||||
"v": 2,
|
||||
"id": "abc-123",
|
||||
"success": true,
|
||||
"data": ["messageGuid": "M-1"],
|
||||
"timestamp": "2026-05-04T12:00:00Z",
|
||||
]
|
||||
let response = try BridgeResponse.parse(raw)
|
||||
#expect(response.id == "abc-123")
|
||||
#expect(response.success == true)
|
||||
#expect(response.error == nil)
|
||||
#expect(response.data["messageGuid"] as? String == "M-1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func parseAcceptsLegacyEnvelopeWithoutDataKey() throws {
|
||||
let raw: [String: Any] = [
|
||||
"id": 42,
|
||||
"success": true,
|
||||
"handle": "+15551234567",
|
||||
"marked_as_read": true,
|
||||
"timestamp": "2026-05-04T12:00:00Z",
|
||||
]
|
||||
let response = try BridgeResponse.parse(raw)
|
||||
#expect(response.id == "42")
|
||||
#expect(response.success == true)
|
||||
#expect(response.data["handle"] as? String == "+15551234567")
|
||||
#expect(response.data["marked_as_read"] as? Bool == true)
|
||||
#expect(response.data["timestamp"] == nil, "envelope keys should be stripped")
|
||||
}
|
||||
|
||||
@Test
|
||||
func parsePropagatesError() throws {
|
||||
let raw: [String: Any] = [
|
||||
"v": 2,
|
||||
"id": "x",
|
||||
"success": false,
|
||||
"error": "Chat not found",
|
||||
]
|
||||
let response = try BridgeResponse.parse(raw)
|
||||
#expect(response.success == false)
|
||||
#expect(response.error == "Chat not found")
|
||||
}
|
||||
}
|
||||
115
Tests/IMsgCoreTests/IMsgEventTailerTests.swift
Normal file
115
Tests/IMsgCoreTests/IMsgEventTailerTests.swift
Normal file
@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
/// Smoke tests for the events-jsonl tailer. Writes a temp file, appends a few
|
||||
/// JSON lines, asserts they surface in order through the AsyncStream.
|
||||
@Suite("IMsgEventTailer")
|
||||
struct IMsgEventTailerTests {
|
||||
@Test
|
||||
func tailerEmitsAppendedLines() async throws {
|
||||
let dir = NSTemporaryDirectory() + "imsg-tailer-test-\(UUID().uuidString)"
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
|
||||
let path = (dir as NSString).appendingPathComponent("events.jsonl")
|
||||
FileManager.default.createFile(atPath: path, contents: Data(), attributes: nil)
|
||||
|
||||
let tailer = IMsgEventTailer(path: path)
|
||||
let stream = tailer.events()
|
||||
|
||||
// Append two events on a background task so the tailer has a chance to
|
||||
// open and start watching before lines arrive.
|
||||
Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
let line1 = """
|
||||
{"event":"started-typing","data":{"chatGuid":"iMessage;-;+15551"}}
|
||||
""" + "\n"
|
||||
let line2 = """
|
||||
{"event":"stopped-typing","data":{"chatGuid":"iMessage;-;+15551"}}
|
||||
""" + "\n"
|
||||
let fp = fopen(path, "a")
|
||||
if let fp = fp {
|
||||
line1.utf8CString.withUnsafeBufferPointer { buf in
|
||||
guard let base = buf.baseAddress else { return }
|
||||
fwrite(base, 1, strlen(base), fp)
|
||||
}
|
||||
line2.utf8CString.withUnsafeBufferPointer { buf in
|
||||
guard let base = buf.baseAddress else { return }
|
||||
fwrite(base, 1, strlen(base), fp)
|
||||
}
|
||||
fflush(fp)
|
||||
fclose(fp)
|
||||
}
|
||||
}
|
||||
|
||||
var collected: [String] = []
|
||||
let deadline = Date().addingTimeInterval(3.0)
|
||||
for await event in stream {
|
||||
collected.append(event.name)
|
||||
if collected.count >= 2 { break }
|
||||
if Date() > deadline { break }
|
||||
}
|
||||
tailer.stop()
|
||||
|
||||
#expect(collected == ["started-typing", "stopped-typing"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func tailerSkipsExistingLinesByDefault() async throws {
|
||||
let dir = NSTemporaryDirectory() + "imsg-tailer-test-\(UUID().uuidString)"
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
|
||||
let path = (dir as NSString).appendingPathComponent("events.jsonl")
|
||||
let oldLine = """
|
||||
{"event":"old-typing","data":{"chatGuid":"iMessage;-;+15551"}}
|
||||
""" + "\n"
|
||||
FileManager.default.createFile(
|
||||
atPath: path,
|
||||
contents: oldLine.data(using: .utf8),
|
||||
attributes: nil
|
||||
)
|
||||
|
||||
let tailer = IMsgEventTailer(path: path)
|
||||
let stream = tailer.events()
|
||||
|
||||
Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
let newLine = """
|
||||
{"event":"started-typing","data":{"chatGuid":"iMessage;-;+15551"}}
|
||||
""" + "\n"
|
||||
let fp = fopen(path, "a")
|
||||
if let fp = fp {
|
||||
newLine.utf8CString.withUnsafeBufferPointer { buf in
|
||||
guard let base = buf.baseAddress else { return }
|
||||
fwrite(base, 1, strlen(base), fp)
|
||||
}
|
||||
fflush(fp)
|
||||
fclose(fp)
|
||||
}
|
||||
}
|
||||
|
||||
var first: String?
|
||||
for await event in stream {
|
||||
first = event.name
|
||||
break
|
||||
}
|
||||
tailer.stop()
|
||||
|
||||
#expect(first == "started-typing")
|
||||
}
|
||||
|
||||
@Test
|
||||
func eventDecodedPayloadRoundTrip() throws {
|
||||
let raw: [String: Any] = ["chatGuid": "iMessage;-;+1", "extra": 42]
|
||||
let data = try JSONSerialization.data(withJSONObject: raw, options: [])
|
||||
let event = IMsgEventTailer.Event(timestamp: nil, name: "x", payloadJSON: data)
|
||||
let decoded = event.decodedPayload()
|
||||
#expect(decoded["chatGuid"] as? String == "iMessage;-;+1")
|
||||
#expect(decoded["extra"] as? Int == 42)
|
||||
}
|
||||
}
|
||||
100
Tests/IMsgCoreTests/MessageDatabaseFixture.swift
Normal file
100
Tests/IMsgCoreTests/MessageDatabaseFixture.swift
Normal file
@ -0,0 +1,100 @@
|
||||
import SQLite
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
enum MessageDatabaseFixture {
|
||||
struct SchemaOptions {
|
||||
var includeAttributedBody = false
|
||||
var includeReactionColumns = false
|
||||
var includeThreadOriginatorGUID = false
|
||||
var includeDestinationCallerID = false
|
||||
var includeAudioMessage = false
|
||||
var includeBalloonBundleID = false
|
||||
var includeAttachmentUserInfo = false
|
||||
var includeChatMessageDate = false
|
||||
var includeChatRouting = true
|
||||
var includeChatHandleJoin = true
|
||||
}
|
||||
|
||||
static func createSchema(_ db: Connection, options: SchemaOptions = SchemaOptions()) throws {
|
||||
let attributedBodyColumn = options.includeAttributedBody ? "attributedBody BLOB," : ""
|
||||
let reactionColumns =
|
||||
options.includeReactionColumns
|
||||
? "guid TEXT, associated_message_guid TEXT, associated_message_type INTEGER,"
|
||||
: ""
|
||||
let threadOriginatorColumn =
|
||||
options.includeThreadOriginatorGUID ? "thread_originator_guid TEXT," : ""
|
||||
let destinationCallerColumn =
|
||||
options.includeDestinationCallerID ? "destination_caller_id TEXT," : ""
|
||||
let audioMessageColumn = options.includeAudioMessage ? "is_audio_message INTEGER," : ""
|
||||
let balloonColumn = options.includeBalloonBundleID ? "balloon_bundle_id TEXT," : ""
|
||||
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
\(attributedBodyColumn)
|
||||
\(reactionColumns)
|
||||
\(threadOriginatorColumn)
|
||||
\(destinationCallerColumn)
|
||||
\(audioMessageColumn)
|
||||
\(balloonColumn)
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
let chatRoutingColumns =
|
||||
options.includeChatRouting
|
||||
? "account_id TEXT, account_login TEXT, last_addressed_handle TEXT,"
|
||||
: ""
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE chat (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
chat_identifier TEXT,
|
||||
guid TEXT,
|
||||
display_name TEXT,
|
||||
service_name TEXT,
|
||||
\(chatRoutingColumns)
|
||||
reserved TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
|
||||
if options.includeChatHandleJoin {
|
||||
try db.execute("CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);")
|
||||
}
|
||||
let messageDateColumn = options.includeChatMessageDate ? ", message_date INTEGER" : ""
|
||||
try db.execute(
|
||||
"CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER\(messageDateColumn));")
|
||||
|
||||
let attachmentUserInfoColumn = options.includeAttachmentUserInfo ? ", user_info BLOB" : ""
|
||||
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
|
||||
\(attachmentUserInfoColumn)
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message_attachment_join (
|
||||
message_id INTEGER,
|
||||
attachment_id INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
@Test
|
||||
func attachmentsByMessageReportsConvertedMetadata() throws {
|
||||
let source = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("gif")
|
||||
try Data("gif".utf8).write(to: source)
|
||||
defer { try? FileManager.default.removeItem(at: source) }
|
||||
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "png")
|
||||
try FileManager.default.createDirectory(
|
||||
at: converted.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("png".utf8).write(to: converted)
|
||||
defer { try? FileManager.default.removeItem(at: converted) }
|
||||
|
||||
let store = try TestDatabase.makeStore(
|
||||
attachmentFilename: source.path,
|
||||
attachmentTransferName: "animation.gif",
|
||||
attachmentUTI: "com.compuserve.gif",
|
||||
attachmentMimeType: "image/gif"
|
||||
)
|
||||
let attachments = try store.attachments(
|
||||
for: 2,
|
||||
options: AttachmentQueryOptions(convertUnsupported: true)
|
||||
)
|
||||
|
||||
#expect(attachments.first?.originalPath == source.path)
|
||||
#expect(attachments.first?.convertedPath == converted.path)
|
||||
#expect(attachments.first?.convertedMimeType == "image/png")
|
||||
}
|
||||
|
||||
@Test
|
||||
func attachmentsByMessagesReportsConvertedMetadata() throws {
|
||||
let source = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("caf")
|
||||
try Data("caf".utf8).write(to: source)
|
||||
defer { try? FileManager.default.removeItem(at: source) }
|
||||
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "m4a")
|
||||
try FileManager.default.createDirectory(
|
||||
at: converted.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("m4a".utf8).write(to: converted)
|
||||
defer { try? FileManager.default.removeItem(at: converted) }
|
||||
|
||||
let store = try TestDatabase.makeStore(
|
||||
attachmentFilename: source.path,
|
||||
attachmentTransferName: "voice.caf",
|
||||
attachmentUTI: "com.apple.coreaudio-format",
|
||||
attachmentMimeType: "audio/x-caf"
|
||||
)
|
||||
let attachmentsByMessageID = try store.attachments(
|
||||
for: [2],
|
||||
options: AttachmentQueryOptions(convertUnsupported: true)
|
||||
)
|
||||
|
||||
#expect(attachmentsByMessageID[2]?.first?.originalPath == source.path)
|
||||
#expect(attachmentsByMessageID[2]?.first?.convertedPath == converted.path)
|
||||
#expect(attachmentsByMessageID[2]?.first?.convertedMimeType == "audio/mp4")
|
||||
}
|
||||
@ -133,6 +133,73 @@ func messagesByChatUsesLengthPrefixedAttributedBodyFallback() throws {
|
||||
#expect(messages.first?.text == "length prefixed")
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesByChatUsesUTF16LittleEndianAttributedBodyFallback() throws {
|
||||
let db = try Connection(.inMemory)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
attributedBody BLOB,
|
||||
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
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
|
||||
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message_attachment_join (
|
||||
message_id INTEGER,
|
||||
attachment_id INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
var bodyData = Data([0xff, 0xfe])
|
||||
let bodyText = "hello 🌤️"
|
||||
let encoded = try #require(bodyText.data(using: .utf16LittleEndian))
|
||||
bodyData.append(encoded)
|
||||
let body = Blob(bytes: [UInt8](bodyData))
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
|
||||
VALUES (1, '+123', 'iMessage;-;+123', 'Direct Chat', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, attributedBody, date, is_from_me, service)
|
||||
VALUES (1, 1, NULL, ?, ?, 0, 'iMessage')
|
||||
""",
|
||||
body,
|
||||
TestDatabase.appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
|
||||
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
let messages = try store.messages(chatID: 1, limit: 10)
|
||||
#expect(messages.count == 1)
|
||||
#expect(messages.first?.text == bodyText)
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesAfterUsesAttributedBodyFallback() throws {
|
||||
let db = try Connection(.inMemory)
|
||||
|
||||
67
Tests/IMsgCoreTests/MessageStoreListChatsTests.swift
Normal file
67
Tests/IMsgCoreTests/MessageStoreListChatsTests.swift
Normal file
@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
@Test
|
||||
func listChatsReturnsChat() throws {
|
||||
let store = try TestDatabase.makeStore()
|
||||
let chats = try store.listChats(limit: 5)
|
||||
#expect(chats.count == 1)
|
||||
#expect(chats.first?.identifier == "+123")
|
||||
#expect(chats.first?.accountID == "iMessage;+;me@icloud.com")
|
||||
#expect(chats.first?.accountLogin == "me@icloud.com")
|
||||
#expect(chats.first?.lastAddressedHandle == "+15551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
func listChatsUsesChatMessageJoinDateWithoutMessageJoinWhenAvailable() throws {
|
||||
let db = try Connection(.inMemory)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE chat (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
chat_identifier TEXT,
|
||||
guid TEXT,
|
||||
display_name TEXT,
|
||||
service_name TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE chat_message_join (
|
||||
chat_id INTEGER,
|
||||
message_id INTEGER,
|
||||
message_date INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
|
||||
VALUES
|
||||
(1, '+111', 'iMessage;-;+111', 'Old Chat', 'iMessage'),
|
||||
(2, '+222', 'iMessage;-;+222', 'New Chat', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat_message_join(chat_id, message_id, message_date)
|
||||
VALUES
|
||||
(1, 100, ?),
|
||||
(2, 200, ?)
|
||||
""",
|
||||
TestDatabase.appleEpoch(Date(timeIntervalSince1970: 100)),
|
||||
TestDatabase.appleEpoch(Date(timeIntervalSince1970: 200))
|
||||
)
|
||||
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
let chats = try store.listChats(limit: 1)
|
||||
#expect(chats.count == 1)
|
||||
#expect(chats.first?.identifier == "+222")
|
||||
#expect(chats.first?.accountID == nil)
|
||||
#expect(chats.first?.accountLogin == nil)
|
||||
#expect(chats.first?.lastAddressedHandle == nil)
|
||||
}
|
||||
@ -137,6 +137,86 @@ func reactionsForMessageReturnsReactions() throws {
|
||||
#expect(reactions[3].sender == "+456")
|
||||
}
|
||||
|
||||
@Test
|
||||
func bulkReactionsForMessagesGroupsByMessageID() throws {
|
||||
let db = try ReactionTestDatabase.makeConnection()
|
||||
let now = Date()
|
||||
try ReactionTestDatabase.seedBaseMessage(db, now: now)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, date, is_from_me, service)
|
||||
VALUES (10, 2, 'Second message', 'msg-guid-2', NULL, 0, ?, 0, 'iMessage')
|
||||
""",
|
||||
ReactionTestDatabase.appleEpoch(now.addingTimeInterval(-550))
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 10)")
|
||||
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, date, is_from_me, service)
|
||||
VALUES (2, 2, '', 'reaction-guid-1', 'p:0/msg-guid-1', 2000, ?, 0, 'iMessage')
|
||||
""",
|
||||
ReactionTestDatabase.appleEpoch(now.addingTimeInterval(-500))
|
||||
)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, date, is_from_me, service)
|
||||
VALUES (3, 1, '', 'reaction-guid-2', 'msg-guid-2', 2001, ?, 1, 'iMessage')
|
||||
""",
|
||||
ReactionTestDatabase.appleEpoch(now.addingTimeInterval(-450))
|
||||
)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, date, is_from_me, service)
|
||||
VALUES (4, 2, 'Removed a love', 'reaction-guid-3', 'p:0/msg-guid-1', 3000, ?, 0, 'iMessage')
|
||||
""",
|
||||
ReactionTestDatabase.appleEpoch(now.addingTimeInterval(-400))
|
||||
)
|
||||
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
let messages = try store.messages(chatID: 1, limit: 10)
|
||||
let reactionsByMessageID = try store.reactions(for: messages)
|
||||
|
||||
#expect(reactionsByMessageID[1]?.isEmpty != false)
|
||||
#expect(reactionsByMessageID[10]?.count == 1)
|
||||
#expect(reactionsByMessageID[10]?.first?.reactionType == .like)
|
||||
#expect(reactionsByMessageID[10]?.first?.isFromMe == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func bulkReactionsReturnsEmptyWhenColumnsMissing() throws {
|
||||
let db = try Connection(.inMemory)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
let reactionsByMessageID = try store.reactions(for: [
|
||||
Message(
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
sender: "+123",
|
||||
text: "hello",
|
||||
date: Date(),
|
||||
isFromMe: false,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 0,
|
||||
guid: "msg-guid-1"
|
||||
)
|
||||
])
|
||||
|
||||
#expect(reactionsByMessageID.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reactionsForMessageWithNoReactionsReturnsEmpty() throws {
|
||||
let db = try ReactionTestDatabase.makeConnection()
|
||||
|
||||
106
Tests/IMsgCoreTests/MessageStoreSchemaDetectionTests.swift
Normal file
106
Tests/IMsgCoreTests/MessageStoreSchemaDetectionTests.swift
Normal file
@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
private func makeStoreWithoutChatRouting() throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
try MessageDatabaseFixture.createSchema(
|
||||
db,
|
||||
options: MessageDatabaseFixture.SchemaOptions(includeChatRouting: false)
|
||||
)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
|
||||
VALUES (1, '+123', 'iMessage;+;chat123', 'Legacy Chat', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (1, NULL, 'hello', ?, 0, 'iMessage')
|
||||
""",
|
||||
TestDatabase.appleEpoch(Date(timeIntervalSince1970: 1_700_000_000))
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
|
||||
return try MessageStore(connection: db, path: ":memory:")
|
||||
}
|
||||
|
||||
@Test
|
||||
func schemaDetectsOptionalMessageColumns() throws {
|
||||
let db = try Connection(.inMemory)
|
||||
try MessageDatabaseFixture.createSchema(
|
||||
db,
|
||||
options: MessageDatabaseFixture.SchemaOptions(
|
||||
includeAttributedBody: true,
|
||||
includeReactionColumns: true,
|
||||
includeThreadOriginatorGUID: true,
|
||||
includeDestinationCallerID: true,
|
||||
includeAudioMessage: true,
|
||||
includeBalloonBundleID: true,
|
||||
includeAttachmentUserInfo: true,
|
||||
includeChatMessageDate: true,
|
||||
includeChatRouting: true
|
||||
)
|
||||
)
|
||||
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
#expect(store.schema.hasAttributedBody)
|
||||
#expect(store.schema.hasReactionColumns)
|
||||
#expect(store.schema.hasThreadOriginatorGUIDColumn)
|
||||
#expect(store.schema.hasDestinationCallerID)
|
||||
#expect(store.schema.hasAudioMessageColumn)
|
||||
#expect(store.schema.hasBalloonBundleIDColumn)
|
||||
#expect(store.schema.hasAttachmentUserInfo)
|
||||
#expect(store.schema.hasChatMessageJoinMessageDateColumn)
|
||||
#expect(store.schema.hasChatAccountIDColumn)
|
||||
#expect(store.schema.hasChatAccountLoginColumn)
|
||||
#expect(store.schema.hasChatLastAddressedHandleColumn)
|
||||
}
|
||||
|
||||
@Test
|
||||
func schemaOverridesKeepLegacyTestFixturesExplicit() throws {
|
||||
let db = try Connection(.inMemory)
|
||||
try MessageDatabaseFixture.createSchema(
|
||||
db,
|
||||
options: MessageDatabaseFixture.SchemaOptions(
|
||||
includeAttributedBody: true,
|
||||
includeReactionColumns: true
|
||||
)
|
||||
)
|
||||
|
||||
let store = try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
#expect(!store.schema.hasAttributedBody)
|
||||
#expect(!store.schema.hasReactionColumns)
|
||||
}
|
||||
|
||||
@Test
|
||||
func listChatsHandlesMissingRoutingColumns() throws {
|
||||
let store = try makeStoreWithoutChatRouting()
|
||||
|
||||
let chats = try store.listChats(limit: 1)
|
||||
|
||||
#expect(chats.count == 1)
|
||||
#expect(chats.first?.name == "Legacy Chat")
|
||||
#expect(chats.first?.accountID == nil)
|
||||
#expect(chats.first?.accountLogin == nil)
|
||||
#expect(chats.first?.lastAddressedHandle == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatInfoHandlesMissingRoutingColumns() throws {
|
||||
let store = try makeStoreWithoutChatRouting()
|
||||
|
||||
let info = try #require(try store.chatInfo(chatID: 1))
|
||||
|
||||
#expect(info.name == "Legacy Chat")
|
||||
#expect(info.accountID == nil)
|
||||
#expect(info.accountLogin == nil)
|
||||
#expect(info.lastAddressedHandle == nil)
|
||||
}
|
||||
@ -60,4 +60,5 @@ func messagesUseDestinationCallerIDWhenSenderMissing() throws {
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
let messages = try store.messages(chatID: 1, limit: 5)
|
||||
#expect(messages.first?.sender == "me@icloud.com")
|
||||
#expect(messages.first?.destinationCallerID == "me@icloud.com")
|
||||
}
|
||||
|
||||
205
Tests/IMsgCoreTests/MessageStoreSentMessageTests.swift
Normal file
205
Tests/IMsgCoreTests/MessageStoreSentMessageTests.swift
Normal file
@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
@Test
|
||||
func latestSentMessageMatchesNewestOutgoingTextInChat() throws {
|
||||
let db = try makeSentMessageDatabase()
|
||||
let now = Date()
|
||||
try insertSentMessageFixture(
|
||||
db,
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
text: "same",
|
||||
guid: "old-guid",
|
||||
date: now.addingTimeInterval(-20),
|
||||
isFromMe: true
|
||||
)
|
||||
try insertSentMessageFixture(
|
||||
db,
|
||||
rowID: 2,
|
||||
chatID: 1,
|
||||
text: "same",
|
||||
guid: "incoming-guid",
|
||||
date: now.addingTimeInterval(-5),
|
||||
isFromMe: false
|
||||
)
|
||||
try insertSentMessageFixture(
|
||||
db,
|
||||
rowID: 3,
|
||||
chatID: 1,
|
||||
text: "same",
|
||||
guid: "chat-guid",
|
||||
date: now,
|
||||
isFromMe: true
|
||||
)
|
||||
try insertSentMessageFixture(
|
||||
db,
|
||||
rowID: 4,
|
||||
chatID: 2,
|
||||
text: "same",
|
||||
guid: "other-chat-guid",
|
||||
date: now.addingTimeInterval(5),
|
||||
isFromMe: true
|
||||
)
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
|
||||
let message = try store.latestSentMessage(
|
||||
matchingText: "same",
|
||||
chatID: 1,
|
||||
since: now.addingTimeInterval(-10)
|
||||
)
|
||||
|
||||
#expect(message?.rowID == 3)
|
||||
#expect(message?.chatID == 1)
|
||||
#expect(message?.guid == "chat-guid")
|
||||
}
|
||||
|
||||
@Test
|
||||
func latestSentMessageFallsBackToNewestOutgoingTextWithoutChatFilter() throws {
|
||||
let db = try makeSentMessageDatabase()
|
||||
let now = Date()
|
||||
try insertSentMessageFixture(
|
||||
db,
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
text: "same",
|
||||
guid: "chat-one-guid",
|
||||
date: now,
|
||||
isFromMe: true
|
||||
)
|
||||
try insertSentMessageFixture(
|
||||
db,
|
||||
rowID: 2,
|
||||
chatID: 2,
|
||||
text: "same",
|
||||
guid: "chat-two-guid",
|
||||
date: now.addingTimeInterval(5),
|
||||
isFromMe: true
|
||||
)
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
|
||||
let message = try store.latestSentMessage(
|
||||
matchingText: "same",
|
||||
chatID: nil,
|
||||
since: now.addingTimeInterval(-1)
|
||||
)
|
||||
|
||||
#expect(message?.rowID == 2)
|
||||
#expect(message?.chatID == 2)
|
||||
#expect(message?.guid == "chat-two-guid")
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatInfoMatchingTargetHandlesAnyGroupPolarityMismatch() throws {
|
||||
let db = try makeSentMessageDatabase()
|
||||
try db.run(
|
||||
"""
|
||||
UPDATE chat
|
||||
SET chat_identifier = 'any;+;chat134', guid = 'any;+;chat134'
|
||||
WHERE ROWID = 1
|
||||
"""
|
||||
)
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
|
||||
let info = try store.chatInfo(matchingTarget: "any;-;chat134")
|
||||
|
||||
#expect(info?.id == 1)
|
||||
#expect(info?.guid == "any;+;chat134")
|
||||
}
|
||||
|
||||
@Test
|
||||
func latestUnjoinedSentMessageRowIDMatchesAnyGroupTargetVariants() throws {
|
||||
let db = try makeSentMessageDatabase()
|
||||
let now = Date()
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (2, 'any;-;chat134')")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(
|
||||
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
|
||||
date, is_from_me, service
|
||||
)
|
||||
VALUES (20, 2, '', 'ghost-guid', NULL, 0, ?, 1, 'SMS')
|
||||
""",
|
||||
TestDatabase.appleEpoch(now)
|
||||
)
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
|
||||
let rowID = try store.latestUnjoinedSentMessageRowID(
|
||||
matchingTargetHandles: ["any;+;chat134"],
|
||||
since: now.addingTimeInterval(-1)
|
||||
)
|
||||
|
||||
#expect(rowID == 20)
|
||||
}
|
||||
|
||||
private func makeSentMessageDatabase() throws -> Connection {
|
||||
let db = try Connection(.inMemory)
|
||||
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
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
|
||||
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
|
||||
try db.execute(
|
||||
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
|
||||
VALUES (1, 'iMessage;+;one', 'iMessage;+;one', 'One', 'iMessage'),
|
||||
(2, 'iMessage;+;two', 'iMessage;+;two', 'Two', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, 'me@icloud.com')")
|
||||
return db
|
||||
}
|
||||
|
||||
private func insertSentMessageFixture(
|
||||
_ db: Connection,
|
||||
rowID: Int64,
|
||||
chatID: Int64,
|
||||
text: String,
|
||||
guid: String,
|
||||
date: Date,
|
||||
isFromMe: Bool
|
||||
) throws {
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(
|
||||
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
|
||||
date, is_from_me, service
|
||||
)
|
||||
VALUES (?, 1, ?, ?, NULL, 0, ?, ?, 'iMessage')
|
||||
""",
|
||||
rowID,
|
||||
text,
|
||||
guid,
|
||||
TestDatabase.appleEpoch(date),
|
||||
isFromMe ? 1 : 0
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (?, ?)", chatID, rowID)
|
||||
}
|
||||
@ -11,73 +11,32 @@ enum TestDatabase {
|
||||
|
||||
static func makeStore(
|
||||
includeAttributedBody: Bool = false,
|
||||
includeReactionColumns: Bool = false
|
||||
includeReactionColumns: Bool = false,
|
||||
attachmentFilename: String = "~/Library/Messages/Attachments/test.dat",
|
||||
attachmentTransferName: String = "test.dat",
|
||||
attachmentUTI: String = "public.data",
|
||||
attachmentMimeType: String = "application/octet-stream"
|
||||
) throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
let attributedBodyColumn = includeAttributedBody ? "attributedBody BLOB," : ""
|
||||
|
||||
let reactionColumns: String
|
||||
if includeReactionColumns {
|
||||
reactionColumns = "guid TEXT, associated_message_guid TEXT, associated_message_type INTEGER,"
|
||||
} else {
|
||||
reactionColumns = ""
|
||||
}
|
||||
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
\(attributedBodyColumn)
|
||||
\(reactionColumns)
|
||||
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
|
||||
);
|
||||
"""
|
||||
)
|
||||
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
|
||||
);
|
||||
"""
|
||||
try MessageDatabaseFixture.createSchema(
|
||||
db,
|
||||
options: MessageDatabaseFixture.SchemaOptions(
|
||||
includeAttributedBody: includeAttributedBody,
|
||||
includeReactionColumns: includeReactionColumns
|
||||
)
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
|
||||
VALUES (1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage')
|
||||
INSERT INTO chat(
|
||||
ROWID, chat_identifier, guid, display_name, service_name,
|
||||
account_id, account_login, last_addressed_handle
|
||||
)
|
||||
VALUES (
|
||||
1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage',
|
||||
'iMessage;+;me@icloud.com', 'me@icloud.com', '+15551234567'
|
||||
)
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'Me')")
|
||||
@ -114,8 +73,12 @@ enum TestDatabase {
|
||||
total_bytes,
|
||||
is_sticker
|
||||
)
|
||||
VALUES (1, '~/Library/Messages/Attachments/test.dat', 'test.dat', 'public.data', 'application/octet-stream', 123, 0)
|
||||
"""
|
||||
VALUES (1, ?, ?, ?, ?, 123, 0)
|
||||
""",
|
||||
attachmentFilename,
|
||||
attachmentTransferName,
|
||||
attachmentUTI,
|
||||
attachmentMimeType
|
||||
)
|
||||
try db.run(
|
||||
"""
|
||||
|
||||
@ -4,9 +4,13 @@ import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
private func makeInMemoryMessageDB(includeThreadOriginatorGUID: Bool = false) throws -> Connection {
|
||||
private func makeInMemoryMessageDB(
|
||||
includeThreadOriginatorGUID: Bool = false,
|
||||
includeBalloonBundleID: Bool = false
|
||||
) throws -> Connection {
|
||||
let db = try Connection(.inMemory)
|
||||
let threadOriginatorColumn = includeThreadOriginatorGUID ? "thread_originator_guid TEXT," : ""
|
||||
let balloonColumn = includeBalloonBundleID ? "balloon_bundle_id TEXT," : ""
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
@ -17,6 +21,7 @@ private func makeInMemoryMessageDB(includeThreadOriginatorGUID: Bool = false) th
|
||||
associated_message_guid TEXT,
|
||||
associated_message_type INTEGER,
|
||||
\(threadOriginatorColumn)
|
||||
\(balloonColumn)
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
@ -30,14 +35,6 @@ private func makeInMemoryMessageDB(includeThreadOriginatorGUID: Bool = false) th
|
||||
return db
|
||||
}
|
||||
|
||||
@Test
|
||||
func listChatsReturnsChat() throws {
|
||||
let store = try TestDatabase.makeStore()
|
||||
let chats = try store.listChats(limit: 5)
|
||||
#expect(chats.count == 1)
|
||||
#expect(chats.first?.identifier == "+123")
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatInfoReturnsMetadata() throws {
|
||||
let store = try TestDatabase.makeStore()
|
||||
@ -46,6 +43,22 @@ func chatInfoReturnsMetadata() throws {
|
||||
#expect(info?.guid == "iMessage;+;chat123")
|
||||
#expect(info?.name == "Test Chat")
|
||||
#expect(info?.service == "iMessage")
|
||||
#expect(info?.accountID == "iMessage;+;me@icloud.com")
|
||||
#expect(info?.accountLogin == "me@icloud.com")
|
||||
#expect(info?.lastAddressedHandle == "+15551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
func sqlRowDecodingThrowsWhenRequiredAliasIsMissing() throws {
|
||||
let db = try Connection(.inMemory)
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
try store.withConnection { db in
|
||||
let rows = try db.prepareRowIterator("SELECT 1 AS actual_value")
|
||||
let row = try #require(try rows.failableNext())
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try store.int64Value(row, "expected_value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -137,6 +150,91 @@ func messagesAfterReturnsMessages() throws {
|
||||
#expect(messages.first?.rowID == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesAfterDeduplicatesURLBalloonsAcrossPolls() throws {
|
||||
let db = try makeInMemoryMessageDB(includeBalloonBundleID: true)
|
||||
let now = Date()
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(
|
||||
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
|
||||
balloon_bundle_id, date, is_from_me, service
|
||||
)
|
||||
VALUES (1, 1, 'https://example.com', 'msg-guid-1', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
|
||||
""",
|
||||
TestDatabase.appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
|
||||
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
let firstPoll = try store.messagesAfter(afterRowID: 0, chatID: 1, limit: 10)
|
||||
#expect(firstPoll.map(\.rowID) == [1])
|
||||
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(
|
||||
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
|
||||
balloon_bundle_id, date, is_from_me, service
|
||||
)
|
||||
VALUES (2, 1, 'https://example.com', 'msg-guid-2', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
|
||||
""",
|
||||
TestDatabase.appleEpoch(now.addingTimeInterval(30))
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 2)")
|
||||
|
||||
let secondPoll = try store.messagesAfter(afterRowID: 1, chatID: 1, limit: 10)
|
||||
#expect(secondPoll.isEmpty)
|
||||
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(
|
||||
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
|
||||
balloon_bundle_id, date, is_from_me, service
|
||||
)
|
||||
VALUES (3, 1, 'https://example.com', 'msg-guid-3', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
|
||||
""",
|
||||
TestDatabase.appleEpoch(now.addingTimeInterval(5 * 60))
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 3)")
|
||||
|
||||
let thirdPoll = try store.messagesAfter(afterRowID: 1, chatID: 1, limit: 10)
|
||||
#expect(thirdPoll.map(\.rowID) == [3])
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesAfterURLBalloonDedupingDoesNotCrossChats() throws {
|
||||
let db = try makeInMemoryMessageDB(includeBalloonBundleID: true)
|
||||
let now = Date()
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(
|
||||
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
|
||||
balloon_bundle_id, date, is_from_me, service
|
||||
)
|
||||
VALUES (1, 1, 'https://example.com', 'msg-guid-1', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
|
||||
""",
|
||||
TestDatabase.appleEpoch(now)
|
||||
)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(
|
||||
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
|
||||
balloon_bundle_id, date, is_from_me, service
|
||||
)
|
||||
VALUES (2, 1, 'https://example.com', 'msg-guid-2', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
|
||||
""",
|
||||
TestDatabase.appleEpoch(now.addingTimeInterval(15))
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (2, 2)")
|
||||
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
let messages = try store.messagesAfter(afterRowID: 0, chatID: nil, limit: 10)
|
||||
#expect(messages.map(\.rowID) == [1, 2])
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesAfterExcludesReactionRows() throws {
|
||||
let db = try makeInMemoryMessageDB()
|
||||
@ -297,6 +395,17 @@ func attachmentsByMessageReturnsMetadata() throws {
|
||||
#expect(attachments.first?.mimeType == "application/octet-stream")
|
||||
}
|
||||
|
||||
@Test
|
||||
func attachmentsByMessagesReturnsMetadataByMessageID() throws {
|
||||
let store = try TestDatabase.makeStore()
|
||||
let attachmentsByMessageID = try store.attachments(for: [1, 2, 2, 3])
|
||||
|
||||
#expect(attachmentsByMessageID[1]?.isEmpty != false)
|
||||
#expect(attachmentsByMessageID[2]?.count == 1)
|
||||
#expect(attachmentsByMessageID[2]?.first?.mimeType == "application/octet-stream")
|
||||
#expect(attachmentsByMessageID[3]?.isEmpty != false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func longRepeatedPatternMessage() throws {
|
||||
// Test the exact pattern that causes crashes: repeated "aaaaaaaaaaaa " pattern
|
||||
|
||||
@ -4,6 +4,11 @@ import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
private struct WatcherTestStore {
|
||||
let store: MessageStore
|
||||
let insertMessage: (Int64, String) throws -> Void
|
||||
}
|
||||
|
||||
private enum WatcherTestDatabase {
|
||||
static func appleEpoch(_ date: Date) -> Int64 {
|
||||
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
|
||||
@ -43,6 +48,47 @@ private enum WatcherTestDatabase {
|
||||
return try MessageStore(
|
||||
connection: db, path: ":memory:", hasAttributedBody: false, hasReactionColumns: false)
|
||||
}
|
||||
|
||||
static func makeMutableStore() throws -> WatcherTestStore {
|
||||
let db = try Connection(.inMemory)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
|
||||
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
|
||||
try db.execute(
|
||||
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
|
||||
|
||||
let store = try MessageStore(
|
||||
connection: db, path: ":memory:", hasAttributedBody: false, hasReactionColumns: false)
|
||||
return WatcherTestStore(
|
||||
store: store,
|
||||
insertMessage: { rowID, text in
|
||||
try store.withConnection { db in
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (?, 1, ?, ?, 0, 'iMessage')
|
||||
""",
|
||||
rowID,
|
||||
text,
|
||||
appleEpoch(Date())
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)", rowID)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -63,3 +109,30 @@ func messageWatcherYieldsExistingMessages() async throws {
|
||||
let message = try await task.value
|
||||
#expect(message?.text == "hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
func messageWatcherFallbackPollYieldsMessagesWithoutFileEvents() async throws {
|
||||
let fixture = try WatcherTestDatabase.makeMutableStore()
|
||||
let watcher = MessageWatcher(store: fixture.store)
|
||||
let stream = watcher.stream(
|
||||
chatID: nil,
|
||||
sinceRowID: 0,
|
||||
configuration: MessageWatcherConfiguration(
|
||||
debounceInterval: 60,
|
||||
fallbackPollInterval: 0.01,
|
||||
batchLimit: 10
|
||||
)
|
||||
)
|
||||
|
||||
let task = Task { () throws -> Message? in
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
return try await iterator.next()
|
||||
}
|
||||
|
||||
try await Task.sleep(nanoseconds: 20_000_000)
|
||||
try fixture.insertMessage(2, "fallback")
|
||||
|
||||
let message = try await task.value
|
||||
#expect(message?.rowID == 2)
|
||||
#expect(message?.text == "fallback")
|
||||
}
|
||||
|
||||
93
Tests/IMsgCoreTests/TypingIndicatorTests.swift
Normal file
93
Tests/IMsgCoreTests/TypingIndicatorTests.swift
Normal file
@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
@Test
|
||||
func typingIndicatorStopsOnCancellation() async {
|
||||
var events: [String] = []
|
||||
|
||||
do {
|
||||
try await TypingIndicator.typeForDuration(
|
||||
chatIdentifier: "iMessage;+;chat123",
|
||||
duration: 1,
|
||||
startTyping: { _ in events.append("start") },
|
||||
stopTyping: { _ in events.append("stop") },
|
||||
sleep: { _ in throw CancellationError() }
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch is CancellationError {
|
||||
#expect(Bool(true))
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
|
||||
#expect(events == ["start", "stop"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingIndicatorStopsAfterNormalDuration() async throws {
|
||||
var events: [String] = []
|
||||
var didSleep = false
|
||||
|
||||
try await TypingIndicator.typeForDuration(
|
||||
chatIdentifier: "iMessage;+;chat123",
|
||||
duration: 1,
|
||||
startTyping: { _ in events.append("start") },
|
||||
stopTyping: { _ in events.append("stop") },
|
||||
sleep: { _ in didSleep = true }
|
||||
)
|
||||
|
||||
#expect(didSleep == true)
|
||||
#expect(events == ["start", "stop"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingLookupCandidatesExpandAnyPrefixToServiceVariants() {
|
||||
let candidates = TypingIndicator.chatLookupCandidates(for: "any;-;+15551234567")
|
||||
|
||||
#expect(
|
||||
candidates == [
|
||||
"any;-;+15551234567",
|
||||
"+15551234567",
|
||||
"iMessage;-;+15551234567",
|
||||
"iMessage;+;+15551234567",
|
||||
"SMS;-;+15551234567",
|
||||
"SMS;+;+15551234567",
|
||||
"any;+;+15551234567",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingLookupCandidatesAvoidDoublePrefixingDirectIdentifiers() {
|
||||
let candidates = TypingIndicator.chatLookupCandidates(for: " iMessage;-;user@example.com ")
|
||||
|
||||
#expect(
|
||||
candidates == [
|
||||
"iMessage;-;user@example.com",
|
||||
"user@example.com",
|
||||
"iMessage;+;user@example.com",
|
||||
"SMS;-;user@example.com",
|
||||
"SMS;+;user@example.com",
|
||||
"any;-;user@example.com",
|
||||
"any;+;user@example.com",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingLookupCandidatesRejectBlankIdentifier() {
|
||||
#expect(TypingIndicator.chatLookupCandidates(for: " ").isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingDaemonUnavailableMessageExplainsTahoeEntitlementBlock() {
|
||||
let message = TypingIndicator.daemonUnavailableMessage()
|
||||
|
||||
#expect(message.contains("imagent"))
|
||||
#expect(message.contains("macOS 26/Tahoe"))
|
||||
#expect(message.contains("Apple-private entitlements"))
|
||||
#expect(message.contains("imsg status"))
|
||||
#expect(message.contains("send"))
|
||||
#expect(message.contains("history"))
|
||||
#expect(message.contains("watch"))
|
||||
}
|
||||
@ -29,6 +29,113 @@ func attachmentResolverDisplayNamePrefersTransfer() {
|
||||
#expect(AttachmentResolver.displayName(filename: "", transferName: "") == "(unknown)")
|
||||
}
|
||||
|
||||
@Test
|
||||
func attachmentResolverReportsCachedConvertedCAF() 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 source = dir.appendingPathComponent("voice.caf")
|
||||
try Data("caf".utf8).write(to: source)
|
||||
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "m4a")
|
||||
try FileManager.default.createDirectory(
|
||||
at: converted.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("m4a".utf8).write(to: converted)
|
||||
defer { try? FileManager.default.removeItem(at: converted) }
|
||||
|
||||
let meta = AttachmentResolver.metadata(
|
||||
filename: source.path,
|
||||
transferName: "voice.caf",
|
||||
uti: "com.apple.coreaudio-format",
|
||||
mimeType: "audio/x-caf",
|
||||
totalBytes: 3,
|
||||
isSticker: false,
|
||||
options: AttachmentQueryOptions(convertUnsupported: true)
|
||||
)
|
||||
|
||||
#expect(meta.originalPath == source.path)
|
||||
#expect(meta.convertedPath == converted.path)
|
||||
#expect(meta.convertedMimeType == "audio/mp4")
|
||||
#expect(meta.mimeType == "audio/x-caf")
|
||||
}
|
||||
|
||||
@Test
|
||||
func attachmentResolverLeavesUnsupportedFilesUnconverted() 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 source = dir.appendingPathComponent("file.txt")
|
||||
try Data("text".utf8).write(to: source)
|
||||
let meta = AttachmentResolver.metadata(
|
||||
filename: source.path,
|
||||
transferName: "file.txt",
|
||||
uti: "public.plain-text",
|
||||
mimeType: "text/plain",
|
||||
totalBytes: 4,
|
||||
isSticker: false,
|
||||
options: AttachmentQueryOptions(convertUnsupported: true)
|
||||
)
|
||||
|
||||
#expect(meta.convertedPath == nil)
|
||||
#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"
|
||||
@ -102,6 +209,92 @@ func typedStreamParserTrimsControlCharacters() {
|
||||
#expect(TypedStreamParser.parseAttributedBody(data) == "hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDecodesShortSingleBytePrefix() {
|
||||
let text = "hello"
|
||||
let bytes: [UInt8] =
|
||||
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
|
||||
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDecodesMediumMessageWith0x81Prefix() {
|
||||
let text = String(repeating: "A", count: 140)
|
||||
let length = UInt8(text.utf8.count)
|
||||
let bytes: [UInt8] =
|
||||
[0x01, 0x2b, 0x81, length] + Array(text.utf8) + [0x86, 0x84]
|
||||
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDecodesLongMessageWith0x82Prefix() {
|
||||
let text = String(repeating: "B", count: 300)
|
||||
let length = UInt16(text.utf8.count)
|
||||
let lengthHi = UInt8((length >> 8) & 0xff)
|
||||
let lengthLo = UInt8(length & 0xff)
|
||||
let bytes: [UInt8] =
|
||||
[0x01, 0x2b, 0x82, lengthHi, lengthLo] + Array(text.utf8) + [0x86, 0x84]
|
||||
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDoesNotPrependPrintableAsciiLengthByte() {
|
||||
// 64-byte body of 'A' → length byte 0x40 ('@'), printable.
|
||||
// Without the structured-prefix-wins rule, the raw decode keeps the '@' and beats the stripped body by one character.
|
||||
let text = String(repeating: "A", count: 64)
|
||||
let bytes: [UInt8] =
|
||||
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
|
||||
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDecodes32ByteBodyAtLowerRegressionEdge() {
|
||||
// 32-byte body → length byte 0x20 (space). Lower edge of the 32–126 printable-ASCII window.
|
||||
let text = String(repeating: "A", count: 32)
|
||||
let bytes: [UInt8] =
|
||||
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
|
||||
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDecodes126ByteBodyAtUpperRegressionEdge() {
|
||||
// 126-byte body → length byte 0x7E ('~'). Upper edge of the window — 0x7F is DEL/control and
|
||||
// would be trimmed (not prepended), so 0x7E is the precise top of the failure range.
|
||||
let text = String(repeating: "A", count: 126)
|
||||
let bytes: [UInt8] =
|
||||
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
|
||||
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDecodesMultibyteUTF8BodyInRegressionWindow() {
|
||||
// 12 × 🎉 = 48 UTF-8 bytes → length byte 0x30 ('0'), printable. Confirms the structured-prefix
|
||||
// preference works for non-ASCII bodies too — the bug is byte-count driven, not ASCII-specific.
|
||||
let text = String(repeating: "🎉", count: 12)
|
||||
let bytes: [UInt8] =
|
||||
[0x01, 0x2b, UInt8(text.utf8.count)] + Array(text.utf8) + [0x86, 0x84]
|
||||
#expect(TypedStreamParser.parseAttributedBody(Data(bytes)) == text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserHandlesMixedBinaryNoise() {
|
||||
// First byte 0x42 is neither 0x81 nor 0x82, and does not equal segment.count - 1 (= 6).
|
||||
// The decoder should fall back to no-prefix decoding without crashing.
|
||||
let bytes: [UInt8] =
|
||||
[0x01, 0x2b, 0x42, 0x68, 0x69, 0x21, 0x86, 0x84]
|
||||
let result = TypedStreamParser.parseAttributedBody(Data(bytes))
|
||||
#expect(result == "Bhi!")
|
||||
}
|
||||
|
||||
@Test
|
||||
func typedStreamParserDecodesUTF16LittleEndianBOM() throws {
|
||||
var data = Data([0xff, 0xfe])
|
||||
let body = "hello 🌤️"
|
||||
let encoded = try #require(body.data(using: .utf16LittleEndian))
|
||||
data.append(encoded)
|
||||
#expect(TypedStreamParser.parseAttributedBody(data) == body)
|
||||
}
|
||||
|
||||
@Test
|
||||
func phoneNumberNormalizerFormatsValidNumber() {
|
||||
let normalizer = PhoneNumberNormalizer()
|
||||
@ -317,4 +510,7 @@ func errorDescriptionsIncludeDetails() {
|
||||
let permissionDescription = permission.errorDescription ?? ""
|
||||
#expect(permissionDescription.contains("Permission Error") == true)
|
||||
#expect(permissionDescription.contains("/tmp/chat.db") == true)
|
||||
#expect(permissionDescription.contains("parent launcher") == true)
|
||||
#expect(permissionDescription.contains("built-in Terminal.app") == true)
|
||||
#expect(permissionDescription.contains("stale entries") == true)
|
||||
}
|
||||
|
||||
142
Tests/imsgTests/BridgeCommandRegistrationTests.swift
Normal file
142
Tests/imsgTests/BridgeCommandRegistrationTests.swift
Normal file
@ -0,0 +1,142 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
import Testing
|
||||
|
||||
@testable import imsg
|
||||
|
||||
/// Snapshot of the bridge-backed commands we expect to be wired up. Locks in
|
||||
/// the surface so an accidental drop from CommandRouter.specs gets caught
|
||||
/// without exercising any IMCore plumbing.
|
||||
@Test
|
||||
func commandRouterIncludesAllBridgeCommands() {
|
||||
let router = CommandRouter()
|
||||
let expected: [String] = [
|
||||
"send-rich", "send-multipart", "send-attachment", "tapback",
|
||||
"edit", "unsend", "delete-message", "notify-anyways",
|
||||
"chat-create", "chat-name", "chat-photo",
|
||||
"chat-add-member", "chat-remove-member",
|
||||
"chat-leave", "chat-delete", "chat-mark",
|
||||
"account", "whois", "nickname",
|
||||
]
|
||||
let registered = Set(router.specs.map { $0.name })
|
||||
for name in expected {
|
||||
#expect(registered.contains(name), "missing bridge command: \(name)")
|
||||
}
|
||||
#expect(registered.contains("search"), "missing local search command")
|
||||
}
|
||||
|
||||
@Test
|
||||
func bridgeMessagingCommandsExposeChatRequirement() async {
|
||||
// Each new bridge messaging command requires a `--chat` option (the chat
|
||||
// guid is the universal addressing key in v2). Ensure missing args bubble
|
||||
// up as a parse-time error rather than dropping into the bridge with empty
|
||||
// strings.
|
||||
let router = CommandRouter()
|
||||
let names = ["send-rich", "edit", "unsend", "delete-message", "tapback"]
|
||||
for name in names {
|
||||
let (_, status) = await StdoutCapture.capture {
|
||||
await router.run(argv: ["imsg", name])
|
||||
}
|
||||
#expect(status == 1, "\(name) should have required missing args")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func bridgeAttachmentStagingUsesChatGuid() throws {
|
||||
let testFile = URL(fileURLWithPath: #filePath)
|
||||
let repoRoot =
|
||||
testFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let helper = repoRoot.appendingPathComponent("Sources/IMsgHelper/IMsgInjected.m")
|
||||
let source = try String(contentsOf: helper, encoding: .utf8)
|
||||
|
||||
#expect(source.contains("NSString *chatGuid, NSString **outErr)"))
|
||||
#expect(source.contains("[inv setArgument:&cg atIndex:5];"))
|
||||
#expect(
|
||||
source.contains("saveAttachmentsForTransfer:chatGUID:storeAtExternalLocation:completion:"))
|
||||
#expect(source.contains("prepareOutgoingTransfer(fileURL, filename, chatGuid, &prepErr)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatMarkRejectsConflictingFlags() async {
|
||||
let router = CommandRouter()
|
||||
let (output, status) = await StdoutCapture.capture {
|
||||
await router.run(argv: [
|
||||
"imsg", "chat-mark", "--chat", "iMessage;-;+15551234567", "--read", "--unread",
|
||||
])
|
||||
}
|
||||
#expect(status == 1)
|
||||
#expect(output.contains("Invalid value for option: --read"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expressiveSendEffectExpandsShortNames() {
|
||||
// Bubble effects map to MobileSMS.expressivesend.<name>.
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("invisibleink")
|
||||
== "com.apple.MobileSMS.expressivesend.invisibleink")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("impact")
|
||||
== "com.apple.MobileSMS.expressivesend.impact")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("loud")
|
||||
== "com.apple.MobileSMS.expressivesend.loud")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("gentle")
|
||||
== "com.apple.MobileSMS.expressivesend.gentle")
|
||||
|
||||
// Screen effects map to messages.effect.CK<TitleCase>Effect.
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("confetti")
|
||||
== "com.apple.messages.effect.CKConfettiEffect")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("lasers")
|
||||
== "com.apple.messages.effect.CKLasersEffect")
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("celebration")
|
||||
== "com.apple.messages.effect.CKCelebrationEffect")
|
||||
|
||||
// Case-insensitive on the short form.
|
||||
#expect(
|
||||
ExpressiveSendEffect.expand("InvisibleInk")
|
||||
== "com.apple.MobileSMS.expressivesend.invisibleink")
|
||||
|
||||
// Already-expanded ids pass through untouched.
|
||||
let expanded = "com.apple.MobileSMS.expressivesend.impact"
|
||||
#expect(ExpressiveSendEffect.expand(expanded) == expanded)
|
||||
let screenExpanded = "com.apple.messages.effect.CKHeartEffect"
|
||||
#expect(ExpressiveSendEffect.expand(screenExpanded) == screenExpanded)
|
||||
|
||||
// Unknown short names pass through so the dylib can return its own error.
|
||||
#expect(ExpressiveSendEffect.expand("totally-not-real") == "totally-not-real")
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatCreateRejectsUnsupportedServiceBeforeBridgeLaunch() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: [
|
||||
"addresses": ["+15551234567"],
|
||||
"service": ["SMS"],
|
||||
],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
|
||||
do {
|
||||
try await ChatCreateCommand.run(values: values, runtime: runtime)
|
||||
#expect(Bool(false))
|
||||
} catch let error as IMsgError {
|
||||
switch error {
|
||||
case .unsupportedService(let value):
|
||||
#expect(value == "SMS")
|
||||
default:
|
||||
#expect(Bool(false))
|
||||
}
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
406
Tests/imsgTests/ChatHistorySendCommandTests.swift
Normal file
406
Tests/imsgTests/ChatHistorySendCommandTests.swift
Normal file
@ -0,0 +1,406 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import SQLite
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func chatsCommandRunsWithJsonOutput() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "limit": ["5"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await ChatsCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { NoOpContactResolver() }
|
||||
)
|
||||
}
|
||||
let payload = try jsonObject(from: output)
|
||||
#expect(payload["is_group"] as? Bool == true)
|
||||
#expect(payload["guid"] as? String == "iMessage;+;chat123")
|
||||
#expect(payload["display_name"] as? String == "Test Chat")
|
||||
#expect(payload["account_id"] as? String == "iMessage;+;me@icloud.com")
|
||||
#expect(payload["account_login"] as? String == "me@icloud.com")
|
||||
#expect(payload["last_addressed_handle"] as? String == "+15551234567")
|
||||
#expect(payload["participants"] as? [String] == ["+123"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatsCommandJsonReportsDirectChatMetadata() async throws {
|
||||
let path = try CommandTestDatabase.makePathDirectChat()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "limit": ["5"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await ChatsCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { NoOpContactResolver() }
|
||||
)
|
||||
}
|
||||
let payload = try jsonObject(from: output)
|
||||
#expect(payload["is_group"] as? Bool == false)
|
||||
#expect(payload["guid"] as? String == "iMessage;-;+123")
|
||||
#expect(payload["display_name"] as? String == "Direct Chat")
|
||||
#expect(payload["account_id"] as? String == "iMessage;+;me@icloud.com")
|
||||
#expect(payload["account_login"] as? String == "me@icloud.com")
|
||||
#expect(payload["last_addressed_handle"] as? String == "+15551234567")
|
||||
#expect(payload["participants"] as? [String] == ["+123"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func historyCommandRunsWithChatID() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await HistoryCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { NoOpContactResolver() }
|
||||
)
|
||||
}
|
||||
let payload = try jsonObject(from: output)
|
||||
#expect(payload["is_group"] as? Bool == true)
|
||||
#expect(payload["chat_identifier"] as? String == "+123")
|
||||
#expect(payload["chat_guid"] as? String == "iMessage;+;chat123")
|
||||
#expect(payload["chat_name"] as? String == "Test Chat")
|
||||
#expect(payload["participants"] as? [String] == ["+123"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func historyCommandJsonReportsDirectChatMetadata() async throws {
|
||||
let path = try CommandTestDatabase.makePathDirectChat()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await HistoryCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { NoOpContactResolver() }
|
||||
)
|
||||
}
|
||||
let payload = try jsonObject(from: output)
|
||||
#expect(payload["is_group"] as? Bool == false)
|
||||
#expect(payload["chat_identifier"] as? String == "+123")
|
||||
#expect(payload["chat_guid"] as? String == "iMessage;-;+123")
|
||||
#expect(payload["chat_name"] as? String == "Direct Chat")
|
||||
#expect(payload["participants"] as? [String] == ["+123"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func searchCommandUsesLocalMessageStore() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "query": ["ell"], "match": ["contains"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await SearchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { NoOpContactResolver() }
|
||||
)
|
||||
}
|
||||
let payload = try jsonObject(from: output)
|
||||
#expect(payload["text"] as? String == "hello")
|
||||
#expect(payload["chat_id"] as? Int == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func historyCommandRunsWithAttachmentsNonJson() async throws {
|
||||
let path = try CommandTestDatabase.makePathWithAttachment()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
|
||||
flags: ["attachments"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await HistoryCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { NoOpContactResolver() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func historyCommandReportsConvertedAttachmentPath() async throws {
|
||||
let source = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("gif")
|
||||
try Data("gif".utf8).write(to: source)
|
||||
defer { try? FileManager.default.removeItem(at: source) }
|
||||
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "png")
|
||||
try FileManager.default.createDirectory(
|
||||
at: converted.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("png".utf8).write(to: converted)
|
||||
defer { try? FileManager.default.removeItem(at: converted) }
|
||||
|
||||
let path = try CommandTestDatabase.makePathWithAttachment(
|
||||
filename: source.path,
|
||||
transferName: "animation.gif",
|
||||
uti: "com.compuserve.gif",
|
||||
mimeType: "image/gif"
|
||||
)
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
|
||||
flags: ["attachments", "convertAttachments"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await HistoryCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { NoOpContactResolver() }
|
||||
)
|
||||
}
|
||||
|
||||
#expect(output.contains("converted_mime=image/png"))
|
||||
#expect(output.contains("converted_path=\(converted.path)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatsCommandRunsWithPlainOutput() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "limit": ["5"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await ChatsCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { NoOpContactResolver() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatsCommandIncludesContactNameInJson() async throws {
|
||||
let path = try CommandTestDatabase.makePathDirectChat()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "limit": ["5"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let resolver = MockContactResolver(names: ["+123": "Alice"])
|
||||
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await ChatsCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { resolver }
|
||||
)
|
||||
}
|
||||
let payload = try jsonObject(from: output)
|
||||
#expect(payload["contact_name"] as? String == "Alice")
|
||||
#expect(payload["identifier"] as? String == "+123")
|
||||
}
|
||||
|
||||
@Test
|
||||
func historyCommandUsesContactNameForPlainIncomingSender() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let resolver = MockContactResolver(names: ["+123": "Alice"])
|
||||
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await HistoryCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
contactResolverFactory: { resolver }
|
||||
)
|
||||
}
|
||||
#expect(output.contains("[recv] Alice: hello"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendCommandRejectsMissingRecipient() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["text": ["hi"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await SendCommand.spec.run(values, runtime)
|
||||
#expect(Bool(false))
|
||||
} catch let error as ParsedValuesError {
|
||||
#expect(error.description.contains("Missing required option"))
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendCommandResolvesUniqueContactName() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["Alice"], "text": ["hi"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let resolver = MockContactResolver(
|
||||
matches: [ContactMatch(name: "Alice Smith", handle: "+15551234567")]
|
||||
)
|
||||
var captured: MessageSendOptions?
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await SendCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
sendMessage: { options in captured = options },
|
||||
resolveSentMessage: { _, _, _, _ in nil },
|
||||
contactResolverFactory: { _ in resolver }
|
||||
)
|
||||
}
|
||||
#expect(captured?.recipient == "+15551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendCommandRejectsAmbiguousContactName() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["John"], "text": ["hi"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let resolver = MockContactResolver(
|
||||
matches: [
|
||||
ContactMatch(name: "John Smith", handle: "+15551234567"),
|
||||
ContactMatch(name: "John Doe", handle: "+15557654321"),
|
||||
]
|
||||
)
|
||||
do {
|
||||
try await SendCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
sendMessage: { _ in },
|
||||
resolveSentMessage: { _, _, _, _ in nil },
|
||||
contactResolverFactory: { _ in resolver }
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch let error as IMsgError {
|
||||
#expect(error.localizedDescription.contains("Multiple contacts match"))
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendCommandRunsWithStubSender() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"], "text": ["hi"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var captured: MessageSendOptions?
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await SendCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
sendMessage: { options in
|
||||
captured = options
|
||||
},
|
||||
resolveSentMessage: { _, _, _, _ in nil }
|
||||
)
|
||||
}
|
||||
#expect(captured?.recipient == "+15551234567")
|
||||
#expect(captured?.text == "hi")
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendCommandResolvesChatID() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "text": ["hi"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var captured: MessageSendOptions?
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await SendCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
sendMessage: { options in
|
||||
captured = options
|
||||
},
|
||||
resolveSentMessage: { _, _, _, _ in nil }
|
||||
)
|
||||
}
|
||||
#expect(captured?.chatIdentifier == "+123")
|
||||
#expect(captured?.chatGUID == "iMessage;+;chat123")
|
||||
#expect(captured?.recipient.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendCommandRejectsMisroutedChatGhost() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "text": ["hi"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
|
||||
do {
|
||||
try await SendCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
sendMessage: { _ in
|
||||
let db = try Connection(path)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (99, 'iMessage;+;chat123')")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (99, 99, '', ?, 1, 'SMS')
|
||||
""",
|
||||
CommandTestDatabase.appleEpoch(Date())
|
||||
)
|
||||
},
|
||||
resolveSentMessage: { _, _, _, _ in nil }
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch let error as IMsgError {
|
||||
#expect(error.localizedDescription.contains("unjoined empty outgoing row"))
|
||||
}
|
||||
}
|
||||
|
||||
private func jsonObject(from output: String) throws -> [String: Any] {
|
||||
let line = output.split(separator: "\n").first.map(String.init) ?? ""
|
||||
let data = Data(line.utf8)
|
||||
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
@ -4,25 +4,90 @@ import Testing
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func commandRouterPrintsVersionFromEnv() async throws {
|
||||
func commandRouterPrintsVersionFromEnv() async {
|
||||
setenv("IMSG_VERSION", "9.9.9-test", 1)
|
||||
defer { unsetenv("IMSG_VERSION") }
|
||||
let router = CommandRouter()
|
||||
#expect(router.version == "9.9.9-test")
|
||||
let status = await router.run(argv: ["imsg", "--version"])
|
||||
let (_, status) = await StdoutCapture.capture {
|
||||
await router.run(argv: ["imsg", "--version"])
|
||||
}
|
||||
#expect(status == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandRouterPrintsHelp() async {
|
||||
let router = CommandRouter()
|
||||
let status = await router.run(argv: ["imsg", "--help"])
|
||||
let (_, status) = await StdoutCapture.capture {
|
||||
await router.run(argv: ["imsg", "--help"])
|
||||
}
|
||||
#expect(status == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandRouterUnknownCommand() async {
|
||||
let router = CommandRouter()
|
||||
let status = await router.run(argv: ["imsg", "nope"])
|
||||
let (_, status) = await StdoutCapture.capture {
|
||||
await router.run(argv: ["imsg", "nope"])
|
||||
}
|
||||
#expect(status == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandRouterIncludesGroupCommand() {
|
||||
let router = CommandRouter()
|
||||
#expect(router.specs.contains { $0.name == "group" })
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandRouterIncludesCompletionsCommand() {
|
||||
let router = CommandRouter()
|
||||
#expect(router.specs.contains { $0.name == "completions" })
|
||||
}
|
||||
|
||||
@Test
|
||||
func completionsGenerateAllFormats() throws {
|
||||
let specs = CommandRouter().specs
|
||||
let bash = try CompletionGenerator.generate(shell: "bash", rootName: "imsg", specs: specs)
|
||||
let zsh = try CompletionGenerator.generate(shell: "zsh", rootName: "imsg", specs: specs)
|
||||
let fish = try CompletionGenerator.generate(shell: "fish", rootName: "imsg", specs: specs)
|
||||
let llm = try CompletionGenerator.generate(shell: "llm", rootName: "imsg", specs: specs)
|
||||
|
||||
#expect(bash.contains("complete -F _imsg imsg"))
|
||||
#expect(zsh.contains("#compdef imsg"))
|
||||
#expect(fish.contains("complete -c imsg"))
|
||||
#expect(llm.contains("# imsg CLI Reference"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func completionsIncludeCurrentCommandsAndOptions() throws {
|
||||
let specs = CommandRouter().specs
|
||||
let output = try CompletionGenerator.generate(shell: "llm", rootName: "imsg", specs: specs)
|
||||
for spec in specs {
|
||||
#expect(output.contains("### \(spec.name)"))
|
||||
}
|
||||
#expect(output.contains("--convert-attachments"))
|
||||
#expect(output.contains("--reaction, -r <value>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func completionsRejectUnknownShell() {
|
||||
do {
|
||||
_ = try CompletionGenerator.generate(shell: "powershell", rootName: "imsg", specs: [])
|
||||
#expect(Bool(false))
|
||||
} catch let error as CompletionError {
|
||||
#expect(error.description.contains("Unknown shell"))
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func completionsCommandRunsThroughRouter() async {
|
||||
let router = CommandRouter()
|
||||
let (output, status) = await StdoutCapture.capture {
|
||||
await router.run(argv: ["imsg", "completions", "fish"])
|
||||
}
|
||||
#expect(status == 0)
|
||||
#expect(output.contains("complete -c imsg"))
|
||||
}
|
||||
|
||||
252
Tests/imsgTests/CommandTestDatabase.swift
Normal file
252
Tests/imsgTests/CommandTestDatabase.swift
Normal file
@ -0,0 +1,252 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
enum CommandTestDatabase {
|
||||
static func appleEpoch(_ date: Date) -> Int64 {
|
||||
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
|
||||
return Int64(seconds * 1_000_000_000)
|
||||
}
|
||||
|
||||
static func makePath() throws -> String {
|
||||
let path = try makeDatabasePath()
|
||||
let db = try Connection(path)
|
||||
try createSchema(db, includeChatHandleJoin: true)
|
||||
try seedBasicChat(db)
|
||||
return path
|
||||
}
|
||||
|
||||
static func makePathDirectChat() throws -> String {
|
||||
let path = try makePath()
|
||||
let db = try Connection(path)
|
||||
try db.run(
|
||||
"""
|
||||
UPDATE chat
|
||||
SET chat_identifier = '+123', guid = 'iMessage;-;+123', display_name = 'Direct Chat'
|
||||
WHERE ROWID = 1
|
||||
"""
|
||||
)
|
||||
return path
|
||||
}
|
||||
|
||||
static func makePathWithAttachment(
|
||||
filename: String = "/tmp/file.dat",
|
||||
transferName: String = "file.dat",
|
||||
uti: String = "public.data",
|
||||
mimeType: String = "application/octet-stream"
|
||||
) throws -> String {
|
||||
let path = try makePath()
|
||||
let db = try Connection(path)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
|
||||
VALUES (1, ?, ?, ?, ?, 10, 0)
|
||||
""",
|
||||
filename,
|
||||
transferName,
|
||||
uti,
|
||||
mimeType
|
||||
)
|
||||
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
|
||||
return path
|
||||
}
|
||||
|
||||
static func makeStoreForRPC() throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
try createSchema(db, includeChatHandleJoin: true)
|
||||
try seedRPCChat(db)
|
||||
return try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
}
|
||||
|
||||
static func makeStoreForRPCDirectChat() throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
try createSchema(db, includeChatHandleJoin: true)
|
||||
try seedRPCChat(db)
|
||||
try db.run(
|
||||
"""
|
||||
UPDATE chat
|
||||
SET chat_identifier = '+123', guid = 'iMessage;-;+123', display_name = 'Direct Chat'
|
||||
WHERE ROWID = 1
|
||||
"""
|
||||
)
|
||||
return try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
}
|
||||
|
||||
static func makeStoreForRPCWithAttachment(
|
||||
filename: String,
|
||||
transferName: String,
|
||||
uti: String,
|
||||
mimeType: String
|
||||
) throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
try createSchema(db, includeChatHandleJoin: true)
|
||||
try seedRPCChat(db)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
|
||||
VALUES (1, ?, ?, ?, ?, 10, 0)
|
||||
""",
|
||||
filename,
|
||||
transferName,
|
||||
uti,
|
||||
mimeType
|
||||
)
|
||||
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (5, 1)")
|
||||
return try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
}
|
||||
|
||||
static func makeStoreForRPCWithReaction() throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
try createSchema(db, includeChatHandleJoin: true, includeReactionColumns: true)
|
||||
try seedRPCChat(db)
|
||||
try db.run("UPDATE message SET guid = 'msg-guid-5' WHERE ROWID = 5")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(
|
||||
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
|
||||
date, is_from_me, service
|
||||
)
|
||||
VALUES (6, 2, '', 'reaction-guid-6', 'p:0/msg-guid-5', 2001, ?, 0, 'iMessage')
|
||||
""",
|
||||
appleEpoch(Date().addingTimeInterval(1))
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 6)")
|
||||
return try MessageStore(connection: db, path: ":memory:")
|
||||
}
|
||||
|
||||
private static func makeDatabasePath() throws -> String {
|
||||
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("chat.db").path
|
||||
}
|
||||
|
||||
private static func createSchema(
|
||||
_ db: Connection,
|
||||
includeChatHandleJoin: Bool,
|
||||
includeReactionColumns: Bool = false
|
||||
) throws {
|
||||
let reactionColumns =
|
||||
includeReactionColumns
|
||||
? [
|
||||
"guid TEXT",
|
||||
"associated_message_guid TEXT",
|
||||
"associated_message_type INTEGER",
|
||||
].joined(separator: ",\n") + ","
|
||||
: ""
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
\(reactionColumns)
|
||||
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);")
|
||||
if includeChatHandleJoin {
|
||||
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 message_attachment_join (message_id INTEGER, attachment_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
|
||||
);
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
private static func seedBasicChat(_ db: Connection) throws {
|
||||
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, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage',
|
||||
'iMessage;+;me@icloud.com', 'me@icloud.com', '+15551234567'
|
||||
)
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
|
||||
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1)")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (1, 1, 'hello', ?, 0, 'iMessage')
|
||||
""",
|
||||
appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
|
||||
}
|
||||
|
||||
private static func seedRPCChat(_ db: Connection) throws {
|
||||
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, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage',
|
||||
'iMessage;+;me@icloud.com', 'me@icloud.com', 'me@icloud.com'
|
||||
)
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'me@icloud.com')")
|
||||
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (5, 1, 'hello', ?, 0, 'iMessage')
|
||||
""",
|
||||
appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 5)")
|
||||
}
|
||||
}
|
||||
@ -1,457 +0,0 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import SQLite
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
private enum CommandTestDatabase {
|
||||
static func appleEpoch(_ date: Date) -> Int64 {
|
||||
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
|
||||
return Int64(seconds * 1_000_000_000)
|
||||
}
|
||||
|
||||
static func makePath() throws -> String {
|
||||
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let path = dir.appendingPathComponent("chat.db").path
|
||||
let db = try Connection(path)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
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
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
|
||||
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
|
||||
try db.execute(
|
||||
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_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
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
|
||||
VALUES (1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (1, 1, 'hello', ?, 0, 'iMessage')
|
||||
""",
|
||||
appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
|
||||
return path
|
||||
}
|
||||
|
||||
static func makePathWithAttachment() throws -> String {
|
||||
let path = try makePath()
|
||||
let db = try Connection(path)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
|
||||
VALUES (1, '/tmp/file.dat', 'file.dat', 'public.data', 'application/octet-stream', 10, 0)
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatsCommandRunsWithJsonOutput() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "limit": ["5"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
try await ChatsCommand.spec.run(values, runtime)
|
||||
}
|
||||
|
||||
@Test
|
||||
func historyCommandRunsWithChatID() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
try await HistoryCommand.spec.run(values, runtime)
|
||||
}
|
||||
|
||||
@Test
|
||||
func historyCommandRunsWithAttachmentsNonJson() async throws {
|
||||
let path = try CommandTestDatabase.makePathWithAttachment()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "limit": ["5"]],
|
||||
flags: ["attachments"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
try await HistoryCommand.spec.run(values, runtime)
|
||||
}
|
||||
|
||||
@Test
|
||||
func chatsCommandRunsWithPlainOutput() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "limit": ["5"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
try await ChatsCommand.spec.run(values, runtime)
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendCommandRejectsMissingRecipient() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["text": ["hi"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await SendCommand.spec.run(values, runtime)
|
||||
#expect(Bool(false))
|
||||
} catch let error as ParsedValuesError {
|
||||
#expect(error.description.contains("Missing required option"))
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendCommandRunsWithStubSender() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"], "text": ["hi"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var captured: MessageSendOptions?
|
||||
try await SendCommand.run(
|
||||
values: values, runtime: runtime,
|
||||
sendMessage: { options in
|
||||
captured = options
|
||||
})
|
||||
#expect(captured?.recipient == "+15551234567")
|
||||
#expect(captured?.text == "hi")
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendCommandResolvesChatID() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "text": ["hi"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var captured: MessageSendOptions?
|
||||
try await SendCommand.run(
|
||||
values: values, runtime: runtime,
|
||||
sendMessage: { options in
|
||||
captured = options
|
||||
})
|
||||
#expect(captured?.chatIdentifier == "+123")
|
||||
#expect(captured?.chatGUID == "iMessage;+;chat123")
|
||||
#expect(captured?.recipient.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchCommandRejectsInvalidDebounce() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["debounce": ["nope"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await WatchCommand.spec.run(values, runtime)
|
||||
#expect(Bool(false))
|
||||
} catch let error as ParsedValuesError {
|
||||
#expect(error.description.contains("Invalid value"))
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchCommandRunsWithStubStream() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let db = try Connection(.inMemory)
|
||||
let store = try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
let message = Message(
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
sender: "+123",
|
||||
text: "hello",
|
||||
date: Date(),
|
||||
isFromMe: false,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 2
|
||||
)
|
||||
let streamProvider:
|
||||
(
|
||||
MessageWatcher,
|
||||
Int64?,
|
||||
Int64?,
|
||||
MessageWatcherConfiguration
|
||||
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(message)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchCommandRunsWithJsonOutput() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let db = try Connection(.inMemory)
|
||||
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);")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
|
||||
VALUES (1, '/tmp/file.dat', 'file.dat', 'public.data', 'application/octet-stream', 10, 0)
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
|
||||
|
||||
let store = try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
let message = Message(
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
sender: "+123",
|
||||
text: "hello",
|
||||
date: Date(),
|
||||
isFromMe: false,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 1
|
||||
)
|
||||
let streamProvider:
|
||||
(
|
||||
MessageWatcher,
|
||||
Int64?,
|
||||
Int64?,
|
||||
MessageWatcherConfiguration
|
||||
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(message)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchCommandFlushesPlainOutput() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let db = try Connection(.inMemory)
|
||||
let store = try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
let message = Message(
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
sender: "+123",
|
||||
text: "hello",
|
||||
date: Date(),
|
||||
isFromMe: false,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 0
|
||||
)
|
||||
let streamProvider:
|
||||
(
|
||||
MessageWatcher,
|
||||
Int64?,
|
||||
Int64?,
|
||||
MessageWatcherConfiguration
|
||||
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(message)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
}
|
||||
#expect(output.contains("hello"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchCommandFlushesJsonOutput() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let db = try Connection(.inMemory)
|
||||
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);")
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
let store = try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
let message = Message(
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
sender: "+123",
|
||||
text: "hello",
|
||||
date: Date(),
|
||||
isFromMe: false,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 0
|
||||
)
|
||||
let streamProvider:
|
||||
(
|
||||
MessageWatcher,
|
||||
Int64?,
|
||||
Int64?,
|
||||
MessageWatcherConfiguration
|
||||
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(message)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
}
|
||||
#expect(output.contains("\"text\":\"hello\""))
|
||||
}
|
||||
102
Tests/imsgTests/ContactResolutionTests.swift
Normal file
102
Tests/imsgTests/ContactResolutionTests.swift
Normal file
@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func contactNameDetectionIgnoresPhonesAndEmails() {
|
||||
#expect(ChatTargetResolver.looksLikeContactName("+15551234567") == false)
|
||||
#expect(ChatTargetResolver.looksLikeContactName("(555) 123-4567") == false)
|
||||
#expect(ChatTargetResolver.looksLikeContactName("user@example.com") == false)
|
||||
#expect(ChatTargetResolver.looksLikeContactName("") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func contactNameDetectionAcceptsNames() {
|
||||
#expect(ChatTargetResolver.looksLikeContactName("John Smith") == true)
|
||||
#expect(ChatTargetResolver.looksLikeContactName("Alice") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func contactNameResolutionPassesThroughUnknownNames() throws {
|
||||
let resolver = MockContactResolver()
|
||||
let resolved = try ChatTargetResolver.resolveRecipientName("Unknown Person", contacts: resolver)
|
||||
#expect(resolved == "Unknown Person")
|
||||
}
|
||||
|
||||
@Test
|
||||
func contactNameResolutionReturnsUniqueMatch() throws {
|
||||
let resolver = MockContactResolver(
|
||||
matches: [ContactMatch(name: "John Smith", handle: "+15551234567")]
|
||||
)
|
||||
let resolved = try ChatTargetResolver.resolveRecipientName("John", contacts: resolver)
|
||||
#expect(resolved == "+15551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
func contactNameResolutionRejectsAmbiguousMatches() {
|
||||
let resolver = MockContactResolver(
|
||||
matches: [
|
||||
ContactMatch(name: "John Smith", handle: "+15551234567"),
|
||||
ContactMatch(name: "John Doe", handle: "+15557654321"),
|
||||
]
|
||||
)
|
||||
#expect(throws: (any Error).self) {
|
||||
try ChatTargetResolver.resolveRecipientName("John", contacts: resolver)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func encodedChatPayloadIncludesContactName() throws {
|
||||
let chat = Chat(
|
||||
id: 1,
|
||||
identifier: "+15551234567",
|
||||
name: "+15551234567",
|
||||
service: "iMessage",
|
||||
lastMessageAt: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
let payload = ChatPayload(chat: chat, contactName: "John Smith")
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let object = try JSONSerialization.jsonObject(with: data)
|
||||
let json = try #require(object as? [String: Any])
|
||||
#expect(json["contact_name"] as? String == "John Smith")
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagePayloadIncludesSenderName() throws {
|
||||
let message = Message(
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
sender: "+15551234567",
|
||||
text: "hello",
|
||||
date: Date(timeIntervalSince1970: 0),
|
||||
isFromMe: false,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 0,
|
||||
guid: "msg-1"
|
||||
)
|
||||
let payload = MessagePayload(message: message, attachments: [], senderName: "John Smith")
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let object = try JSONSerialization.jsonObject(with: data)
|
||||
let json = try #require(object as? [String: Any])
|
||||
#expect(json["sender_name"] as? String == "John Smith")
|
||||
}
|
||||
|
||||
@Test
|
||||
func reactionPayloadIncludesSenderName() throws {
|
||||
let reaction = Reaction(
|
||||
rowID: 2,
|
||||
reactionType: .like,
|
||||
sender: "+15551234567",
|
||||
isFromMe: false,
|
||||
date: Date(timeIntervalSince1970: 0),
|
||||
associatedMessageID: 1
|
||||
)
|
||||
let payload = ReactionPayload(reaction: reaction, senderName: "John Smith")
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let object = try JSONSerialization.jsonObject(with: data)
|
||||
let json = try #require(object as? [String: Any])
|
||||
#expect(json["sender_name"] as? String == "John Smith")
|
||||
}
|
||||
98
Tests/imsgTests/GroupCommandTests.swift
Normal file
98
Tests/imsgTests/GroupCommandTests.swift
Normal file
@ -0,0 +1,98 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func groupCommandRequiresChatID() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: [:],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await GroupCommand.spec.run(values, runtime)
|
||||
#expect(Bool(false))
|
||||
} catch let error as ParsedValuesError {
|
||||
#expect(error.description.contains("Missing required option"))
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func groupCommandThrowsOnUnknownChatID() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["9999"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await GroupCommand.spec.run(values, runtime)
|
||||
#expect(Bool(false))
|
||||
} catch let error as IMsgError {
|
||||
#expect(error.errorDescription?.contains("9999") == true)
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func groupCommandPrintsPlainTextForGroup() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await GroupCommand.spec.run(values, runtime)
|
||||
}
|
||||
#expect(output.contains("id: 1"))
|
||||
#expect(output.contains("identifier: +123"))
|
||||
#expect(output.contains("guid: iMessage;+;chat123"))
|
||||
#expect(output.contains("name: Test Chat"))
|
||||
#expect(output.contains("service: iMessage"))
|
||||
#expect(output.contains("account_id: iMessage;+;me@icloud.com"))
|
||||
#expect(output.contains("account_login: me@icloud.com"))
|
||||
#expect(output.contains("last_addressed_handle: +15551234567"))
|
||||
#expect(output.contains("is_group: true"))
|
||||
#expect(output.contains("- +123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func groupCommandEmitsJsonPayload() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await GroupCommand.spec.run(values, runtime)
|
||||
}
|
||||
let payload = try jsonObject(from: output)
|
||||
#expect(payload["id"] as? Int == 1)
|
||||
#expect(payload["identifier"] as? String == "+123")
|
||||
#expect(payload["guid"] as? String == "iMessage;+;chat123")
|
||||
#expect(payload["name"] as? String == "Test Chat")
|
||||
#expect(payload["service"] as? String == "iMessage")
|
||||
#expect(payload["account_id"] as? String == "iMessage;+;me@icloud.com")
|
||||
#expect(payload["account_login"] as? String == "me@icloud.com")
|
||||
#expect(payload["last_addressed_handle"] as? String == "+15551234567")
|
||||
#expect(payload["is_group"] as? Bool == true)
|
||||
#expect(payload["participants"] as? [String] == ["+123"])
|
||||
}
|
||||
|
||||
private func jsonObject(from output: String) throws -> [String: Any] {
|
||||
let line = output.split(separator: "\n").first.map(String.init) ?? ""
|
||||
let data = Data(line.utf8)
|
||||
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
59
Tests/imsgTests/LaunchStatusCommandTests.swift
Normal file
59
Tests/imsgTests/LaunchStatusCommandTests.swift
Normal file
@ -0,0 +1,59 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func commandRouterIncludesLaunchCommand() async {
|
||||
let router = CommandRouter()
|
||||
let names = router.specs.map(\.name)
|
||||
#expect(names.contains("launch"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandRouterIncludesReadCommand() async {
|
||||
let router = CommandRouter()
|
||||
let names = router.specs.map(\.name)
|
||||
#expect(names.contains("read"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandRouterIncludesStatusCommand() async {
|
||||
let router = CommandRouter()
|
||||
let names = router.specs.map(\.name)
|
||||
#expect(names.contains("status"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func statusCommandProducesJsonOutput() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: [:],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
|
||||
let (output, _) = await StdoutCapture.capture {
|
||||
try? await StatusCommand.run(values: values, runtime: runtime)
|
||||
}
|
||||
// JSON output should contain expected keys
|
||||
#expect(output.contains("basic_features"))
|
||||
#expect(output.contains("advanced_features"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func statusCommandProducesTextOutput() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: [:],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
|
||||
let (output, _) = await StdoutCapture.capture {
|
||||
try? await StatusCommand.run(values: values, runtime: runtime)
|
||||
}
|
||||
#expect(output.contains("imsg Status Report"))
|
||||
}
|
||||
36
Tests/imsgTests/MockContactResolver.swift
Normal file
36
Tests/imsgTests/MockContactResolver.swift
Normal file
@ -0,0 +1,36 @@
|
||||
import IMsgCore
|
||||
|
||||
final class MockContactResolver: ContactResolving, Sendable {
|
||||
let contactsUnavailable: Bool
|
||||
private let names: [String: String]
|
||||
private let matches: [ContactMatch]
|
||||
|
||||
init(
|
||||
names: [String: String] = [:],
|
||||
matches: [ContactMatch] = [],
|
||||
contactsUnavailable: Bool = false
|
||||
) {
|
||||
self.names = names
|
||||
self.matches = matches
|
||||
self.contactsUnavailable = contactsUnavailable
|
||||
}
|
||||
|
||||
func displayName(for handle: String) -> String? {
|
||||
names[handle]
|
||||
}
|
||||
|
||||
func displayNames(for handles: [String]) -> [String: String] {
|
||||
var resolved: [String: String] = [:]
|
||||
for handle in handles {
|
||||
if let name = displayName(for: handle) {
|
||||
resolved[handle] = name
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func searchByName(_ query: String) -> [ContactMatch] {
|
||||
let normalizedQuery = query.lowercased()
|
||||
return matches.filter { $0.name.lowercased().contains(normalizedQuery) }
|
||||
}
|
||||
}
|
||||
106
Tests/imsgTests/README-live.md
Normal file
106
Tests/imsgTests/README-live.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Live bridge smoke tests
|
||||
|
||||
These exercises run on a real SIP-disabled Mac with `Messages.app` signed in
|
||||
and the helper dylib injected. They are gated by `IMSG_LIVE_BRIDGE=1` so they
|
||||
never run in CI. Each step prints what should happen so you can eyeball the
|
||||
result in `Messages.app` (the dylib has no way to fake-confirm a UI mutation).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
# In Recovery mode
|
||||
csrutil disable
|
||||
|
||||
# Back in normal boot:
|
||||
make build && make build-dylib
|
||||
imsg launch # kills + relaunches Messages with DYLD_INSERT
|
||||
imsg status # expect: bridge version: v2 (v2 inbox active)
|
||||
```
|
||||
|
||||
## Pick a target chat
|
||||
|
||||
```bash
|
||||
imsg chats --limit 10 --json | jq -r '.[] | "\(.guid)\t\(.name // .identifier)"'
|
||||
export CHAT='iMessage;-;+15551234567' # paste guid from above
|
||||
```
|
||||
|
||||
## 1. send-rich + effects
|
||||
|
||||
```bash
|
||||
imsg send-rich --chat "$CHAT" --text "test from imsg v2"
|
||||
imsg send-rich --chat "$CHAT" --text "BOOM" \
|
||||
--effect com.apple.MobileSMS.expressivesend.impact
|
||||
imsg send-rich --chat "$CHAT" --text "📜 ---" \
|
||||
--effect com.apple.MobileSMS.expressivesend.invisibleink
|
||||
```
|
||||
|
||||
Expect: each message shows in Messages.app immediately. The 2nd applies the
|
||||
slam effect; the 3rd shows as invisible ink.
|
||||
|
||||
## 2. tapback round-trip
|
||||
|
||||
```bash
|
||||
# Capture the messageGuid of an existing message you want to react to
|
||||
imsg history --chat-id 1 --limit 1 --json | jq -r '.guid'
|
||||
export MSG=<paste guid>
|
||||
imsg tapback --chat "$CHAT" --message "$MSG" --kind love
|
||||
imsg tapback --chat "$CHAT" --message "$MSG" --kind love --remove
|
||||
```
|
||||
|
||||
Expect: 💖 appears, then disappears.
|
||||
|
||||
## 3. edit / unsend (macOS 13+ only)
|
||||
|
||||
```bash
|
||||
imsg send-rich --chat "$CHAT" --text "rough draft"
|
||||
# Capture the new guid:
|
||||
imsg history --chat-id 1 --limit 1 --json | jq -r '.guid'
|
||||
export MSG=<paste guid>
|
||||
imsg edit --chat "$CHAT" --message "$MSG" --new-text "polished version"
|
||||
imsg unsend --chat "$CHAT" --message "$MSG"
|
||||
```
|
||||
|
||||
Expect: the message text changes, then a "You unsent a message" placeholder
|
||||
appears. If `imsg status` shows `editMessageItem: ✗` AND `editMessage: ✗`,
|
||||
your macOS is too old (pre-13) — these will return an error.
|
||||
|
||||
## 4. chat creation + member management
|
||||
|
||||
```bash
|
||||
imsg chat-create --addresses '+15551111111,+15552222222' \
|
||||
--name 'imsg test' --text 'hello' --json
|
||||
# Capture the new chatGuid from the JSON output:
|
||||
export GROUP=<paste chatGuid>
|
||||
imsg chat-add-member --chat "$GROUP" --address +15553333333
|
||||
imsg chat-name --chat "$GROUP" --name 'imsg test renamed'
|
||||
imsg chat-photo --chat "$GROUP" --file ~/Pictures/test.jpg
|
||||
imsg chat-remove-member --chat "$GROUP" --address +15553333333
|
||||
imsg chat-leave --chat "$GROUP"
|
||||
```
|
||||
`chat-create` is iMessage-only; use `imsg send --service sms` for SMS sends.
|
||||
|
||||
Expect: each step is visible in Messages.app within a second or two.
|
||||
|
||||
## 5. typing events streaming
|
||||
|
||||
```bash
|
||||
imsg watch --bb-events --json &
|
||||
# from another device or simulator, type into your conversation
|
||||
# you should see started-typing / stopped-typing JSON objects emit
|
||||
kill %1
|
||||
```
|
||||
|
||||
## 6. introspection
|
||||
|
||||
```bash
|
||||
imsg account
|
||||
imsg whois --address +15551234567 --type phone
|
||||
imsg nickname --address +15551234567
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
killall Messages # un-inject; next launch is normal
|
||||
csrutil enable # in Recovery, re-enable SIP when done
|
||||
```
|
||||
@ -7,7 +7,7 @@ import Testing
|
||||
@Test
|
||||
func isGroupHandleFlagsGroup() {
|
||||
#expect(isGroupHandle(identifier: "iMessage;+;chat123", guid: "") == true)
|
||||
#expect(isGroupHandle(identifier: "", guid: "iMessage;-;chat999") == true)
|
||||
#expect(isGroupHandle(identifier: "", guid: "iMessage;-;chat999") == false)
|
||||
#expect(isGroupHandle(identifier: "+1555", guid: "") == false)
|
||||
}
|
||||
|
||||
@ -30,7 +30,22 @@ func chatPayloadIncludesParticipantsAndGroupFlag() {
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagePayloadIncludesChatFields() {
|
||||
func chatPayloadIncludesContactName() {
|
||||
let payload = chatPayload(
|
||||
id: 2,
|
||||
identifier: "+15551234567",
|
||||
guid: "iMessage;-;+15551234567",
|
||||
name: "+15551234567",
|
||||
service: "iMessage",
|
||||
lastMessageAt: Date(timeIntervalSince1970: 0),
|
||||
participants: ["+15551234567"],
|
||||
contactName: "Alice"
|
||||
)
|
||||
#expect(payload["contact_name"] as? String == "Alice")
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagePayloadIncludesChatFields() throws {
|
||||
let message = Message(
|
||||
rowID: 5,
|
||||
chatID: 10,
|
||||
@ -43,7 +58,8 @@ func messagePayloadIncludesChatFields() {
|
||||
attachmentsCount: 1,
|
||||
guid: "msg-guid-5",
|
||||
replyToGUID: "msg-guid-1",
|
||||
threadOriginatorGUID: "thread-guid-5"
|
||||
threadOriginatorGUID: "thread-guid-5",
|
||||
destinationCallerID: "me@icloud.com"
|
||||
)
|
||||
let chatInfo = ChatInfo(
|
||||
id: 10,
|
||||
@ -60,6 +76,8 @@ func messagePayloadIncludesChatFields() {
|
||||
totalBytes: 12,
|
||||
isSticker: false,
|
||||
originalPath: "/tmp/file.dat",
|
||||
convertedPath: "/tmp/file.png",
|
||||
convertedMimeType: "image/png",
|
||||
missing: false
|
||||
)
|
||||
let reaction = Reaction(
|
||||
@ -70,7 +88,7 @@ func messagePayloadIncludesChatFields() {
|
||||
date: Date(timeIntervalSince1970: 2),
|
||||
associatedMessageID: 5
|
||||
)
|
||||
let payload = messagePayload(
|
||||
let payload = try messagePayload(
|
||||
message: message,
|
||||
chatInfo: chatInfo,
|
||||
participants: ["+111"],
|
||||
@ -80,18 +98,58 @@ func messagePayloadIncludesChatFields() {
|
||||
#expect(payload["chat_id"] as? Int64 == 10)
|
||||
#expect(payload["guid"] as? String == "msg-guid-5")
|
||||
#expect(payload["reply_to_guid"] as? String == "msg-guid-1")
|
||||
#expect(payload["destination_caller_id"] as? String == "me@icloud.com")
|
||||
#expect(payload["thread_originator_guid"] as? String == "thread-guid-5")
|
||||
#expect(payload["chat_identifier"] as? String == "iMessage;+;chat123")
|
||||
#expect(payload["chat_name"] as? String == "Group")
|
||||
#expect(payload["is_group"] as? Bool == true)
|
||||
#expect((payload["attachments"] as? [[String: Any]])?.count == 1)
|
||||
let attachmentPayload = (payload["attachments"] as? [[String: Any]])?.first
|
||||
#expect(attachmentPayload?["converted_path"] as? String == "/tmp/file.png")
|
||||
#expect(attachmentPayload?["converted_mime_type"] as? String == "image/png")
|
||||
#expect(
|
||||
(payload["reactions"] as? [[String: Any]])?.first?["emoji"] as? String
|
||||
== ReactionType.like.emoji)
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagePayloadOmitsEmptyReplyToGuid() {
|
||||
func messagePayloadIncludesSenderAndReactionNames() throws {
|
||||
let message = Message(
|
||||
rowID: 7,
|
||||
chatID: 10,
|
||||
sender: "+123",
|
||||
text: "hello",
|
||||
date: Date(timeIntervalSince1970: 1),
|
||||
isFromMe: false,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 0,
|
||||
guid: "msg-guid-7"
|
||||
)
|
||||
let reaction = Reaction(
|
||||
rowID: 101,
|
||||
reactionType: .love,
|
||||
sender: "+456",
|
||||
isFromMe: false,
|
||||
date: Date(timeIntervalSince1970: 2),
|
||||
associatedMessageID: 7
|
||||
)
|
||||
let payload = try messagePayload(
|
||||
message: message,
|
||||
chatInfo: nil,
|
||||
participants: [],
|
||||
attachments: [],
|
||||
reactions: [reaction],
|
||||
senderName: "Alice",
|
||||
reactionSenderNames: [101: "Bob"]
|
||||
)
|
||||
#expect(payload["sender_name"] as? String == "Alice")
|
||||
let reactions = payload["reactions"] as? [[String: Any]]
|
||||
#expect(reactions?.first?["sender_name"] as? String == "Bob")
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagePayloadOmitsEmptyReplyToGuid() throws {
|
||||
let message = Message(
|
||||
rowID: 6,
|
||||
chatID: 10,
|
||||
@ -105,7 +163,7 @@ func messagePayloadOmitsEmptyReplyToGuid() {
|
||||
guid: "msg-guid-6",
|
||||
replyToGUID: nil
|
||||
)
|
||||
let payload = messagePayload(
|
||||
let payload = try messagePayload(
|
||||
message: message,
|
||||
chatInfo: nil,
|
||||
participants: [],
|
||||
@ -113,10 +171,35 @@ func messagePayloadOmitsEmptyReplyToGuid() {
|
||||
reactions: []
|
||||
)
|
||||
#expect(payload["reply_to_guid"] == nil)
|
||||
#expect(payload["destination_caller_id"] == nil)
|
||||
#expect(payload["thread_originator_guid"] == nil)
|
||||
#expect(payload["guid"] as? String == "msg-guid-6")
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchDebounceIntervalDefaultsToHalfSecond() throws {
|
||||
#expect(try watchDebounceIntervalParam([:]) == 0.5)
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchDebounceIntervalAcceptsSnakeAndCamelCaseMilliseconds() throws {
|
||||
#expect(try watchDebounceIntervalParam(["debounce_ms": 750]) == 0.75)
|
||||
#expect(try watchDebounceIntervalParam(["debounceMs": "125"]) == 0.125)
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchDebounceIntervalRejectsInvalidValues() {
|
||||
do {
|
||||
_ = try watchDebounceIntervalParam(["debounce_ms": -1])
|
||||
#expect(Bool(false))
|
||||
} catch let error as RPCError {
|
||||
#expect(error.code == -32602)
|
||||
#expect(error.data?.contains("debounce_ms") == true)
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func paramParsingHelpers() {
|
||||
#expect(stringParam(123 as NSNumber) == "123")
|
||||
|
||||
@ -5,79 +5,6 @@ import Testing
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
private enum RPCTestDatabase {
|
||||
static func appleEpoch(_ date: Date) -> Int64 {
|
||||
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
|
||||
return Int64(seconds * 1_000_000_000)
|
||||
}
|
||||
|
||||
static func makeStore() throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
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
|
||||
);
|
||||
"""
|
||||
)
|
||||
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);")
|
||||
|
||||
let now = Date()
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
|
||||
VALUES (1, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'me@icloud.com')")
|
||||
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (5, 1, 'hello', ?, 0, 'iMessage')
|
||||
""",
|
||||
appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 5)")
|
||||
|
||||
return try MessageStore(
|
||||
connection: db, path: ":memory:", hasAttributedBody: false, hasReactionColumns: false)
|
||||
}
|
||||
}
|
||||
|
||||
final class TestRPCOutput: RPCOutput, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private(set) var responses: [[String: Any]] = []
|
||||
@ -117,9 +44,10 @@ private func int64Value(_ value: Any?) -> Int64? {
|
||||
|
||||
@Test
|
||||
func rpcChatsListReturnsChatPayload() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
let resolver = MockContactResolver(names: ["iMessage;+;chat123": "Family"])
|
||||
let server = RPCServer(store: store, verbose: false, output: output, contactResolver: resolver)
|
||||
|
||||
let line = #"{"jsonrpc":"2.0","id":"1","method":"chats.list","params":{"limit":10}}"#
|
||||
await server.handleLineForTesting(line)
|
||||
@ -132,12 +60,13 @@ func rpcChatsListReturnsChatPayload() async throws {
|
||||
#expect(int64Value(chat["id"]) == 1)
|
||||
#expect(chat["identifier"] as? String == "iMessage;+;chat123")
|
||||
#expect(chat["is_group"] as? Bool == true)
|
||||
#expect(chat["contact_name"] == nil)
|
||||
#expect((chat["participants"] as? [String])?.count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcMessagesHistoryIncludesChatFields() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -154,16 +83,53 @@ func rpcMessagesHistoryIncludesChatFields() async throws {
|
||||
#expect(message["is_group"] as? Bool == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcMessagesHistoryReportsConvertedAttachmentsWhenRequested() async throws {
|
||||
let source = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("caf")
|
||||
try Data("caf".utf8).write(to: source)
|
||||
defer { try? FileManager.default.removeItem(at: source) }
|
||||
let converted = AttachmentResolver.convertedURL(for: source.path, targetExtension: "m4a")
|
||||
try FileManager.default.createDirectory(
|
||||
at: converted.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("m4a".utf8).write(to: converted)
|
||||
defer { try? FileManager.default.removeItem(at: converted) }
|
||||
|
||||
let store = try CommandTestDatabase.makeStoreForRPCWithAttachment(
|
||||
filename: source.path,
|
||||
transferName: "voice.caf",
|
||||
uti: "com.apple.coreaudio-format",
|
||||
mimeType: "audio/x-caf"
|
||||
)
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
let line =
|
||||
#"{"jsonrpc":"2.0","id":2,"method":"messages.history","params":{"chat_id":1,"attachments":true,"convert_attachments":true}}"#
|
||||
await server.handleLineForTesting(line)
|
||||
|
||||
let result = output.responses.first?["result"] as? [String: Any]
|
||||
let messages = result?["messages"] as? [[String: Any]] ?? []
|
||||
let attachments = messages.first?["attachments"] as? [[String: Any]]
|
||||
#expect(attachments?.first?["original_path"] as? String == source.path)
|
||||
#expect(attachments?.first?["converted_path"] as? String == converted.path)
|
||||
#expect(attachments?.first?["converted_mime_type"] as? String == "audio/mp4")
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcSendResolvesChatID() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
var captured: MessageSendOptions?
|
||||
let server = RPCServer(
|
||||
store: store,
|
||||
verbose: false,
|
||||
output: output,
|
||||
sendMessage: { options in captured = options }
|
||||
sendMessage: { options in captured = options },
|
||||
resolveSentMessage: { _, _, _, _ in nil }
|
||||
)
|
||||
|
||||
let line = #"{"jsonrpc":"2.0","id":"3","method":"send","params":{"chat_id":1,"text":"yo"}}"#
|
||||
@ -175,9 +141,138 @@ func rpcSendResolvesChatID() async throws {
|
||||
#expect(output.responses.first?["result"] as? [String: Any] != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcSendResolvesUniqueContactName() async throws {
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let resolver = MockContactResolver(
|
||||
matches: [ContactMatch(name: "Alice Smith", handle: "+15551234567")]
|
||||
)
|
||||
var captured: MessageSendOptions?
|
||||
let server = RPCServer(
|
||||
store: store,
|
||||
verbose: false,
|
||||
output: output,
|
||||
sendMessage: { options in captured = options },
|
||||
resolveSentMessage: { _, _, _, _ in nil },
|
||||
contactResolver: resolver
|
||||
)
|
||||
|
||||
let line = #"{"jsonrpc":"2.0","id":"3n","method":"send","params":{"to":"Alice","text":"yo"}}"#
|
||||
await server.handleLineForTesting(line)
|
||||
|
||||
#expect(captured?.recipient == "+15551234567")
|
||||
#expect(output.responses.first?["result"] as? [String: Any] != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsAmbiguousContactName() async throws {
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let resolver = MockContactResolver(
|
||||
matches: [
|
||||
ContactMatch(name: "John Smith", handle: "+15551234567"),
|
||||
ContactMatch(name: "John Doe", handle: "+15557654321"),
|
||||
]
|
||||
)
|
||||
let server = RPCServer(store: store, verbose: false, output: output, contactResolver: resolver)
|
||||
|
||||
let line = #"{"jsonrpc":"2.0","id":"3m","method":"send","params":{"to":"John","text":"yo"}}"#
|
||||
await server.handleLineForTesting(line)
|
||||
|
||||
let error = output.errors.first?["error"] as? [String: Any]
|
||||
#expect(int64Value(error?["code"]) == -32602)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcSendReturnsSentMessageIdentifiersWhenResolved() async throws {
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(
|
||||
store: store,
|
||||
verbose: false,
|
||||
output: output,
|
||||
sendMessage: { _ in },
|
||||
resolveSentMessage: { _, options, chatID, _ in
|
||||
Message(
|
||||
rowID: 1_979,
|
||||
chatID: chatID ?? 0,
|
||||
sender: "me@icloud.com",
|
||||
text: options.text,
|
||||
date: Date(),
|
||||
isFromMe: true,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 0,
|
||||
guid: "8DF1B3D7"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
let line = #"{"jsonrpc":"2.0","id":"3b","method":"send","params":{"chat_id":1,"text":"yo"}}"#
|
||||
await server.handleLineForTesting(line)
|
||||
|
||||
let result = output.responses.first?["result"] as? [String: Any]
|
||||
#expect(result?["ok"] as? Bool == true)
|
||||
#expect(int64Value(result?["id"]) == 1_979)
|
||||
#expect(result?["guid"] as? String == "8DF1B3D7")
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcSendKeepsOkResponseWhenSentMessageIsNotResolved() async throws {
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(
|
||||
store: store,
|
||||
verbose: false,
|
||||
output: output,
|
||||
sendMessage: { _ in },
|
||||
resolveSentMessage: { _, _, _, _ in nil }
|
||||
)
|
||||
|
||||
let line = #"{"jsonrpc":"2.0","id":"3c","method":"send","params":{"chat_id":1,"text":"yo"}}"#
|
||||
await server.handleLineForTesting(line)
|
||||
|
||||
let result = output.responses.first?["result"] as? [String: Any]
|
||||
#expect(result?["ok"] as? Bool == true)
|
||||
#expect(result?["id"] == nil)
|
||||
#expect(result?["guid"] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcSendReportsMisroutedChatGhost() async throws {
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(
|
||||
store: store,
|
||||
verbose: false,
|
||||
output: output,
|
||||
sendMessage: { _ in
|
||||
try store.withConnection { db in
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (99, 'iMessage;+;chat123')")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (99, 99, '', ?, 1, 'SMS')
|
||||
""",
|
||||
CommandTestDatabase.appleEpoch(Date())
|
||||
)
|
||||
}
|
||||
},
|
||||
resolveSentMessage: { _, _, _, _ in nil }
|
||||
)
|
||||
|
||||
let line = #"{"jsonrpc":"2.0","id":"3d","method":"send","params":{"chat_id":1,"text":"yo"}}"#
|
||||
await server.handleLineForTesting(line)
|
||||
|
||||
let error = output.errors.first?["error"] as? [String: Any]
|
||||
#expect(int64Value(error?["code"]) == -32603)
|
||||
#expect((error?["data"] as? String)?.contains("unjoined empty outgoing row") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsMissingTextAndFile() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -191,7 +286,7 @@ func rpcSendRejectsMissingTextAndFile() async throws {
|
||||
|
||||
@Test
|
||||
func rpcRejectsInvalidJSON() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -203,7 +298,7 @@ func rpcRejectsInvalidJSON() async throws {
|
||||
|
||||
@Test
|
||||
func rpcRejectsNonObjectRequest() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -215,7 +310,7 @@ func rpcRejectsNonObjectRequest() async throws {
|
||||
|
||||
@Test
|
||||
func rpcRejectsInvalidJSONRPCVersion() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -228,7 +323,7 @@ func rpcRejectsInvalidJSONRPCVersion() async throws {
|
||||
|
||||
@Test
|
||||
func rpcRejectsMissingMethod() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -241,7 +336,7 @@ func rpcRejectsMissingMethod() async throws {
|
||||
|
||||
@Test
|
||||
func rpcReportsMethodNotFound() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -254,7 +349,7 @@ func rpcReportsMethodNotFound() async throws {
|
||||
|
||||
@Test
|
||||
func rpcHistoryRequiresChatID() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -267,7 +362,7 @@ func rpcHistoryRequiresChatID() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsInvalidService() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -281,7 +376,7 @@ func rpcSendRejectsInvalidService() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsMissingRecipientForDirectSend() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -294,7 +389,7 @@ func rpcSendRejectsMissingRecipientForDirectSend() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsChatAndRecipient() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -308,7 +403,7 @@ func rpcSendRejectsChatAndRecipient() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsUnknownChatID() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -321,7 +416,7 @@ func rpcSendRejectsUnknownChatID() async throws {
|
||||
|
||||
@Test
|
||||
func rpcWatchSubscribeEmitsNotificationAndUnsubscribe() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -349,9 +444,33 @@ func rpcWatchSubscribeEmitsNotificationAndUnsubscribe() async throws {
|
||||
#expect(output.responses.count >= 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcWatchIncludeReactionsDoesNotRequireAttachments() async throws {
|
||||
let store = try CommandTestDatabase.makeStoreForRPCWithReaction()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
let subscribe =
|
||||
#"{"jsonrpc":"2.0","id":13,"method":"watch.subscribe","params":{"chat_id":1,"#
|
||||
+ #""since_rowid":-1,"include_reactions":true,"attachments":false}}"#
|
||||
await server.handleLineForTesting(subscribe)
|
||||
|
||||
for _ in 0..<20 {
|
||||
if output.notifications.count >= 1 { break }
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
|
||||
let params = output.notifications.first?["params"] as? [String: Any]
|
||||
let message = params?["message"] as? [String: Any]
|
||||
let reactions = message?["reactions"] as? [[String: Any]] ?? []
|
||||
#expect(reactions.count == 1)
|
||||
#expect(reactions.first?["type"] as? String == "like")
|
||||
#expect((message?["attachments"] as? [[String: Any]])?.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rpcWatchUnsubscribeRequiresSubscription() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
|
||||
92
Tests/imsgTests/ReactCommandTests.swift
Normal file
92
Tests/imsgTests/ReactCommandTests.swift
Normal file
@ -0,0 +1,92 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func reactCommandRejectsMultiCharacterEmojiInput() async {
|
||||
do {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "reaction": ["🎉 party"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
try await ReactCommand.run(values: values, runtime: runtime)
|
||||
#expect(Bool(false))
|
||||
} catch let error as IMsgError {
|
||||
switch error {
|
||||
case .invalidReaction(let value):
|
||||
#expect(value == "🎉 party")
|
||||
default:
|
||||
#expect(Bool(false))
|
||||
}
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func reactCommandBuildsParameterizedAppleScriptForStandardTapback() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "reaction": ["like"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var capturedScript = ""
|
||||
var capturedArguments: [String] = []
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await ReactCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
appleScriptRunner: { source, arguments in
|
||||
capturedScript = source
|
||||
capturedArguments = arguments
|
||||
}
|
||||
)
|
||||
}
|
||||
#expect(capturedArguments == ["iMessage;+;chat123", "Test Chat", "2"])
|
||||
#expect(capturedScript.contains("on run argv"))
|
||||
#expect(capturedScript.contains("keystroke \"f\" using command down"))
|
||||
#expect(capturedScript.contains("set targetChat to chat id chatGUID"))
|
||||
#expect(capturedScript.contains("keystroke reactionKey"))
|
||||
#expect(capturedScript.contains("keystroke reactionKey\n delay 0.1\n key code 36"))
|
||||
#expect(capturedScript.contains("chat123") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reactCommandRejectsCustomEmojiSend() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"], "reaction": ["🎉"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await ReactCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
appleScriptRunner: { _, _ in
|
||||
#expect(Bool(false))
|
||||
}
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch let error as IMsgError {
|
||||
switch error {
|
||||
case .unsupportedReaction(let message):
|
||||
#expect(message.contains("custom emoji tapback"))
|
||||
#expect(message.contains("AppleScript automation"))
|
||||
#expect(message.contains("love"))
|
||||
default:
|
||||
#expect(Bool(false))
|
||||
}
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
71
Tests/imsgTests/ReadCommandTests.swift
Normal file
71
Tests/imsgTests/ReadCommandTests.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func readCommandRejectsChatAndRecipient() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"], "chatIdentifier": ["iMessage;+;chat123"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await ReadCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
markAsRead: { _ in }
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch let error as ParsedValuesError {
|
||||
#expect(error.description == "Invalid value for option: --to")
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func readCommandRunsWithRecipient() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var capturedHandle: String?
|
||||
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await ReadCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
markAsRead: { handle in capturedHandle = handle }
|
||||
)
|
||||
}
|
||||
|
||||
#expect(capturedHandle == "+15551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
func readCommandResolvesChatID() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var capturedHandle: String?
|
||||
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await ReadCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
markAsRead: { handle in capturedHandle = handle }
|
||||
)
|
||||
}
|
||||
|
||||
#expect(capturedHandle == "iMessage;+;chat123")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user