Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## 0.8.3 - Unreleased

### Private API Bridge
- fix: support threaded attachment replies via `send-rich --file` and
`send-attachment --reply-to`, including the macOS 26 attachment staging
fallback (#113, #114, thanks @omarshahine).

## 0.8.2 - 2026-05-11

### JSON-RPC
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ Advanced IMCore (require `imsg launch` with SIP off — see
- `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 send-rich [--reply-to <guid>] [--file <path>]`,
`imsg send-multipart`, `imsg send-attachment [--reply-to <guid>]`,
`imsg tapback`
- `imsg edit`, `imsg unsend`, `imsg delete-message`, `imsg notify-anyways`
- `imsg chat-create`, `imsg chat-name`, `imsg chat-photo`,
Expand Down Expand Up @@ -274,6 +275,10 @@ imsg send-rich --chat 'iMessage;-;+15551234567' --text "boom" \
--effect com.apple.MobileSMS.expressivesend.impact \
--reply-to <messageGuid>

# Threaded reply with an attachment in one message
imsg send-rich --chat 'iMessage;-;+15551234567' \
--reply-to <messageGuid> --text "here it is" --file ~/Pictures/img.jpg

# Text formatting (macOS 15+ Sequoia): bold/italic/underline/strikethrough
# applied to specific ranges of the message body.
imsg send-rich --chat ... --text 'hello world' \
Expand All @@ -287,6 +292,7 @@ imsg send-multipart --chat 'iMessage;+;chat0000' \

# Attachment (file or audio)
imsg send-attachment --chat ... --file ~/Pictures/img.jpg --transport auto
imsg send-attachment --chat ... --reply-to <messageGuid> --file ~/Pictures/img.jpg
imsg send-attachment --chat ... --file ~/audio.caf --audio

# Bridge tapback (custom emoji + remove supported here, unlike `imsg react`)
Expand Down
257 changes: 143 additions & 114 deletions Sources/IMsgHelper/IMsgInjected.m

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions Sources/imsg/Commands/BridgeAttachmentCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ enum SendAttachmentCommand {
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "file", names: [.long("file")], help: "absolute path to file"),
.make(label: "replyTo", names: [.long("reply-to")], help: "guid of message to reply to"),
.make(
label: "transport", names: [.long("transport")],
help: "transport to use: auto|dylib|applescript"),
Expand Down Expand Up @@ -44,21 +45,28 @@ enum SendAttachmentCommand {
if transport == "applescript" && audio {
throw ParsedValuesError.invalidOption("audio")
}
let replyTo = values.option("replyTo") ?? ""
if transport == "applescript" && !replyTo.isEmpty {
throw ParsedValuesError.invalidOption("reply-to")
}

if transport != "applescript" {
let staged = try MessageSender.stageAttachmentForMessagesApp(at: expanded)
let params: [String: Any] = [
var params: [String: Any] = [
"chatGuid": chat,
"filePath": staged,
"isAudioMessage": audio,
]
if !replyTo.isEmpty {
params["selectedMessageGuid"] = replyTo
}
do {
let data = try await IMsgBridgeClient.shared.invoke(action: .sendAttachment, params: params)
let guid = (data["messageGuid"] as? String) ?? ""
BridgeOutput.emit(data, runtime: runtime, summary: "send-attachment: queued (guid=\(guid))")
return
} catch {
if transport == "dylib" || audio {
if transport == "dylib" || audio || !replyTo.isEmpty {
BridgeOutput.emitError(String(describing: error), runtime: runtime)
throw BridgeOutput.EmittedError()
}
Expand Down
38 changes: 35 additions & 3 deletions Sources/imsg/Commands/BridgeMessagingCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,14 @@ enum BridgeOutput {
action: BridgeAction,
params: [String: Any],
runtime: RuntimeOptions,
invokeBridge: @escaping (BridgeAction, [String: Any]) async throws -> [String: Any] = {
action, params in
try await IMsgBridgeClient.shared.invoke(action: action, params: params)
},
summary: (([String: Any]) -> String)
) async throws -> [String: Any] {
do {
let data = try await IMsgBridgeClient.shared.invoke(action: action, params: params)
let data = try await invokeBridge(action, params)
emit(data, runtime: runtime, summary: summary(data))
return data
} catch {
Expand All @@ -95,6 +99,7 @@ enum SendRichCommand {
.make(
label: "chat", names: [.long("chat")], help: "chat guid (e.g. iMessage;-;+15551234567)"),
.make(label: "text", names: [.long("text")], help: "message body"),
.make(label: "file", names: [.long("file")], help: "path to attachment"),
.make(
label: "effect", names: [.long("effect")],
help: "expressive send id (impact, loud, gentle, invisibleink, confetti, …)"),
Expand All @@ -118,6 +123,7 @@ enum SendRichCommand {
),
usageExamples: [
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi'",
"imsg send-rich --chat 'iMessage;-;+15551234567' --reply-to ABCD --file ~/Desktop/pic.jpg",
"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\"]}]'",
Expand All @@ -126,11 +132,21 @@ enum SendRichCommand {
try await run(values: values, runtime: runtime)
}

static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
static func run(
values: ParsedValues,
runtime: RuntimeOptions,
invokeBridge: @escaping (BridgeAction, [String: Any]) async throws -> [String: Any] = {
action, params in
try await IMsgBridgeClient.shared.invoke(action: action, params: params)
},
stageAttachment: @escaping (String) throws -> String = MessageSender
.stageAttachmentForMessagesApp
) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let text = values.option("text") ?? ""
let file = values.option("file") ?? ""
var params: [String: Any] = [
"chatGuid": chat,
"message": text,
Expand Down Expand Up @@ -166,8 +182,24 @@ enum SendRichCommand {
params["textFormatting"] = ranges
}

if !file.isEmpty {
let expanded = (file as NSString).expandingTildeInPath
params["filePath"] = try stageAttachment(expanded)
params["isAudioMessage"] = false
_ = try await BridgeOutput.invokeAndEmit(
action: .sendAttachment,
params: params,
runtime: runtime,
invokeBridge: invokeBridge
) { data in
let guid = (data["messageGuid"] as? String) ?? ""
return guid.isEmpty ? "send-rich: attachment queued" : "send-rich: sent (guid=\(guid))"
}
return
}

_ = try await BridgeOutput.invokeAndEmit(
action: .sendMessage, params: params, runtime: runtime
action: .sendMessage, params: params, runtime: runtime, invokeBridge: invokeBridge
) { data in
let guid = (data["messageGuid"] as? String) ?? ""
return guid.isEmpty ? "send-rich: queued" : "send-rich: sent (guid=\(guid))"
Expand Down
89 changes: 89 additions & 0 deletions Tests/imsgTests/BridgeRichCommandTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Commander
import Foundation
import Testing

@testable import IMsgCore
@testable import imsg

@Test
func sendRichWithFileAndReplyUsesAttachmentBridge() async throws {
let values = ParsedValues(
positional: [],
options: [
"chat": ["iMessage;-;+15551234567"],
"text": ["here it is"],
"file": ["~/Desktop/pic.jpg"],
"replyTo": ["parent-guid"],
"effect": ["impact"],
"subject": ["subject"],
"part": ["2"],
],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var capturedAction: BridgeAction?
var capturedParams: [String: Any] = [:]
var stagedSource = ""

let (output, _) = try await StdoutCapture.capture {
try await SendRichCommand.run(
values: values,
runtime: runtime,
invokeBridge: { action, params in
capturedAction = action
capturedParams = params
return ["messageGuid": "sent-guid"]
},
stageAttachment: { path in
stagedSource = path
return "/staged/pic.jpg"
}
)
}

#expect(capturedAction == .sendAttachment)
#expect(capturedParams["chatGuid"] as? String == "iMessage;-;+15551234567")
#expect(capturedParams["message"] as? String == "here it is")
#expect(capturedParams["filePath"] as? String == "/staged/pic.jpg")
#expect(capturedParams["selectedMessageGuid"] as? String == "parent-guid")
#expect(capturedParams["effectId"] as? String == "com.apple.MobileSMS.expressivesend.impact")
#expect(capturedParams["subject"] as? String == "subject")
#expect(capturedParams["partIndex"] as? Int == 2)
#expect(capturedParams["isAudioMessage"] as? Bool == false)
#expect(stagedSource.hasSuffix("/Desktop/pic.jpg"))
#expect(output.contains("send-rich: sent (guid=sent-guid)"))
}

@Test
func sendRichTextOnlyStillUsesMessageBridge() async throws {
let values = ParsedValues(
positional: [],
options: [
"chat": ["iMessage;-;+15551234567"],
"text": ["hi"],
"replyTo": ["parent-guid"],
],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var capturedAction: BridgeAction?
var capturedParams: [String: Any] = [:]

_ = try await StdoutCapture.capture {
try await SendRichCommand.run(
values: values,
runtime: runtime,
invokeBridge: { action, params in
capturedAction = action
capturedParams = params
return ["messageGuid": "sent-guid"]
}
)
}

#expect(capturedAction == .sendMessage)
#expect(capturedParams["chatGuid"] as? String == "iMessage;-;+15551234567")
#expect(capturedParams["message"] as? String == "hi")
#expect(capturedParams["selectedMessageGuid"] as? String == "parent-guid")
#expect(capturedParams["filePath"] == nil)
}
7 changes: 5 additions & 2 deletions docs/advanced-imcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ You almost certainly do not need any of this for normal use.
- `imsg typing --to <handle> [--duration 5s] [--stop true]` — show or stop the typing indicator.
- `imsg launch [--dylib <path>] [--kill-only]` — launch Messages.app with the helper dylib injected.
- `imsg status` — read-only IMCore bridge status.
- `imsg send-attachment --chat <guid> --file <path>` — prefers the bridge for
private attachment sends, with AppleScript fallback for normal files.
- `imsg send-rich --chat <guid> --reply-to <message-guid> --file <path>` —
sends a threaded reply with an attachment through the bridge.
- `imsg send-attachment --chat <guid> --file <path> [--reply-to <message-guid>]` —
prefers the bridge for private attachment sends, with AppleScript fallback
for normal files when no reply target is requested.

## Why they're separate

Expand Down
10 changes: 10 additions & 0 deletions docs/attachments.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ Before invoking AppleScript, `imsg` stages the file under `~/Library/Messages/At

The staged copies live under `imsg/`, distinct from Messages' own subdirectories, and are not pruned automatically. Clear them periodically if disk space matters.

For bridge-backed threaded replies, use `send-rich --file` or
`send-attachment --reply-to`:

```bash
imsg send-rich --chat 'iMessage;-;+15551234567' \
--reply-to <messageGuid> --text "here it is" --file ~/Desktop/photo.jpg
imsg send-attachment --chat 'iMessage;-;+15551234567' \
--reply-to <messageGuid> --file ~/Desktop/photo.jpg
```

## Why not just copy or upload?

The CLI's contract is "read what's there, send what you give it." Anything beyond that — bulk archival, cloud upload, format conversion at rest — is left to callers, who know their retention and privacy requirements. The conversion feature is the one exception, and only because some receive-side formats (CAF, animated GIF) are awkward for downstream tools to handle.
Expand Down