Compare commits

...

2 Commits

Author SHA1 Message Date
Claude 6153ec1ad1 Prune old NSE notification avatars
The avatar cache files written for Communication Notifications cannot be
deleted right after the handler returns (the system reads them lazily, e.g.
the Apple Watch fetching the image), and the NSE gets no dismissal callback.

Add best-effort, age-based cleanup that drops files older than 24 hours each
time a new avatar is written. Notifications are ephemeral, so any device that
needed an old file is long done. The cleanup tolerates concurrent NSE
instances removing the same file.
2026-06-14 15:07:44 +00:00
Claude b5f61182c5 Fix missing chat notification avatar on Apple Watch
Chat notifications are upgraded to Communication Notifications and attach
the sender's avatar to the INPerson via downloadAvatarImage. The image was
built with INImage(imageData:), which renders on the iPhone but is not
relayed to paired devices like the Apple Watch. The Watch then falls back to
drawing a monogram from the sender's initials instead of the avatar.

Write the downloaded avatar bytes to a file in the shared App Group
container and back the INImage with INImage(url:) so the system can resolve
the image lazily across devices. Files are named by a stable hash of the
content-addressed thumbnail URL so notifications from the same sender reuse
one file.
2026-06-14 14:29:48 +00:00
2 changed files with 109 additions and 0 deletions
@@ -1,3 +1,4 @@
import CryptoKit
import Intents
import UIKit
import UserNotifications
@@ -190,9 +191,107 @@ class NotificationService: UNNotificationServiceExtension {
semaphore.wait()
guard let data = imageData else { return nil }
// Back the INImage with a file in the shared App Group container rather
// than raw image data. `INImage(imageData:)` renders fine on the iPhone
// itself, but the in-memory bytes are not relayed to paired devices like
// the Apple Watch. The Watch then receives an INPerson with no usable
// image and falls back to drawing a monogram from the sender's initials.
// Pointing the INImage at a file URL the Watch can resolve lets it render
// the actual avatar.
if let fileURL = writeAvatarToSharedContainer(data: data, urlString: thumbnailUrlString) {
return INImage(url: fileURL)
}
// Fall back to in-memory data if we could not write to the container. The
// avatar still shows on the iPhone in that case.
return INImage(imageData: data)
}
// Writes avatar bytes to a file in the shared App Group container so the
// resulting INImage can be backed by a URL. The system reads this file
// lazily (including when relaying the notification to the Apple Watch), so
// it must live in the persistent shared container rather than a temporary
// directory. Files are named by a stable hash of the source URL, which is
// content-addressed (the CID changes when a user updates their avatar), so
// repeated notifications from the same sender reuse one file instead of
// accumulating duplicates.
func writeAvatarToSharedContainer(data: Data, urlString: String) -> URL? {
guard
let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: APP_GROUP
)
else {
return nil
}
let avatarsDir = containerURL.appendingPathComponent(
"notification-avatars",
isDirectory: true
)
let fileName = Insecure.MD5.hash(data: Data(urlString.utf8))
.map { String(format: "%02x", $0) }
.joined()
let fileURL = avatarsDir.appendingPathComponent(fileName)
// Reuse an already-downloaded avatar if present.
if FileManager.default.fileExists(atPath: fileURL.path) {
return fileURL
}
do {
try FileManager.default.createDirectory(
at: avatarsDir,
withIntermediateDirectories: true
)
try data.write(to: fileURL, options: .atomic)
} catch {
return nil
}
pruneOldAvatars(in: avatarsDir)
return fileURL
}
// Best-effort, age-based cleanup of the avatar cache. We never delete files
// right after delivering a notification because the system reads them
// lazily (the Apple Watch may fetch the avatar seconds later), and the NSE
// gets no "notification dismissed" callback. Instead we drop files old
// enough that any device that needed them is long done. Notifications are
// ephemeral, so a one-day window is comfortably safe.
//
// This runs inside the time-limited extension, but the directory holds only
// a handful of small files. Every step is best-effort: multiple NSE
// instances may run this concurrently, so a file another instance just
// removed is expected and harmless (`try?`).
func pruneOldAvatars(
in directory: URL,
olderThan maxAge: TimeInterval = 24 * 60 * 60
) {
let fileManager = FileManager.default
guard
let entries = try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.contentModificationDateKey],
options: .skipsHiddenFiles
)
else {
return
}
let cutoff = Date().addingTimeInterval(-maxAge)
for fileURL in entries {
let modified = (try? fileURL.resourceValues(
forKeys: [.contentModificationDateKey]
))?.contentModificationDate
if let modified, modified < cutoff {
try? fileManager.removeItem(at: fileURL)
}
}
}
// MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) {
+10
View File
@@ -47,6 +47,16 @@ Two sound types are supported:
DM sound only plays if the user has enabled the `playSoundChat` preference in the main app's chat settings.
### Communication Notifications (Avatars)
Chat notifications (`reason == "chat-message"` / `"chat-reaction"`) are upgraded to iOS Communication Notifications via `INSendMessageIntent`, which lets the sender's avatar appear alongside the message.
The avatar is downloaded from `senderAvatarUrl` (rewritten to the `avatar_thumbnail` variant to keep it small) and attached to the `INPerson` sender. The downloaded bytes are written to a file in the shared App Group container and the `INImage` is created with `INImage(url:)`, **not** `INImage(imageData:)`.
This distinction matters for paired devices: an `INImage` backed by in-memory data renders on the iPhone but is not relayed to the Apple Watch, which then falls back to drawing a monogram from the sender's initials. Backing the image with a file URL the system can resolve lazily lets the Watch render the real avatar. Avatar files are written under `notification-avatars/` in the container and named by a stable hash of the (content-addressed) source URL, so notifications from the same sender reuse one file.
Because the system reads these files lazily, they cannot be deleted as soon as the handler returns, and the extension gets no "notification dismissed" callback. Instead `pruneOldAvatars` performs best-effort, age-based cleanup (files older than 24 hours) each time a new avatar is written. Notifications are ephemeral, so by then any device that needed the file is done. The cleanup tolerates concurrent NSE instances removing the same file.
## Key Files
| File | Purpose |