fix: clarify Tahoe typing failures

This commit is contained in:
Peter Steinberger 2026-05-05 00:34:45 +01:00
parent 9ec34e69fb
commit 715a75fb4e
No known key found for this signature in database
6 changed files with 57 additions and 6 deletions

View File

@ -23,6 +23,7 @@
- fix: dedupe URL balloon preview duplicates in watch stream without cross-chat/schema regressions (#64, thanks @lesaai)
- fix: normalize IMCore typing chat lookup across `iMessage`, `SMS`, and `any` prefixes (#51, #54, #56, #58)
- docs: document macOS 26 advanced IMCore injection limits (#60)
- fix: report macOS 26/Tahoe IMCore typing entitlement failures as advanced-feature setup errors (#60)
- docs: add a local release helper for dispatching Homebrew tap updates (#97, thanks @dinakars777)
- feat: resolve contact names in chat/message output and direct sends (#75, #77, thanks @regaw-leinad and @jsindy)

View File

@ -144,6 +144,9 @@ Important:
- macOS 26 can also block Messages.app dylib injection with library validation.
In that case `imsg status` reports advanced features unavailable even with SIP
disabled; normal send/history/watch commands still work.
- On macOS 26/Tahoe, direct IMCore access can also fail because `imagent`
rejects clients without Apple-private entitlements. That only affects advanced
IMCore features such as `typing`.
Setup:
1) Disable SIP from Recovery mode: `csrutil disable`

View File

@ -23,8 +23,9 @@ public enum IMCoreBridgeError: Error, CustomStringConvertible {
/// Bridge to IMCore via DYLD injection into Messages.app.
///
/// Communicates with an injected dylib inside Messages.app via file-based IPC.
/// The dylib has full access to IMCore because it runs within the Messages.app
/// context with proper entitlements.
/// The dylib has access to IMCore when Messages.app accepts the injection.
/// macOS 26/Tahoe can still block this path with library validation/private
/// entitlement checks.
///
/// Requires:
/// - SIP disabled (for `DYLD_INSERT_LIBRARIES` on system apps)
@ -139,6 +140,10 @@ public final class IMCoreBridge: @unchecked Sendable {
"""
SIP is disabled and the helper dylib is present, but Messages.app is not currently injected.
Run `imsg launch` to enable advanced IMCore features.
Note: macOS 26/Tahoe can still block advanced IMCore features through
library validation or imagent private entitlement checks. Basic send,
history, and watch commands do not use this path.
"""
)
}

View File

@ -134,6 +134,7 @@ public struct TypingIndicator: Sendable {
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.hasAttemptedConnection = true
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
@ -154,6 +155,9 @@ public struct TypingIndicator: Sendable {
let maxAttempts = 50
for _ in 0..<maxAttempts {
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
Thread.sleep(forTimeInterval: 0.1)
@ -161,11 +165,11 @@ public struct TypingIndicator: Sendable {
}
if !hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = true
daemonConnectionTracker.lock.unlock()
throw IMsgError.typingIndicatorFailed(
"Failed to connect to imagent (iMessage daemon). "
+ "This requires either SIP disabled with 'imsg launch', "
+ "or system modifications (AMFI disabled + XPC plist). "
+ "Run 'imsg status' for setup instructions."
daemonUnavailableMessage()
)
}
}
@ -221,11 +225,27 @@ public struct TypingIndicator: Sendable {
}
}
daemonConnectionTracker.lock.lock()
let connectionKnownUnavailable = daemonConnectionTracker.connectionKnownUnavailable
daemonConnectionTracker.lock.unlock()
if connectionKnownUnavailable {
throw IMsgError.typingIndicatorFailed(daemonUnavailableMessage())
}
throw IMsgError.typingIndicatorFailed(
"Chat not found for identifier: \(identifier). "
+ "Make sure Messages.app has an active conversation with this contact.")
}
static func daemonUnavailableMessage() -> String {
"Failed to connect to imagent (Messages daemon) for IMCore typing indicators. "
+ "On macOS 26/Tahoe, imagent can reject third-party clients without "
+ "Apple-private entitlements, and Messages.app may also block the injected "
+ "bridge via library validation. Run 'imsg status' and 'imsg launch' to "
+ "verify advanced feature setup. Normal 'send', 'history', and 'watch' "
+ "commands do not use this IMCore path."
}
static func chatLookupCandidates(for identifier: String) -> [String] {
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
@ -272,6 +292,7 @@ public struct TypingIndicator: Sendable {
private final class DaemonConnectionTracker: @unchecked Sendable {
let lock = NSLock()
var hasAttemptedConnection = false
var connectionKnownUnavailable = false
}
/// Thread-safe box for passing an error out of a Task back to the calling thread.

View File

@ -81,6 +81,14 @@ enum StatusCommand {
StdoutWriter.writeLine(" make build-dylib")
StdoutWriter.writeLine(" imsg launch")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("macOS 26/Tahoe note:")
StdoutWriter.writeLine(
" Advanced IMCore features may still be blocked by library validation"
)
StdoutWriter.writeLine(
" or imagent private entitlement checks. Basic commands still work."
)
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Note: Basic messaging features work without these steps.")
}
}

View File

@ -78,3 +78,16 @@ func typingLookupCandidatesAvoidDoublePrefixingDirectIdentifiers() {
func typingLookupCandidatesRejectBlankIdentifier() {
#expect(TypingIndicator.chatLookupCandidates(for: " ").isEmpty)
}
@Test
func typingDaemonUnavailableMessageExplainsTahoeEntitlementBlock() {
let message = TypingIndicator.daemonUnavailableMessage()
#expect(message.contains("imagent"))
#expect(message.contains("macOS 26/Tahoe"))
#expect(message.contains("Apple-private entitlements"))
#expect(message.contains("imsg status"))
#expect(message.contains("send"))
#expect(message.contains("history"))
#expect(message.contains("watch"))
}