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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 0.13.5 - Unreleased

### JSON-RPC
- fix: let non-interactive RPC startup proceed without a Contacts prompt while rejecting ambiguous name targets when Contacts is unavailable (#186, #187, thanks @SebTardif).

## 0.13.4 - 2026-07-27

### Highlights
Expand Down
5 changes: 5 additions & 0 deletions Sources/imsg/ChatTargetResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ enum ChatTargetResolver {
contacts: any ContactResolving
) throws -> String {
guard looksLikeContactName(recipient) else { return recipient }
guard !contacts.contactsUnavailable else {
throw IMsgError.invalidChatTarget(
"Contacts access is unavailable; specify a phone number or email instead."
)
}
let matches = contacts.searchByName(recipient)
switch matches.count {
case 0:
Expand Down
33 changes: 32 additions & 1 deletion Sources/imsg/Commands/RpcCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,28 @@ import Commander
import Foundation
import IMsgCore

#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif

enum RpcCommand {
/// Contacts policy for RPC startup.
///
/// Headless RPC (stdin not a TTY: LaunchAgent, pipes, automation) must not
/// block on a Contacts prompt that will never resolve while authorization
/// remains `.notDetermined`. Interactive terminals keep the prompt-capable
/// path so Contacts-backed name resolution still works.
static var startupContactsAccessPolicy: ContactsAccessPolicy {
contactsAccessPolicy(stdinIsTTY: isatty(STDIN_FILENO) != 0)
}

/// Pure policy helper for tests and callers that already know interactivity.
static func contactsAccessPolicy(stdinIsTTY: Bool) -> ContactsAccessPolicy {
stdinIsTTY ? .requestIfNeeded : .skipIfNotDetermined
}

static let spec = CommandSpec(
name: "rpc",
abstract: "Run JSON-RPC over stdin/stdout",
Expand All @@ -15,6 +36,16 @@ enum RpcCommand {
"imsg rpc --db ~/Library/Messages/chat.db",
]
) { 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(accessPolicy: startupContactsAccessPolicy)
}
) async throws {
let dbPath = values.option("db") ?? MessageStore.defaultPath
let store: MessageStore
do {
Expand All @@ -23,7 +54,7 @@ enum RpcCommand {
await RPCStartupErrorServer(error: error).run()
throw CommandOutputEmittedError()
}
let contacts = await ContactResolver.create()
let contacts = await contactResolverFactory()
let server = RPCServer(store: store, verbose: runtime.verbose, contactResolver: contacts)
try await server.run()
}
Expand Down
8 changes: 8 additions & 0 deletions Tests/imsgTests/ContactResolutionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ func contactNameResolutionPassesThroughUnknownNames() throws {
#expect(resolved == "Unknown Person")
}

@Test
func contactNameResolutionRejectsNamesWhenContactsAreUnavailable() {
let resolver = MockContactResolver(contactsUnavailable: true)
#expect(throws: (any Error).self) {
try ChatTargetResolver.resolveRecipientName("Unknown Person", contacts: resolver)
}
}

@Test
func contactNameResolutionReturnsUniqueMatch() throws {
let resolver = MockContactResolver(
Expand Down
22 changes: 22 additions & 0 deletions Tests/imsgTests/RPCServerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,28 @@ func rpcSendRejectsAmbiguousContactName() async throws {
#expect(int64Value(error?["code"]) == -32602)
}

@Test
func rpcSendRejectsContactNameWhenContactsAreUnavailable() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let resolver = MockContactResolver(contactsUnavailable: true)
var didSend = false
let server = RPCServer(
store: store,
verbose: false,
output: output,
sendMessage: { _ in didSend = true },
contactResolver: resolver
)

let line = #"{"jsonrpc":"2.0","id":"3u","method":"send","params":{"to":"Alice","text":"yo"}}"#
await server.handleLineForTesting(line)

let error = output.errors.first?["error"] as? [String: Any]
#expect(int64Value(error?["code"]) == -32602)
#expect(didSend == false)
}

@Test
func rpcSendReturnsSentMessageIdentifiersWhenResolved() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()
Expand Down
17 changes: 17 additions & 0 deletions Tests/imsgTests/RpcCommandContactsPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation
import IMsgCore
import Testing
@testable import imsg

@Suite("RpcCommand Contacts policy")
struct RpcCommandContactsPolicyTests {
@Test("headless stdin uses skipIfNotDetermined")
func headlessSkipsUndetermined() {
#expect(RpcCommand.contactsAccessPolicy(stdinIsTTY: false) == .skipIfNotDetermined)
}

@Test("interactive stdin keeps requestIfNeeded")
func interactiveRequestsIfNeeded() {
#expect(RpcCommand.contactsAccessPolicy(stdinIsTTY: true) == .requestIfNeeded)
}
}