fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files: - Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files - Silent failure elimination: all 34 try? sites replaced with do/try/catch + logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService, CanonicalModels, CKModels, SportsTimeApp, and more) - Performance: cached DateFormatters (7 files), O(1) team lookups via AppDataProvider, achievement definition dictionary, AnimatedBackground consolidated from 19 Tasks to 1, task cancellation in SharePreviewView - Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard, @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix - Planning engine: game end time in travel feasibility, state-aware city normalization, exact city matching, DrivingConstraints parameter propagation - IAP: unknown subscription states → expired, unverified transaction logging, entitlements updated before paywall dismiss, restore visible to all users - Security: API key to Info.plist lookup, filename sanitization in PDF export, honest User-Agent, removed stale "Feels" analytics super properties - Navigation: consolidated competing navigationDestination, boolean → value-based - Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat - Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel MKDirections, Sendable-safe POI struct Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import CryptoKit
|
||||
import os
|
||||
|
||||
private let logger = Logger(subsystem: "com.88oakapps.SportsTime", category: "CanonicalModels")
|
||||
|
||||
// MARK: - Schema Version
|
||||
|
||||
@@ -84,8 +87,15 @@ final class SyncState {
|
||||
let descriptor = FetchDescriptor<SyncState>(
|
||||
predicate: #Predicate { $0.id == "singleton" }
|
||||
)
|
||||
if let existing = try? context.fetch(descriptor).first {
|
||||
return existing
|
||||
do {
|
||||
if let existing = try context.fetch(descriptor).first {
|
||||
return existing
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to fetch SyncState: \(error.localizedDescription)")
|
||||
// Don't insert a new SyncState on fetch error — return a transient one
|
||||
// to avoid duplicates if the store is temporarily unavailable
|
||||
return SyncState()
|
||||
}
|
||||
let new = SyncState()
|
||||
context.insert(new)
|
||||
@@ -174,7 +184,11 @@ final class CanonicalStadium {
|
||||
var isActive: Bool { deprecatedAt == nil }
|
||||
|
||||
func toDomain() -> Stadium {
|
||||
Stadium(
|
||||
let resolvedSport = Sport(rawValue: sport) ?? {
|
||||
logger.warning("Unknown sport identifier for stadium '\(self.canonicalId)': \(self.sport)")
|
||||
return Sport.mlb
|
||||
}()
|
||||
return Stadium(
|
||||
id: canonicalId,
|
||||
name: name,
|
||||
city: city,
|
||||
@@ -182,7 +196,7 @@ final class CanonicalStadium {
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
capacity: capacity,
|
||||
sport: Sport(rawValue: sport) ?? .mlb,
|
||||
sport: resolvedSport,
|
||||
yearOpened: yearOpened,
|
||||
imageURL: imageURL.flatMap { URL(string: $0) },
|
||||
timeZoneIdentifier: timezoneIdentifier
|
||||
@@ -313,11 +327,15 @@ final class CanonicalTeam {
|
||||
var sportEnum: Sport? { Sport(rawValue: sport) }
|
||||
|
||||
func toDomain() -> Team {
|
||||
Team(
|
||||
let resolvedSport = sportEnum ?? {
|
||||
logger.warning("Unknown sport identifier for team '\(self.canonicalId)': \(self.sport)")
|
||||
return Sport.mlb
|
||||
}()
|
||||
return Team(
|
||||
id: canonicalId,
|
||||
name: name,
|
||||
abbreviation: abbreviation,
|
||||
sport: sportEnum ?? .mlb,
|
||||
sport: resolvedSport,
|
||||
city: city,
|
||||
stadiumId: stadiumCanonicalId,
|
||||
conferenceId: conferenceId,
|
||||
@@ -482,13 +500,17 @@ final class CanonicalGame {
|
||||
var sportEnum: Sport? { Sport(rawValue: sport) }
|
||||
|
||||
func toDomain() -> Game {
|
||||
Game(
|
||||
let resolvedSport = sportEnum ?? {
|
||||
logger.warning("Unknown sport identifier for game '\(self.canonicalId)': \(self.sport)")
|
||||
return Sport.mlb
|
||||
}()
|
||||
return Game(
|
||||
id: canonicalId,
|
||||
homeTeamId: homeTeamCanonicalId,
|
||||
awayTeamId: awayTeamCanonicalId,
|
||||
stadiumId: stadiumCanonicalId,
|
||||
dateTime: dateTime,
|
||||
sport: sportEnum ?? .mlb,
|
||||
sport: resolvedSport,
|
||||
season: season,
|
||||
isPlayoff: isPlayoff,
|
||||
broadcastInfo: broadcastInfo
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import os
|
||||
|
||||
private let logger = Logger(subsystem: "com.88oakapps.SportsTime", category: "LocalPoll")
|
||||
|
||||
// MARK: - Local Trip Poll
|
||||
|
||||
@@ -48,7 +51,12 @@ final class LocalTripPoll {
|
||||
}
|
||||
|
||||
var tripSnapshots: [Trip] {
|
||||
(try? JSONDecoder().decode([Trip].self, from: tripSnapshotsData)) ?? []
|
||||
do {
|
||||
return try JSONDecoder().decode([Trip].self, from: tripSnapshotsData)
|
||||
} catch {
|
||||
logger.error("Failed to decode tripSnapshots: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func toPoll() -> TripPoll {
|
||||
@@ -66,7 +74,13 @@ final class LocalTripPoll {
|
||||
}
|
||||
|
||||
static func from(_ poll: TripPoll) -> LocalTripPoll? {
|
||||
guard let tripsData = try? JSONEncoder().encode(poll.tripSnapshots) else { return nil }
|
||||
let tripsData: Data
|
||||
do {
|
||||
tripsData = try JSONEncoder().encode(poll.tripSnapshots)
|
||||
} catch {
|
||||
logger.error("Failed to encode tripSnapshots for poll \(poll.id.uuidString): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
return LocalTripPoll(
|
||||
id: poll.id,
|
||||
title: poll.title,
|
||||
@@ -114,7 +128,7 @@ final class LocalPollVote {
|
||||
PollVote(
|
||||
id: id,
|
||||
pollId: pollId,
|
||||
odg: voterId,
|
||||
voterId: voterId,
|
||||
rankings: rankings,
|
||||
votedAt: votedAt,
|
||||
modifiedAt: modifiedAt
|
||||
@@ -125,7 +139,7 @@ final class LocalPollVote {
|
||||
LocalPollVote(
|
||||
id: vote.id,
|
||||
pollId: vote.pollId,
|
||||
voterId: vote.odg,
|
||||
voterId: vote.voterId,
|
||||
rankings: vote.rankings,
|
||||
votedAt: vote.votedAt,
|
||||
modifiedAt: vote.modifiedAt
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import os
|
||||
|
||||
private let logger = Logger(subsystem: "com.88oakapps.SportsTime", category: "SavedTrip")
|
||||
|
||||
@Model
|
||||
final class SavedTrip {
|
||||
@@ -40,12 +43,22 @@ final class SavedTrip {
|
||||
}
|
||||
|
||||
var trip: Trip? {
|
||||
try? JSONDecoder().decode(Trip.self, from: tripData)
|
||||
do {
|
||||
return try JSONDecoder().decode(Trip.self, from: tripData)
|
||||
} catch {
|
||||
logger.error("Failed to decode Trip from tripData: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var games: [String: RichGame] {
|
||||
guard let data = gamesData else { return [:] }
|
||||
return (try? JSONDecoder().decode([String: RichGame].self, from: data)) ?? [:]
|
||||
do {
|
||||
return try JSONDecoder().decode([String: RichGame].self, from: data)
|
||||
} catch {
|
||||
logger.error("Failed to decode games from gamesData: \(error.localizedDescription)")
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
var tripStatus: TripStatus {
|
||||
@@ -53,8 +66,20 @@ final class SavedTrip {
|
||||
}
|
||||
|
||||
static func from(_ trip: Trip, games: [String: RichGame] = [:], status: TripStatus = .planned) -> SavedTrip? {
|
||||
guard let tripData = try? JSONEncoder().encode(trip) else { return nil }
|
||||
let gamesData = try? JSONEncoder().encode(games)
|
||||
let tripData: Data
|
||||
do {
|
||||
tripData = try JSONEncoder().encode(trip)
|
||||
} catch {
|
||||
logger.error("Failed to encode Trip: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
let gamesData: Data?
|
||||
do {
|
||||
gamesData = try JSONEncoder().encode(games)
|
||||
} catch {
|
||||
logger.error("Failed to encode games dictionary: \(error.localizedDescription)")
|
||||
gamesData = nil
|
||||
}
|
||||
return SavedTrip(
|
||||
id: trip.id,
|
||||
name: trip.name,
|
||||
@@ -189,18 +214,33 @@ final class UserPreferences {
|
||||
maxDrivingHours: Double? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.defaultSports = (try? JSONEncoder().encode(defaultSports)) ?? Data()
|
||||
do {
|
||||
self.defaultSports = try JSONEncoder().encode(defaultSports)
|
||||
} catch {
|
||||
logger.error("Failed to encode defaultSports: \(error.localizedDescription)")
|
||||
self.defaultSports = Data()
|
||||
}
|
||||
self.defaultTravelMode = defaultTravelMode.rawValue
|
||||
self.defaultLeisureLevel = defaultLeisureLevel.rawValue
|
||||
self.defaultLodgingType = defaultLodgingType.rawValue
|
||||
self.homeLocation = try? JSONEncoder().encode(homeLocation)
|
||||
do {
|
||||
self.homeLocation = try JSONEncoder().encode(homeLocation)
|
||||
} catch {
|
||||
logger.error("Failed to encode homeLocation: \(error.localizedDescription)")
|
||||
self.homeLocation = nil
|
||||
}
|
||||
self.needsEVCharging = needsEVCharging
|
||||
self.numberOfDrivers = numberOfDrivers
|
||||
self.maxDrivingHours = maxDrivingHours
|
||||
}
|
||||
|
||||
var sports: [Sport] {
|
||||
(try? JSONDecoder().decode([Sport].self, from: defaultSports)) ?? Sport.supported
|
||||
do {
|
||||
return try JSONDecoder().decode([Sport].self, from: defaultSports)
|
||||
} catch {
|
||||
logger.error("Failed to decode sports: \(error.localizedDescription)")
|
||||
return Sport.supported
|
||||
}
|
||||
}
|
||||
|
||||
var travelMode: TravelMode {
|
||||
@@ -217,7 +257,12 @@ final class UserPreferences {
|
||||
|
||||
var home: LocationInput? {
|
||||
guard let data = homeLocation else { return nil }
|
||||
return try? JSONDecoder().decode(LocationInput.self, from: data)
|
||||
do {
|
||||
return try JSONDecoder().decode(LocationInput.self, from: data)
|
||||
} catch {
|
||||
logger.error("Failed to decode homeLocation: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,21 +291,51 @@ final class CachedSchedule {
|
||||
self.sport = sport.rawValue
|
||||
self.season = season
|
||||
self.lastUpdated = lastUpdated
|
||||
self.gamesData = (try? JSONEncoder().encode(games)) ?? Data()
|
||||
self.teamsData = (try? JSONEncoder().encode(teams)) ?? Data()
|
||||
self.stadiumsData = (try? JSONEncoder().encode(stadiums)) ?? Data()
|
||||
do {
|
||||
self.gamesData = try JSONEncoder().encode(games)
|
||||
} catch {
|
||||
logger.error("Failed to encode cached games: \(error.localizedDescription)")
|
||||
self.gamesData = Data()
|
||||
}
|
||||
do {
|
||||
self.teamsData = try JSONEncoder().encode(teams)
|
||||
} catch {
|
||||
logger.error("Failed to encode cached teams: \(error.localizedDescription)")
|
||||
self.teamsData = Data()
|
||||
}
|
||||
do {
|
||||
self.stadiumsData = try JSONEncoder().encode(stadiums)
|
||||
} catch {
|
||||
logger.error("Failed to encode cached stadiums: \(error.localizedDescription)")
|
||||
self.stadiumsData = Data()
|
||||
}
|
||||
}
|
||||
|
||||
var games: [Game] {
|
||||
(try? JSONDecoder().decode([Game].self, from: gamesData)) ?? []
|
||||
do {
|
||||
return try JSONDecoder().decode([Game].self, from: gamesData)
|
||||
} catch {
|
||||
logger.error("Failed to decode cached games: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
var teams: [Team] {
|
||||
(try? JSONDecoder().decode([Team].self, from: teamsData)) ?? []
|
||||
do {
|
||||
return try JSONDecoder().decode([Team].self, from: teamsData)
|
||||
} catch {
|
||||
logger.error("Failed to decode cached teams: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
var stadiums: [Stadium] {
|
||||
(try? JSONDecoder().decode([Stadium].self, from: stadiumsData)) ?? []
|
||||
do {
|
||||
return try JSONDecoder().decode([Stadium].self, from: stadiumsData)
|
||||
} catch {
|
||||
logger.error("Failed to decode cached stadiums: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
var isStale: Bool {
|
||||
|
||||
@@ -351,11 +351,15 @@ final class CachedGameScore {
|
||||
return Date() > expiresAt
|
||||
}
|
||||
|
||||
private static let cacheKeyDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Generate cache key for a game query
|
||||
static func generateKey(sport: Sport, date: Date, homeAbbrev: String, awayAbbrev: String) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let dateString = dateFormatter.string(from: date)
|
||||
let dateString = cacheKeyDateFormatter.string(from: date)
|
||||
return "\(sport.rawValue)_\(dateString)_\(homeAbbrev)_\(awayAbbrev)"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user