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.
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
## April 2026
|
## 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
|
- 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
|
- 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"
|
- Playoff series rows in the ROUND block are always clickable (open the NHL series page) and mark completed series as "Final"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 211 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
)
|
)
|
||||||
|
|
||||||
private var mainService = MainService.shared
|
private var mainService = MainService.shared
|
||||||
|
private let observerAdapter = MacObserverAdapter()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
logger.info("applicationDidFinishLaunching")
|
logger.info("applicationDidFinishLaunching")
|
||||||
UNUserNotificationCenter.current().delegate = self
|
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
|
// Force re-register with Launch Services to refresh cached icon
|
||||||
LSRegisterURL(Bundle.main.bundleURL as CFURL, true)
|
LSRegisterURL(Bundle.main.bundleURL as CFURL, true)
|
||||||
|
|
||||||
@@ -48,3 +52,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
completionHandler()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ class MenuManager: @unchecked Sendable {
|
|||||||
trailing = ""
|
trailing = ""
|
||||||
} else if let n = series.nextGameNumber {
|
} else if let n = series.nextGameNumber {
|
||||||
statusTag = "(Game \(n))"
|
statusTag = "(Game \(n))"
|
||||||
trailing = roundItem.nextGame.map { Self.nextGameLabel(for: $0) } ?? ""
|
trailing = roundItem.nextGame?.nextGameLabel ?? ""
|
||||||
} else {
|
} else {
|
||||||
statusTag = ""
|
statusTag = ""
|
||||||
trailing = ""
|
trailing = ""
|
||||||
@@ -381,28 +381,4 @@ class MenuManager: @unchecked Sendable {
|
|||||||
return item
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# IceGlass
|
# 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
|
## Key Features
|
||||||
|
|
||||||
|
### macOS menu bar app
|
||||||
- NHL shield icon in the menu bar with game count
|
- NHL shield icon in the menu bar with game count
|
||||||
- Shows games from yesterday, today, and tomorrow grouped by date (configurable)
|
- Shows games from yesterday, today, and tomorrow grouped by date (configurable)
|
||||||
- Regular-season rows show league-wide game number (`#547 NYR @ WAS …`)
|
- 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`
|
- 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
|
- Click a game to open NHL GameCenter; option-click for NHL Videocast
|
||||||
- Goal scored notifications with scoring team logo
|
- 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
|
- Dynamic polling: 7s during live games, scales back when idle
|
||||||
- Display Options: choose which days to show (yesterday/today/tomorrow)
|
- Display Options: choose which days to show (yesterday/today/tomorrow)
|
||||||
- Refresh Now (⌘R) for immediate updates
|
- Refresh Now (⌘R) for immediate updates
|
||||||
- Launch at Login support
|
- Launch at Login support
|
||||||
- About window via IndieAbout
|
- 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
|
## Building
|
||||||
|
|
||||||
Requires XcodeGen to generate the project:
|
Requires XcodeGen to generate the project:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xcodegen generate
|
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
|
## Architecture
|
||||||
|
|
||||||
Menu bar app using singleton services pattern:
|
Two targets sharing a common data layer (`Shared/`) and platform-specific UI:
|
||||||
- **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.
|
- **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) {
|
init(url: URL?, callback: @escaping ApiServiceCallback) {
|
||||||
guard let url = url else {
|
guard let url = url else {
|
||||||
AppTerminator.terminate()
|
preconditionFailure("ApiService initialised with nil URL — caller bug.")
|
||||||
self.url = URL(string: "")!
|
|
||||||
self.callback = { (_: Data, _: URLResponse) in }
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
self.url = url
|
self.url = url
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
@@ -7,6 +7,36 @@
|
|||||||
|
|
||||||
import Foundation
|
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 {
|
class MainService: @unchecked Sendable {
|
||||||
private let logger = IceGlassLogger(
|
private let logger = IceGlassLogger(
|
||||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
||||||
@@ -15,10 +45,14 @@ class MainService: @unchecked Sendable {
|
|||||||
|
|
||||||
static let shared = 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 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 pollingTimer: Timer?
|
||||||
private var scoreboardApi: ApiService?
|
private var scoreboardApi: ApiService?
|
||||||
@@ -35,6 +69,10 @@ class MainService: @unchecked Sendable {
|
|||||||
/// Current playoff bracket (nil during regular season or before first fetch)
|
/// Current playoff bracket (nil during regular season or before first fetch)
|
||||||
var bracket: PlayoffBracket?
|
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)
|
/// Previous game snapshots for detecting state/score changes (keyed by game ID)
|
||||||
private var previousGameStates: [Int: GameSnapshot] = [:]
|
private var previousGameStates: [Int: GameSnapshot] = [:]
|
||||||
|
|
||||||
@@ -118,12 +156,10 @@ class MainService: @unchecked Sendable {
|
|||||||
private init() {
|
private init() {
|
||||||
logger.debug("Initializing")
|
logger.debug("Initializing")
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self else {
|
guard let self = self else { return }
|
||||||
AppTerminator.terminate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
await self.loadFromCache()
|
||||||
self.initApis()
|
self.initApis()
|
||||||
self.reschedulePollingTimer(.bootstrap)
|
self.reschedulePollingTimer(.bootstrap)
|
||||||
await self.fetchScoreboard()
|
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() {
|
private func initApis() {
|
||||||
scoreboardApi = ApiService(
|
scoreboardApi = ApiService(
|
||||||
url: URL(string: "https://api-web.nhle.com/v1/scoreboard/now")
|
url: URL(string: "https://api-web.nhle.com/v1/scoreboard/now")
|
||||||
@@ -154,20 +232,12 @@ class MainService: @unchecked Sendable {
|
|||||||
|
|
||||||
self.allGamesByDate = filtered
|
self.allGamesByDate = filtered
|
||||||
self.updateSnapshots(from: filtered)
|
self.updateSnapshots(from: filtered)
|
||||||
|
self.lastUpdated = Date()
|
||||||
|
|
||||||
let wasFirstFetch = self.isFirstFetch
|
|
||||||
if self.isFirstFetch {
|
if self.isFirstFetch {
|
||||||
self.isFirstFetch = false
|
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: ", "))")
|
self.logger.info("Scoreboard updated: \(filtered.map { "\($0.date): \($0.games.count) games" }.joined(separator: ", "))")
|
||||||
|
|
||||||
let interval = self.bestPollingInterval
|
let interval = self.bestPollingInterval
|
||||||
@@ -178,7 +248,8 @@ class MainService: @unchecked Sendable {
|
|||||||
await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket)
|
await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.updateUI()
|
self.persistCache()
|
||||||
|
self.notifyObserverDidUpdate()
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)")
|
self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
@@ -191,17 +262,26 @@ class MainService: @unchecked Sendable {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
self.standings = try JSONDecoder().decode(Standings.self, from: data)
|
self.standings = try JSONDecoder().decode(Standings.self, from: data)
|
||||||
|
self.lastUpdated = Date()
|
||||||
self.logger.info("Standings updated: \(self.standings?.standings.count ?? 0) teams")
|
self.logger.info("Standings updated: \(self.standings?.standings.count ?? 0) teams")
|
||||||
self.updateUI()
|
self.persistCache()
|
||||||
|
self.notifyObserverDidUpdate()
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("Failed to decode standings: \(error.localizedDescription)")
|
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() {
|
func updateUI() {
|
||||||
statusItemManager.updateStatusText(statusBarText)
|
notifyObserverDidUpdate()
|
||||||
menuManager.scoreboardChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Change Detection
|
// MARK: - Change Detection
|
||||||
@@ -216,13 +296,15 @@ class MainService: @unchecked Sendable {
|
|||||||
|
|
||||||
if let prevState = previousState, prevState.isFuture, currentState.isLive {
|
if let prevState = previousState, prevState.isFuture, currentState.isLive {
|
||||||
logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
|
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)
|
// Game ended: transition to any over-state (OVER/FINAL/OFF)
|
||||||
if let prevState = previousState, !prevState.isOver, currentState.isOver {
|
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)")
|
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,
|
if game.gameType == 3,
|
||||||
@@ -253,7 +335,9 @@ class MainService: @unchecked Sendable {
|
|||||||
awayScore: awayScore,
|
awayScore: awayScore,
|
||||||
homeScore: homeScore
|
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 {
|
private func fetchScoreboard() async {
|
||||||
await scoreboardApi?.fetch()
|
await scoreboardApi?.fetch()
|
||||||
}
|
}
|
||||||
@@ -360,8 +458,10 @@ class MainService: @unchecked Sendable {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
do {
|
do {
|
||||||
self.bracket = try JSONDecoder().decode(PlayoffBracket.self, from: data)
|
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.logger.info("Bracket updated: \(self.bracket?.series.count ?? 0) series (round \(self.bracket?.currentRound ?? 0))")
|
||||||
self.updateUI()
|
self.persistCache()
|
||||||
|
self.notifyObserverDidUpdate()
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("Failed to decode bracket: \(error.localizedDescription)")
|
self.logger.error("Failed to decode bracket: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
@@ -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.
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
#if os(macOS)
|
||||||
import ServiceManagement
|
import ServiceManagement
|
||||||
|
#endif
|
||||||
|
|
||||||
class AppSettings: @unchecked Sendable {
|
class AppSettings: @unchecked Sendable {
|
||||||
private let logger = IceGlassLogger(
|
private let logger = IceGlassLogger(
|
||||||
@@ -93,6 +96,7 @@ class AppSettings: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateLoginItem(enabled: Bool) {
|
func updateLoginItem(enabled: Bool) {
|
||||||
|
#if os(macOS)
|
||||||
do {
|
do {
|
||||||
if enabled {
|
if enabled {
|
||||||
try SMAppService.mainApp.register()
|
try SMAppService.mainApp.register()
|
||||||
@@ -102,6 +106,7 @@ class AppSettings: @unchecked Sendable {
|
|||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to \(enabled ? "enable" : "disable") launch at login: \(error.localizedDescription)")
|
logger.error("Failed to \(enabled ? "enable" : "disable") launch at login: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
@@ -3,6 +3,7 @@ options:
|
|||||||
bundleIdPrefix: dev.rzen.indie
|
bundleIdPrefix: dev.rzen.indie
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
macOS: "13.0"
|
macOS: "13.0"
|
||||||
|
iOS: "17.0"
|
||||||
xcodeVersion: "16.0"
|
xcodeVersion: "16.0"
|
||||||
generateEmptyDirectories: true
|
generateEmptyDirectories: true
|
||||||
|
|
||||||
@@ -16,12 +17,13 @@ targets:
|
|||||||
type: application
|
type: application
|
||||||
platform: macOS
|
platform: macOS
|
||||||
sources:
|
sources:
|
||||||
- path: IceGlass
|
- path: Shared
|
||||||
excludes:
|
excludes:
|
||||||
- Resources/TeamLogos
|
- Resources/TeamLogos
|
||||||
- path: IceGlass/Resources/TeamLogos
|
- path: Shared/Resources/TeamLogos
|
||||||
type: folder
|
type: folder
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
|
- path: IceGlass
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.IceGlass
|
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.IceGlass
|
||||||
@@ -57,6 +59,45 @@ targets:
|
|||||||
com.apple.security.app-sandbox: true
|
com.apple.security.app-sandbox: true
|
||||||
com.apple.security.network.client: 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:
|
schemes:
|
||||||
IceGlass:
|
IceGlass:
|
||||||
build:
|
build:
|
||||||
@@ -66,3 +107,12 @@ schemes:
|
|||||||
config: Debug
|
config: Debug
|
||||||
archive:
|
archive:
|
||||||
config: Release
|
config: Release
|
||||||
|
|
||||||
|
IceGlass-iOS:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
IceGlass-iOS: all
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
|
|||||||