@mapier/imsg-sdk 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @mapier/imsg-sdk might be problematic. Click here for more details.

@@ -61,6 +61,8 @@ interface Gateway {
61
61
  chatBackgroundRemove: boolean;
62
62
  editMessage: boolean;
63
63
  mentionFormatting: boolean;
64
+ sharedLocations: boolean;
65
+ locationRequest: boolean;
64
66
  };
65
67
  subscribe(sinceId?: number): AsyncIterable<GatewayEvent>;
66
68
  history(chatId: number, limit?: number): Promise<ImsgMessage[]>;
@@ -104,10 +106,10 @@ interface Gateway {
104
106
  deleteMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
105
107
  setTyping(chatId: number, on: boolean): Promise<{ ok: boolean }>;
106
108
  markRead(chatId: number): Promise<{ ok: boolean }>;
107
- renameGroup(chatId: number, name: string): Promise<{ ok: boolean }>;
109
+ renameGroup(chatId: number, name: string): Promise<GroupMutationResult>;
108
110
  setGroupPhoto(chatId: number, filePath?: string): Promise<{ ok: boolean }>;
109
- addParticipant(chatId: number, handle: string): Promise<{ ok: boolean }>;
110
- removeParticipant(chatId: number, handle: string): Promise<{ ok: boolean }>;
111
+ addParticipant(chatId: number, handle: string): Promise<GroupMutationResult>;
112
+ removeParticipant(chatId: number, handle: string): Promise<GroupMutationResult>;
111
113
  leaveGroup(chatId: number): Promise<{ ok: boolean }>;
112
114
  shareNamePhoto(chatId: number): Promise<{
113
115
  ok: boolean;
@@ -129,8 +131,30 @@ interface Gateway {
129
131
  skipped?: 'no-background';
130
132
  effectStarted?: boolean;
131
133
  }>;
134
+
135
+ // Find My locations (M2B-31). See "Location" below.
136
+ readSharedLocations(chatGuid?: string): Promise<SharedLocation[]>;
137
+ sendLocationRequest(chatId: number): Promise<SendResult>;
138
+ watchLocations(): AsyncIterable<LocationUpdate>;
139
+ }
140
+
141
+ interface SharedLocation {
142
+ handle: string; // IMFindMyHandle.identifier: E.164 phone or Apple ID email
143
+ chatGuid?: string; // the handle's DM chat when resolvable
144
+ latitude?: number; // present together with longitude, only on a valid fix
145
+ longitude?: number;
146
+ horizontalAccuracyM?: number;
147
+ altitudeM?: number;
148
+ speedMps?: number; // -1 = unknown
149
+ locationType?: number; // FMLLocation.locationType raw
150
+ coarseAddress?: string;
151
+ capturedAt?: string; // ISO8601 UTC, FMLLocation.timestamp
152
+ isValid: boolean; // false = sharing, no fix yet (no coordinates)
132
153
  }
133
154
 
155
+ type LocationShareEvent = 'location_update' | 'share_started' | 'share_ended';
156
+ type LocationUpdate = SharedLocation & { event: LocationShareEvent };
157
+
134
158
  interface ReactionNote {
135
159
  id: number; // the reaction's global rowid
136
160
  // 'custom' covers an arbitrary-emoji tapback (iOS 18+, imsg surfaces it as
@@ -145,7 +169,12 @@ interface ReactResult {
145
169
  skipped?: 'already-reacted' | 'stale-target' | 'not-reacted';
146
170
  }
147
171
 
148
- type GatewayEvent = ImsgMessage; // message or reaction; see §2 shape
172
+ // message or reaction; see §2 shape. Deliberately NOT widened to a union
173
+ // for Find My updates (0.4.0): every pinned consumer reads `.id`/`.chat_guid`
174
+ // straight off the subscribe value (imsg-relay mac-daemon, imsg-agent
175
+ // runtime-event-source), so live location changes have their own stream,
176
+ // `watchLocations()`, and this type stays exactly what it was.
177
+ type GatewayEvent = ImsgMessage;
149
178
 
150
179
  type Reaction = 'love' | 'like' | 'dislike' | 'laugh' | 'emphasis' | 'question';
151
180
 
@@ -160,6 +189,20 @@ interface GroupChatResolutionRequest {
160
189
  expectedParticipantExternalIds: readonly string[];
161
190
  excludedParticipantExternalIds?: readonly string[];
162
191
  }
192
+
193
+ // renameGroup / addParticipant / removeParticipant. A success is proven (read
194
+ // back from a fresh chats.list) and carries neither field. Every failure
195
+ // carries both, and `effectStarted` follows from `reason` alone.
196
+ interface GroupMutationResult {
197
+ ok: boolean;
198
+ effectStarted?: boolean;
199
+ reason?:
200
+ | 'unsupported' // capability marker absent — effectStarted:false
201
+ | 'refused' // proven rejection before IMChat — effectStarted:false
202
+ | 'fire-failed' // the call errored, may have applied — effectStarted:true
203
+ | 'unverified' // fired, never confirmed in the window — effectStarted:true
204
+ | 'verify-read-failed'; // fired, the verification read broke — effectStarted:true
205
+ }
163
206
  ```
164
207
 
165
208
  ### Tier 2 (bridge-backed)
@@ -403,7 +446,63 @@ prevent.
403
446
  The long-lived RPC process keeps a stale chat-metadata view after its own
404
447
  mutation on macOS 26, while a fresh `imsg chats` process sees the update.
405
448
  Verification therefore polls fresh, read-only CLI processes (using the
406
- same `IMSG_BIN`) for up to 20 seconds before returning `ok:false`.
449
+ same `IMSG_BIN`) `GROUP_MUTATION_VERIFY_ATTEMPTS` (20) × 1 s, so a
450
+ **20-second** window — before giving up.
451
+
452
+ **A failure says which failure it is** (`GroupMutationResult`, M2B-40).
453
+ These three verbs used to answer a bare `ok:false` whether nothing had
454
+ fired or the mutation had gone out unconfirmed, which left a consumer no
455
+ choice but to settle every failure as possibly-applied — un-retirable
456
+ reconciliation work by construction. The classification, in the order the
457
+ gateway reaches it:
458
+
459
+ - **`unsupported`** (`effectStarted:false`) — `addParticipant`/
460
+ `removeParticipant` without the `groupParticipants` marker. Nothing was
461
+ called and no request on this host build ever could be.
462
+ - **`refused`** (`effectStarted:false`) — a proven rejection before IMChat
463
+ was invoked. Either the gateway's own preflight (target is a DM; the
464
+ removal would break the member floor below) or the helper's: a `-32602`,
465
+ or a `-32603` whose `error.data` carries one of that verb's known
466
+ pre-invoke refusals (`Chat not found`, `Could not vend handle`,
467
+ `Participant not found on chat`, `_setDisplayName: not available`, the
468
+ missing-selector and missing-parameter texts). The lists are per verb, in
469
+ `gateway/group-mutation.ts`, and are matched the same way the
470
+ chat-background pair matches its own.
471
+ - **`fire-failed`** (`effectStarted:true`) — the mutation call errored but
472
+ the error does NOT prove the helper stopped short: a bridge timeout, an
473
+ exception raised after IMChat was invoked, a dead rpc child, any
474
+ unmodelled `-32603`. It may already have applied.
475
+ - **`unverified`** (`effectStarted:true`) — it fired cleanly and the 20 s
476
+ window closed with `chats.list` never showing the wanted state.
477
+ - **`verify-read-failed`** (`effectStarted:true`) — it fired cleanly and
478
+ then the verification read itself failed, so there is no evidence either
479
+ way. One failed read ends the wait (it always has): `imsg chats` is a
480
+ local chat.db read, so a failure means a broken host, not a blip. Kept
481
+ distinct from `unverified` because the remediation is an operator's, not
482
+ a retry.
483
+
484
+ A success stays the bare `{ok:true}` it has always been: it was read back
485
+ from chat.db, so neither field would add anything.
486
+
487
+ **Apple's three-member floor.** A group conversation stays at three or more
488
+ members, the local user included; Apple silently declines a removal that
489
+ would drop it below that — `removeParticipants:reason:` returns normally and
490
+ the membership does not change. There is no error to classify, so
491
+ `removeParticipant` counts first: one fresh `chats.list` read before firing,
492
+ and a group whose `participants[]` (which excludes the local user, so the
493
+ group has `participants.length + 1` members) is shorter than
494
+ `GROUP_MIN_MEMBERS` is `refused` outright instead of costing the full 20 s
495
+ and then reporting ambiguity. The same read refuses a DM target for all
496
+ three verbs, which the bridge likewise answers by doing nothing.
497
+
498
+ Both preflight rules are **best-effort and fail open**: only what that read
499
+ actually reported is acted on. A chat outside `CHAT_SCAN_LIMIT`, a read that
500
+ failed, a row without the field, or a membership list that no longer
501
+ contains the target handle all fall through to fire-and-verify. An unknown
502
+ target must never become a confident refusal — the same discipline
503
+ `participant_directory_unavailable` applies in `resolveGroupChat`. Trusting
504
+ the count only when the target is still listed is what keeps a stale
505
+ chat.db view from refusing a removal that would have worked.
407
506
  - **`setGroupPhoto`/`leaveGroup`** — `group.setIcon` / `group.leave`. No field
408
507
  in imsg's JSON output reflects a group photo, and `participants[]` always
409
508
  excludes the local user (see "Participants exclude the local user" above),
@@ -527,6 +626,73 @@ both implementations — none of Apple's group primitives apply to a 1:1 chat.
527
626
  (`scripts/tier2-chat-background-leg.ts`) stops at the first failed check
528
627
  and never clears a background whose guid it did not read back.
529
628
 
629
+ - **Location** — `readSharedLocations` / `watchLocations` /
630
+ `sendLocationRequest` (M2B-31; the shared wire contract lives with the
631
+ Mac and relay tracks, `location-wire.md`). Find My shares are
632
+ account-scoped state on the host Mac — the handles in
633
+ `findMyHandlesSharingLocationWithMe` and each handle's latest
634
+ `FMLLocation` — not message rows: no `subscribe()` event, no history row
635
+ on either implementation. Two separate helper markers gate the surface,
636
+ read from `imsg status --json` like every other patched verb:
637
+ `sharedLocations` (the read path) and `locationRequest` (the request
638
+ card, which reuses polls' extension-balloon initializer); neither implies
639
+ the other.
640
+ `readSharedLocations(chatGuid?)` is the native `locations.read`
641
+ (`{chat_guid?}` → `{locations, read_at}`): every sharing handle, or only
642
+ the one whose DM chat is `chatGuid`. The helper turns tracking on for a
643
+ handle's DM chat once per process and reads the current fix; a handle
644
+ that shares but has no fix yet comes back `isValid:false` with **no
645
+ coordinates** — never a zero fix. The mapping (`src/gateway/locations.ts`)
646
+ is total over malformed rows and drops what it cannot vouch for (no
647
+ handle; a "valid" row missing a coordinate). **It throws** — on both
648
+ implementations — when the bridge does not advertise `sharedLocations`
649
+ and on any rpc failure: an empty list is a positive claim ("nobody is
650
+ sharing") that a bridge which could not read must not make.
651
+ `sendLocationRequest(chatId)` is `location_request.send` (`{chat_id}`;
652
+ the handler resolves `chat_id`/`chat_guid`/`chat_identifier` alike, so the
653
+ Gateway keeps its numeric local id like every other verb): Apple's native
654
+ "Request Location" card, the one Messages sends from + › Location ›
655
+ Request, built by the helper with a fresh payload id. Targets an
656
+ **existing** chat only. **Fire-and-forget like `sendPoll`**: `ok:true`
657
+ means the helper dispatched the card; `guid` is the balloon row when
658
+ Messages exposed it synchronously and is absent otherwise (documented on
659
+ the native side). The recipient's answer, if any, arrives as a location
660
+ share — a `location` update below, or a decoded Find My share card on a
661
+ message row (imsg `docs/json.md` "Location card"), which this Gateway
662
+ does not model yet. FakeGateway emits the bare outbound row (like
663
+ `sendSticker`; `ImsgMessage` models nothing of the card) and returns only
664
+ the guid.
665
+ `watchLocations()` streams one `LocationUpdate` per change the native
666
+ watch reports — the `location` notification the watch stream emits
667
+ alongside `message`: a new fix (`location_update`), a handle starting
668
+ (`share_started`) or stopping (`share_ended`, which may carry no
669
+ coordinates) to share; on the native side this is a 30 s poll of
670
+ `locations.read` inside the watch session. `ImsgGateway` therefore holds
671
+ a **dedicated, non-rotated, cursorless** native watch session for it
672
+ (`src/imsg/location-watch.ts`, opened on the first `watchLocations()`
673
+ call): the message watch retires its process every 10 s once it has a
674
+ cursor, which would starve that poll. Its `message` notifications are
675
+ ignored. That poll runs in *every* subscription unless the subscriber
676
+ turns it off, so the message watch subscribes with `location_interval_s:
677
+ 0` and this session is the only one that leaves the interval unset — one
678
+ Find My reader per connector, not one per watch session.
679
+ Location updates carry no rowid, so there is no catch-up: a
680
+ restarted session (child death) resumes nothing and a consumer takes the
681
+ baseline from `readSharedLocations()`, then applies updates. Malformed
682
+ notifications are dropped and reported as
683
+ `imsg_watch_location_malformed`, never forwarded. Single consumer, like
684
+ `subscribe()`, with the same documented divergence (FakeGateway allows
685
+ sequential re-watch). `GatewayEvent` is unchanged: see the note on it in
686
+ §1. FakeGateway's `injectSharedLocation(update)` is the fixture driver —
687
+ it goes through the real wire mapper, updates the in-memory share list
688
+ (`share_ended` removes the handle) and feeds `watchLocations()`.
689
+ **Host-verified 2026-09-13** (ledger below): `scripts/tier2-smoke.ts
690
+ <chatId> --only-location` (read leg, sends nothing) and
691
+ `--location-request` (posts the card; opt-in, the recipient sees it) are
692
+ the gate. Still open on the native side, to be confirmed in the relay
693
+ e2e: whether a fresh watch session emits the current snapshot
694
+ immediately or only diffs after its first poll.
695
+
530
696
  **Host-verification ledger.** Exercised through `ImsgGateway` on the host Mac
531
697
  2026-07-14 (`scripts/tier2-smoke.ts`, chat 3996):
532
698
 
@@ -608,6 +774,29 @@ three phone-handle participants; `scripts/tier2-smoke.ts 12 --only-mention
608
774
  receiving device rendering "Edited" (the inbound echo carried the original
609
775
  text, which is the partner's copy, not evidence either way).
610
776
 
777
+ Exercised 2026-09-13 on the host (SDK `d6fecca` against the deployed M2B-31
778
+ helper, imsg `0071842` built on the host, `sharedLocations true` /
779
+ `locationRequest true`; `scripts/tier2-smoke.ts 7 --only-location
780
+ --location-request`, bench `~/imsg-sdk-location-e2e-d6fecca`):
781
+
782
+ - **`readSharedLocations` / `sendLocationRequest`** — live-verified: both
783
+ capability rows; `readSharedLocations()` returned a list (0 handles: no
784
+ share was active during the run, so the fix shape was not exercised
785
+ through the SDK here); `sendLocationRequest(7)` returned
786
+ `{ok:true, guid:23DB6804-EAAC-476D-8CA5-7AE71642B960}` and the row read
787
+ back from `chat.db` as a Find My balloon
788
+ (`…:0000000000:com.apple.findmy.FindMyMessagesApp`); an unknown chat
789
+ (`999999999`) → `ok:false` (native `-32602`). 6/6 PASS, `ALL PASS`, exit
790
+ 0; the audit recorded exactly one event, in chat 7 ("Requested your
791
+ location"). Recipient-side, same helper build on a second Mac (macOS
792
+ 26.6.2, 2026-09-13): the card rendered with a Share button on an iPhone
793
+ for a sender NOT in contacts, a 1 h share came back as a valid fix from
794
+ `imsg locations --json`, and `imsg watch --json --location-interval 30`
795
+ emitted `share_started` / `location_update` / `share_ended` — native
796
+ layer only; `ImsgGateway.watchLocations()` end to end is the relay e2e.
797
+ Not covered: group chats, email-handle shares, share expiry vs. Apple's
798
+ handle drop.
799
+
611
800
  Eyeballed 2026-09-04 on the same host (SDK `5826316` = v0.3.0, deployed
612
801
  `imsg` 0.13.0 helper, all four 0.3.0 capability flags true) with a human
613
802
  watching the receiving iPhone (iOS 26.6.1) between steps, one gateway call
@@ -660,6 +849,22 @@ the phone, created that day) and group chat 12 (three phone handles):
660
849
  backgrounds) THEN failure`). That is per-endpoint pruning, not a failure
661
850
  of the send, and the phone's endpoints are unaffected.
662
851
 
852
+ **NOT YET HOST-VERIFIED — `GroupMutationResult` (M2B-40).** The reason
853
+ vocabulary, the three-member floor and the DM preflight are derived from the
854
+ native sources (`IMsgInjected.m` `handleAddParticipant` /
855
+ `handleRemoveParticipant` / `handleSetDisplayName`,
856
+ `RPCServer+ChatHandlers.swift` `invokeBridge`) and are covered by unit tests,
857
+ which under hard rule 1 is **not** proof: `imsg` exit codes lie and Messages.app
858
+ cannot be faked. Before this ships, `scripts/tier2-smoke.ts --group-chat-id
859
+ <group> --participant <handle>` has to run on the host and answer three
860
+ questions: (1) does a removal at the floor actually get refused by Apple, or
861
+ does it land — the whole preflight is wrong if it lands; (2) is the floor three
862
+ members counting the local user, i.e. does a 4-member group allow exactly one
863
+ removal; (3) do the add/remove/rename round trips still return the bare
864
+ `{ok:true}` they did before the preflight read was added. The smoke's group leg
865
+ prints the measured membership and asserts the floor refusal, so a single run
866
+ settles all three.
867
+
663
868
  - **subscribe(sinceId?)** — long-lived event stream of new messages/reactions across all
664
869
  chats the host Mac's Messages account can see. `sinceId` is an **exclusive** cursor: only
665
870
  events with `id > sinceId` are delivered (catch-up semantics, not "starting at"). Today
@@ -916,6 +1121,28 @@ a different product.
916
1121
  There is no "create empty group". Adding/removing a member on an *existing* group is possible —
917
1122
  see "Tier 2" above (`addParticipant`/`removeParticipant`, bridge-backed) — but only through
918
1123
  those methods, not through `createGroup`.
1124
+ - **A group never shrinks below three members.** Apple keeps a group
1125
+ conversation at `GROUP_MIN_MEMBERS` (3) or more, counting the local user,
1126
+ and silently declines any removal that would break that — no error, no
1127
+ change. So `removeParticipant` on a group whose `participants[]` (local
1128
+ user excluded, see "Participants exclude the local user") is shorter than
1129
+ three is `{ok:false, effectStarted:false, reason:'refused'}` and nothing
1130
+ is fired. FakeGateway models the floor too: a fake that happily shrinks a
1131
+ group to two exceeds what the real gateway can do, and fixtures built on
1132
+ it would rehearse a removal iMessage refuses.
1133
+ - **A group mutation that failed says where it failed.** `renameGroup`,
1134
+ `addParticipant` and `removeParticipant` return `GroupMutationResult`
1135
+ (§1): a failure always carries `reason` and the `effectStarted` that
1136
+ follows from it, so a proven no-op is settled as a clean failure and only
1137
+ a mutation that actually reached IMChat is reconciled. The reason
1138
+ vocabulary and the per-verb proven-rejection lists are under "Tier 2"
1139
+ above. FakeGateway reaches only the proven-no-op half (`unsupported`,
1140
+ `refused`) — it has no bridge to time out on and must never manufacture
1141
+ an `effectStarted:true` that would send a consumer off to reconcile a
1142
+ mutation that never existed. Removing a handle the chat does not list is
1143
+ one of those refusals on both sides: the helper matches against the live
1144
+ IMChat participant list and answers `Participant not found on chat`,
1145
+ so the fake must not model it as a silent success either.
919
1146
  - **Compose guard**: if Cmd+N fails to enter compose (sibling of the react Cmd+T failure,
920
1147
  observed live 2026-07-17), every scripted keystroke would land in the focused chat's
921
1148
  compose field and each Return would send it — recipient handles texted into an open
@@ -1099,10 +1326,20 @@ harness (`tsx --test`, see `package.json`). Required cases:
1099
1326
  - `setTyping`/`markRead` dispatch `{ ok: true }` against an existing chat and `{ ok: false }`
1100
1327
  against an unknown one, with no queryable state either way
1101
1328
  - `renameGroup`/`addParticipant`/`removeParticipant` are reflected in a message sent
1102
- afterward (`chat_name`/`participants`) and return `ok: false` against a DM
1329
+ afterward (`chat_name`/`participants`); a DM target is the proven
1330
+ `{ok:false, effectStarted:false, reason:'refused'}`, and so is a removal that
1331
+ would break Apple's three-member floor or that names a handle the chat does
1332
+ not list — in both cases the membership is untouched afterward
1103
1333
  - `setGroupPhoto`/`leaveGroup` return `ok: true` against a group and `ok: false` against a DM
1104
1334
  - patched sticker, group-photo, participant, and chat-background verbs return `ok:false`
1105
- without mutation when their `imsg status` capability markers are absent
1335
+ without mutation when their `imsg status` capability markers are absent; the
1336
+ participant verbs name it `reason:'unsupported'`, distinct from a request the
1337
+ host declined
1338
+ - (real-path only, `tests/group-mutation.test.ts` over the pure module) a bridge
1339
+ rejection raised before IMChat is `refused`; a timeout, an unmodelled `-32603`
1340
+ or a non-rpc error is `fire-failed`; an exhausted verify window is
1341
+ `unverified`; a failed verification read is `verify-read-failed` — one verb's
1342
+ proven-rejection text does not excuse another's
1106
1343
  - chat background: `setChatBackground` persists a NEW guid on a DM and a group (a re-set
1107
1344
  changes the guid), `chatBackgroundStatus` reflects it, `removeChatBackground` clears it;
1108
1345
  remove on a bare chat is the explicit `no-background` skip (also with an empty guard), a
@@ -1111,9 +1348,18 @@ harness (`tsx --test`, see `package.json`). Required cases:
1111
1348
  unknown chat never starts an effect
1112
1349
  - `recentReactions` surfaces an inbound custom-emoji reaction (`reaction: 'custom'`) with the
1113
1350
  real `emoji` intact
1351
+ - `readSharedLocations` lists every sharing handle, narrows by chat guid, replaces (never
1352
+ accumulates) a handle's row on a newer fix, and keeps a no-fix handle coordinate-free;
1353
+ it throws without the `sharedLocations` marker
1354
+ - `watchLocations` streams `share_started` → `location_update` → `share_ended` in order,
1355
+ `share_ended` drops the handle from the read, and a second concurrent consumer is refused
1356
+ - `sendLocationRequest` posts the card as its own outbound row to an existing chat, returns
1357
+ the balloon guid, refuses an unknown chat (no find-or-create), and fails closed without
1358
+ the `locationRequest` marker (no row written)
1114
1359
  - Fake-only restart coverage snapshots and restores a world, then proves
1115
1360
  `sendStatus`, `checkHandle`, Name & Photo idempotency, chat backgrounds (and their
1116
- guid sequence), current group metadata, fixture state, and monotonic IDs survive exactly
1361
+ guid sequence), Find My shares, current group metadata, fixture state, and monotonic IDs
1362
+ survive exactly; a snapshot carrying a share the wire mapper would refuse does not restore
1117
1363
 
1118
1364
  FakeGateway runs this suite in CI on every PR. ImsgGateway runs it as a manual runbook step on
1119
1365
  the host Mac (see `docs/host-mac-control.md`) until a host-Mac CI runner exists — at that point
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapier/imsg-sdk",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript SDK for iMessage automation on macOS: the Gateway contract, ImsgGateway (real), and a device-free FakeGateway.",
5
5
  "license": "MIT",
6
6
  "repository": {