Initial commit: IceGlass NHL game tracker
macOS menu bar app providing NHL game situational awareness with league-wide scoreboard, dynamic polling, notifications with team logos, and configurable display options.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// AppSettings.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import ServiceManagement
|
||||
|
||||
class AppSettings: @unchecked Sendable {
|
||||
private let logger = IceGlassLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
||||
category: "AppSettings"
|
||||
)
|
||||
|
||||
static let shared = AppSettings()
|
||||
|
||||
private enum UserDefaultsKey {
|
||||
static let launchAtLogin = "launchAtLogin"
|
||||
static let selectedTeam = "selectedTeam"
|
||||
static let displayOption = "displayOption"
|
||||
}
|
||||
|
||||
/// Controls which days are shown in the menu and counted in the status bar
|
||||
enum DisplayOption: String, CaseIterable {
|
||||
case yesterdayTodayTomorrow = "yesterdayTodayTomorrow"
|
||||
case todayTomorrow = "todayTomorrow"
|
||||
case todayOnly = "todayOnly"
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .yesterdayTodayTomorrow: return "Yesterday / Today / Tomorrow"
|
||||
case .todayTomorrow: return "Today / Tomorrow"
|
||||
case .todayOnly: return "Today"
|
||||
}
|
||||
}
|
||||
|
||||
/// Which date strings to include
|
||||
func includedDates() -> Set<String> {
|
||||
switch self {
|
||||
case .yesterdayTodayTomorrow:
|
||||
return [Date.yesterdayET, Date.todayET, Date.tomorrowET]
|
||||
case .todayTomorrow:
|
||||
return [Date.todayET, Date.tomorrowET]
|
||||
case .todayOnly:
|
||||
return [Date.todayET]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch at login
|
||||
var launchAtLogin: Bool {
|
||||
get {
|
||||
UserDefaults.standard.bool(forKey: UserDefaultsKey.launchAtLogin)
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.launchAtLogin)
|
||||
}
|
||||
}
|
||||
|
||||
// Selected team filter (nil = all teams)
|
||||
var selectedTeam: String? {
|
||||
get {
|
||||
UserDefaults.standard.string(forKey: UserDefaultsKey.selectedTeam)
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.selectedTeam)
|
||||
}
|
||||
}
|
||||
|
||||
// Display option
|
||||
var displayOption: DisplayOption {
|
||||
get {
|
||||
if let rawValue = UserDefaults.standard.string(forKey: UserDefaultsKey.displayOption),
|
||||
let option = DisplayOption(rawValue: rawValue) {
|
||||
return option
|
||||
}
|
||||
return .yesterdayTodayTomorrow
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.displayOption)
|
||||
}
|
||||
}
|
||||
|
||||
func updateLoginItem(enabled: Bool) {
|
||||
do {
|
||||
if enabled {
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to \(enabled ? "enable" : "disable") launch at login: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private init() {}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//
|
||||
// MenuManager.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
class MenuManager: @unchecked Sendable {
|
||||
private let logger = IceGlassLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
||||
category: "MenuManager"
|
||||
)
|
||||
|
||||
static let shared = MenuManager()
|
||||
|
||||
private lazy var settings = AppSettings.shared
|
||||
private lazy var mainService = MainService.shared
|
||||
private lazy var statusItemManager = StatusItemManager.shared
|
||||
|
||||
private var menuUpdateTimer: Timer?
|
||||
|
||||
private init() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
AppTerminator.terminate()
|
||||
return
|
||||
}
|
||||
self.logger.info("Initializing")
|
||||
|
||||
self.menuUpdateTimer = Timer.startTimer(
|
||||
timer: self.menuUpdateTimer,
|
||||
interval: PollingInterval.bootstrap.rawValue
|
||||
) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.updateMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private func isSafe() -> Bool {
|
||||
if let currentEvent = NSApp.currentEvent,
|
||||
currentEvent.type == .leftMouseDown || currentEvent.type == .rightMouseDown {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func scoreboardChanged() {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
let interval = self.mainService.anyGameLive
|
||||
? PollingInterval.everyMinute.rawValue
|
||||
: PollingInterval.idle.rawValue
|
||||
self.logger.debug("Starting menu update timer \(interval)")
|
||||
self.menuUpdateTimer = Timer.startTimer(
|
||||
timer: self.menuUpdateTimer,
|
||||
interval: interval
|
||||
) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.updateMenu()
|
||||
}
|
||||
}
|
||||
updateMenuSafely()
|
||||
}
|
||||
|
||||
func updateMenuSafely() {
|
||||
Task { @MainActor in
|
||||
if self.isSafe() {
|
||||
self.updateMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMenu() {
|
||||
logger.info("Setting up menu")
|
||||
|
||||
let menu = NSMenu()
|
||||
|
||||
// Refresh Now
|
||||
menu.addItem(
|
||||
NSMenuItem(
|
||||
title: "Refresh Now",
|
||||
action: #selector(refreshStats),
|
||||
keyEquivalent: "r"
|
||||
).withTarget(self)
|
||||
)
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let monoFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||
let boldFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold)
|
||||
|
||||
let gameDays = mainService.gamesByDate
|
||||
|
||||
if gameDays.isEmpty {
|
||||
menu.addItem(NSMenuItem(title: "No Games Available", action: nil, keyEquivalent: ""))
|
||||
} else {
|
||||
for (index, gameDay) in gameDays.enumerated() {
|
||||
// Date header with game count
|
||||
let dateLabel = Date.fullDateLabel(for: gameDay.date)
|
||||
let gameCount = gameDay.games.count
|
||||
let headerText = "\(dateLabel) (\(gameCount) game\(gameCount == 1 ? "" : "s"))"
|
||||
let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "")
|
||||
headerItem.attributedTitle = NSAttributedString(
|
||||
string: headerText,
|
||||
attributes: [.font: boldFont]
|
||||
)
|
||||
menu.addItem(headerItem)
|
||||
|
||||
if gameDay.games.isEmpty {
|
||||
let noGames = NSMenuItem(title: " No games scheduled", action: nil, keyEquivalent: "")
|
||||
noGames.attributedTitle = NSAttributedString(
|
||||
string: " No games scheduled",
|
||||
attributes: [
|
||||
.font: monoFont,
|
||||
.foregroundColor: NSColor.secondaryLabelColor
|
||||
]
|
||||
)
|
||||
menu.addItem(noGames)
|
||||
} else {
|
||||
for game in gameDay.games {
|
||||
let items = createGameMenuItems(for: game, font: monoFont)
|
||||
for item in items {
|
||||
menu.addItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separator between date groups
|
||||
if index < gameDays.count - 1 {
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Display Options submenu
|
||||
let displayMenuItem = NSMenuItem()
|
||||
displayMenuItem.title = "Display Options"
|
||||
let displaySubmenu = NSMenu()
|
||||
for option in AppSettings.DisplayOption.allCases {
|
||||
let item = NSMenuItem(
|
||||
title: option.title,
|
||||
action: #selector(changeDisplayOption(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
item.target = self
|
||||
item.representedObject = option.rawValue
|
||||
item.state = option == settings.displayOption ? .on : .off
|
||||
displaySubmenu.addItem(item)
|
||||
}
|
||||
displayMenuItem.submenu = displaySubmenu
|
||||
menu.addItem(displayMenuItem)
|
||||
|
||||
// About IceGlass
|
||||
menu.addItem(
|
||||
NSMenuItem(
|
||||
title: "About IceGlass",
|
||||
action: #selector(showAbout),
|
||||
keyEquivalent: ""
|
||||
).withTarget(self)
|
||||
)
|
||||
|
||||
// Launch at Login
|
||||
let launchItem = NSMenuItem(
|
||||
title: "Launch at Login",
|
||||
action: #selector(toggleLaunchAtLogin(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
launchItem.target = self
|
||||
launchItem.state = settings.launchAtLogin ? .on : .off
|
||||
menu.addItem(launchItem)
|
||||
|
||||
#if DEBUG
|
||||
// Developer menu
|
||||
let devMenuItem = NSMenuItem()
|
||||
devMenuItem.title = "Developer"
|
||||
let devSubmenu = NSMenu()
|
||||
devSubmenu.addItem(
|
||||
NSMenuItem(
|
||||
title: "Force Refresh",
|
||||
action: #selector(refreshStats),
|
||||
keyEquivalent: ""
|
||||
).withTarget(self)
|
||||
)
|
||||
devSubmenu.addItem(
|
||||
NSMenuItem(
|
||||
title: "Test Game Start Notification",
|
||||
action: #selector(triggerTestGameStart),
|
||||
keyEquivalent: ""
|
||||
).withTarget(self)
|
||||
)
|
||||
devSubmenu.addItem(
|
||||
NSMenuItem(
|
||||
title: "Test Goal Scored Notification",
|
||||
action: #selector(triggerTestGoalScored),
|
||||
keyEquivalent: ""
|
||||
).withTarget(self)
|
||||
)
|
||||
devMenuItem.submenu = devSubmenu
|
||||
menu.addItem(devMenuItem)
|
||||
#endif
|
||||
|
||||
// Quit
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
|
||||
|
||||
statusItemManager.statusItem?.menu = menu
|
||||
}
|
||||
|
||||
// MARK: - Menu Actions
|
||||
|
||||
@objc private func refreshStats() {
|
||||
Task { @MainActor in
|
||||
await mainService.fetchAll()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleGameMenuClick(_ sender: NSMenuItem) {
|
||||
if NSEvent.modifierFlags.contains(.option) {
|
||||
openGameStream(sender)
|
||||
} else {
|
||||
openGame(sender)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func openGame(_ sender: NSMenuItem) {
|
||||
guard let game = sender.representedObject as? Scoreboard.Game,
|
||||
let url = URL(string: game.gameCenterUrl) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
@objc private func openGameStream(_ sender: NSMenuItem) {
|
||||
guard let game = sender.representedObject as? Scoreboard.Game,
|
||||
let url = URL(string: game.videocastUrl) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
@objc private func changeDisplayOption(_ sender: NSMenuItem) {
|
||||
guard let rawValue = sender.representedObject as? String,
|
||||
let option = AppSettings.DisplayOption(rawValue: rawValue) else { return }
|
||||
settings.displayOption = option
|
||||
statusItemManager.updateGameCounts(mainService.gamesByDate)
|
||||
updateMenu()
|
||||
}
|
||||
|
||||
@objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) {
|
||||
settings.launchAtLogin.toggle()
|
||||
settings.updateLoginItem(enabled: settings.launchAtLogin)
|
||||
updateMenu()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@objc private func triggerTestGameStart() {
|
||||
let notificationManager = NotificationManager.shared
|
||||
if let game = mainService.gamesByDate.flatMap(\.games).first {
|
||||
notificationManager.notifyGameStarted(game, bypassDedup: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func triggerTestGoalScored() {
|
||||
let notificationManager = NotificationManager.shared
|
||||
if let game = mainService.gamesByDate.flatMap(\.games).first {
|
||||
notificationManager.notifyGoalScored(game, scoringTeam: game.homeTeam, bypassDedup: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@objc private func showAbout() {
|
||||
Task { @MainActor in
|
||||
showAboutWindow()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu Item Creation
|
||||
|
||||
private func createGameMenuItems(for game: Scoreboard.Game, font: NSFont) -> [NSMenuItem] {
|
||||
let title = " \(game.menuTitle)"
|
||||
|
||||
// Regular click item
|
||||
let item = NSMenuItem(title: title, action: #selector(handleGameMenuClick(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = game
|
||||
item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
|
||||
|
||||
// Alternate item for option-click (videocast)
|
||||
let altItem = NSMenuItem(title: title, action: #selector(openGameStream(_:)), keyEquivalent: "")
|
||||
altItem.target = self
|
||||
altItem.representedObject = game
|
||||
altItem.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
|
||||
altItem.isAlternate = true
|
||||
altItem.keyEquivalentModifierMask = .option
|
||||
|
||||
return [item, altItem]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//
|
||||
// NotificationManager.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
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, 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!"
|
||||
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
|
||||
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 {
|
||||
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(
|
||||
identifier: "team-logo-\(teamAbbrev)",
|
||||
url: tempFile,
|
||||
options: [
|
||||
UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier,
|
||||
UNNotificationAttachmentOptionsThumbnailHiddenKey: false
|
||||
]
|
||||
)
|
||||
return attachment
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// StatusItemManager.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
final class StatusItemManager: @unchecked Sendable {
|
||||
private let logger = IceGlassLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
||||
category: "StatusItemManager"
|
||||
)
|
||||
|
||||
static let shared = StatusItemManager()
|
||||
|
||||
var statusItem: NSStatusItem?
|
||||
|
||||
private init() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
AppTerminator.terminate()
|
||||
return
|
||||
}
|
||||
self.setupStatusItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupStatusItem() {
|
||||
logger.debug("Initializing")
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
updateIcon()
|
||||
}
|
||||
|
||||
func updateIcon() {
|
||||
Task { @MainActor in
|
||||
guard let button = self.statusItem?.button else { return }
|
||||
|
||||
guard let baseImage = NSImage(named: NSImage.Name("NHLShield")) else {
|
||||
button.title = "NHL"
|
||||
return
|
||||
}
|
||||
|
||||
let pointSize = button.frame.size.height > 0 ? button.frame.size.height : 22
|
||||
let resizedImage = NSImage(size: NSSize(width: pointSize, height: pointSize))
|
||||
resizedImage.lockFocus()
|
||||
baseImage.draw(
|
||||
in: NSRect(x: 0, y: 0, width: pointSize, height: pointSize),
|
||||
from: NSRect(x: 0, y: 0, width: baseImage.size.width, height: baseImage.size.height),
|
||||
operation: .copy,
|
||||
fraction: 1.0
|
||||
)
|
||||
resizedImage.unlockFocus()
|
||||
|
||||
button.image = resizedImage
|
||||
button.imageScaling = .scaleProportionallyDown
|
||||
}
|
||||
}
|
||||
|
||||
/// Update status bar text with per-day game counts (e.g. "6/10/9" or "10/9")
|
||||
func updateGameCounts(_ gameDays: [Scoreboard.GameDay]) {
|
||||
Task { @MainActor in
|
||||
guard let button = self.statusItem?.button else { return }
|
||||
if gameDays.isEmpty {
|
||||
button.title = ""
|
||||
return
|
||||
}
|
||||
let counts = gameDays.map { "\($0.games.count)" }.joined(separator: "/")
|
||||
button.title = " \(counts)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user