// // 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() /// Track which score changes we've already notified about (gameId-awayScore-homeScore) private var scoreChangesSent = Set() 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 }