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

## Unreleased

### Fixes

- Let headless `watch` and `search` start without waiting for an undetermined Contacts permission prompt while preserving interactive prompting (#238, thanks @SebTardif).

## 0.14.1 - 2026-08-11

**Highlight:** search now finds messages whose text lives only in the rich-text
Expand Down
18 changes: 18 additions & 0 deletions Sources/IMsgCore/ContactResolver.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import Foundation

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

#if os(macOS)
@preconcurrency import Contacts
#endif
Expand Down Expand Up @@ -37,6 +43,18 @@ public final class NoOpContactResolver: ContactResolving, Sendable {
public enum ContactsAccessPolicy: Sendable {
case requestIfNeeded
case skipIfNotDetermined

/// Headless stdin (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.
public static func forStdin(isTTY: Bool) -> ContactsAccessPolicy {
isTTY ? .requestIfNeeded : .skipIfNotDetermined
}

/// Whether the current process stdin is an interactive TTY.
public static var stdinIsTTY: Bool {
isatty(STDIN_FILENO) != 0
}
}

/// A process-owned Contacts catalog. Reads stay synchronous for existing callers, while the
Expand Down
9 changes: 8 additions & 1 deletion Sources/imsg/Commands/BridgeIntroCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import IMsgCore
// MARK: - search

enum SearchCommand {
/// Same TTY rule as RPC: headless stdin must not prompt for Contacts.
static func contactsAccessPolicy(stdinIsTTY: Bool) -> ContactsAccessPolicy {
.forStdin(isTTY: stdinIsTTY)
}

static let spec = CommandSpec(
name: "search",
abstract: "Search local Messages history",
Expand All @@ -30,7 +35,9 @@ enum SearchCommand {
values: ParsedValues,
runtime: RuntimeOptions,
contactResolverFactory: @escaping () async -> any ContactResolving = {
await ContactResolver.create()
await ContactResolver.create(
accessPolicy: contactsAccessPolicy(stdinIsTTY: ContactsAccessPolicy.stdinIsTTY)
)
}
) async throws {
guard let q = values.option("query"), !q.isEmpty else {
Expand Down
10 changes: 2 additions & 8 deletions Sources/imsg/Commands/RpcCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@ import Commander
import Foundation
import IMsgCore

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

enum RpcCommand {
/// Contacts policy for RPC startup.
///
Expand All @@ -16,12 +10,12 @@ enum RpcCommand {
/// 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)
contactsAccessPolicy(stdinIsTTY: ContactsAccessPolicy.stdinIsTTY)
}

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

static let spec = CommandSpec(
Expand Down
9 changes: 8 additions & 1 deletion Sources/imsg/Commands/WatchCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import Foundation
import IMsgCore

enum WatchCommand {
/// Same TTY rule as RPC: headless stdin must not prompt for Contacts.
static func contactsAccessPolicy(stdinIsTTY: Bool) -> ContactsAccessPolicy {
.forStdin(isTTY: stdinIsTTY)
}

static let spec = CommandSpec(
name: "watch",
abstract: "Stream incoming messages",
Expand Down Expand Up @@ -55,7 +60,9 @@ enum WatchCommand {
runtime: RuntimeOptions,
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
contactResolverFactory: @escaping () async -> any ContactResolving = {
await ContactResolver.create()
await ContactResolver.create(
accessPolicy: contactsAccessPolicy(stdinIsTTY: ContactsAccessPolicy.stdinIsTTY)
)
},
streamProvider:
@escaping (
Expand Down
39 changes: 39 additions & 0 deletions Tests/imsgTests/RpcCommandContactsPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ import Testing

@testable import imsg

@Suite("Shared Contacts stdin policy")
struct ContactsAccessPolicyStdinTests {
@Test("headless stdin uses skipIfNotDetermined")
func headlessSkipsUndetermined() {
#expect(ContactsAccessPolicy.forStdin(isTTY: false) == .skipIfNotDetermined)
}

@Test("interactive stdin keeps requestIfNeeded")
func interactiveRequestsIfNeeded() {
#expect(ContactsAccessPolicy.forStdin(isTTY: true) == .requestIfNeeded)
}
}

@Suite("RpcCommand Contacts policy")
struct RpcCommandContactsPolicyTests {
@Test("headless stdin uses skipIfNotDetermined")
Expand All @@ -16,3 +29,29 @@ struct RpcCommandContactsPolicyTests {
#expect(RpcCommand.contactsAccessPolicy(stdinIsTTY: true) == .requestIfNeeded)
}
}

@Suite("WatchCommand Contacts policy")
struct WatchCommandContactsPolicyTests {
@Test("watch factory uses skipIfNotDetermined when stdin is not a TTY")
func headlessFactorySkipsUndetermined() {
#expect(WatchCommand.contactsAccessPolicy(stdinIsTTY: false) == .skipIfNotDetermined)
}

@Test("watch factory keeps requestIfNeeded on interactive stdin")
func interactiveFactoryRequestsIfNeeded() {
#expect(WatchCommand.contactsAccessPolicy(stdinIsTTY: true) == .requestIfNeeded)
}
}

@Suite("SearchCommand Contacts policy")
struct SearchCommandContactsPolicyTests {
@Test("search factory uses skipIfNotDetermined when stdin is not a TTY")
func headlessFactorySkipsUndetermined() {
#expect(SearchCommand.contactsAccessPolicy(stdinIsTTY: false) == .skipIfNotDetermined)
}

@Test("search factory keeps requestIfNeeded on interactive stdin")
func interactiveFactoryRequestsIfNeeded() {
#expect(SearchCommand.contactsAccessPolicy(stdinIsTTY: true) == .requestIfNeeded)
}
}