Skip to content

Commit 041b406

Browse files
authored
fix: avoid Contacts prompt for read commands
Fixes #135. Narrowed the Contacts `.notDetermined` no-prompt behavior to optional read-command enrichment only: `chats` and `history` now skip the Contacts prompt, while send/RPC/bridge/name-resolution call sites preserve the default request behavior. Proof: - `swift test --filter ContactResolver` passed: 4 tests. - `swift test --filter 'ChatHistory|ContactResolver|sendCommandResolvesUniqueContactName|sendCommandRejectsAmbiguousContactName|accountNickname'` passed: 26 tests. - `make build` passed. - Live `.notDetermined` proof on this Mac: `bin/imsg chats --limit 1 --json` and `bin/imsg history --chat-id <id> --limit 1 --json` both returned within a 10s alarm with sanitized output. - `make lint` exited 0 with existing warning-level violations only. - `make test` passed: 323 tests. Co-authored-by: Eduardo Mendes <cemendes@users.noreply.github.com>
1 parent 3eb6f41 commit 041b406

5 files changed

Lines changed: 90 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## 0.11.1 - Unreleased
44

5+
### Read Commands
6+
- fix: let `chats` and `history` run without prompting for Contacts when permission is still undecided, while preserving Contacts prompts for explicit name-resolution flows (#135, thanks @cemendes).
7+
58
## 0.11.0 - 2026-05-31
69

710
### Send

Sources/IMsgCore/ContactResolver.swift

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ public final class NoOpContactResolver: ContactResolving, Sendable {
3434
public func searchByName(_ query: String) -> [ContactMatch] { [] }
3535
}
3636

37+
public enum ContactsAccessPolicy: Sendable {
38+
case requestIfNeeded
39+
case skipIfNotDetermined
40+
}
41+
3742
public final class ContactResolver: ContactResolving, @unchecked Sendable {
3843
#if os(macOS)
3944
private let phoneToName: [String: String]
@@ -60,26 +65,51 @@ public final class ContactResolver: ContactResolving, @unchecked Sendable {
6065
public let contactsUnavailable = true
6166
#endif
6267

63-
public static func create(region: String = "US") async -> any ContactResolving {
68+
public static func create(
69+
region: String = "US",
70+
accessPolicy: ContactsAccessPolicy = .requestIfNeeded
71+
) async -> any ContactResolving {
6472
#if os(macOS)
6573
let store = CNContactStore()
66-
switch CNContactStore.authorizationStatus(for: .contacts) {
74+
return await create(
75+
region: region,
76+
accessPolicy: accessPolicy,
77+
store: store,
78+
authorizationStatus: CNContactStore.authorizationStatus(for: .contacts),
79+
requestAccess: requestAccess(store:)
80+
)
81+
#else
82+
_ = region
83+
_ = accessPolicy
84+
return NoOpContactResolver(contactsUnavailable: true)
85+
#endif
86+
}
87+
88+
#if os(macOS)
89+
static func create(
90+
region: String = "US",
91+
accessPolicy: ContactsAccessPolicy = .requestIfNeeded,
92+
store: CNContactStore,
93+
authorizationStatus: CNAuthorizationStatus,
94+
requestAccess: @escaping (CNContactStore) async -> Bool
95+
) async -> any ContactResolving {
96+
switch authorizationStatus {
6797
case .authorized:
6898
return load(store: store, region: region)
6999
case .notDetermined:
70-
let granted = await requestAccess(store: store)
100+
guard accessPolicy == .requestIfNeeded else {
101+
return NoOpContactResolver(contactsUnavailable: true)
102+
}
103+
let granted = await requestAccess(store)
71104
return granted
72105
? load(store: store, region: region) : NoOpContactResolver(contactsUnavailable: true)
73106
case .denied, .restricted:
74107
return NoOpContactResolver(contactsUnavailable: true)
75108
@unknown default:
76109
return NoOpContactResolver(contactsUnavailable: true)
77110
}
78-
#else
79-
_ = region
80-
return NoOpContactResolver(contactsUnavailable: true)
81-
#endif
82-
}
111+
}
112+
#endif
83113

84114
public func displayName(for handle: String) -> String? {
85115
#if os(macOS)

Sources/imsg/Commands/ChatsCommand.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ enum ChatsCommand {
2626
values: ParsedValues,
2727
runtime: RuntimeOptions,
2828
contactResolverFactory: @escaping () async -> any ContactResolving = {
29-
await ContactResolver.create()
29+
await ContactResolver.create(accessPolicy: .skipIfNotDetermined)
3030
}
3131
) async throws {
3232
let dbPath = values.option("db") ?? MessageStore.defaultPath

Sources/imsg/Commands/HistoryCommand.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ enum HistoryCommand {
4141
values: ParsedValues,
4242
runtime: RuntimeOptions,
4343
contactResolverFactory: @escaping () async -> any ContactResolving = {
44-
await ContactResolver.create()
44+
await ContactResolver.create(accessPolicy: .skipIfNotDetermined)
4545
}
4646
) async throws {
4747
guard let chatID = values.optionInt64("chatID") else {

Tests/IMsgCoreTests/ContactResolverTests.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import Testing
22

33
@testable import IMsgCore
44

5+
#if os(macOS)
6+
@preconcurrency import Contacts
7+
#endif
8+
59
@Test
610
func noOpContactResolverReturnsNoMatches() {
711
let resolver = NoOpContactResolver()
@@ -16,3 +20,46 @@ func noOpContactResolverCanRepresentUnavailableContacts() {
1620
let resolver = NoOpContactResolver(contactsUnavailable: true)
1721
#expect(resolver.contactsUnavailable == true)
1822
}
23+
24+
#if os(macOS)
25+
private actor ContactAccessSpy {
26+
private var requestCount = 0
27+
28+
func request(_ store: CNContactStore) async -> Bool {
29+
_ = store
30+
requestCount += 1
31+
return false
32+
}
33+
34+
func count() -> Int {
35+
requestCount
36+
}
37+
}
38+
39+
@Test
40+
func contactResolverSkipsPromptWhenContactsAreUndeterminedAndPolicyAllowsFailOpen() async {
41+
let spy = ContactAccessSpy()
42+
let resolver = await ContactResolver.create(
43+
accessPolicy: .skipIfNotDetermined,
44+
store: CNContactStore(),
45+
authorizationStatus: .notDetermined,
46+
requestAccess: { store in await spy.request(store) }
47+
)
48+
49+
#expect(resolver.contactsUnavailable == true)
50+
#expect(await spy.count() == 0)
51+
}
52+
53+
@Test
54+
func contactResolverStillRequestsAccessByDefaultWhenContactsAreUndetermined() async {
55+
let spy = ContactAccessSpy()
56+
let resolver = await ContactResolver.create(
57+
store: CNContactStore(),
58+
authorizationStatus: .notDetermined,
59+
requestAccess: { store in await spy.request(store) }
60+
)
61+
62+
#expect(resolver.contactsUnavailable == true)
63+
#expect(await spy.count() == 1)
64+
}
65+
#endif

0 commit comments

Comments
 (0)