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:
Trey t
2026-02-27 17:03:09 -06:00
parent e046cb6b34
commit c94e373e33
82 changed files with 1163 additions and 599 deletions

View File

@@ -7,6 +7,9 @@
import Foundation
import CloudKit
import os
private let logger = Logger(subsystem: "com.88oakapps.SportsTime", category: "CKModels")
// MARK: - Record Type Constants
@@ -201,13 +204,18 @@ nonisolated struct CKStadium {
let sport = Sport(rawValue: sportRaw.uppercased()) ?? .mlb
let timezoneIdentifier = (record[CKStadium.timezoneIdentifierKey] as? String)?.ckTrimmed
guard let location else {
logger.warning("Missing location coordinates for stadium '\(id)' — skipping")
return nil
}
return Stadium(
id: id,
name: name,
city: city,
state: state,
latitude: location?.coordinate.latitude ?? 0,
longitude: location?.coordinate.longitude ?? 0,
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
capacity: capacity,
sport: sport,
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
@@ -622,8 +630,11 @@ nonisolated struct CKTripPoll {
record[CKTripPoll.ownerIdKey] = poll.ownerId
record[CKTripPoll.shareCodeKey] = poll.shareCode
// Encode trips as JSON data
if let tripsData = try? JSONEncoder().encode(poll.tripSnapshots) {
do {
let tripsData = try JSONEncoder().encode(poll.tripSnapshots)
record[CKTripPoll.tripSnapshotsKey] = tripsData
} catch {
logger.error("Failed to encode tripSnapshots for poll \(poll.id.uuidString): \(error.localizedDescription)")
}
record[CKTripPoll.tripVersionsKey] = poll.tripVersions
record[CKTripPoll.createdAtKey] = poll.createdAt
@@ -730,7 +741,7 @@ nonisolated struct CKPollVote {
let record = CKRecord(recordType: CKRecordType.pollVote, recordID: CKRecord.ID(recordName: vote.id.uuidString))
record[CKPollVote.voteIdKey] = vote.id.uuidString
record[CKPollVote.pollIdKey] = vote.pollId.uuidString
record[CKPollVote.voterIdKey] = vote.odg
record[CKPollVote.voterIdKey] = vote.voterId
record[CKPollVote.rankingsKey] = vote.rankings
record[CKPollVote.votedAtKey] = vote.votedAt
record[CKPollVote.modifiedAtKey] = vote.modifiedAt
@@ -751,7 +762,7 @@ nonisolated struct CKPollVote {
return PollVote(
id: voteId,
pollId: pollId,
odg: voterId,
voterId: voterId,
rankings: rankings,
votedAt: votedAt,
modifiedAt: modifiedAt

View File

@@ -631,11 +631,22 @@ enum AchievementRegistry {
)
]
// MARK: - Lookup Dictionary
private static let definitionsById: [String: AchievementDefinition] = {
Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
}()
/// O(1) lookup by typeId
static func definition(for typeId: String) -> AchievementDefinition? {
definitionsById[typeId]
}
// MARK: - Lookup Methods
/// Get achievement by ID
static func achievement(byId id: String) -> AchievementDefinition? {
all.first { $0.id == id }
definitionsById[id]
}
/// Get achievements by category

View File

@@ -81,7 +81,7 @@ struct Game: Identifiable, Codable, Hashable {
}
var gameDate: Date {
Calendar.current.startOfDay(for: dateTime)
dateTime.startOfDay(in: TimeZone(identifier: "UTC"))
}
/// Alias for TripPlanningEngine compatibility

View File

@@ -137,6 +137,18 @@ struct VisitSummary: Identifiable {
let photoCount: Int
let notes: String?
private static let mediumDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
return f
}()
private static let shortDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM d, yyyy"
return f
}()
/// Combined matchup for backwards compatibility
var matchup: String? {
guard let home = homeTeamName, let away = awayTeamName else { return nil }
@@ -144,15 +156,11 @@ struct VisitSummary: Identifiable {
}
var dateDescription: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: visitDate)
Self.mediumDateFormatter.string(from: visitDate)
}
var shortDateDescription: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
return formatter.string(from: visitDate)
Self.shortDateFormatter.string(from: visitDate)
}
}

View File

@@ -95,4 +95,8 @@ extension Stadium: Equatable {
static func == (lhs: Stadium, rhs: Stadium) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@@ -64,4 +64,8 @@ extension Team: Equatable {
static func == (lhs: Team, rhs: Team) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@@ -96,10 +96,14 @@ struct Trip: Identifiable, Codable, Hashable {
var startDate: Date { stops.first?.arrivalDate ?? preferences.startDate }
var endDate: Date { stops.last?.departureDate ?? preferences.endDate }
private static let dateRangeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM d, yyyy"
return f
}()
var formattedDateRange: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
"\(Self.dateRangeFormatter.string(from: startDate)) - \(Self.dateRangeFormatter.string(from: endDate))"
}
var formattedTotalDistance: String { String(format: "%.0f miles", totalDistanceMiles) }
@@ -191,10 +195,14 @@ struct ItineraryDay: Identifiable, Hashable {
let stops: [TripStop]
let travelSegments: [TravelSegment]
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEE, MMM d"
return f
}()
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: date)
Self.dayFormatter.string(from: date)
}
var isRestDay: Bool { stops.first?.isRestDay ?? false }

View File

@@ -63,12 +63,17 @@ struct TripPoll: Identifiable, Codable, Hashable {
// MARK: - Trip Hash
static func computeTripHash(_ trip: Trip) -> String {
var hasher = Hasher()
hasher.combine(trip.stops.map { $0.city })
hasher.combine(trip.stops.flatMap { $0.games })
hasher.combine(trip.preferences.startDate)
hasher.combine(trip.preferences.endDate)
return String(hasher.finalize())
let cities = trip.stops.map { $0.city }.joined(separator: "|")
let games = trip.stops.flatMap { $0.games }.joined(separator: "|")
let start = String(trip.preferences.startDate.timeIntervalSince1970)
let end = String(trip.preferences.endDate.timeIntervalSince1970)
let input = "\(cities);\(games);\(start);\(end)"
// Simple deterministic hash: DJB2
var hash: UInt64 = 5381
for byte in input.utf8 {
hash = hash &* 33 &+ UInt64(byte)
}
return String(hash, radix: 16)
}
// MARK: - Deep Link URL
@@ -87,22 +92,53 @@ struct TripPoll: Identifiable, Codable, Hashable {
struct PollVote: Identifiable, Codable, Hashable {
let id: UUID
let pollId: UUID
let odg: String // voter's userRecordID
let voterId: String // voter's userRecordID
var rankings: [Int] // trip indices in preference order
let votedAt: Date
var modifiedAt: Date
/// Backward-compatible decoding: accepts both "voterId" and legacy "odg" keys
enum CodingKeys: String, CodingKey {
case id, pollId, voterId, rankings, votedAt, modifiedAt
case odg // legacy key
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
pollId = try container.decode(UUID.self, forKey: .pollId)
// Decode from "voterId" first, falling back to legacy "odg"
if let vid = try container.decodeIfPresent(String.self, forKey: .voterId) {
voterId = vid
} else {
voterId = try container.decode(String.self, forKey: .odg)
}
rankings = try container.decode([Int].self, forKey: .rankings)
votedAt = try container.decode(Date.self, forKey: .votedAt)
modifiedAt = try container.decode(Date.self, forKey: .modifiedAt)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(pollId, forKey: .pollId)
try container.encode(voterId, forKey: .voterId)
try container.encode(rankings, forKey: .rankings)
try container.encode(votedAt, forKey: .votedAt)
try container.encode(modifiedAt, forKey: .modifiedAt)
}
init(
id: UUID = UUID(),
pollId: UUID,
odg: String,
voterId: String,
rankings: [Int],
votedAt: Date = Date(),
modifiedAt: Date = Date()
) {
self.id = id
self.pollId = pollId
self.odg = odg
self.voterId = voterId
self.rankings = rankings
self.votedAt = votedAt
self.modifiedAt = modifiedAt

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)"
}
}