// // 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 roundItems = mainService.currentRoundSeriesItems if !roundItems.isEmpty, let round = mainService.bracket?.currentRound { let headerText = "ROUND \(round)" let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "") headerItem.attributedTitle = NSAttributedString( string: headerText, attributes: [.font: boldFont] ) menu.addItem(headerItem) for item in roundItems { menu.addItem(createRoundSeriesItem(for: item, font: monoFont)) } menu.addItem(NSMenuItem.separator()) } 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))" 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) } } } 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() // Date range options 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) } displaySubmenu.addItem(NSMenuItem.separator()) // Status bar options for option in AppSettings.StatusBarOption.allCases { let item = NSMenuItem( title: option.title, action: #selector(changeStatusBarOption(_:)), keyEquivalent: "" ) item.target = self item.representedObject = option.rawValue item.state = option == settings.statusBarOption ? .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 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) ) devSubmenu.addItem( NSMenuItem(title: "Test Game Ended Notification", action: #selector(triggerTestGameEnded), keyEquivalent: "").withTarget(self) ) devSubmenu.addItem( NSMenuItem(title: "Thumbnail Size Test", action: #selector(triggerThumbnailSizeTest), keyEquivalent: "").withTarget(self) ) devMenuItem.submenu = devSubmenu menu.addItem(devMenuItem) #endif 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 openSeriesPage(_ sender: NSMenuItem) { guard let urlString = sender.representedObject as? String, let url = URL(string: urlString) 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 mainService.updateUI() } @objc private func changeStatusBarOption(_ sender: NSMenuItem) { guard let rawValue = sender.representedObject as? String, let option = AppSettings.StatusBarOption(rawValue: rawValue) else { return } settings.statusBarOption = option mainService.updateUI() } @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 { let sampleScorer = GoalScorer(name: "J. Eriksson Ek", sweaterNumber: 14, strength: "PPG") notificationManager.notifyGoalScored( game, scoringTeam: game.homeTeam, scorer: sampleScorer, bypassDedup: true ) } } @objc private func triggerTestGameEnded() { let notificationManager = NotificationManager.shared if let game = mainService.gamesByDate.flatMap(\.games).first { notificationManager.notifyGameEnded(game, bypassDedup: true) } } @objc private func triggerThumbnailSizeTest() { NotificationManager.shared.sendThumbnailSizeTest() } #endif @objc private func showAbout() { Task { @MainActor in showAboutWindow() } } // MARK: - Menu Item Creation private func createGameMenuItems(for game: Scoreboard.Game, font: NSFont) -> [NSMenuItem] { let prefix = game.gameType == 2 ? String(format: "#%4d ", game.seasonGameNumber) : " " let title = "\(prefix)\(game.menuTitle)" let item = NSMenuItem(title: title, action: #selector(handleGameMenuClick(_:)), keyEquivalent: "") item.target = self item.representedObject = game item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font]) 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] } private func createRoundSeriesItem(for roundItem: MainService.RoundSeriesItem, font: NSFont) -> NSMenuItem { let series = roundItem.series let top = series.topSeedTeam?.abbrev ?? "TBD" let bottom = series.bottomSeedTeam?.abbrev ?? "TBD" let matchup = "\(bottom) @ \(top)" let score = "\(series.bottomSeedWins)-\(series.topSeedWins)" let statusTag: String let trailing: String if let winner = series.winner { statusTag = "(Final \(winner) wins)" trailing = "" } else if let n = series.nextGameNumber { statusTag = "(Game \(n))" trailing = roundItem.nextGame.map { Self.nextGameLabel(for: $0) } ?? "" } else { statusTag = "" trailing = "" } let tagColumn = statusTag.padding(toLength: 20, withPad: " ", startingAt: 0) let title = " \(matchup) \(score) \(tagColumn)\(trailing)" let item = NSMenuItem(title: title, action: #selector(openSeriesPage(_:)), keyEquivalent: "") item.target = self item.representedObject = series.fullSeriesUrl item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font]) return item } private static func nextGameLabel(for game: Scoreboard.Game) -> String { let state = game.parsedGameState if state.isLive { return state.shortTag } let dayLabel: String switch game.gameDate { case Date.todayET: dayLabel = "Today" case Date.tomorrowET: dayLabel = "Tomorrow" case Date.yesterdayET: dayLabel = "Yesterday" default: let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" formatter.timeZone = TimeZone(identifier: "America/New_York") if let date = formatter.date(from: game.gameDate) { dayLabel = date.formatDateET(format: "EEE") } else { dayLabel = "" } } let time = game.startTimeET.trimmingCharacters(in: .whitespaces) let base = "\(dayLabel) \(time)" return state == .pre ? "\(base) (PRE)" : base } }