89060d7177
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
289 lines
11 KiB
Swift
289 lines
11 KiB
Swift
//
|
||
// 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
|
||
}
|