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 |