Rebake team logos at 72×72 for crisp notification thumbnails

The notification thumbnail's native display size is ~48 physical pixels
(measured empirically with a size-ladder test attached via the dev menu).
Shipping logos at 512×342 forced macOS to downsample ~10×, which is what
was producing the blurry/aliased team logos in banner thumbnails.

- Pre-render logos at 72×72 (1.5× over native; stays sharp, gives a little
  extra detail for retina displays without triggering aliasing)
- Trim transparent margins before fitting: NHL brand SVGs pad their
  viewBox generously, so the actual logo was only ~60% of the bundled
  image. square_logo.swift now scans the alpha channel, crops to the
  tight bounding box, then fits aspect-preserved into the 72×72 canvas.
- Drop the 32 unused TeamLogo_*.imageset asset-catalog entries (dead code
  since the team-filter feature was removed); notifications load PNGs
  from the filesystem bundle subdir
- Move TeamLogos/ → Resources/TeamLogos/ and update project.yml source
  paths; excludes: on the recursive scan prevents duplicate flat copies
  that were bloating the bundle
- Simplify NotificationManager: drop SVG fallback (macOS doesn't accept
  SVG attachments) and content-hash identifier experiments; back to the
  minimal working config
- Dev menu: add "Thumbnail Size Test" which fires a ladder of 10 test
  notifications (16…128px) for future sizing verification
- Fire a test game-start notification on startup in DEBUG builds so the
  dev loop doesn't require clicking through the menu after each launch
- Scripts/square_logo.swift: alpha-bbox trim + aspect-preserved fit
This commit is contained in:
2026-04-19 20:59:33 -04:00
parent 57358797e1
commit 89060d7177
137 changed files with 320 additions and 595 deletions
+112 -7
View File
@@ -5,7 +5,9 @@
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
import UserNotifications
@@ -143,24 +145,29 @@ class NotificationManager: @unchecked Sendable {
// MARK: - Team Logo Attachment
/// Creates a UNNotificationAttachment from the bundled team logo PNG
/// Creates a UNNotificationAttachment from the bundled team logo PNG.
/// PNGs live on the filesystem (TeamLogos/ folder) rather than the asset
/// catalog because UNNotificationAttachment takes a file URL asset
/// catalog images aren't accessible as standalone files.
///
/// PNGs are pre-rendered at 48×48 (the measured native thumbnail size)
/// so macOS doesn't downsample them during display.
private func teamLogoAttachment(for teamAbbrev: String) -> UNNotificationAttachment? {
// Look for PNG in the TeamLogos bundle directory
guard let logoURL = Bundle.main.url(forResource: teamAbbrev, withExtension: "png", subdirectory: "TeamLogos") else {
guard let logoURL = Bundle.main.url(
forResource: teamAbbrev, withExtension: "png", subdirectory: "TeamLogos"
) else {
logger.debug("No logo found for \(teamAbbrev)")
return nil
}
// UNNotificationAttachment needs its own copy in a temp directory
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("team-logos", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempFile = tempDir.appendingPathComponent("\(teamAbbrev)-\(UUID().uuidString).png")
do {
try FileManager.default.copyItem(at: logoURL, to: tempFile)
let attachment = try UNNotificationAttachment(
return try UNNotificationAttachment(
identifier: "team-logo-\(teamAbbrev)",
url: tempFile,
options: [
@@ -168,7 +175,6 @@ class NotificationManager: @unchecked Sendable {
UNNotificationAttachmentOptionsThumbnailHiddenKey: false
]
)
return attachment
} catch {
logger.error("Failed to create logo attachment for \(teamAbbrev): \(error.localizedDescription)")
return nil
@@ -180,4 +186,103 @@ class NotificationManager: @unchecked Sendable {
gameStartsSent.removeAll()
scoreChangesSent.removeAll()
}
#if DEBUG
// MARK: - Thumbnail Size Test
/// Fires one notification per size in the ladder with a distinctive test
/// image generated at that exact pixel size. The crispest thumbnail
/// reveals the notification system's native display size.
func sendThumbnailSizeTest() {
let sizes = [16, 24, 32, 40, 48, 56, 64, 80, 96, 128]
for size in sizes {
guard let attachment = testAttachment(size: size) else {
logger.warning("Failed to create test attachment for size \(size)")
continue
}
let content = UNMutableNotificationContent()
content.title = "Thumb Test"
content.body = "\(size)px"
content.sound = nil
content.interruptionLevel = .active
content.attachments = [attachment]
let request = UNNotificationRequest(
identifier: "thumb-test-\(size)-\(UUID().uuidString)",
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
)
UNUserNotificationCenter.current().add(request) { [weak self] error in
if let error = error {
self?.logger.error("Thumb test \(size)px failed: \(error.localizedDescription)")
}
}
}
}
/// Builds a UNNotificationAttachment with a generated NxN PNG.
/// Pattern: solid white background, 1-source-pixel black border, a
/// small centered 4x4 black pixel grid (so we can see pixel-level
/// behaviour). Unique filename + attachment identifier so macOS
/// treats each size as a distinct attachment (no cache reuse).
private func testAttachment(size: Int) -> UNNotificationAttachment? {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("thumb-test", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempFile = tempDir.appendingPathComponent("thumb-\(size)-\(UUID().uuidString).png")
guard generateTestPNG(size: size, at: tempFile) else { return nil }
do {
return try UNNotificationAttachment(
identifier: "thumb-test-attachment-\(size)-\(UUID().uuidString)",
url: tempFile,
options: [
UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier,
UNNotificationAttachmentOptionsThumbnailHiddenKey: false
]
)
} catch {
logger.error("Failed to create thumb test attachment (\(size)): \(error.localizedDescription)")
return nil
}
}
private func generateTestPNG(size: Int, at url: URL) -> Bool {
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let ctx = CGContext(
data: nil, width: size, height: size,
bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
// White fill
ctx.setFillColor(red: 1, green: 1, blue: 1, alpha: 1)
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
// 1-pixel black border (no antialiasing for pixel accuracy)
ctx.setShouldAntialias(false)
ctx.setFillColor(red: 0, green: 0, blue: 0, alpha: 1)
// Top row
ctx.fill(CGRect(x: 0, y: size - 1, width: size, height: 1))
// Bottom row
ctx.fill(CGRect(x: 0, y: 0, width: size, height: 1))
// Left column
ctx.fill(CGRect(x: 0, y: 0, width: 1, height: size))
// Right column
ctx.fill(CGRect(x: size - 1, y: 0, width: 1, height: size))
// Centered 4x4 black square at exact source pixel positions
let cx = size / 2
let cy = size / 2
ctx.fill(CGRect(x: cx - 2, y: cy - 2, width: 4, height: 4))
guard let image = ctx.makeImage(),
let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)
else { return false }
CGImageDestinationAddImage(dest, image, nil)
return CGImageDestinationFinalize(dest)
}
#endif
}