Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions SF50 Shared/Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ public let latestSchemaVersion = 8
extension TerrainRegion: Defaults.Serializable {}

extension Defaults.Keys {
/// Which generation of the nav-data store the app is reading.
///
/// An import writes the next generation to its own file and this is switched to it only once
/// that file has been opened and found sound. Recording a number is the whole of the swap: no
/// store is ever overwritten, so an import that dies part-way leaves a file nobody points at
/// rather than a dataset half-replaced.
///
/// Lives in the group suite because the widget and the App Intents surfaces open the same store.
public static let activeNavDataGeneration = Key<Int>(
"SF50/3/activeNavDataGeneration",
default: 0,
suite: groupDefaults
)

/// Terrain regions the pilot has asked for, whether or not a payload is on disk right now.
///
/// Asset packs are purgeable: the system reclaims one under storage pressure without telling
Expand Down
163 changes: 110 additions & 53 deletions SF50 Shared/Store/AppStore.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
public import Foundation

Check warning on line 1 in SF50 Shared/Store/AppStore.swift

View workflow job for this annotation

GitHub Actions / Build Documentation

public import of 'Foundation' was not used in public declarations or inlinable code

Check warning on line 1 in SF50 Shared/Store/AppStore.swift

View workflow job for this annotation

GitHub Actions / Build Documentation

public import of 'Foundation' was not used in public declarations or inlinable code

Check warning on line 1 in SF50 Shared/Store/AppStore.swift

View workflow job for this annotation

GitHub Actions / ci / Build (iOS latest)

public import of 'Foundation' was not used in public declarations or inlinable code
import os
public import SwiftData

/// The app's two persistent stores, shared with its extensions through the app group.
import Defaults
import os

/// The app's stores, shared with its extensions through the app group.
///
/// Nav data is opened read-only. It is a downloaded artifact replaced whole every cycle, and a
/// store the app cannot write is a store the app cannot leave half-written — which is the failure
/// this separation exists to remove. What the pilot authored lives in its own writable store that
/// no cycle touches.
///
/// Both configurations are named. An unnamed second configuration was historically the difference
/// between a container that opened both stores and one that silently opened only the first.
/// Every store is opened through the same pair of configurations, and both are named. SwiftData
/// records the whole container's schema in each store it opens, so a store written by a container
/// of a different shape reads back as one needing migration — and a store opened read-only cannot
/// be migrated. An unnamed second configuration was also historically the difference between a
/// container that opened both stores and one that silently opened only the first.
public enum AppStore {
private static let navConfigurationName = "navData"
private static let userConfigurationName = "userData"
Expand All @@ -20,78 +25,131 @@
category: "AppStore"
)

/// The container backing both shared stores.
public static let shared: ModelContainer = {
let layout = StoreLayout.appGroup
do {
return try makeContainer(layout: layout)
} catch {
// Nav data is a downloaded file, so a corrupt one must not be fatal: discard it and open an
// empty store, which the app reads as "no data" and offers to download again.
logger.error("Discarding an unopenable nav-data store: \(error.localizedDescription)")
StoreLayout.removeStore(at: layout.navStoreURL)
do { return try makeContainer(layout: layout) } catch {
fatalError("Couldn’t open the model container: \(error)")
}
private static let lock = OSAllocatedUnfairLock<ModelContainer?>(initialState: nil)
private static let hasSweptStaleGenerations = OSAllocatedUnfairLock(initialState: false)

/// The container backing the app's stores.
///
/// Rebuilt by ``reopen()`` when a newly imported generation is switched to, so a running app
/// picks up a new dataset without being relaunched.
public static var shared: ModelContainer {
lock.withLock { container in
if let container { return container }
let opened = openActiveGeneration()
container = opened
return opened
}
}()
}

/// Opens both stores as the layout arranges them.
/// Opens both stores, reading the nav-data store of `generation`.
///
/// - Parameter layout: Where the stores live.
/// - Parameters:
/// - layout: Where the stores live.
/// - generation: Which generation of nav data to read.
/// - Returns: A container holding a read-only nav store and a writable user store.
public static func makeContainer(layout: StoreLayout) throws -> ModelContainer {
public static func makeContainer(layout: StoreLayout, generation: Int) throws -> ModelContainer {
try layout.createDirectories()
try LegacyStoreMigration(layout: layout).migrateIfNeeded()
try bootstrapIfAbsent(layout: layout)
return try open(layout: layout, navAllowsSave: false)
try bootstrapIfAbsent(layout: layout, generation: generation)
return try open(layout: layout, generation: generation, navAllowsSave: false)
}

private static func open(layout: StoreLayout, navAllowsSave: Bool) throws -> ModelContainer {
/// Opens both stores with a nav-data generation writable, for an importer.
///
/// The importer writes through its own container so its bulk transactions queue on their own
/// coordinator, leaving the store the rest of the app reads read-only — and it writes a
/// generation nothing is reading yet, so an import that fails costs nothing.
///
/// - Parameters:
/// - layout: Where the stores live.
/// - generation: The generation to write.
/// - Returns: A container whose nav-data store accepts writes.
public static func makeWritableContainer(
layout: StoreLayout,
generation: Int
) throws -> ModelContainer {
try layout.createDirectories()
return try open(layout: layout, generation: generation, navAllowsSave: true)
}

/// Opens throwaway stores held only in memory, for tests, previews and screenshot runs.
///
/// Nav data is writable here: seeding a test or a preview means inserting airports.
public static func makeInMemoryContainer() throws -> ModelContainer {
let navData = ModelConfiguration(
navConfigurationName,
schema: NavDataSchema.schema,
url: layout.navStoreURL,
allowsSave: navAllowsSave
isStoredInMemoryOnly: true
)
let userData = ModelConfiguration(
userConfigurationName,
schema: UserDataSchema.schema,
url: layout.userStoreURL
isStoredInMemoryOnly: true
)
return try ModelContainer(for: AppSchema.schema, configurations: navData, userData)
}

/// Opens both stores with the nav-data store writable, for an importer.
///
/// The importer writes through its own container so its bulk transactions queue on their own
/// coordinator, leaving the store the rest of the app reads read-only.
///
/// It opens the same pair of configurations rather than the nav store alone. SwiftData records
/// the whole container's schema in every store it opens, so a nav store written by a container
/// of a different shape reads back as one needing migration — and a store opened read-only
/// cannot be migrated.
/// Rebuilds ``shared`` against whichever generation is now current.
///
/// - Parameter layout: Where the stores live, usually with the nav store addressed elsewhere.
/// - Returns: A container whose nav-data store accepts writes.
public static func makeWritableContainer(layout: StoreLayout) throws -> ModelContainer {
try layout.createDirectories()
return try open(layout: layout, navAllowsSave: true)
/// The container holds an open SQLite handle, so a generation is only ever switched to by
/// opening the new file — never by replacing the old one underneath a reader.
public static func reopen() {
lock.withLock { container in
container = nil
container = openActiveGeneration()
}
}

/// Opens throwaway stores held only in memory, for tests, previews and screenshot runs.
private static func openActiveGeneration() -> ModelContainer {
let layout = StoreLayout.appGroup,
generation = Defaults[.activeNavDataGeneration]
// Swept however this process first got a container, including by discarding a bad one: a sweep
// that had not happened yet would happen on the next reopen instead, under a container that may
// still be reading what it reclaims.
defer { sweepStaleGenerationsOnce(layout: layout, keeping: generation) }
do {
return try makeContainer(layout: layout, generation: generation)
} catch {
// Nav data is a downloaded file, so a corrupt one must not be fatal: discard it and open an
// empty store, which the app reads as "no data" and offers to download again.
logger.error("Discarding an unopenable nav-data store: \(error.localizedDescription)")
StoreLayout.removeStore(at: layout.navStoreURL(generation: generation))
do { return try makeContainer(layout: layout, generation: generation) } catch {
fatalError("Couldn’t open the model container: \(error)")
}
}
}

/// Reclaims superseded generations, but only before this process has opened one.
///
/// Nav data is writable here: seeding a test or a preview means inserting airports.
public static func makeInMemoryContainer() throws -> ModelContainer {
/// A generation is reclaimed at launch and never afterwards. Reopening onto a newer generation
/// leaves the previous file alone, because the container that was reading it may still be alive —
/// deleting it would leave that reader on a file that no longer exists, which is exactly what
/// numbering generations avoids.
private static func sweepStaleGenerationsOnce(layout: StoreLayout, keeping generation: Int) {
let shouldSweep = hasSweptStaleGenerations.withLock { hasSwept in
defer { hasSwept = true }
return !hasSwept
}
guard shouldSweep else { return }
layout.removeNavStores(exceptGeneration: generation)
}

private static func open(
layout: StoreLayout,
generation: Int,
navAllowsSave: Bool
) throws -> ModelContainer {
let navData = ModelConfiguration(
navConfigurationName,
schema: NavDataSchema.schema,
isStoredInMemoryOnly: true
url: layout.navStoreURL(generation: generation),
allowsSave: navAllowsSave
)
let userData = ModelConfiguration(
userConfigurationName,
schema: UserDataSchema.schema,
isStoredInMemoryOnly: true
url: layout.userStoreURL
)
return try ModelContainer(for: AppSchema.schema, configurations: navData, userData)
}
Expand All @@ -102,12 +160,11 @@
/// by this binary matches this binary's schema by construction — which is also why no store needs
/// to ship inside the app.
///
/// It is created through the same pair of configurations that will read it. SwiftData records the
/// whole container's schema in each store it opens, so a store stamped by a container of a
/// different shape reads back as one needing migration — and migrating a store opened read-only
/// fails outright.
private static func bootstrapIfAbsent(layout: StoreLayout) throws {
guard !FileManager.default.fileExists(atPath: layout.navStoreURL.path) else { return }
_ = try open(layout: layout, navAllowsSave: true)
/// It is created through the same pair of configurations that will read it, for the reason given
/// on the type.
private static func bootstrapIfAbsent(layout: StoreLayout, generation: Int) throws {
let url = layout.navStoreURL(generation: generation)
guard !FileManager.default.fileExists(atPath: url.path) else { return }
_ = try open(layout: layout, generation: generation, navAllowsSave: true)
}
}
86 changes: 86 additions & 0 deletions SF50 Shared/Store/NavDataStoreInstaller.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
public import Foundation

import Defaults
import SwiftData
import os

/// Switches the app to a newly written generation of the nav-data store.
///
/// Installing is a single `Defaults` write, and it happens only after the candidate has been opened
/// and found to hold a dataset. Nothing is overwritten and nothing is deleted, so an import that
/// fails — or is killed mid-flight when the pilot swipes the app away — leaves the dataset in use
/// exactly as it was.
public struct NavDataStoreInstaller: Sendable {
private static let logger = Logger(
subsystem: "codes.tim.SF50-TOLD",
category: "NavDataStoreInstaller"
)

private let layout: StoreLayout

/// The generation currently in use.
public var activeGeneration: Int { Defaults[.activeNavDataGeneration] }

/// Creates an installer for the stores `layout` arranges.
///
/// - Parameter layout: Where the stores live.
public init(layout: StoreLayout) {
self.layout = layout
}

/// A generation number no store is using, for an import to write.
///
/// Numbers rise rather than alternate, so a generation an extension still has open is never
/// reused underneath it.
public func reserveGeneration() -> Int {
let next = max(activeGeneration, layout.navStoreGenerations().max() ?? 0) + 1
StoreLayout.removeStore(at: layout.navStoreURL(generation: next))
return next
}

/// Switches to `generation`, if the store it names holds a usable dataset.
///
/// - Parameter generation: The generation an import has just written.
/// - Throws: ``Errors/storeIsEmpty`` if the candidate holds no airports, or the error SwiftData
/// raised trying to open it.
public func install(generation: Int) throws {
try validate(generation: generation)
Defaults[.activeNavDataGeneration] = generation
Self.logger.notice("Switched to nav-data generation \(generation, privacy: .public)")
}

/// Opens a candidate generation and confirms it holds a dataset.
///
/// Opening it here, through the same configurations the app uses, is what turns a store this
/// binary cannot read into a failed install rather than a broken launch.
///
/// - Parameter generation: The generation to check.
private func validate(generation: Int) throws {
let container = try AppStore.makeContainer(layout: layout, generation: generation)
let context = ModelContext(container)
guard try context.fetchCount(FetchDescriptor<Airport>()) > 0 else {
throw Errors.storeIsEmpty
}
}

/// Reasons a candidate store was refused.
public enum Errors: Swift.Error, LocalizedError {
/// The store held no airports.
case storeIsEmpty

public var errorDescription: String? {
String(localized: "Couldn’t use the navigation data that was downloaded.")
}

public var failureReason: String? {
switch self {
case .storeIsEmpty:
String(localized: "The downloaded database contained no airports.")
}
}

public var recoverySuggestion: String? {
String(localized: "Try downloading the navigation data again.")
}
}
}
Loading
Loading