Files
iceglass/IceGlass/Managers/MenuManager.swift
T
rzen 8f8f8b2755 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.
2026-04-13 21:44:08 -04:00

299 lines
9.9 KiB
Swift

//
// 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]
}
}