commit 8f8f8b2755ec258e243f8ef1fa28c24db826a679 Author: rzen Date: Mon Apr 13 21:44:08 2026 -0400 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50b2ca7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Xcode +build/ +DerivedData/ +*.xcuserdata +*.xcworkspace +xcuserdata/ +*.xcscmblueprint + +# Swift Package Manager +.build/ +.swiftpm/ + +# macOS +.DS_Store +*.swp +*~ + +# Project-specific +IceGlass.xcodeproj/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b7b8769 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## April 2026 + +- Initial project setup with macOS menu bar app +- NHL scoreboard API integration showing yesterday/today/tomorrow games +- Dynamic polling based on game state (7s live, 10min game day, 1hr idle) +- Click game to open NHL GameCenter, option-click for NHL Videocast +- NHL shield icon in menu bar +- About window using IndieAbout +- Launch at Login support +- Game start notifications (triggered on FUT→LIVE state transition) +- Goal scored notifications with scoring team logo +- 32 team logos downloaded from NHL CDN (SVG + PNG) +- Logo download script for seasonal updates (Scripts/download_team_logos.sh) +- Display Options: Yesterday/Today/Tomorrow, Today/Tomorrow, or Today only +- Game count shown next to menu bar icon +- Game count shown per day in dropdown headers +- Menu item format: "NYR @ WAS 0:2 (FINAL)" / "DAL @ TOR Today @ 7:30 PM" +- NHL shield renders with full color (not template silhouette) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2945859 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,146 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +IceGlass is a macOS menu bar application that provides NHL game situational awareness. It shows recent scores, upcoming games, and game states for all NHL teams in a yesterday/today/tomorrow window. The app fetches live data from the NHL API and displays it in a dropdown menu. + +## Building and Testing + +### Build Commands +```bash +# Generate Xcode project (after modifying project.yml) +xcodegen generate + +# Build the project +xcodebuild -scheme IceGlass -configuration Debug build + +# Build for release +xcodebuild -scheme IceGlass -configuration Release build + +# Clean build folder +xcodebuild -scheme IceGlass clean +``` + +### Running the App +Open `IceGlass.xcodeproj` in Xcode and run the scheme, or build and run the resulting .app bundle from the build directory. + +### Debug vs Release Builds +- **DEBUG** builds include a "Developer" menu with "Force Refresh" + +## Architecture + +### Core Services Pattern + +The app uses a singleton-based architecture following the same pattern as the Ovi app: + +1. **MainService** (`Services/MainService.swift`) - Central coordinator that: + - Manages a single polling timer for the scoreboard API + - Dynamically adjusts polling intervals based on game state (7s live, 180s pre-game, 600s game day, 3600s idle) + - Filters API response to yesterday/today/tomorrow window in Eastern Time + +2. **StatusItemManager** (`Managers/StatusItemManager.swift`) - Manages the menu bar item: + - Displays NHL shield icon (template image for dark/light mode) + - Creates and manages the NSStatusItem + +3. **MenuManager** (`Managers/MenuManager.swift`) - Builds and updates the dropdown menu: + - Displays games grouped by date (YESTERDAY/TODAY/TOMORROW headers) + - Game rows show: away/home teams, scores, game state/time + - Click opens NHL GameCenter; option-click opens NHL Videocast + - Menu updates safely (skips during mouse clicks to prevent crashes) + +### API Integration + +**Single endpoint**: `https://api-web.nhle.com/v1/scoreboard/now` +- Returns games grouped by date with scores, covering a multi-day window +- Response includes team names, abbreviations, scores, game state, period info + +**ApiService** (`Services/ApiService.swift`) - Generic URL+callback fetcher + +### Game States (from NHL API) + +Defined in `Models/GameState.swift`: +- **FUT** (Future) - More than 30 minutes before game start +- **PRE** (Pre-game) - Less than 30 minutes before puck drop +- **LIVE** - Game in progress +- **CRIT** - Last 5 minutes of regulation, OT, or SO +- **OVER** - Soft final (game over, not yet confirmed) +- **FINAL** - Hard final (confirmed by arena scorers) +- **OFF** - Official (confirmed by NHL statisticians) + +### Polling Intervals + +Defined in `Services/PollingInterval.swift`: +- **idle**: 3600s (1 hour) - No games today +- **bootstrap**: 42s - Initial app launch +- **gameDay**: 600s (10 minutes) - Games scheduled today but not starting soon +- **preGame**: 180s (3 minutes) - Game starting within 30 minutes +- **liveGame**: 7s - Any game currently live +- **everyMinute**: 60s - Intermissions, post-game + +### Notification System + +**NotificationManager** (`Managers/NotificationManager.swift`) - Handles macOS notifications: +- **Game start notifications**: triggered when game state transitions from FUT/PRE to LIVE +- **Goal scored notifications**: triggered when a team's score increases, with scoring team's logo attached +- Team logo PNGs (80x80) bundled in `TeamLogos/` directory for `UNNotificationAttachment` +- Deduplicates via Set tracking: game IDs for starts, "gameId-awayScore-homeScore" keys for goals +- First fetch suppressed (no notifications on app startup) +- AppDelegate handles notification clicks to open URLs in browser + +**Change Detection** (in MainService): +- `previousGameStates: [Int: GameSnapshot]` tracks game state and scores between polls +- `detectChanges(in:)` compares current vs previous to fire notifications + +### Team Logos + +Downloaded from NHL CDN via `Scripts/download_team_logos.sh`: +- Source: `https://assets.nhle.com/logos/nhl/svg/{TEAM}_light.svg?season={SEASON}` +- SVGs stored in `Assets.xcassets/TeamLogo_{TEAM}.imageset/` for future UI use +- PNGs (80x80) stored in `TeamLogos/` as bundle resources for notification attachments +- Update logos each season by running the script with the new season parameter + +### Settings & Persistence + +**AppSettings** (`Managers/AppSettings.swift`) - UserDefaults-backed: +- Launch at login (via SMAppService) +- Selected team filter (nil = all teams) + +### Eastern Time Handling + +NHL operates on Eastern Time. Date extensions handle timezone conversions: +- `Date+easternTimeZone.swift` - ET timezone constant +- `Date+etCalendar.swift` - Calendar configured for ET +- `Date+formatDateET.swift` - ET date formatters +- `Date+gameWindow.swift` - Yesterday/today/tomorrow date computation in ET + +### Important Implementation Notes + +- All services use singleton pattern with private initializers +- All singleton classes use `@unchecked Sendable` for Swift 6 strict concurrency +- Menu updates are skipped during mouse clicks (`isSafe()` check) to prevent crashes +- Timer rescheduling includes tolerance checks to avoid unnecessary invalidation +- Weak self references used throughout to prevent retain cycles +- XcodeGen is used to generate the Xcode project from `project.yml` +- LSUIElement=true in Info.plist (no dock icon, menu bar only) + +### Dependencies + +- **IndieAbout** (SPM) - About window UI (`https://git.rzen.dev/rzen/indie-about.git`) + +### File Organization + +``` +IceGlass/ +├── Services/ - Core business logic (MainService, ApiService, PollingInterval) +├── Managers/ - UI coordination (StatusItemManager, MenuManager, NotificationManager, AppSettings) +├── Models/ - Codable data models (ScoreboardModel, GameState, NHLTeam) +├── Extensions/ - Date/Timer/NSMenuItem/TimeInterval helpers +├── Lib/ - Utilities (IceGlassLogger, AppTerminator) +├── About/ - About window (AboutWindow with IndieAbout) +├── Assets.xcassets/ - AppIcon, NHLShield, TeamLogo_*.imageset (32 SVGs) +├── TeamLogos/ - 32 team logo PNGs (80x80, for notification attachments) +├── IceGlassApp.swift - App entry point (@main) +└── AppDelegate.swift - NSApplicationDelegate, notification click handler +``` diff --git a/IceGlass.entitlements b/IceGlass.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/IceGlass.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/IceGlass/About/AboutWindow.swift b/IceGlass/About/AboutWindow.swift new file mode 100644 index 0000000..ee47d43 --- /dev/null +++ b/IceGlass/About/AboutWindow.swift @@ -0,0 +1,57 @@ +// +// AboutWindow.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import IndieAbout + +@MainActor private var aboutWindowController: NSWindowController? + +@MainActor func showAboutWindow() { + if let controller = aboutWindowController { + controller.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let configuration = AppInfoConfiguration( + showDeviceInfo: false, + documents: [ + .license(), + .releaseNotes() + ] + ) + + let aboutView = IndieAbout(configuration: configuration) + .frame(width: 300) + + let window = NSWindow( + contentRect: .zero, + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + window.title = "About IceGlass" + window.contentView = NSHostingView(rootView: aboutView) + window.center() + + let controller = NSWindowController(window: window) + aboutWindowController = controller + + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { _ in + Task { @MainActor in + aboutWindowController = nil + } + } + + controller.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) +} diff --git a/IceGlass/AppDelegate.swift b/IceGlass/AppDelegate.swift new file mode 100644 index 0000000..ee2caa0 --- /dev/null +++ b/IceGlass/AppDelegate.swift @@ -0,0 +1,45 @@ +// +// AppDelegate.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import UserNotifications +import AppKit + +class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { + private let logger = IceGlassLogger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass", + category: "AppDelegate" + ) + + private var mainService = MainService.shared + + func applicationDidFinishLaunching(_ notification: Notification) { + logger.info("applicationDidFinishLaunching") + UNUserNotificationCenter.current().delegate = self + + // Set app icon explicitly (LSUIElement apps may not pick it up automatically) + if let icon = NSImage(named: "AppIcon") { + NSApp.applicationIconImage = icon + } + } + + func applicationWillTerminate(_ notification: Notification) { + // nothing to deinit + } + + // Notification click handler — opens URLs + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if let urlString = response.notification.request.content.userInfo["url"] as? String, + let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + completionHandler() + } +} diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/Contents.json b/IceGlass/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f70dcf3 --- /dev/null +++ b/IceGlass/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,176 @@ +{ + "images": [ + { + "filename": "icon-20@2x.png", + "idiom": "iphone", + "size": "20x20", + "scale": "2x" + }, + { + "filename": "icon-20@3x.png", + "idiom": "iphone", + "size": "20x20", + "scale": "3x" + }, + { + "filename": "icon-29@2x.png", + "idiom": "iphone", + "size": "29x29", + "scale": "2x" + }, + { + "filename": "icon-29@3x.png", + "idiom": "iphone", + "size": "29x29", + "scale": "3x" + }, + { + "filename": "icon-40@2x.png", + "idiom": "iphone", + "size": "40x40", + "scale": "2x" + }, + { + "filename": "icon-40@3x.png", + "idiom": "iphone", + "size": "40x40", + "scale": "3x" + }, + { + "filename": "icon-60@2x.png", + "idiom": "iphone", + "size": "60x60", + "scale": "2x" + }, + { + "filename": "icon-60@3x.png", + "idiom": "iphone", + "size": "60x60", + "scale": "3x" + }, + { + "filename": "icon-20.png", + "idiom": "ipad", + "size": "20x20", + "scale": "1x" + }, + { + "filename": "icon-20@2x.png", + "idiom": "ipad", + "size": "20x20", + "scale": "2x" + }, + { + "filename": "icon-29.png", + "idiom": "ipad", + "size": "29x29", + "scale": "1x" + }, + { + "filename": "icon-29@2x.png", + "idiom": "ipad", + "size": "29x29", + "scale": "2x" + }, + { + "filename": "icon-40.png", + "idiom": "ipad", + "size": "40x40", + "scale": "1x" + }, + { + "filename": "icon-40@2x.png", + "idiom": "ipad", + "size": "40x40", + "scale": "2x" + }, + { + "filename": "icon-76.png", + "idiom": "ipad", + "size": "76x76", + "scale": "1x" + }, + { + "filename": "icon-76@2x.png", + "idiom": "ipad", + "size": "76x76", + "scale": "2x" + }, + { + "filename": "icon-83.5@2x.png", + "idiom": "ipad", + "size": "83.5x83.5", + "scale": "2x" + }, + { + "filename": "icon-1024.png", + "idiom": "ios-marketing", + "size": "1024x1024", + "scale": "1x" + }, + { + "filename": "icon-mac-16.png", + "idiom": "mac", + "size": "16x16", + "scale": "1x" + }, + { + "filename": "icon-mac-16@2x.png", + "idiom": "mac", + "size": "16x16", + "scale": "2x" + }, + { + "filename": "icon-mac-32.png", + "idiom": "mac", + "size": "32x32", + "scale": "1x" + }, + { + "filename": "icon-mac-32@2x.png", + "idiom": "mac", + "size": "32x32", + "scale": "2x" + }, + { + "filename": "icon-mac-128.png", + "idiom": "mac", + "size": "128x128", + "scale": "1x" + }, + { + "filename": "icon-mac-128@2x.png", + "idiom": "mac", + "size": "128x128", + "scale": "2x" + }, + { + "filename": "icon-mac-256.png", + "idiom": "mac", + "size": "256x256", + "scale": "1x" + }, + { + "filename": "icon-mac-256@2x.png", + "idiom": "mac", + "size": "256x256", + "scale": "2x" + }, + { + "filename": "icon-mac-512.png", + "idiom": "mac", + "size": "512x512", + "scale": "1x" + }, + { + "filename": "icon-mac-512@2x.png", + "idiom": "mac", + "size": "512x512", + "scale": "2x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..3f6fa3d Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20.png new file mode 100644 index 0000000..be997d0 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png new file mode 100644 index 0000000..d9b9dc8 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png new file mode 100644 index 0000000..5a3140d Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29.png new file mode 100644 index 0000000..acdbeff Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png new file mode 100644 index 0000000..3312dfc Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png new file mode 100644 index 0000000..13b7c07 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40.png new file mode 100644 index 0000000..d9b9dc8 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png new file mode 100644 index 0000000..4374690 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png new file mode 100644 index 0000000..a670d6a Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png new file mode 100644 index 0000000..a670d6a Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png new file mode 100644 index 0000000..9c5e714 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76.png new file mode 100644 index 0000000..7685cff Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png new file mode 100644 index 0000000..52cd991 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png new file mode 100644 index 0000000..c44eab3 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128.png new file mode 100644 index 0000000..a0fb059 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png new file mode 100644 index 0000000..ea2b48d Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16.png new file mode 100644 index 0000000..e27490d Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png new file mode 100644 index 0000000..9803fc1 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256.png new file mode 100644 index 0000000..ea2b48d Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png new file mode 100644 index 0000000..a33506d Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32.png new file mode 100644 index 0000000..9803fc1 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png new file mode 100644 index 0000000..94922d1 Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512.png new file mode 100644 index 0000000..a33506d Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512.png differ diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png new file mode 100644 index 0000000..3f6fa3d Binary files /dev/null and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png differ diff --git a/IceGlass/Assets.xcassets/Contents.json b/IceGlass/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/IceGlass/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IceGlass/Assets.xcassets/NHLShield.imageset/Contents.json b/IceGlass/Assets.xcassets/NHLShield.imageset/Contents.json new file mode 100644 index 0000000..ab83900 --- /dev/null +++ b/IceGlass/Assets.xcassets/NHLShield.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "NHLShield.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "NHLShield@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/NHLShield.imageset/NHLShield.png b/IceGlass/Assets.xcassets/NHLShield.imageset/NHLShield.png new file mode 100644 index 0000000..0c19c76 Binary files /dev/null and b/IceGlass/Assets.xcassets/NHLShield.imageset/NHLShield.png differ diff --git a/IceGlass/Assets.xcassets/NHLShield.imageset/NHLShield@2x.png b/IceGlass/Assets.xcassets/NHLShield.imageset/NHLShield@2x.png new file mode 100644 index 0000000..f0971d0 Binary files /dev/null and b/IceGlass/Assets.xcassets/NHLShield.imageset/NHLShield@2x.png differ diff --git a/IceGlass/Assets.xcassets/TeamLogo_ANA.imageset/ANA_light.svg b/IceGlass/Assets.xcassets/TeamLogo_ANA.imageset/ANA_light.svg new file mode 100644 index 0000000..d1bde81 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_ANA.imageset/ANA_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_ANA.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_ANA.imageset/Contents.json new file mode 100644 index 0000000..5d509a5 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_ANA.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ANA_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_BOS.imageset/BOS_light.svg b/IceGlass/Assets.xcassets/TeamLogo_BOS.imageset/BOS_light.svg new file mode 100644 index 0000000..2349ee4 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_BOS.imageset/BOS_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_BOS.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_BOS.imageset/Contents.json new file mode 100644 index 0000000..ccc6609 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_BOS.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BOS_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_BUF.imageset/BUF_light.svg b/IceGlass/Assets.xcassets/TeamLogo_BUF.imageset/BUF_light.svg new file mode 100644 index 0000000..f9988e8 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_BUF.imageset/BUF_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_BUF.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_BUF.imageset/Contents.json new file mode 100644 index 0000000..75dc599 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_BUF.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BUF_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_CAR.imageset/CAR_light.svg b/IceGlass/Assets.xcassets/TeamLogo_CAR.imageset/CAR_light.svg new file mode 100644 index 0000000..6eff6de --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_CAR.imageset/CAR_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_CAR.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_CAR.imageset/Contents.json new file mode 100644 index 0000000..ff42297 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_CAR.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CAR_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_CBJ.imageset/CBJ_light.svg b/IceGlass/Assets.xcassets/TeamLogo_CBJ.imageset/CBJ_light.svg new file mode 100644 index 0000000..1cc24ec --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_CBJ.imageset/CBJ_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_CBJ.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_CBJ.imageset/Contents.json new file mode 100644 index 0000000..93c5ba5 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_CBJ.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CBJ_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_CGY.imageset/CGY_light.svg b/IceGlass/Assets.xcassets/TeamLogo_CGY.imageset/CGY_light.svg new file mode 100644 index 0000000..f497b98 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_CGY.imageset/CGY_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_CGY.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_CGY.imageset/Contents.json new file mode 100644 index 0000000..d3c11a8 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_CGY.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CGY_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_CHI.imageset/CHI_light.svg b/IceGlass/Assets.xcassets/TeamLogo_CHI.imageset/CHI_light.svg new file mode 100644 index 0000000..a4013ae --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_CHI.imageset/CHI_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_CHI.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_CHI.imageset/Contents.json new file mode 100644 index 0000000..8f77e4a --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_CHI.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CHI_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_COL.imageset/COL_light.svg b/IceGlass/Assets.xcassets/TeamLogo_COL.imageset/COL_light.svg new file mode 100644 index 0000000..601d211 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_COL.imageset/COL_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_COL.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_COL.imageset/Contents.json new file mode 100644 index 0000000..c3e0e00 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_COL.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "COL_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_DAL.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_DAL.imageset/Contents.json new file mode 100644 index 0000000..4a373db --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_DAL.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "DAL_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_DAL.imageset/DAL_light.svg b/IceGlass/Assets.xcassets/TeamLogo_DAL.imageset/DAL_light.svg new file mode 100644 index 0000000..5ee18ca --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_DAL.imageset/DAL_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_DET.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_DET.imageset/Contents.json new file mode 100644 index 0000000..1f187c9 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_DET.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "DET_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_DET.imageset/DET_light.svg b/IceGlass/Assets.xcassets/TeamLogo_DET.imageset/DET_light.svg new file mode 100644 index 0000000..426d963 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_DET.imageset/DET_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_EDM.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_EDM.imageset/Contents.json new file mode 100644 index 0000000..576e167 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_EDM.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "EDM_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_EDM.imageset/EDM_light.svg b/IceGlass/Assets.xcassets/TeamLogo_EDM.imageset/EDM_light.svg new file mode 100644 index 0000000..edf6dc4 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_EDM.imageset/EDM_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_FLA.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_FLA.imageset/Contents.json new file mode 100644 index 0000000..88990d9 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_FLA.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FLA_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_FLA.imageset/FLA_light.svg b/IceGlass/Assets.xcassets/TeamLogo_FLA.imageset/FLA_light.svg new file mode 100644 index 0000000..bb482d9 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_FLA.imageset/FLA_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_LAK.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_LAK.imageset/Contents.json new file mode 100644 index 0000000..f22051a --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_LAK.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "LAK_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_LAK.imageset/LAK_light.svg b/IceGlass/Assets.xcassets/TeamLogo_LAK.imageset/LAK_light.svg new file mode 100644 index 0000000..8782b42 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_LAK.imageset/LAK_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_MIN.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_MIN.imageset/Contents.json new file mode 100644 index 0000000..48fdf26 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_MIN.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "MIN_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_MIN.imageset/MIN_light.svg b/IceGlass/Assets.xcassets/TeamLogo_MIN.imageset/MIN_light.svg new file mode 100644 index 0000000..fc0e9ce --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_MIN.imageset/MIN_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_MTL.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_MTL.imageset/Contents.json new file mode 100644 index 0000000..71c5b06 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_MTL.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "MTL_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_MTL.imageset/MTL_light.svg b/IceGlass/Assets.xcassets/TeamLogo_MTL.imageset/MTL_light.svg new file mode 100644 index 0000000..cab0075 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_MTL.imageset/MTL_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_NJD.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_NJD.imageset/Contents.json new file mode 100644 index 0000000..407b5fa --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_NJD.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "NJD_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_NJD.imageset/NJD_light.svg b/IceGlass/Assets.xcassets/TeamLogo_NJD.imageset/NJD_light.svg new file mode 100644 index 0000000..ad19cea --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_NJD.imageset/NJD_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_NSH.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_NSH.imageset/Contents.json new file mode 100644 index 0000000..5d65a7d --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_NSH.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "NSH_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_NSH.imageset/NSH_light.svg b/IceGlass/Assets.xcassets/TeamLogo_NSH.imageset/NSH_light.svg new file mode 100644 index 0000000..a813051 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_NSH.imageset/NSH_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_NYI.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_NYI.imageset/Contents.json new file mode 100644 index 0000000..93cbdd7 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_NYI.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "NYI_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_NYI.imageset/NYI_light.svg b/IceGlass/Assets.xcassets/TeamLogo_NYI.imageset/NYI_light.svg new file mode 100644 index 0000000..7d2caf5 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_NYI.imageset/NYI_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_NYR.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_NYR.imageset/Contents.json new file mode 100644 index 0000000..b0c0bc6 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_NYR.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "NYR_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_NYR.imageset/NYR_light.svg b/IceGlass/Assets.xcassets/TeamLogo_NYR.imageset/NYR_light.svg new file mode 100644 index 0000000..9794b7c --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_NYR.imageset/NYR_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_OTT.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_OTT.imageset/Contents.json new file mode 100644 index 0000000..fb2e442 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_OTT.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "OTT_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_OTT.imageset/OTT_light.svg b/IceGlass/Assets.xcassets/TeamLogo_OTT.imageset/OTT_light.svg new file mode 100644 index 0000000..b657670 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_OTT.imageset/OTT_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_PHI.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_PHI.imageset/Contents.json new file mode 100644 index 0000000..1a0a8ce --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_PHI.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PHI_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_PHI.imageset/PHI_light.svg b/IceGlass/Assets.xcassets/TeamLogo_PHI.imageset/PHI_light.svg new file mode 100644 index 0000000..5e71709 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_PHI.imageset/PHI_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_PIT.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_PIT.imageset/Contents.json new file mode 100644 index 0000000..74ccaa4 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_PIT.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PIT_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_PIT.imageset/PIT_light.svg b/IceGlass/Assets.xcassets/TeamLogo_PIT.imageset/PIT_light.svg new file mode 100644 index 0000000..a73a223 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_PIT.imageset/PIT_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_SEA.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_SEA.imageset/Contents.json new file mode 100644 index 0000000..c390bb5 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_SEA.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SEA_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_SEA.imageset/SEA_light.svg b/IceGlass/Assets.xcassets/TeamLogo_SEA.imageset/SEA_light.svg new file mode 100644 index 0000000..9927ddf --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_SEA.imageset/SEA_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_SJS.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_SJS.imageset/Contents.json new file mode 100644 index 0000000..49320b6 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_SJS.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SJS_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_SJS.imageset/SJS_light.svg b/IceGlass/Assets.xcassets/TeamLogo_SJS.imageset/SJS_light.svg new file mode 100644 index 0000000..335ca3d --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_SJS.imageset/SJS_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_STL.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_STL.imageset/Contents.json new file mode 100644 index 0000000..4fe3ab3 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_STL.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "STL_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_STL.imageset/STL_light.svg b/IceGlass/Assets.xcassets/TeamLogo_STL.imageset/STL_light.svg new file mode 100644 index 0000000..5a05aeb --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_STL.imageset/STL_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_TBL.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_TBL.imageset/Contents.json new file mode 100644 index 0000000..b0b28a3 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_TBL.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "TBL_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_TBL.imageset/TBL_light.svg b/IceGlass/Assets.xcassets/TeamLogo_TBL.imageset/TBL_light.svg new file mode 100644 index 0000000..09ab6bb --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_TBL.imageset/TBL_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_TOR.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_TOR.imageset/Contents.json new file mode 100644 index 0000000..1bb7d64 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_TOR.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "TOR_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_TOR.imageset/TOR_light.svg b/IceGlass/Assets.xcassets/TeamLogo_TOR.imageset/TOR_light.svg new file mode 100644 index 0000000..8e16ff5 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_TOR.imageset/TOR_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_UTA.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_UTA.imageset/Contents.json new file mode 100644 index 0000000..c8c64cf --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_UTA.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "UTA_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_UTA.imageset/UTA_light.svg b/IceGlass/Assets.xcassets/TeamLogo_UTA.imageset/UTA_light.svg new file mode 100644 index 0000000..6292849 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_UTA.imageset/UTA_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_VAN.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_VAN.imageset/Contents.json new file mode 100644 index 0000000..ae40818 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_VAN.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "VAN_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_VAN.imageset/VAN_light.svg b/IceGlass/Assets.xcassets/TeamLogo_VAN.imageset/VAN_light.svg new file mode 100644 index 0000000..17d3d82 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_VAN.imageset/VAN_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_VGK.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_VGK.imageset/Contents.json new file mode 100644 index 0000000..3414e40 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_VGK.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "VGK_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_VGK.imageset/VGK_light.svg b/IceGlass/Assets.xcassets/TeamLogo_VGK.imageset/VGK_light.svg new file mode 100644 index 0000000..c857e40 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_VGK.imageset/VGK_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_WPG.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_WPG.imageset/Contents.json new file mode 100644 index 0000000..cf1bfa9 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_WPG.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "WPG_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_WPG.imageset/WPG_light.svg b/IceGlass/Assets.xcassets/TeamLogo_WPG.imageset/WPG_light.svg new file mode 100644 index 0000000..da0f542 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_WPG.imageset/WPG_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Assets.xcassets/TeamLogo_WSH.imageset/Contents.json b/IceGlass/Assets.xcassets/TeamLogo_WSH.imageset/Contents.json new file mode 100644 index 0000000..126fc7d --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_WSH.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "WSH_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/IceGlass/Assets.xcassets/TeamLogo_WSH.imageset/WSH_light.svg b/IceGlass/Assets.xcassets/TeamLogo_WSH.imageset/WSH_light.svg new file mode 100644 index 0000000..275bbd4 --- /dev/null +++ b/IceGlass/Assets.xcassets/TeamLogo_WSH.imageset/WSH_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/IceGlass/Extensions/Date+easternTimeZone.swift b/IceGlass/Extensions/Date+easternTimeZone.swift new file mode 100644 index 0000000..6ed8fd9 --- /dev/null +++ b/IceGlass/Extensions/Date+easternTimeZone.swift @@ -0,0 +1,14 @@ +// +// Date+easternTimeZone.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +extension Date { + static var easternTimeZone: TimeZone { + TimeZone(identifier: "America/New_York")! + } +} diff --git a/IceGlass/Extensions/Date+etCalendar.swift b/IceGlass/Extensions/Date+etCalendar.swift new file mode 100644 index 0000000..a965d10 --- /dev/null +++ b/IceGlass/Extensions/Date+etCalendar.swift @@ -0,0 +1,16 @@ +// +// Date+etCalendar.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +extension Date { + static func etCalendar() -> Calendar { + var calendar = Calendar.current + calendar.timeZone = Date.easternTimeZone + return calendar + } +} diff --git a/IceGlass/Extensions/Date+formatDateET.swift b/IceGlass/Extensions/Date+formatDateET.swift new file mode 100644 index 0000000..f9db500 --- /dev/null +++ b/IceGlass/Extensions/Date+formatDateET.swift @@ -0,0 +1,21 @@ +// +// Date+formatDateET.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +extension Date { + func formatDateET(format: String = "MMMM, d yyyy @ h:mm a z") -> String { + let formatter = DateFormatter() + formatter.timeZone = TimeZone(identifier: "America/New_York") + formatter.dateFormat = format + return formatter.string(from: self) + } + + static var ISO8601: String { + "yyyy-MM-dd'T'HH:mm:ssZ" + } +} diff --git a/IceGlass/Extensions/Date+gameWindow.swift b/IceGlass/Extensions/Date+gameWindow.swift new file mode 100644 index 0000000..e414d5a --- /dev/null +++ b/IceGlass/Extensions/Date+gameWindow.swift @@ -0,0 +1,68 @@ +// +// Date+gameWindow.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +extension Date { + /// Returns YYYY-MM-DD string for yesterday in Eastern Time + static var yesterdayET: String { + let calendar = etCalendar() + let yesterday = calendar.date(byAdding: .day, value: -1, to: .now)! + return formatDateString(yesterday) + } + + /// Returns YYYY-MM-DD string for today in Eastern Time + static var todayET: String { + formatDateString(.now) + } + + /// Returns YYYY-MM-DD string for tomorrow in Eastern Time + static var tomorrowET: String { + let calendar = etCalendar() + let tomorrow = calendar.date(byAdding: .day, value: 1, to: .now)! + return formatDateString(tomorrow) + } + + private static func formatDateString(_ date: Date) -> String { + date.formatDateET(format: "yyyy-MM-dd") + } + + /// Returns a friendly label like "YESTERDAY", "TODAY", "TOMORROW", or a formatted date + static func friendlyDateLabel(for dateString: String) -> String { + if dateString == todayET { return "TODAY" } + if dateString == yesterdayET { return "YESTERDAY" } + if dateString == tomorrowET { return "TOMORROW" } + + // Parse and format as weekday abbreviation + date + let inputFormatter = DateFormatter() + inputFormatter.dateFormat = "yyyy-MM-dd" + inputFormatter.timeZone = easternTimeZone + + if let date = inputFormatter.date(from: dateString) { + return date.formatDateET(format: "EEE, MMM d") + } + return dateString + } + + /// Returns a full label like "TODAY — Sun, Apr 12" + static func fullDateLabel(for dateString: String) -> String { + let friendly = friendlyDateLabel(for: dateString) + + let inputFormatter = DateFormatter() + inputFormatter.dateFormat = "yyyy-MM-dd" + inputFormatter.timeZone = easternTimeZone + + if let date = inputFormatter.date(from: dateString) { + let formatted = date.formatDateET(format: "EEE, MMM d") + if friendly == "TODAY" || friendly == "YESTERDAY" || friendly == "TOMORROW" { + return "\(friendly) — \(formatted)" + } + return formatted + } + return friendly + } +} diff --git a/IceGlass/Extensions/NSMenuItem+withTarget.swift b/IceGlass/Extensions/NSMenuItem+withTarget.swift new file mode 100644 index 0000000..fcaf09f --- /dev/null +++ b/IceGlass/Extensions/NSMenuItem+withTarget.swift @@ -0,0 +1,15 @@ +// +// NSMenuItem+withTarget.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import AppKit + +extension NSMenuItem { + func withTarget(_ target: AnyObject) -> NSMenuItem { + self.target = target + return self + } +} diff --git a/IceGlass/Extensions/TimeInterval+humanReadableTime.swift b/IceGlass/Extensions/TimeInterval+humanReadableTime.swift new file mode 100644 index 0000000..88f6e32 --- /dev/null +++ b/IceGlass/Extensions/TimeInterval+humanReadableTime.swift @@ -0,0 +1,36 @@ +// +// TimeInterval+humanReadableTime.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +extension TimeInterval { + static func humanReadableTime(from timeInterval: TimeInterval) -> String { + let seconds = Int(timeInterval) + + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + let remainingSeconds = seconds % 60 + + var components: [String] = [] + + if hours > 0 { + components.append("\(hours) \(hours == 1 ? "hour" : "hours")") + } + if minutes > 0 { + components.append("\(minutes) \(minutes == 1 ? "minute" : "minutes")") + } + if remainingSeconds > 0 || components.isEmpty { + components.append("\(remainingSeconds) \(remainingSeconds == 1 ? "second" : "seconds")") + } + + return components.joined(separator: " ") + } + + func humanReadableTime() -> String { + return TimeInterval.humanReadableTime(from: self) + } +} diff --git a/IceGlass/Extensions/Timer+startTimer.swift b/IceGlass/Extensions/Timer+startTimer.swift new file mode 100644 index 0000000..156f423 --- /dev/null +++ b/IceGlass/Extensions/Timer+startTimer.swift @@ -0,0 +1,19 @@ +// +// Timer+startTimer.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +extension Timer { + static func startTimer(timer: Timer?, interval: TimeInterval, action: @escaping @Sendable (Timer) -> Void) -> Timer { + timer?.invalidate() + return Timer.scheduledTimer( + withTimeInterval: interval, + repeats: true, + block: action + ) + } +} diff --git a/IceGlass/IceGlassApp.swift b/IceGlass/IceGlassApp.swift new file mode 100644 index 0000000..9c920a0 --- /dev/null +++ b/IceGlass/IceGlassApp.swift @@ -0,0 +1,19 @@ +// +// IceGlassApp.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI + +@main +struct IceGlassApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + Settings { + EmptyView() + } + } +} diff --git a/IceGlass/Info.plist b/IceGlass/Info.plist new file mode 100644 index 0000000..0062e07 --- /dev/null +++ b/IceGlass/Info.plist @@ -0,0 +1,26 @@ + + + + + LSUIElement + + CFBundleName + IceGlass + CFBundleDisplayName + IceGlass + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundlePackageType + APPL + CFBundleExecutable + $(EXECUTABLE_NAME) + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + + diff --git a/IceGlass/Lib/AppTerminator.swift b/IceGlass/Lib/AppTerminator.swift new file mode 100644 index 0000000..5190754 --- /dev/null +++ b/IceGlass/Lib/AppTerminator.swift @@ -0,0 +1,33 @@ +// +// AppTerminator.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import AppKit + +final class AppTerminator { + static func terminate( + title: String = "Critical Error", + message: String = "The application failed to initialize properly and must close." + ) { + DispatchQueue.main.async { + let logger = IceGlassLogger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass", + category: "AppTerminator" + ) + + logger.error("\(title): \(message)") + + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .critical + alert.addButton(withTitle: "OK") + alert.runModal() + + NSApplication.shared.terminate(nil) + } + } +} diff --git a/IceGlass/Lib/IceGlassLogger.swift b/IceGlass/Lib/IceGlassLogger.swift new file mode 100644 index 0000000..cb88585 --- /dev/null +++ b/IceGlass/Lib/IceGlassLogger.swift @@ -0,0 +1,44 @@ +// +// IceGlassLogger.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import OSLog + +struct IceGlassLogger { + private let logger: Logger + private let subsystem: String + private let category: String + + init(subsystem: String, category: String) { + self.subsystem = subsystem + self.category = category + self.logger = Logger(subsystem: subsystem, category: category) + } + + func timestamp() -> String { + Date.now.formatDateET(format: "yyyy-MM-dd HH:mm:ss") + } + + func formattedMessage(_ message: String) -> String { + "\(timestamp()) [\(subsystem):\(category)] \(message)" + } + + func debug(_ message: String) { + logger.debug("\(formattedMessage(message))") + } + + func info(_ message: String) { + logger.info("\(formattedMessage(message))") + } + + func warning(_ message: String) { + logger.warning("\(formattedMessage(message))") + } + + func error(_ message: String) { + logger.error("\(formattedMessage(message))") + } +} diff --git a/IceGlass/Managers/AppSettings.swift b/IceGlass/Managers/AppSettings.swift new file mode 100644 index 0000000..3399d16 --- /dev/null +++ b/IceGlass/Managers/AppSettings.swift @@ -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 { + 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() {} +} diff --git a/IceGlass/Managers/MenuManager.swift b/IceGlass/Managers/MenuManager.swift new file mode 100644 index 0000000..b05e583 --- /dev/null +++ b/IceGlass/Managers/MenuManager.swift @@ -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] + } +} diff --git a/IceGlass/Managers/NotificationManager.swift b/IceGlass/Managers/NotificationManager.swift new file mode 100644 index 0000000..8b7b0cd --- /dev/null +++ b/IceGlass/Managers/NotificationManager.swift @@ -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() + + /// 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, 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() + } +} diff --git a/IceGlass/Managers/StatusItemManager.swift b/IceGlass/Managers/StatusItemManager.swift new file mode 100644 index 0000000..01aaa8c --- /dev/null +++ b/IceGlass/Managers/StatusItemManager.swift @@ -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)" + } + } +} diff --git a/IceGlass/Models/GameState.swift b/IceGlass/Models/GameState.swift new file mode 100644 index 0000000..665b88b --- /dev/null +++ b/IceGlass/Models/GameState.swift @@ -0,0 +1,45 @@ +// +// GameState.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +enum GameState: String, Codable { + case future = "FUT" // More than 30 minutes prior to game start + case pre = "PRE" // Pre-game, <30 minutes until puck drops + case live = "LIVE" // Game has started + case crit = "CRIT" // Last 5 minutes of regulation, OT or SO + case over = "OVER" // Soft final + case final_ = "FINAL" // Hard final + case official = "OFF" // Official + + var isLive: Bool { + self == .live || self == .crit + } + + var isOver: Bool { + self == .over || self == .final_ || self == .official + } + + var isFuture: Bool { + self == .future || self == .pre + } + + var pollingInterval: PollingInterval { + switch self { + case .future: + return .gameDay + case .pre: + return .preGame + case .live, .crit: + return .liveGame + case .over, .final_: + return .everyMinute + case .official: + return .idle + } + } +} diff --git a/IceGlass/Models/NHLTeam.swift b/IceGlass/Models/NHLTeam.swift new file mode 100644 index 0000000..221a87f --- /dev/null +++ b/IceGlass/Models/NHLTeam.swift @@ -0,0 +1,82 @@ +// +// NHLTeam.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +enum NHLTeam: String, CaseIterable { + case ana = "ANA" + case bos = "BOS" + case buf = "BUF" + case car = "CAR" + case cbj = "CBJ" + case cgy = "CGY" + case chi = "CHI" + case col = "COL" + case dal = "DAL" + case det = "DET" + case edm = "EDM" + case fla = "FLA" + case lak = "LAK" + case min = "MIN" + case mtl = "MTL" + case njd = "NJD" + case nsh = "NSH" + case nyi = "NYI" + case nyr = "NYR" + case ott = "OTT" + case phi = "PHI" + case pit = "PIT" + case sea = "SEA" + case sjs = "SJS" + case stl = "STL" + case tbl = "TBL" + case tor = "TOR" + case uta = "UTA" + case van = "VAN" + case vgk = "VGK" + case wpg = "WPG" + case wsh = "WSH" + + var abbreviation: String { rawValue } + + var fullName: String { + switch self { + case .ana: return "Anaheim Ducks" + case .bos: return "Boston Bruins" + case .buf: return "Buffalo Sabres" + case .car: return "Carolina Hurricanes" + case .cbj: return "Columbus Blue Jackets" + case .cgy: return "Calgary Flames" + case .chi: return "Chicago Blackhawks" + case .col: return "Colorado Avalanche" + case .dal: return "Dallas Stars" + case .det: return "Detroit Red Wings" + case .edm: return "Edmonton Oilers" + case .fla: return "Florida Panthers" + case .lak: return "Los Angeles Kings" + case .min: return "Minnesota Wild" + case .mtl: return "Montreal Canadiens" + case .njd: return "New Jersey Devils" + case .nsh: return "Nashville Predators" + case .nyi: return "New York Islanders" + case .nyr: return "New York Rangers" + case .ott: return "Ottawa Senators" + case .phi: return "Philadelphia Flyers" + case .pit: return "Pittsburgh Penguins" + case .sea: return "Seattle Kraken" + case .sjs: return "San Jose Sharks" + case .stl: return "St. Louis Blues" + case .tbl: return "Tampa Bay Lightning" + case .tor: return "Toronto Maple Leafs" + case .uta: return "Utah Hockey Club" + case .van: return "Vancouver Canucks" + case .vgk: return "Vegas Golden Knights" + case .wpg: return "Winnipeg Jets" + case .wsh: return "Washington Capitals" + } + } +} diff --git a/IceGlass/Models/ScoreboardModel.swift b/IceGlass/Models/ScoreboardModel.swift new file mode 100644 index 0000000..2c4089c --- /dev/null +++ b/IceGlass/Models/ScoreboardModel.swift @@ -0,0 +1,102 @@ +// +// ScoreboardModel.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +struct Scoreboard: Codable { + let focusedDate: String + let focusedDateCount: Int + let gamesByDate: [GameDay] + + struct GameDay: Codable { + let date: String // "YYYY-MM-DD" + let games: [Game] + } + + struct Game: Codable, Equatable { + static func == (lhs: Game, rhs: Game) -> Bool { + lhs.id == rhs.id + } + + let id: Int + let season: Int + let gameType: Int + let gameDate: String + let gameCenterLink: String + let startTimeUTC: String + let gameState: String + let gameScheduleState: String + let awayTeam: Team + let homeTeam: Team + let period: Int? + let periodDescriptor: PeriodDescriptor? + + struct LocalizedString: Codable { + let `default`: String + } + + struct Team: Codable { + let id: Int + let name: LocalizedString + let commonName: LocalizedString + let abbrev: String + let score: Int? + let record: String? + let logo: String + } + + struct PeriodDescriptor: Codable { + let number: Int + let periodType: String + let maxRegulationPeriods: Int + } + + // MARK: - Computed Properties + + var parsedGameState: GameState { + GameState(rawValue: gameState) ?? .future + } + + var date: Date { + ISO8601DateFormatter().date(from: startTimeUTC) ?? .now + } + + var gameCenterUrl: String { + "https://www.nhl.com\(gameCenterLink)" + } + + var videocastUrl: String { + "https://videocast.nhl.com/game/\(id)/usnded?autoplay=true" + } + + /// Time string in ET for display (e.g., "7:00 PM") + var startTimeET: String { + date.formatDateET(format: "h:mm a") + } + + /// Formatted menu title: "NYR @ WAS 0:2 (FINAL)" or "DAL @ TOR Today @ 7:30 PM" + var menuTitle: String { + let state = parsedGameState + let matchup = "\(awayTeam.abbrev) @ \(homeTeam.abbrev)" + + if state.isFuture { + let isToday = gameDate == Date.todayET + let prefix = isToday ? "Today @ " : "" + return "\(matchup) \(prefix)\(startTimeET)" + } + + // Has scores + let score = "\(awayTeam.score ?? 0):\(homeTeam.score ?? 0)" + return "\(matchup) \(score) (\(gameState))" + } + + /// Whether this game involves a specific team + func involves(team abbrev: String) -> Bool { + awayTeam.abbrev == abbrev || homeTeam.abbrev == abbrev + } + } +} diff --git a/IceGlass/Services/ApiService.swift b/IceGlass/Services/ApiService.swift new file mode 100644 index 0000000..306b92a --- /dev/null +++ b/IceGlass/Services/ApiService.swift @@ -0,0 +1,45 @@ +// +// ApiService.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +typealias ApiServiceCallback = (_ jsonData: Data, _ response: URLResponse) -> Void + +class ApiService: @unchecked Sendable { + private let logger = IceGlassLogger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass", + category: "ApiService" + ) + + let url: URL + let callback: ApiServiceCallback + + var previousFetchTime: Date = .distantPast + + init(url: URL?, callback: @escaping ApiServiceCallback) { + guard let url = url else { + AppTerminator.terminate() + self.url = URL(string: "")! + self.callback = { (_: Data, _: URLResponse) in } + return + } + self.url = url + self.callback = callback + } + + func fetch() async { + do { + let (data, response) = try await URLSession.shared.data(from: url) + logger.info("Polling: \(self.url)") + logger.debug("Previous: \(Date.now.timeIntervalSince(previousFetchTime).humanReadableTime())") + previousFetchTime = Date.now + callback(data, response) + } catch { + logger.error("Invalid response from \(self.url) \(error)") + } + } +} diff --git a/IceGlass/Services/MainService.swift b/IceGlass/Services/MainService.swift new file mode 100644 index 0000000..9dd54ec --- /dev/null +++ b/IceGlass/Services/MainService.swift @@ -0,0 +1,226 @@ +// +// MainService.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +class MainService: @unchecked Sendable { + private let logger = IceGlassLogger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass", + category: "MainService" + ) + + static let shared = MainService() + + private lazy var menuManager = MenuManager.shared + private lazy var statusItemManager = StatusItemManager.shared + private lazy var settings = AppSettings.shared + private lazy var notificationManager = NotificationManager.shared + + private var pollingTimer: Timer? + private var scoreboardApi: ApiService? + + /// All game days from the API (full window: yesterday/today/tomorrow) + private var allGamesByDate: [Scoreboard.GameDay] = [] + + /// Previous game snapshots for detecting state/score changes (keyed by game ID) + private var previousGameStates: [Int: GameSnapshot] = [:] + + /// Whether this is the first fetch (suppress notifications on initial load) + private var isFirstFetch = true + + struct GameSnapshot { + let gameState: String + let awayScore: Int? + let homeScore: Int? + } + + /// Game days filtered by display option + var gamesByDate: [Scoreboard.GameDay] { + let includedDates = settings.displayOption.includedDates() + return allGamesByDate.filter { includedDates.contains($0.date) } + } + + /// Whether any game across all days is currently live + var anyGameLive: Bool { + allGamesByDate.flatMap(\.games).contains { game in + game.parsedGameState.isLive + } + } + + /// Whether any game is in pre-game state + var anyGamePre: Bool { + allGamesByDate.flatMap(\.games).contains { game in + game.parsedGameState == .pre + } + } + + /// Whether any game today is scheduled (future) + var anyGameToday: Bool { + let today = Date.todayET + return allGamesByDate.contains { gd in + gd.date == today && !gd.games.isEmpty + } + } + + /// The best polling interval based on current game states + var bestPollingInterval: PollingInterval { + if anyGameLive { return .liveGame } + if anyGamePre { return .preGame } + if anyGameToday { return .gameDay } + return .idle + } + + private init() { + logger.debug("Initializing") + DispatchQueue.main.async { [weak self] in + guard let self = self else { + AppTerminator.terminate() + return + } + + Task { @MainActor in + self.initApis() + self.reschedulePollingTimer(.bootstrap) + await self.fetchScoreboard() + } + } + } + + private func initApis() { + scoreboardApi = ApiService( + url: URL(string: "https://api-web.nhle.com/v1/scoreboard/now") + ) { [weak self] data, _ in + guard let self = self else { return } + + do { + let scoreboard = try JSONDecoder().decode(Scoreboard.self, from: data) + + // Filter to yesterday/today/tomorrow window + let yesterday = Date.yesterdayET + let today = Date.todayET + let tomorrow = Date.tomorrowET + let windowDates = Set([yesterday, today, tomorrow]) + + let filtered = scoreboard.gamesByDate.filter { gd in + windowDates.contains(gd.date) + } + + // Detect state and score changes before updating + if !self.isFirstFetch { + self.detectChanges(in: filtered) + } + + self.allGamesByDate = filtered + + // Update snapshots for next comparison + self.updateSnapshots(from: filtered) + + if self.isFirstFetch { + self.isFirstFetch = false + } + + self.logger.info("Scoreboard updated: \(filtered.map { "\($0.date): \($0.games.count) games" }.joined(separator: ", "))") + + // Adjust polling based on game states + let interval = self.bestPollingInterval + Task { @MainActor in + self.reschedulePollingTimer(interval) + } + + self.statusItemManager.updateGameCounts(self.gamesByDate) + self.menuManager.scoreboardChanged() + } catch { + self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)") + } + } + } + + // MARK: - Change Detection + + private func detectChanges(in gameDays: [Scoreboard.GameDay]) { + for gameDay in gameDays { + for game in gameDay.games { + guard let previous = previousGameStates[game.id] else { continue } + + let previousState = GameState(rawValue: previous.gameState) + let currentState = game.parsedGameState + + // Game started: FUT/PRE → LIVE + if let prevState = previousState, prevState.isFuture, currentState.isLive { + logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)") + notificationManager.notifyGameStarted(game) + } + + // Goal scored: score increased on either team + if let prevAway = previous.awayScore, let prevHome = previous.homeScore, + let curAway = game.awayTeam.score, let curHome = game.homeTeam.score { + + if curAway > prevAway { + logger.info("Goal! \(game.awayTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)") + notificationManager.notifyGoalScored(game, scoringTeam: game.awayTeam) + } + if curHome > prevHome { + logger.info("Goal! \(game.homeTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)") + notificationManager.notifyGoalScored(game, scoringTeam: game.homeTeam) + } + } + } + } + } + + private func updateSnapshots(from gameDays: [Scoreboard.GameDay]) { + for gameDay in gameDays { + for game in gameDay.games { + previousGameStates[game.id] = GameSnapshot( + gameState: game.gameState, + awayScore: game.awayTeam.score, + homeScore: game.homeTeam.score + ) + } + } + } + + // MARK: - Polling + + private func reschedulePollingTimer(_ pollingInterval: PollingInterval) { + if !Thread.isMainThread { + logger.warning("reschedulePollingTimer called not on main thread") + return + } + + let rescheduleTolerance: TimeInterval = 3 + var needsRescheduling = false + + if self.pollingTimer == nil { + needsRescheduling = true + } else if !self.pollingTimer!.isValid { + needsRescheduling = true + } else if abs(self.pollingTimer!.fireDate.timeIntervalSinceNow - pollingInterval.rawValue) > rescheduleTolerance { + needsRescheduling = true + } + + if needsRescheduling { + self.logger.debug("Rescheduling polling timer to \(pollingInterval.rawValue)s") + self.pollingTimer = Timer.startTimer( + timer: self.pollingTimer, + interval: pollingInterval.rawValue + ) { [weak self] _ in + Task { @MainActor in + await self?.fetchScoreboard() + } + } + } + } + + private func fetchScoreboard() async { + await scoreboardApi?.fetch() + } + + func fetchAll() async { + await fetchScoreboard() + } +} diff --git a/IceGlass/Services/PollingInterval.swift b/IceGlass/Services/PollingInterval.swift new file mode 100644 index 0000000..44719dd --- /dev/null +++ b/IceGlass/Services/PollingInterval.swift @@ -0,0 +1,17 @@ +// +// PollingInterval.swift +// IceGlass +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation + +enum PollingInterval: TimeInterval { + case idle = 3600 + case bootstrap = 42 + case liveGame = 7 + case gameDay = 600 + case preGame = 180 + case everyMinute = 60 +} diff --git a/IceGlass/TeamLogos/ANA.png b/IceGlass/TeamLogos/ANA.png new file mode 100644 index 0000000..ab955db Binary files /dev/null and b/IceGlass/TeamLogos/ANA.png differ diff --git a/IceGlass/TeamLogos/BOS.png b/IceGlass/TeamLogos/BOS.png new file mode 100644 index 0000000..9715e22 Binary files /dev/null and b/IceGlass/TeamLogos/BOS.png differ diff --git a/IceGlass/TeamLogos/BUF.png b/IceGlass/TeamLogos/BUF.png new file mode 100644 index 0000000..5556b3d Binary files /dev/null and b/IceGlass/TeamLogos/BUF.png differ diff --git a/IceGlass/TeamLogos/CAR.png b/IceGlass/TeamLogos/CAR.png new file mode 100644 index 0000000..138458a Binary files /dev/null and b/IceGlass/TeamLogos/CAR.png differ diff --git a/IceGlass/TeamLogos/CBJ.png b/IceGlass/TeamLogos/CBJ.png new file mode 100644 index 0000000..8fb2c21 Binary files /dev/null and b/IceGlass/TeamLogos/CBJ.png differ diff --git a/IceGlass/TeamLogos/CGY.png b/IceGlass/TeamLogos/CGY.png new file mode 100644 index 0000000..0dd211a Binary files /dev/null and b/IceGlass/TeamLogos/CGY.png differ diff --git a/IceGlass/TeamLogos/CHI.png b/IceGlass/TeamLogos/CHI.png new file mode 100644 index 0000000..6725fa4 Binary files /dev/null and b/IceGlass/TeamLogos/CHI.png differ diff --git a/IceGlass/TeamLogos/COL.png b/IceGlass/TeamLogos/COL.png new file mode 100644 index 0000000..533f251 Binary files /dev/null and b/IceGlass/TeamLogos/COL.png differ diff --git a/IceGlass/TeamLogos/DAL.png b/IceGlass/TeamLogos/DAL.png new file mode 100644 index 0000000..89e46b8 Binary files /dev/null and b/IceGlass/TeamLogos/DAL.png differ diff --git a/IceGlass/TeamLogos/DET.png b/IceGlass/TeamLogos/DET.png new file mode 100644 index 0000000..c1a0a55 Binary files /dev/null and b/IceGlass/TeamLogos/DET.png differ diff --git a/IceGlass/TeamLogos/EDM.png b/IceGlass/TeamLogos/EDM.png new file mode 100644 index 0000000..39585ff Binary files /dev/null and b/IceGlass/TeamLogos/EDM.png differ diff --git a/IceGlass/TeamLogos/FLA.png b/IceGlass/TeamLogos/FLA.png new file mode 100644 index 0000000..4ed6408 Binary files /dev/null and b/IceGlass/TeamLogos/FLA.png differ diff --git a/IceGlass/TeamLogos/LAK.png b/IceGlass/TeamLogos/LAK.png new file mode 100644 index 0000000..335aaa6 Binary files /dev/null and b/IceGlass/TeamLogos/LAK.png differ diff --git a/IceGlass/TeamLogos/MIN.png b/IceGlass/TeamLogos/MIN.png new file mode 100644 index 0000000..717839d Binary files /dev/null and b/IceGlass/TeamLogos/MIN.png differ diff --git a/IceGlass/TeamLogos/MTL.png b/IceGlass/TeamLogos/MTL.png new file mode 100644 index 0000000..c732ad0 Binary files /dev/null and b/IceGlass/TeamLogos/MTL.png differ diff --git a/IceGlass/TeamLogos/NJD.png b/IceGlass/TeamLogos/NJD.png new file mode 100644 index 0000000..f2564dc Binary files /dev/null and b/IceGlass/TeamLogos/NJD.png differ diff --git a/IceGlass/TeamLogos/NSH.png b/IceGlass/TeamLogos/NSH.png new file mode 100644 index 0000000..07ce369 Binary files /dev/null and b/IceGlass/TeamLogos/NSH.png differ diff --git a/IceGlass/TeamLogos/NYI.png b/IceGlass/TeamLogos/NYI.png new file mode 100644 index 0000000..27ad502 Binary files /dev/null and b/IceGlass/TeamLogos/NYI.png differ diff --git a/IceGlass/TeamLogos/NYR.png b/IceGlass/TeamLogos/NYR.png new file mode 100644 index 0000000..94f06af Binary files /dev/null and b/IceGlass/TeamLogos/NYR.png differ diff --git a/IceGlass/TeamLogos/OTT.png b/IceGlass/TeamLogos/OTT.png new file mode 100644 index 0000000..d4b02c8 Binary files /dev/null and b/IceGlass/TeamLogos/OTT.png differ diff --git a/IceGlass/TeamLogos/PHI.png b/IceGlass/TeamLogos/PHI.png new file mode 100644 index 0000000..a7fd3c7 Binary files /dev/null and b/IceGlass/TeamLogos/PHI.png differ diff --git a/IceGlass/TeamLogos/PIT.png b/IceGlass/TeamLogos/PIT.png new file mode 100644 index 0000000..1681dd2 Binary files /dev/null and b/IceGlass/TeamLogos/PIT.png differ diff --git a/IceGlass/TeamLogos/SEA.png b/IceGlass/TeamLogos/SEA.png new file mode 100644 index 0000000..4aa278b Binary files /dev/null and b/IceGlass/TeamLogos/SEA.png differ diff --git a/IceGlass/TeamLogos/SJS.png b/IceGlass/TeamLogos/SJS.png new file mode 100644 index 0000000..7de6420 Binary files /dev/null and b/IceGlass/TeamLogos/SJS.png differ diff --git a/IceGlass/TeamLogos/STL.png b/IceGlass/TeamLogos/STL.png new file mode 100644 index 0000000..f1b6724 Binary files /dev/null and b/IceGlass/TeamLogos/STL.png differ diff --git a/IceGlass/TeamLogos/TBL.png b/IceGlass/TeamLogos/TBL.png new file mode 100644 index 0000000..caef44c Binary files /dev/null and b/IceGlass/TeamLogos/TBL.png differ diff --git a/IceGlass/TeamLogos/TOR.png b/IceGlass/TeamLogos/TOR.png new file mode 100644 index 0000000..f4fd73f Binary files /dev/null and b/IceGlass/TeamLogos/TOR.png differ diff --git a/IceGlass/TeamLogos/UTA.png b/IceGlass/TeamLogos/UTA.png new file mode 100644 index 0000000..79eccb0 Binary files /dev/null and b/IceGlass/TeamLogos/UTA.png differ diff --git a/IceGlass/TeamLogos/VAN.png b/IceGlass/TeamLogos/VAN.png new file mode 100644 index 0000000..952a6f6 Binary files /dev/null and b/IceGlass/TeamLogos/VAN.png differ diff --git a/IceGlass/TeamLogos/VGK.png b/IceGlass/TeamLogos/VGK.png new file mode 100644 index 0000000..678edcc Binary files /dev/null and b/IceGlass/TeamLogos/VGK.png differ diff --git a/IceGlass/TeamLogos/WPG.png b/IceGlass/TeamLogos/WPG.png new file mode 100644 index 0000000..137e92d Binary files /dev/null and b/IceGlass/TeamLogos/WPG.png differ diff --git a/IceGlass/TeamLogos/WSH.png b/IceGlass/TeamLogos/WSH.png new file mode 100644 index 0000000..51a4cba Binary files /dev/null and b/IceGlass/TeamLogos/WSH.png differ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6cf97c8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1 @@ +Copyright 2026 Rouslan Zenetl. All Rights Reserved. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6800ceb --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# IceGlass + +A macOS menu bar app for NHL game situational awareness. + +## Key Features + +- NHL shield icon in the menu bar with game count +- Shows games from yesterday, today, and tomorrow grouped by date (configurable) +- Game format: `NYR @ WAS 0:2 (FINAL)` / `DAL @ TOR Today @ 7:30 PM` +- Click a game to open NHL GameCenter; option-click for NHL Videocast +- Goal scored notifications with scoring team logo +- Game start notifications on FUT→LIVE state transition +- Dynamic polling: 7s during live games, scales back when idle +- Display Options: choose which days to show (yesterday/today/tomorrow) +- Refresh Now (⌘R) for immediate updates +- Launch at Login support +- About window via IndieAbout + +## Building + +Requires XcodeGen to generate the project: + +```bash +xcodegen generate +xcodebuild -scheme IceGlass -configuration Debug build +``` + +## Architecture + +Menu bar app using singleton services pattern: +- **MainService** — polls NHL scoreboard API, manages game data +- **MenuManager** — builds dropdown menu with date-grouped games +- **StatusItemManager** — manages menu bar icon +- **NotificationManager** — game start and goal scored notifications with team logos + +Uses the NHL Web API (`api-web.nhle.com/v1/scoreboard/now`) for league-wide schedule and score data. diff --git a/Scripts/download_team_logos.sh b/Scripts/download_team_logos.sh new file mode 100755 index 0000000..dfa56a3 --- /dev/null +++ b/Scripts/download_team_logos.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# +# download_team_logos.sh +# Downloads NHL team logos (SVG) from the NHL assets CDN. +# +# Usage: ./Scripts/download_team_logos.sh [season] +# season: e.g. 20252026 (defaults to current season) +# +# SVGs are saved to Assets.xcassets imagesets for UI use. +# PNGs (80x80) are saved to TeamLogos/ for notification attachments. +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ASSETS_DIR="$PROJECT_DIR/IceGlass/Assets.xcassets" +LOGOS_DIR="$PROJECT_DIR/IceGlass/TeamLogos" + +# Determine season +if [ -n "$1" ]; then + SEASON="$1" +else + MONTH=$(date +%m) + YEAR=$(date +%Y) + if [ "$MONTH" -lt 7 ]; then + SEASON="$((YEAR - 1))$YEAR" + else + SEASON="$YEAR$((YEAR + 1))" + fi +fi + +echo "Downloading NHL team logos for season $SEASON" + +TEAMS=( + ANA BOS BUF CAR CBJ CGY CHI COL DAL DET + EDM FLA LAK MIN MTL NJD NSH NYI NYR OTT + PHI PIT SEA SJS STL TBL TOR UTA VAN VGK + WPG WSH +) + +# Create logos directory for notification attachments +mkdir -p "$LOGOS_DIR" + +for TEAM in "${TEAMS[@]}"; do + IMAGESET_DIR="$ASSETS_DIR/TeamLogo_${TEAM}.imageset" + mkdir -p "$IMAGESET_DIR" + + SVG_URL="https://assets.nhle.com/logos/nhl/svg/${TEAM}_light.svg" + SVG_FILE="$IMAGESET_DIR/${TEAM}_light.svg" + LOGO_PNG="$LOGOS_DIR/${TEAM}.png" + + echo " Downloading $TEAM..." + curl -s -o "$SVG_FILE" "${SVG_URL}?season=${SEASON}" + + # Convert SVG to PNG (200px wide, aspect-ratio preserved) for notification attachments + if command -v rsvg-convert &>/dev/null; then + rsvg-convert -w 200 -h 200 --keep-aspect-ratio --background-color=transparent "$SVG_FILE" -o "$LOGO_PNG" 2>/dev/null + else + python3 -c " +try: + import cairosvg + cairosvg.svg2png(url='$SVG_FILE', write_to='$LOGO_PNG', output_width=200) +except ImportError: + pass +" 2>/dev/null || true + fi + + # Write Contents.json for the imageset (SVG only, no PNG) + cat > "$IMAGESET_DIR/Contents.json" << EOJSON +{ + "images" : [ + { + "filename" : "${TEAM}_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} +EOJSON +done + +echo "" +echo "Done! Downloaded ${#TEAMS[@]} team logos to:" +echo " Asset catalogs: $ASSETS_DIR/TeamLogo_*.imageset/ (SVG)" +echo " Notifications: $LOGOS_DIR/ (PNG 80x80)" +echo "" +echo "Season: $SEASON" diff --git a/Scripts/update_build_number.sh b/Scripts/update_build_number.sh new file mode 100755 index 0000000..00c4b12 --- /dev/null +++ b/Scripts/update_build_number.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +## IMPORTANT ## +# Add the following files to Input Files configuraiton of the build phase +# $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH) +# $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist + +git=$(sh /etc/profile; which git) +number_of_commits=$("$git" rev-list HEAD --count) +git_release_version=$("$git" describe --tags --always --abbrev=0) + +target_plist="$TARGET_BUILD_DIR/$INFOPLIST_PATH" +dsym_plist="$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME/Contents/Info.plist" + +git_commit=`"$git" rev-parse --short HEAD` +bundle_version=`/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$target_plist"` +build_date=`date +%F` + +build="v$bundle_version-$git_commit b$number_of_commits $build_date" + +#echo "version=$bundle_version-$git_commit build $number_of_commits" + +"$git" tag "$bundle_version" + +for plist in "$target_plist" "$dsym_plist"; do + if [ -f "$plist" ]; then + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $number_of_commits" "$plist" +# /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${version}" "$plist" +# /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${git_release_version#*v}" "$plist" + + # Add build date for AppInfoKit + /usr/libexec/PlistBuddy -c "Set :BuildDate $build_date" "$plist" 2>/dev/null || \ + /usr/libexec/PlistBuddy -c "Add :BuildDate string $build_date" "$plist" + fi +done diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..3dfe69b --- /dev/null +++ b/project.yml @@ -0,0 +1,64 @@ +name: IceGlass +options: + bundleIdPrefix: dev.rzen.indie + deploymentTarget: + macOS: "13.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true + +packages: + IndieAbout: + url: https://git.rzen.dev/rzen/indie-about.git + from: 0.1.0 + +targets: + IceGlass: + type: application + platform: macOS + sources: + - IceGlass + - path: IceGlass/TeamLogos + type: folder + buildPhase: resources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.IceGlass + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + INFOPLIST_FILE: IceGlass/Info.plist + CODE_SIGN_ENTITLEMENTS: IceGlass.entitlements + SWIFT_VERSION: "6.0" + MACOSX_DEPLOYMENT_TARGET: "13.0" + PRODUCT_NAME: IceGlass + COMBINE_HIDPI_IMAGES: true + SWIFT_STRICT_CONCURRENCY: complete + ENABLE_USER_SCRIPT_SANDBOXING: false + configs: + Debug: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + Release: + SWIFT_OPTIMIZATION_LEVEL: -O + dependencies: + - package: IndieAbout + postCompileScripts: + - script: "\"${SRCROOT}/Scripts/update_build_number.sh\"" + name: "Update Build Number" + shell: /bin/sh + inputFiles: + - $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH) + - $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist + entitlements: + path: IceGlass.entitlements + properties: + com.apple.security.app-sandbox: true + com.apple.security.network.client: true + +schemes: + IceGlass: + build: + targets: + IceGlass: all + run: + config: Debug + archive: + config: Release