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:
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// 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 {
|
||||
preconditionFailure("ApiService initialised with nil URL — caller bug.")
|
||||
}
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
//
|
||||
// MainService.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
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",
|
||||
category: "MainService"
|
||||
)
|
||||
|
||||
static let shared = MainService()
|
||||
|
||||
private lazy var settings = AppSettings.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?
|
||||
private var standingsApi: ApiService?
|
||||
private var bracketApi: ApiService?
|
||||
private var bracketApiSeasonYear: Int?
|
||||
|
||||
/// All game days from the API (full window: yesterday/today/tomorrow)
|
||||
private var allGamesByDate: [Scoreboard.GameDay] = []
|
||||
|
||||
/// Current standings data
|
||||
var standings: Standings?
|
||||
|
||||
/// 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 fetch (suppress notifications on initial load)
|
||||
private var isFirstFetch = true
|
||||
|
||||
/// Set during change detection when a playoff game transitions to a final state,
|
||||
/// so the next scoreboard cycle can force a bracket refresh.
|
||||
private var hadPlayoffFinalTransition = false
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
/// Status bar text based on current settings
|
||||
var statusBarText: String {
|
||||
switch settings.statusBarOption {
|
||||
case .gameCount:
|
||||
let gameDays = gamesByDate
|
||||
if gameDays.isEmpty { return "" }
|
||||
return gameDays.map { "\($0.games.count)" }.joined(separator: "/")
|
||||
|
||||
case .gamesPlayed:
|
||||
return "\(standings?.totalGamesPlayed ?? 0)"
|
||||
|
||||
case .gamesPlayedTotal:
|
||||
return "\(standings?.totalGamesPlayed ?? 0)/\(Standings.totalRegularSeasonGames)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any game across all days is currently live
|
||||
var anyGameLive: Bool {
|
||||
allGamesByDate.flatMap(\.games).contains { $0.parsedGameState.isLive }
|
||||
}
|
||||
|
||||
/// Whether any game is in pre-game state
|
||||
var anyGamePre: Bool {
|
||||
allGamesByDate.flatMap(\.games).contains { $0.parsedGameState == .pre }
|
||||
}
|
||||
|
||||
/// Whether any game today is scheduled (future)
|
||||
var anyGameToday: Bool {
|
||||
let today = Date.todayET
|
||||
return allGamesByDate.contains { $0.date == today && !$0.games.isEmpty }
|
||||
}
|
||||
|
||||
/// Series in the current playoff round paired with each one's next scheduled
|
||||
/// game from the fetched window (if any). Empty during regular season.
|
||||
var currentRoundSeriesItems: [RoundSeriesItem] {
|
||||
guard let bracket = bracket else { return [] }
|
||||
let windowGames = allGamesByDate.flatMap(\.games)
|
||||
return bracket.currentRoundSeries.map { series in
|
||||
let nextGame = windowGames
|
||||
.filter { !$0.parsedGameState.isOver && series.involves(away: $0.awayTeam.abbrev, home: $0.homeTeam.abbrev) }
|
||||
.min { $0.date < $1.date }
|
||||
return RoundSeriesItem(series: series, nextGame: nextGame)
|
||||
}
|
||||
}
|
||||
|
||||
struct RoundSeriesItem {
|
||||
let series: PlayoffBracket.Series
|
||||
let nextGame: Scoreboard.Game?
|
||||
}
|
||||
|
||||
/// 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 { return }
|
||||
|
||||
Task { @MainActor in
|
||||
await self.loadFromCache()
|
||||
self.initApis()
|
||||
self.reschedulePollingTimer(.bootstrap)
|
||||
await self.fetchScoreboard()
|
||||
await self.fetchStandings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
) { [weak self] data, _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
let scoreboard = try JSONDecoder().decode(Scoreboard.self, from: data)
|
||||
|
||||
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) }
|
||||
|
||||
if !self.isFirstFetch {
|
||||
self.detectChanges(in: filtered)
|
||||
}
|
||||
|
||||
self.allGamesByDate = filtered
|
||||
self.updateSnapshots(from: filtered)
|
||||
self.lastUpdated = Date()
|
||||
|
||||
if self.isFirstFetch {
|
||||
self.isFirstFetch = false
|
||||
}
|
||||
|
||||
self.logger.info("Scoreboard updated: \(filtered.map { "\($0.date): \($0.games.count) games" }.joined(separator: ", "))")
|
||||
|
||||
let interval = self.bestPollingInterval
|
||||
let shouldForceBracket = self.hadPlayoffFinalTransition
|
||||
self.hadPlayoffFinalTransition = false
|
||||
Task { @MainActor in
|
||||
self.reschedulePollingTimer(interval)
|
||||
await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket)
|
||||
}
|
||||
|
||||
self.persistCache()
|
||||
self.notifyObserverDidUpdate()
|
||||
} catch {
|
||||
self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
standingsApi = ApiService(
|
||||
url: URL(string: "https://api-web.nhle.com/v1/standings/\(Date.todayET)")
|
||||
) { [weak self] data, _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
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.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() {
|
||||
notifyObserverDidUpdate()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
if let prevState = previousState, prevState.isFuture, currentState.isLive {
|
||||
logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
|
||||
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)")
|
||||
let captured = game
|
||||
Task { @MainActor in self.observer?.mainServiceDidDetectGameEnd(captured) }
|
||||
}
|
||||
|
||||
if game.gameType == 3,
|
||||
let prevState = previousState, !prevState.isOver, currentState.isOver {
|
||||
hadPlayoffFinalTransition = true
|
||||
}
|
||||
|
||||
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)")
|
||||
handleGoal(game: game, scoringTeam: game.awayTeam, awayScore: curAway, homeScore: curHome)
|
||||
}
|
||||
if curHome > prevHome {
|
||||
logger.info("Goal! \(game.homeTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)")
|
||||
handleGoal(game: game, scoringTeam: game.homeTeam, awayScore: curAway, homeScore: curHome)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleGoal(game: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, awayScore: Int, homeScore: Int) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let scorer = await self.fetchGoalScorer(
|
||||
gameId: game.id,
|
||||
awayScore: awayScore,
|
||||
homeScore: homeScore
|
||||
)
|
||||
await MainActor.run {
|
||||
self.observer?.mainServiceDidDetectGoal(game, scoringTeam: scoringTeam, scorer: scorer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchGoalScorer(gameId: Int, awayScore: Int, homeScore: Int) async -> GoalScorer? {
|
||||
guard let url = URL(string: "https://api-web.nhle.com/v1/gamecenter/\(gameId)/play-by-play") else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let pbp = try JSONDecoder().decode(PlayByPlay.self, from: data)
|
||||
guard let goal = pbp.goal(matchingAwayScore: awayScore, homeScore: homeScore),
|
||||
let playerId = goal.details?.scoringPlayerId,
|
||||
let player = pbp.player(id: playerId) else {
|
||||
logger.info("Play-by-play not yet caught up for goal at \(awayScore)-\(homeScore) in game \(gameId)")
|
||||
return nil
|
||||
}
|
||||
let scoringTeamIsAway = goal.details?.eventOwnerTeamId == pbp.awayTeam.id
|
||||
let strength = PlayByPlay.strengthTag(
|
||||
situationCode: goal.situationCode,
|
||||
scoringTeamIsAway: scoringTeamIsAway
|
||||
)
|
||||
let firstInitial = player.firstName.default.first.map { "\($0)." } ?? ""
|
||||
let name = "\(firstInitial) \(player.lastName.default)".trimmingCharacters(in: .whitespaces)
|
||||
return GoalScorer(name: name, sweaterNumber: player.sweaterNumber, strength: strength)
|
||||
} catch {
|
||||
logger.error("Failed to fetch play-by-play for game \(gameId): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
private func fetchStandings() async {
|
||||
await standingsApi?.fetch()
|
||||
}
|
||||
|
||||
func fetchAll() async {
|
||||
await fetchScoreboard()
|
||||
await fetchStandings()
|
||||
}
|
||||
|
||||
// MARK: - Playoff Bracket
|
||||
|
||||
private func refreshBracketIfNeeded(from gameDays: [Scoreboard.GameDay], force: Bool) async {
|
||||
let playoffGame = gameDays.flatMap(\.games).first { $0.gameType == 3 }
|
||||
guard let playoffGame = playoffGame else {
|
||||
bracket = nil
|
||||
return
|
||||
}
|
||||
|
||||
let seasonYear = playoffGame.season / 10_000
|
||||
let seasonChanged = bracketApi == nil || bracketApiSeasonYear != seasonYear
|
||||
if seasonChanged {
|
||||
bracketApiSeasonYear = seasonYear
|
||||
bracketApi = ApiService(
|
||||
url: URL(string: "https://api-web.nhle.com/v1/playoff-bracket/\(seasonYear + 1)")
|
||||
) { [weak self] data, _ in
|
||||
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.persistCache()
|
||||
self.notifyObserverDidUpdate()
|
||||
} catch {
|
||||
self.logger.error("Failed to decode bracket: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bracket == nil || seasonChanged || force {
|
||||
await bracketApi?.fetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user