diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ac924..f3fac62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/imsg/ChatTargetResolver.swift b/Sources/imsg/ChatTargetResolver.swift index af8b966..628ede0 100644 --- a/Sources/imsg/ChatTargetResolver.swift +++ b/Sources/imsg/ChatTargetResolver.swift @@ -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: diff --git a/Sources/imsg/Commands/RpcCommand.swift b/Sources/imsg/Commands/RpcCommand.swift index bff73ee..c94dcae 100644 --- a/Sources/imsg/Commands/RpcCommand.swift +++ b/Sources/imsg/Commands/RpcCommand.swift @@ -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", @@ -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 { @@ -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() } diff --git a/Tests/imsgTests/ContactResolutionTests.swift b/Tests/imsgTests/ContactResolutionTests.swift index 2d5865e..df3cb4e 100644 --- a/Tests/imsgTests/ContactResolutionTests.swift +++ b/Tests/imsgTests/ContactResolutionTests.swift @@ -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( diff --git a/Tests/imsgTests/RPCServerTests.swift b/Tests/imsgTests/RPCServerTests.swift index 3ca80d1..ceb30ef 100644 --- a/Tests/imsgTests/RPCServerTests.swift +++ b/Tests/imsgTests/RPCServerTests.swift @@ -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() diff --git a/Tests/imsgTests/RpcCommandContactsPolicyTests.swift b/Tests/imsgTests/RpcCommandContactsPolicyTests.swift new file mode 100644 index 0000000..16c4bbc --- /dev/null +++ b/Tests/imsgTests/RpcCommandContactsPolicyTests.swift @@ -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) + } +}