From 6153ec1ad12f7de08cc97cc1be251dcfc7d8d9f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 15:07:44 +0000 Subject: [PATCH] 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. --- modules/BlueskyNSE/NotificationService.swift | 39 ++++++++++++++++++++ modules/BlueskyNSE/README.md | 2 + 2 files changed, 41 insertions(+) diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift index d81b83a6bb..449fea8492 100644 --- a/modules/BlueskyNSE/NotificationService.swift +++ b/modules/BlueskyNSE/NotificationService.swift @@ -250,9 +250,48 @@ class NotificationService: UNNotificationServiceExtension { 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) { diff --git a/modules/BlueskyNSE/README.md b/modules/BlueskyNSE/README.md index 51ed9dea55..021530088b 100644 --- a/modules/BlueskyNSE/README.md +++ b/modules/BlueskyNSE/README.md @@ -55,6 +55,8 @@ The avatar is downloaded from `senderAvatarUrl` (rewritten to the `avatar_thumbn 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 |