Add iPhone target with shared data layer and persistent cache

Two-target restructure: shared sources (models, services, settings,
extensions, team logos) move into Shared/, consumed by both the
existing macOS menu bar app and a new iOS app. MainService no longer
imports AppKit — platform code attaches via a MainServiceObserver
protocol (MacObserverAdapter wires back to MenuManager / StatusItemManager
/ NotificationManager).

iPhone app is a single SwiftUI page mirroring the macOS menu (playoff
round + yesterday/today/tomorrow), with a gear-icon settings sheet
(display option + IndieAbout for license/changelog). Persistent JSON
snapshot in Application Support paints last-known data on cold launch;
"Updated …" header escalates secondary → orange (>5min) → red (>30min)
so staleness is visually unmistakable. Foreground polling, scenePhase
refresh, and pull-to-refresh; no notifications on iOS in v1.
This commit is contained in:
2026-04-25 06:34:36 -04:00
parent 18c4ef64d6
commit aaffa3771c
70 changed files with 1011 additions and 65 deletions
+3
View File
@@ -2,6 +2,9 @@
## April 2026
- New iPhone target (`IceGlass-iOS`): single-page SwiftUI app mirroring the macOS menu's playoff round + yesterday/today/tomorrow content, with a gear-icon settings sheet (display option + IndieAbout)
- Persistent JSON cache (`Shared/Services/NHLDataCache.swift`) stored in Application Support so iPhone cold launches paint last-known data immediately; surfaced with a stale-aware "Updated …" header (secondary → orange after 5 min → red after 30 min)
- Shared code refactored into a top-level `Shared/` directory consumed by both targets; introduced `MainServiceObserver` protocol so `MainService` no longer imports AppKit
- Team logo download pipeline now produces 128×128 transparent squares directly (via a new `Scripts/square_logo.swift` helper called from `download_team_logos.sh`), so notification attachments can ship the bundled PNG as-is and the banner thumbnail renders crisply without runtime compositing
- Game state tag (PRE / LIVE / CRIT / OVER / FINAL / OFF) now surfaced on each daily game row and on live series rows in the ROUND block
- Playoff series rows in the ROUND block are always clickable (open the NHL series page) and mark completed series as "Final"
+14
View File
@@ -0,0 +1,14 @@
//
// ContentView.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ContentView: View {
var body: some View {
MainView()
}
}
+32
View File
@@ -0,0 +1,32 @@
//
// IceGlassApp.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
@main
struct IceGlassApp: App {
@State private var viewModel = ScoreboardViewModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environment(viewModel)
.onAppear { viewModel.attach() }
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .active:
viewModel.handleScenePhaseActive()
case .background, .inactive:
viewModel.handleScenePhaseInactive()
@unknown default:
break
}
}
}
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon-ios-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>IceGlass</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,103 @@
//
// ScoreboardViewModel.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import Observation
/// Bridges MainService SwiftUI. Re-published whenever data lands so views
/// re-render. Owns refresh kick-off on scene activation and pull-to-refresh.
@Observable
@MainActor
final class ScoreboardViewModel {
/// Bumped on every observer callback; views read it implicitly via the
/// Observation framework so the entire tree re-evaluates.
private var revision: Int = 0
/// Tracks the most recent successful fetch time for the "Updated " header.
var lastUpdated: Date?
/// Toggled by pull-to-refresh; views can show a spinner.
var isRefreshing: Bool = false
/// Settings surfaced separately so the settings sheet can bind directly.
var displayOption: AppSettings.DisplayOption {
get {
_ = revision
return AppSettings.shared.displayOption
}
set {
AppSettings.shared.displayOption = newValue
revision += 1
}
}
var gamesByDate: [Scoreboard.GameDay] {
_ = revision
return MainService.shared.gamesByDate
}
var currentRoundSeriesItems: [MainService.RoundSeriesItem] {
_ = revision
return MainService.shared.currentRoundSeriesItems
}
var currentRoundNumber: Int? {
_ = revision
return MainService.shared.bracket?.currentRound
}
/// Bridge object that forwards MainService callbacks back to this view model.
/// Held strongly so MainService's weak observer reference stays alive.
private var bridge: ObserverBridge?
init() {}
/// Wire this view model up to MainService. Idempotent; called from `.onAppear`.
func attach() {
guard bridge == nil else { return }
let bridge = ObserverBridge { [weak self] in self?.handleUpdate() }
self.bridge = bridge
MainService.shared.observer = bridge
// Initial sync MainService may have already loaded a cached snapshot.
handleUpdate()
}
private func handleUpdate() {
lastUpdated = MainService.shared.lastUpdated
revision += 1
}
func refreshNow() async {
isRefreshing = true
await MainService.shared.fetchAll()
isRefreshing = false
}
func handleScenePhaseActive() {
MainService.shared.resumePolling()
Task { await refreshNow() }
}
func handleScenePhaseInactive() {
MainService.shared.suspendPolling()
}
}
/// Class adapter so MainService's `weak var observer` has something to hold;
/// @Observable view-model classes don't compose cleanly with weak refs.
@MainActor
private final class ObserverBridge: MainServiceObserver {
private let onUpdate: () -> Void
init(onUpdate: @escaping () -> Void) {
self.onUpdate = onUpdate
}
func mainServiceDidUpdate() {
onUpdate()
}
}
+52
View File
@@ -0,0 +1,52 @@
//
// GameDaySection.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct GameDaySection: View {
let gameDay: Scoreboard.GameDay
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
Text(headerText)
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.secondary)
Spacer()
}
if gameDay.games.isEmpty {
Text("No games scheduled")
.font(.subheadline)
.italic()
.foregroundStyle(.tertiary)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
VStack(spacing: 0) {
ForEach(Array(gameDay.games.enumerated()), id: \.element.id) { idx, game in
GameRow(game: game)
if idx < gameDay.games.count - 1 {
Divider()
}
}
}
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
private var headerText: String {
let label = Date.fullDateLabel(for: gameDay.date)
return "\(label) (\(gameDay.games.count))"
}
}
+78
View File
@@ -0,0 +1,78 @@
//
// GameRow.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct GameRow: View {
let game: Scoreboard.Game
var body: some View {
Button(action: open) {
HStack(spacing: 12) {
if game.gameType == 2 {
Text("#\(game.seasonGameNumber)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.tertiary)
.frame(width: 44, alignment: .leading)
}
Text("\(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
.font(.body)
.fontWeight(.medium)
.foregroundStyle(.primary)
Spacer()
rightContent
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private var rightContent: some View {
let state = game.parsedGameState
VStack(alignment: .trailing, spacing: 2) {
if state.isFuture {
Text(game.startTimeET.trimmingCharacters(in: .whitespaces))
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
} else {
Text(scoreText)
.font(.body.monospacedDigit())
.fontWeight(.semibold)
.foregroundStyle(.primary)
Text(statusLine)
.font(.caption2)
.foregroundStyle(state.isLive ? .red : .secondary)
}
}
}
private var scoreText: String {
let a = game.awayTeam.score ?? 0
let h = game.homeTeam.score ?? 0
return "\(a) \(h)"
}
private var statusLine: String {
let state = game.parsedGameState
let tag = state.shortTag
let time = game.startTimeET.trimmingCharacters(in: .whitespaces)
if tag.isEmpty { return time }
if state.isLive { return tag }
return tag
}
private func open() {
guard let url = URL(string: game.gameCenterUrl) else { return }
UIApplication.shared.open(url)
}
}
+82
View File
@@ -0,0 +1,82 @@
//
// MainView.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct MainView: View {
@Environment(ScoreboardViewModel.self) private var viewModel
@State private var showingSettings = false
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16, pinnedViews: []) {
UpdatedHeader(
lastUpdated: viewModel.lastUpdated,
isRefreshing: viewModel.isRefreshing
)
.padding(.horizontal)
if !viewModel.currentRoundSeriesItems.isEmpty,
let round = viewModel.currentRoundNumber {
PlayoffRoundSection(
round: round,
items: viewModel.currentRoundSeriesItems
)
.padding(.horizontal)
}
let gameDays = viewModel.gamesByDate
if gameDays.isEmpty && viewModel.currentRoundSeriesItems.isEmpty {
emptyState
.padding(.horizontal)
.padding(.top, 40)
} else {
ForEach(gameDays, id: \.date) { gameDay in
GameDaySection(gameDay: gameDay)
.padding(.horizontal)
}
}
}
.padding(.vertical)
}
.refreshable {
await viewModel.refreshNow()
}
.navigationTitle("IceGlass")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingSettings = true
} label: {
Image(systemName: "gearshape")
}
.accessibilityLabel("Settings")
}
}
.sheet(isPresented: $showingSettings) {
SettingsSheet()
}
}
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "hockey.puck")
.font(.system(size: 40))
.foregroundStyle(.tertiary)
Text("No games scheduled")
.font(.headline)
.foregroundStyle(.secondary)
Text("Pull down to refresh")
.font(.subheadline)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
}
}
@@ -0,0 +1,33 @@
//
// PlayoffRoundSection.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct PlayoffRoundSection: View {
let round: Int
let items: [MainService.RoundSeriesItem]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("ROUND \(round)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.secondary)
VStack(spacing: 0) {
ForEach(items, id: \.series.seriesLetter) { item in
SeriesRow(item: item)
if item.series.seriesLetter != items.last?.series.seriesLetter {
Divider()
}
}
}
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
+75
View File
@@ -0,0 +1,75 @@
//
// SeriesRow.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct SeriesRow: View {
let item: MainService.RoundSeriesItem
var body: some View {
Button(action: open) {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(matchupText)
.font(.body)
.fontWeight(.medium)
.foregroundStyle(.primary)
Text(scoreText)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(statusText)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
if let trailing = trailingText {
Text(trailing)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var matchupText: String {
let top = item.series.topSeedTeam?.abbrev ?? "TBD"
let bottom = item.series.bottomSeedTeam?.abbrev ?? "TBD"
return "\(bottom) @ \(top)"
}
private var scoreText: String {
"\(item.series.bottomSeedWins) \(item.series.topSeedWins)"
}
private var statusText: String {
if let winner = item.series.winner {
return "Final · \(winner) wins"
}
if let n = item.series.nextGameNumber {
return "Game \(n)"
}
return ""
}
private var trailingText: String? {
guard item.series.winner == nil else { return nil }
return item.nextGame?.nextGameLabel
}
private func open() {
guard let urlString = item.series.fullSeriesUrl,
let url = URL(string: urlString) else { return }
UIApplication.shared.open(url)
}
}
+54
View File
@@ -0,0 +1,54 @@
//
// SettingsSheet.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import IndieAbout
import SwiftUI
struct SettingsSheet: View {
@Environment(ScoreboardViewModel.self) private var viewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
@Bindable var vm = viewModel
NavigationStack {
Form {
Section("Display") {
Picker("Days shown", selection: Binding(
get: { vm.displayOption },
set: { newValue in
vm.displayOption = newValue
MainService.shared.updateUI()
}
)) {
ForEach(AppSettings.DisplayOption.allCases, id: \.self) { option in
Text(option.title).tag(option)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
Section {
IndieAbout(configuration: AppInfoConfiguration(
showDeviceInfo: false,
documents: [
.license(filename: "LICENSE", extension: "md"),
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md")
]
))
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
}
+81
View File
@@ -0,0 +1,81 @@
//
// UpdatedHeader.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// "Updated 2 min ago" + "as of Apr 24, 4:32 PM ET" header.
/// Color escalates as data ages: secondary orange (>5min) red (>30min or never).
/// Self-refreshes every 30s via TimelineView so the relative label stays current
/// without a network call.
struct UpdatedHeader: View {
let lastUpdated: Date?
let isRefreshing: Bool
var body: some View {
TimelineView(.periodic(from: .now, by: 30)) { context in
HStack(alignment: .firstTextBaseline, spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(relativeLabel(now: context.date))
.font(.subheadline)
.fontWeight(.medium)
if let absolute = absoluteLabel() {
Text(absolute)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.foregroundStyle(staleness(now: context.date).color)
if isRefreshing {
ProgressView()
.controlSize(.small)
}
Spacer()
}
}
}
private enum Staleness {
case fresh
case warm // >5 min
case stale // >30 min or no data
var color: Color {
switch self {
case .fresh: return .secondary
case .warm: return .orange
case .stale: return .red
}
}
}
private func staleness(now: Date) -> Staleness {
guard let lastUpdated else { return .stale }
let age = now.timeIntervalSince(lastUpdated)
if age > 30 * 60 { return .stale }
if age > 5 * 60 { return .warm }
return .fresh
}
private func relativeLabel(now: Date) -> String {
guard let lastUpdated else { return "Never updated" }
let age = now.timeIntervalSince(lastUpdated)
if age < 60 { return "Updated just now" }
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return "Updated \(formatter.localizedString(for: lastUpdated, relativeTo: now))"
}
private func absoluteLabel() -> String? {
guard let lastUpdated else { return nil }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, h:mm a"
formatter.timeZone = TimeZone(identifier: "America/New_York")
let stamp = formatter.string(from: lastUpdated)
return "as of \(stamp) ET"
}
}
+30
View File
@@ -16,11 +16,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
)
private var mainService = MainService.shared
private let observerAdapter = MacObserverAdapter()
func applicationDidFinishLaunching(_ notification: Notification) {
logger.info("applicationDidFinishLaunching")
UNUserNotificationCenter.current().delegate = self
// Install MainService AppKit bridge before any data flows in.
mainService.observer = observerAdapter
// Force re-register with Launch Services to refresh cached icon
LSRegisterURL(Bundle.main.bundleURL as CFURL, true)
@@ -48,3 +52,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
completionHandler()
}
}
/// Bridges MainService callbacks to the macOS-only managers so MainService
/// stays AppKit-free and shareable with the iOS target.
@MainActor
final class MacObserverAdapter: MainServiceObserver {
func mainServiceDidUpdate() {
StatusItemManager.shared.updateStatusText(MainService.shared.statusBarText)
MenuManager.shared.scoreboardChanged()
}
func mainServiceDidDetectGameStart(_ game: Scoreboard.Game) {
NotificationManager.shared.notifyGameStarted(game)
}
func mainServiceDidDetectGoal(
_ game: Scoreboard.Game,
scoringTeam: Scoreboard.Game.Team,
scorer: GoalScorer?
) {
NotificationManager.shared.notifyGoalScored(game, scoringTeam: scoringTeam, scorer: scorer)
}
func mainServiceDidDetectGameEnd(_ game: Scoreboard.Game) {
NotificationManager.shared.notifyGameEnded(game)
}
}
+1 -25
View File
@@ -366,7 +366,7 @@ class MenuManager: @unchecked Sendable {
trailing = ""
} else if let n = series.nextGameNumber {
statusTag = "(Game \(n))"
trailing = roundItem.nextGame.map { Self.nextGameLabel(for: $0) } ?? ""
trailing = roundItem.nextGame?.nextGameLabel ?? ""
} else {
statusTag = ""
trailing = ""
@@ -381,28 +381,4 @@ class MenuManager: @unchecked Sendable {
return item
}
private static func nextGameLabel(for game: Scoreboard.Game) -> String {
let state = game.parsedGameState
if state.isLive {
return state.shortTag
}
let dayLabel: String
switch game.gameDate {
case Date.todayET: dayLabel = "Today"
case Date.tomorrowET: dayLabel = "Tomorrow"
case Date.yesterdayET: dayLabel = "Yesterday"
default:
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(identifier: "America/New_York")
if let date = formatter.date(from: game.gameDate) {
dayLabel = date.formatDateET(format: "EEE")
} else {
dayLabel = ""
}
}
let time = game.startTimeET.trimmingCharacters(in: .whitespaces)
let base = "\(dayLabel) \(time)"
return state == .pre ? "\(base) (PRE)" : base
}
}
+19 -9
View File
@@ -1,9 +1,10 @@
# IceGlass
A macOS menu bar app for NHL game situational awareness.
NHL game situational awareness for macOS (menu bar) and iPhone (single-page app).
## Key Features
### macOS menu bar app
- NHL shield icon in the menu bar with game count
- Shows games from yesterday, today, and tomorrow grouped by date (configurable)
- Regular-season rows show league-wide game number (`#547 NYR @ WAS …`)
@@ -11,28 +12,37 @@ A macOS menu bar app for NHL game situational awareness.
- 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
- Game start / game ended notifications
- 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
### iPhone app
- Single scrollable page mirroring the macOS menu's content
- "Updated …" header showing relative + absolute ET timestamps; turns orange after 5 min, red after 30 min
- Persistent JSON cache in Application Support so cold launches paint last-known data instantly
- Pull-to-refresh, plus auto-refresh on scene activation and foreground polling timer
- Settings sheet (gear, top-right): display option picker + IndieAbout (license + changelog)
- Tap a game to open NHL GameCenter; tap a series to open the NHL series page
## Building
Requires XcodeGen to generate the project:
```bash
xcodegen generate
xcodebuild -scheme IceGlass -configuration Debug build
xcodebuild -scheme IceGlass -configuration Debug build # macOS
xcodebuild -scheme IceGlass-iOS -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17 Pro' 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
Two targets sharing a common data layer (`Shared/`) and platform-specific UI:
Uses the NHL Web API (`api-web.nhle.com/v1/scoreboard/now`) for league-wide schedule and score data.
- **Shared/** — `MainService`, `ApiService`, `AppSettings`, `NHLDataCache`, models, Date/Time/Logger helpers
- **IceGlass/** — macOS-only managers (MenuManager, StatusItemManager, NotificationManager, AboutWindow)
- **IceGlass-iOS/** — SwiftUI views, ScoreboardViewModel (`@Observable`)
`MainService` exposes a `MainServiceObserver` protocol; each platform installs its own adapter to bridge data updates into AppKit (macOS) or `@Observable` invalidation (iOS). Data is fetched from the NHL Web API (`api-web.nhle.com/v1/scoreboard/now`, `/standings/{date}`, `/playoff-bracket/{year}`).
@@ -0,0 +1,38 @@
//
// Game+nextGameLabel.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
extension Scoreboard.Game {
/// Friendly "next game" label used by both the macOS menu and the iOS
/// playoff series row: "Today 9:30 PM", "Tomorrow 7:00 PM", "Wed 7:00 PM (PRE)",
/// or just "LIVE" when the game is in progress.
var nextGameLabel: String {
let state = parsedGameState
if state.isLive {
return state.shortTag
}
let dayLabel: String
switch gameDate {
case Date.todayET: dayLabel = "Today"
case Date.tomorrowET: dayLabel = "Tomorrow"
case Date.yesterdayET: dayLabel = "Yesterday"
default:
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(identifier: "America/New_York")
if let date = formatter.date(from: gameDate) {
dayLabel = date.formatDateET(format: "EEE")
} else {
dayLabel = ""
}
}
let time = startTimeET.trimmingCharacters(in: .whitespaces)
let base = "\(dayLabel) \(time)"
return state == .pre ? "\(base) (PRE)" : base
}
}

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -22,10 +22,7 @@ class ApiService: @unchecked Sendable {
init(url: URL?, callback: @escaping ApiServiceCallback) {
guard let url = url else {
AppTerminator.terminate()
self.url = URL(string: "")!
self.callback = { (_: Data, _: URLResponse) in }
return
preconditionFailure("ApiService initialised with nil URL — caller bug.")
}
self.url = url
self.callback = callback
@@ -7,6 +7,36 @@
import Foundation
/// Platform-agnostic callback so MainService doesn't import AppKit/UIKit.
/// macOS sets this to drive MenuManager + StatusItemManager + NotificationManager;
/// iOS sets it to invalidate its @Observable view model.
@MainActor
protocol MainServiceObserver: AnyObject {
/// Fired after every successful scoreboard / standings / bracket update.
func mainServiceDidUpdate()
/// Fired when a future game transitions to live. macOS shows a notification;
/// iOS v1 ignores (no notifications).
func mainServiceDidDetectGameStart(_ game: Scoreboard.Game)
/// Fired when a goal is detected. `scorer` may be nil if play-by-play hasn't
/// caught up yet.
func mainServiceDidDetectGoal(
_ game: Scoreboard.Game,
scoringTeam: Scoreboard.Game.Team,
scorer: GoalScorer?
)
/// Fired when a game transitions to a final state (OVER/FINAL/OFF).
func mainServiceDidDetectGameEnd(_ game: Scoreboard.Game)
}
extension MainServiceObserver {
func mainServiceDidDetectGameStart(_: Scoreboard.Game) {}
func mainServiceDidDetectGoal(_: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, scorer: GoalScorer?) {}
func mainServiceDidDetectGameEnd(_: Scoreboard.Game) {}
}
class MainService: @unchecked Sendable {
private let logger = IceGlassLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
@@ -15,10 +45,14 @@ class MainService: @unchecked Sendable {
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 let cache = NHLDataCache()
/// Set by each platform's app entry to receive update callbacks.
/// Always invoked on the main actor by the methods below; the property
/// itself is unisolated so MainService.shared can be referenced from
/// nonisolated contexts (e.g. AppDelegate's stored property init).
nonisolated(unsafe) weak var observer: (any MainServiceObserver)?
private var pollingTimer: Timer?
private var scoreboardApi: ApiService?
@@ -35,10 +69,14 @@ class MainService: @unchecked Sendable {
/// Current playoff bracket (nil during regular season or before first fetch)
var bracket: PlayoffBracket?
/// Timestamp of the most recent successful fetch (any endpoint).
/// Surface this in the iOS UI as the "as of" indicator.
private(set) var lastUpdated: Date?
/// Previous game snapshots for detecting state/score changes (keyed by game ID)
private var previousGameStates: [Int: GameSnapshot] = [:]
/// Whether this is the first fe tch (suppress notifications on initial load)
/// Whether this is the first fetch (suppress notifications on initial load)
private var isFirstFetch = true
/// Set during change detection when a playoff game transitions to a final state,
@@ -118,12 +156,10 @@ class MainService: @unchecked Sendable {
private init() {
logger.debug("Initializing")
DispatchQueue.main.async { [weak self] in
guard let self = self else {
AppTerminator.terminate()
return
}
guard let self = self else { return }
Task { @MainActor in
await self.loadFromCache()
self.initApis()
self.reschedulePollingTimer(.bootstrap)
await self.fetchScoreboard()
@@ -132,6 +168,48 @@ class MainService: @unchecked Sendable {
}
}
// MARK: - Cache
/// Loads the persisted snapshot (if any) so the UI can paint last-known
/// data immediately. The fresh fetch in `init` then overwrites in-memory
/// state and the cache file.
private func loadFromCache() async {
guard let snapshot = await cache.load() else { return }
if let scoreboard = snapshot.scoreboard {
let yesterday = Date.yesterdayET
let today = Date.todayET
let tomorrow = Date.tomorrowET
let windowDates = Set([yesterday, today, tomorrow])
let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) }
self.allGamesByDate = filtered
self.updateSnapshots(from: filtered)
}
self.standings = snapshot.standings
self.bracket = snapshot.bracket
self.lastUpdated = snapshot.lastUpdated
logger.info("Loaded cached snapshot from \(snapshot.lastUpdated)")
Task { @MainActor in self.observer?.mainServiceDidUpdate() }
}
private func persistCache() {
// Reconstruct a synthetic Scoreboard from the windowed allGamesByDate
// so the cached payload is exactly what we'd render on next launch.
let scoreboard = Scoreboard(
focusedDate: Date.todayET,
focusedDateCount: allGamesByDate.first { $0.date == Date.todayET }?.games.count ?? 0,
gamesByDate: allGamesByDate
)
let snapshot = CachedSnapshot(
lastUpdated: Date(),
scoreboard: scoreboard,
standings: standings,
bracket: bracket
)
Task { [cache] in
await cache.save(snapshot)
}
}
private func initApis() {
scoreboardApi = ApiService(
url: URL(string: "https://api-web.nhle.com/v1/scoreboard/now")
@@ -154,20 +232,12 @@ class MainService: @unchecked Sendable {
self.allGamesByDate = filtered
self.updateSnapshots(from: filtered)
self.lastUpdated = Date()
let wasFirstFetch = self.isFirstFetch
if self.isFirstFetch {
self.isFirstFetch = false
}
#if DEBUG
// Fire a test game-start notification on startup so the dev
// loop doesn't require clicking through the menu each time.
if wasFirstFetch, let game = filtered.flatMap(\.games).first {
self.notificationManager.notifyGameStarted(game, bypassDedup: true)
}
#endif
self.logger.info("Scoreboard updated: \(filtered.map { "\($0.date): \($0.games.count) games" }.joined(separator: ", "))")
let interval = self.bestPollingInterval
@@ -178,7 +248,8 @@ class MainService: @unchecked Sendable {
await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket)
}
self.updateUI()
self.persistCache()
self.notifyObserverDidUpdate()
} catch {
self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)")
}
@@ -191,17 +262,26 @@ class MainService: @unchecked Sendable {
do {
self.standings = try JSONDecoder().decode(Standings.self, from: data)
self.lastUpdated = Date()
self.logger.info("Standings updated: \(self.standings?.standings.count ?? 0) teams")
self.updateUI()
self.persistCache()
self.notifyObserverDidUpdate()
} catch {
self.logger.error("Failed to decode standings: \(error.localizedDescription)")
}
}
}
private func notifyObserverDidUpdate() {
Task { @MainActor in
self.observer?.mainServiceDidUpdate()
}
}
/// Fires `mainServiceDidUpdate` without triggering a network fetch call
/// after settings changes that affect what the existing data renders as.
func updateUI() {
statusItemManager.updateStatusText(statusBarText)
menuManager.scoreboardChanged()
notifyObserverDidUpdate()
}
// MARK: - Change Detection
@@ -216,13 +296,15 @@ class MainService: @unchecked Sendable {
if let prevState = previousState, prevState.isFuture, currentState.isLive {
logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
notificationManager.notifyGameStarted(game)
let captured = game
Task { @MainActor in self.observer?.mainServiceDidDetectGameStart(captured) }
}
// Game ended: transition to any over-state (OVER/FINAL/OFF)
if let prevState = previousState, !prevState.isOver, currentState.isOver {
logger.info("Game \(game.id) ended: \(game.awayTeam.abbrev) \(game.awayTeam.score ?? 0)\(game.homeTeam.abbrev) \(game.homeTeam.score ?? 0)")
notificationManager.notifyGameEnded(game)
let captured = game
Task { @MainActor in self.observer?.mainServiceDidDetectGameEnd(captured) }
}
if game.gameType == 3,
@@ -253,7 +335,9 @@ class MainService: @unchecked Sendable {
awayScore: awayScore,
homeScore: homeScore
)
self.notificationManager.notifyGoalScored(game, scoringTeam: scoringTeam, scorer: scorer)
await MainActor.run {
self.observer?.mainServiceDidDetectGoal(game, scoringTeam: scoringTeam, scorer: scorer)
}
}
}
@@ -328,6 +412,20 @@ class MainService: @unchecked Sendable {
}
}
/// Cancel the polling timer (call from iOS when entering background).
@MainActor
func suspendPolling() {
pollingTimer?.invalidate()
pollingTimer = nil
logger.debug("Polling suspended")
}
/// Resume polling at the appropriate interval (call from iOS on scenePhase=.active).
@MainActor
func resumePolling() {
reschedulePollingTimer(bestPollingInterval)
}
private func fetchScoreboard() async {
await scoreboardApi?.fetch()
}
@@ -360,8 +458,10 @@ class MainService: @unchecked Sendable {
guard let self = self else { return }
do {
self.bracket = try JSONDecoder().decode(PlayoffBracket.self, from: data)
self.lastUpdated = Date()
self.logger.info("Bracket updated: \(self.bracket?.series.count ?? 0) series (round \(self.bracket?.currentRound ?? 0))")
self.updateUI()
self.persistCache()
self.notifyObserverDidUpdate()
} catch {
self.logger.error("Failed to decode bracket: \(error.localizedDescription)")
}
+68
View File
@@ -0,0 +1,68 @@
//
// NHLDataCache.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Snapshot of API state persisted between app launches so cold-launch shows
/// last-known data with an "as-of" timestamp instead of an empty page.
struct CachedSnapshot: Codable, Sendable {
let lastUpdated: Date
let scoreboard: Scoreboard?
let standings: Standings?
let bracket: PlayoffBracket?
}
/// Single-file Codable cache in Application Support. Atomic writes; fail-soft
/// reads corrupt or version-mismatched payloads return nil and the next
/// fetch overwrites with fresh data.
actor NHLDataCache {
private let logger = IceGlassLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
category: "NHLDataCache"
)
private let fileURL: URL
init(filename: String = "nhl-snapshot.json") {
let fm = FileManager.default
let supportDir = (try? fm.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)) ?? fm.temporaryDirectory
let bundleId = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass"
let appDir = supportDir.appendingPathComponent(bundleId, isDirectory: true)
try? fm.createDirectory(at: appDir, withIntermediateDirectories: true)
self.fileURL = appDir.appendingPathComponent(filename)
}
func load() -> CachedSnapshot? {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
do {
let data = try Data(contentsOf: fileURL)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(CachedSnapshot.self, from: data)
} catch {
logger.warning("Cache load failed (will refetch): \(error.localizedDescription)")
return nil
}
}
func save(_ snapshot: CachedSnapshot) {
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(snapshot)
try data.write(to: fileURL, options: .atomic)
} catch {
logger.error("Cache save failed: \(error.localizedDescription)")
}
}
}
@@ -5,7 +5,10 @@
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
#if os(macOS)
import ServiceManagement
#endif
class AppSettings: @unchecked Sendable {
private let logger = IceGlassLogger(
@@ -93,6 +96,7 @@ class AppSettings: @unchecked Sendable {
}
func updateLoginItem(enabled: Bool) {
#if os(macOS)
do {
if enabled {
try SMAppService.mainApp.register()
@@ -102,6 +106,7 @@ class AppSettings: @unchecked Sendable {
} catch {
logger.error("Failed to \(enabled ? "enable" : "disable") launch at login: \(error.localizedDescription)")
}
#endif
}
private init() {}
+52 -2
View File
@@ -3,6 +3,7 @@ options:
bundleIdPrefix: dev.rzen.indie
deploymentTarget:
macOS: "13.0"
iOS: "17.0"
xcodeVersion: "16.0"
generateEmptyDirectories: true
@@ -16,12 +17,13 @@ targets:
type: application
platform: macOS
sources:
- path: IceGlass
- path: Shared
excludes:
- Resources/TeamLogos
- path: IceGlass/Resources/TeamLogos
- path: Shared/Resources/TeamLogos
type: folder
buildPhase: resources
- path: IceGlass
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.IceGlass
@@ -57,6 +59,45 @@ targets:
com.apple.security.app-sandbox: true
com.apple.security.network.client: true
IceGlass-iOS:
type: application
platform: iOS
sources:
- path: Shared
excludes:
- Resources/TeamLogos
- path: Shared/Resources/TeamLogos
type: folder
buildPhase: resources
- path: IceGlass-iOS
excludes:
- Resources
- path: LICENSE.md
buildPhase: resources
- path: CHANGELOG.md
buildPhase: resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.IceGlass.iOS
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
INFOPLIST_FILE: IceGlass-iOS/Info.plist
DEVELOPMENT_TEAM: C32Z8JNLG6
SWIFT_VERSION: "6.0"
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
TARGETED_DEVICE_FAMILY: "1"
PRODUCT_NAME: IceGlass
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_USER_SCRIPT_SANDBOXING: false
GENERATE_INFOPLIST_FILE: false
configs:
Debug:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
Release:
SWIFT_OPTIMIZATION_LEVEL: -O
dependencies:
- package: IndieAbout
schemes:
IceGlass:
build:
@@ -66,3 +107,12 @@ schemes:
config: Debug
archive:
config: Release
IceGlass-iOS:
build:
targets:
IceGlass-iOS: all
run:
config: Debug
archive:
config: Release