Files
iceglass/IceGlass/Managers/NotificationManager.swift
T
rzen 89060d7177 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
2026-04-19 20:59:33 -04:00

289 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// NotificationManager.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
import UserNotifications
class NotificationManager: @unchecked Sendable {
private let logger = IceGlassLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
category: "NotificationManager"
)
static let shared = NotificationManager()
/// Track which game starts we've already notified about
private var gameStartsSent = Set<Int>()
/// Track which score changes we've already notified about (gameId-awayScore-homeScore)
private var scoreChangesSent = Set<String>()
private init() {
logger.info("Initializing")
requestNotificationPermissions()
}
private func requestNotificationPermissions() {
UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
guard let self = self else { return }
self.logger.info("Current notification settings: \(settings.authorizationStatus.rawValue)")
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .provisional, .badge]
) { [weak self] granted, error in
guard let self = self else { return }
if let error = error {
self.logger.error("Failed to request notification permission: \(error.localizedDescription)")
}
if granted {
self.logger.info("Notification permission granted")
} else {
self.logger.warning("Notification permission denied")
}
}
}
}
// MARK: - Game Started
func notifyGameStarted(_ game: Scoreboard.Game, bypassDedup: Bool = false) {
if !bypassDedup {
guard !gameStartsSent.contains(game.id) else { return }
gameStartsSent.insert(game.id)
}
let content = UNMutableNotificationContent()
content.title = "Game Started"
content.body = "\(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)"
content.sound = .default
content.interruptionLevel = .active
content.userInfo = ["url": game.gameCenterUrl]
// Attach away team logo (visiting team at the other team's arena)
if let attachment = teamLogoAttachment(for: game.awayTeam.abbrev) {
content.attachments = [attachment]
}
let identifier = bypassDedup
? "game-start-test-\(UUID().uuidString)"
: "game-start-\(game.id)"
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
)
UNUserNotificationCenter.current().add(request) { [weak self] error in
if let error = error {
self?.logger.error("Error sending game start notification: \(error.localizedDescription)")
} else {
self?.logger.info("Game start notification sent for \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
}
}
}
// MARK: - Goal Scored
func notifyGoalScored(
_ game: Scoreboard.Game,
scoringTeam: Scoreboard.Game.Team,
scorer: GoalScorer? = nil,
bypassDedup: Bool = false
) {
let awayScore = game.awayTeam.score ?? 0
let homeScore = game.homeTeam.score ?? 0
let key = "\(game.id)-\(awayScore)-\(homeScore)"
if !bypassDedup {
guard !scoreChangesSent.contains(key) else { return }
scoreChangesSent.insert(key)
}
let content = UNMutableNotificationContent()
content.title = "\(scoringTeam.abbrev) Goal!"
if let scorer = scorer {
content.subtitle = scorer.displayLine
}
content.body = "\(game.awayTeam.abbrev) \(awayScore) : \(game.homeTeam.abbrev) \(homeScore)"
content.sound = UNNotificationSound(named: UNNotificationSoundName("Glass"))
content.interruptionLevel = .active
content.userInfo = ["url": game.gameCenterUrl]
// Attach scoring team's logo
if let attachment = teamLogoAttachment(for: scoringTeam.abbrev) {
content.attachments = [attachment]
}
let identifier = bypassDedup
? "goal-test-\(UUID().uuidString)"
: "goal-\(key)"
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
)
UNUserNotificationCenter.current().add(request) { [weak self] error in
if let error = error {
self?.logger.error("Error sending goal notification: \(error.localizedDescription)")
} else {
self?.logger.info("Goal notification sent: \(scoringTeam.abbrev) scored, \(awayScore):\(homeScore)")
}
}
}
// MARK: - Team Logo Attachment
/// 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? {
guard let logoURL = Bundle.main.url(
forResource: teamAbbrev, withExtension: "png", subdirectory: "TeamLogos"
) else {
logger.debug("No logo found for \(teamAbbrev)")
return nil
}
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)
return try UNNotificationAttachment(
identifier: "team-logo-\(teamAbbrev)",
url: tempFile,
options: [
UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier,
UNNotificationAttachmentOptionsThumbnailHiddenKey: false
]
)
} catch {
logger.error("Failed to create logo attachment for \(teamAbbrev): \(error.localizedDescription)")
return nil
}
}
func resetForNewDay() {
logger.debug("Resetting notification tracking for new day")
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
}