From 576d8eb5944b8bfa8392428d022ba8ad47b68b48 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Tue, 8 Sep 2026 00:28:10 -0700 Subject: [PATCH] Download the airport database instead of building it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is what the rest of the work was for. The app asks for the store that was built for this cycle, expands it into a new generation, and switches to it — so the minutes it spent inserting a decoded property list into SwiftData become a transfer and a decompression. Cycles are asked for by name and walked backwards, one at a time. A manifest states the window it is effective for, which a mutable "latest" pointer could not, and a cycle whose publish failed therefore costs the pilot the previous cycle's data rather than four weeks with no update at all. The published schema fingerprint is checked before anything is downloaded, let alone opened. SwiftData answers a near-miss by migrating the store rather than refusing it, so a build that opened first would get a silent, slow migration where a clean fall back to importing was wanted. The download is checked against the digest its manifest published, and the expanded store still has to open and hold airports before anything switches to it. The OurAirports processing date comes across in the manifest, so About reports it whichever path installed the data. Nothing in the store file records when its non-FAA airports were gathered, and a store that arrived prebuilt would otherwise report none. The consent screen no longer claims the wait is a few minutes. It is seconds when the prepared store is there, and only the property-list fallback behind it still takes minutes. Every step may fail and none of them is fatal: a cycle nobody published, a store built for a different schema, a truncated transfer — each falls back to importing the property list, which is still published and still works. That is what keeps this an optimization rather than a dependency, and it is why the import path was kept rather than replaced. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01471376Uhug2TYZN8RDFGtq --- .../NavDataLoaderViewModel.swift | 64 ++++++- .../NavDataLoader/PrebuiltNavDataStore.swift | 181 ++++++++++++++++++ SF50 TOLD/Localizable.xcstrings | 2 +- .../Views/Loading/LoadingConsentView.swift | 2 +- 4 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 SF50 TOLD/Loaders/NavDataLoader/PrebuiltNavDataStore.swift diff --git a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift index c2900e9..4d7b8c9 100644 --- a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift +++ b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift @@ -1,5 +1,6 @@ import BackgroundTasks import Defaults +import os import Observation import SF50_Shared import Sentry @@ -40,6 +41,11 @@ final class NavDataLoaderViewModel: WithIdentifiableError { private(set) var networkIsExpensive = false private(set) var deferred = false + private let logger = Logger( + subsystem: "codes.tim.SF50-TOLD", + category: "NavDataLoaderViewModel" + ) + private let container: ModelContainer private let installer = NavDataStoreInstaller(layout: .appGroup) private var cancellables: Set> = [] @@ -161,7 +167,6 @@ final class NavDataLoaderViewModel: WithIdentifiableError { private func runLoad() async { let generation = installer.reserveGeneration() - guard let loader = await makeLoader(generation: generation) else { return } // Ask the system to let this keep running if the pilot leaves the app. Safe now that an import // writes a generation nothing reads: a task the system cancels costs a file, not a database. @@ -172,11 +177,66 @@ final class NavDataLoaderViewModel: WithIdentifiableError { backgroundTask?.expirationHandler = { [weak self] in MainActor.assumeIsolated { self?.cancelLoad() } } + defer { backgroundTask?.setTaskCompleted(success: error == nil) } + + // A store built ahead of time turns minutes of assembling the database into a download. It is + // an optimization, not a dependency: anything that goes wrong falls back to importing the + // property list, which is still published and still works. + if await installPrebuiltStore(generation: generation, reportingTo: backgroundTask) { return } + + await importPropertyList(generation: generation, reportingTo: backgroundTask) + } + + /// Downloads and installs a store that was built ahead of time. + /// + /// - Returns: Whether the app is now running on a prebuilt store. + private func installPrebuiltStore( + generation: Int, + reportingTo backgroundTask: BGContinuedProcessingTask? + ) async -> Bool { + let (updates, continuation) = AsyncStream.makeStream( + of: NavDataLoader.State.self, + bufferingPolicy: .bufferingNewest(1) + ) + backgroundTask?.progress.totalUnitCount = Self.progressUnits + let mirror = Task { [weak self] in + for await update in updates where !Task.isCancelled { + guard let self else { return } + state = update + report(update, to: backgroundTask) + } + } + defer { mirror.cancel() } + + do { + let manifest = try await PrebuiltNavDataStore().download( + to: StoreLayout.appGroup.navStoreURL(generation: generation), + reportingTo: continuation + ) + continuation.finish() + try install(generation: generation) + Defaults[.ourAirportsLastUpdated] = manifest.ourAirportsLastUpdated + Defaults[.schemaVersion] = latestSchemaVersion + state = .finished + logger.notice("Installed the prebuilt store for cycle \(manifest.cycle, privacy: .public)") + return true + } catch { + continuation.finish() + logger.notice("Importing instead of using a prebuilt store: \(error.localizedDescription)") + StoreLayout.removeStore(at: StoreLayout.appGroup.navStoreURL(generation: generation)) + return false + } + } + + private func importPropertyList( + generation: Int, + reportingTo backgroundTask: BGContinuedProcessingTask? + ) async { + guard let loader = await makeLoader(generation: generation) else { return } let progressTask = await observeProgress(of: loader, reportingTo: backgroundTask) addTask(progressTask) await performLoad(with: loader, generation: generation, progressTask: progressTask) - backgroundTask?.setTaskCompleted(success: error == nil) } /// Abandons an import the system has asked to stop. diff --git a/SF50 TOLD/Loaders/NavDataLoader/PrebuiltNavDataStore.swift b/SF50 TOLD/Loaders/NavDataLoader/PrebuiltNavDataStore.swift new file mode 100644 index 0000000..0ed6e1b --- /dev/null +++ b/SF50 TOLD/Loaders/NavDataLoader/PrebuiltNavDataStore.swift @@ -0,0 +1,181 @@ +import CryptoKit +import Foundation +import SF50_Shared +import SwiftNASR +import os + +/// Fetches a nav-data store that was built ahead of time, instead of assembling one on the device. +/// +/// The app used to spend minutes inserting a decoded property list into SwiftData before it could be +/// used at all. The same store is now built on a Mac once per cycle and published, so this downloads +/// and expands it — the work that remains is a transfer and a decompression. +/// +/// Every step here is allowed to fail. A cycle that was never published, a store built against a +/// different schema, a truncated download: each falls back to importing the property list, which is +/// still published and still works. That is what makes this an optimization rather than a dependency. +actor PrebuiltNavDataStore { + /// Where published stores are served from. + /// + /// The same bucket the terrain payloads use. + private static let manifestURLTemplate = + "https://pub-becd30c7b4e24860bee04cbbab788fb3.r2.dev/navdata/%@.json" + + /// How many cycles back to look before giving up and importing instead. + /// + /// One is enough to cover a publish that failed on the day a cycle took effect; more than that and + /// the data would be old enough that importing the current cycle is the better answer. + private static let cyclesToWalkBack = 2 + + private let logger = Logger( + subsystem: "codes.tim.SF50-TOLD", + category: "PrebuiltNavDataStore" + ) + + nonisolated private static func fetch( + from url: URL, + logger: Logger, + reportingTo continuation: AsyncStream.Continuation + ) async throws -> URL { + defer { continuation.finish() } + let (fileURL, response) = try await downloadWithRetry( + from: url, + configuration: .ephemeral, + logger: logger, + label: "prebuilt nav data", + reportingTo: continuation + ) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + try? FileManager.default.removeItem(at: fileURL) + throw Errors.noPublishedCycle + } + return fileURL + } + + /// Hashes the downloaded payload, off this actor. + @concurrent + nonisolated private static func digest(of payload: URL) async throws -> String { + let data = try Data(contentsOf: payload, options: .mappedIfSafe) + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + /// Expands the compressed store, off this actor. + @concurrent + nonisolated private static func expand(_ payload: URL, to destination: URL) async throws { + let compressed = try Data(contentsOf: payload, options: .mappedIfSafe) + // swiftlint:disable:next legacy_objc_type + let store = try (compressed as NSData).decompressed(using: .lzma) + try (store as Data).write(to: destination, options: .atomic) + } + + /// Downloads the newest published store this build can read, and expands it to `destination`. + /// + /// Cycles are asked for by name and walked backwards, because a manifest states the window it is + /// effective for and a mutable "latest" pointer could not. A cycle whose publish failed therefore + /// costs the pilot the previous cycle's data rather than four weeks of no update at all. + /// + /// - Parameters: + /// - destination: Where to write the expanded store. + /// - continuation: Yielded progress as the transfer and expansion proceed. + /// - Returns: The manifest of the cycle installed. + /// - Throws: ``Errors`` if nothing publishable could be found or read. + func download( + to destination: URL, + reportingTo continuation: AsyncStream.Continuation + ) async throws -> NavDataStoreManifest { + let manifest = try await newestReadableManifest() + + continuation.yield(.downloading(progress: 0)) + let payload = try await fetchStore(described: manifest, reportingTo: continuation) + defer { try? FileManager.default.removeItem(at: payload) } + + continuation.yield(.extracting(progress: nil)) + try await Self.expand(payload, to: destination) + + return manifest + } + + /// The newest published cycle whose store this build's schema can read. + /// + /// Checked before anything is downloaded, let alone opened: SwiftData answers a near-miss schema + /// by migrating rather than by refusing, so a store opened first would be silently, slowly + /// migrated where a clean fall back to the import path was wanted. + private func newestReadableManifest() async throws -> NavDataStoreManifest { + var cycle: SwiftNASR.Cycle? = .effective + for _ in 0.. NavDataStoreManifest? { + guard let url = URL(string: String(format: Self.manifestURLTemplate, cycle)) else { return nil } + do { + let (data, response) = try await URLSession(configuration: .ephemeral).data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + logger.info("No published store for cycle \(cycle, privacy: .public)") + return nil + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(NavDataStoreManifest.self, from: data) + } catch { + logger.notice("Couldn’t read the manifest for \(cycle, privacy: .public): \(error)") + return nil + } + } + + /// Downloads the store a manifest describes, and checks it against the digest published for it. + private func fetchStore( + described manifest: NavDataStoreManifest, + reportingTo continuation: AsyncStream.Continuation + ) async throws -> URL { + let base = URL(string: String(format: Self.manifestURLTemplate, manifest.cycle))! + .deletingLastPathComponent() + let url = base.appending(path: manifest.store.filename) + + let (progressUpdates, progress) = AsyncStream.makeStream( + of: Float.self, + bufferingPolicy: .bufferingNewest(1) + ) + async let downloaded = Self.fetch(from: url, logger: logger, reportingTo: progress) + for await completed in progressUpdates { continuation.yield(.downloading(progress: completed)) } + + let payload = try await downloaded + guard try await Self.digest(of: payload) == manifest.store.sha256 else { + try? FileManager.default.removeItem(at: payload) + throw Errors.digestMismatch + } + return payload + } + + /// Reasons a prebuilt store could not be used. + enum Errors: Swift.Error { + /// No cycle in range had a manifest this build could read. + case noPublishedCycle + + /// The published store was built for a different shape of store than this build reads. + case schemaMismatch(published: String, expected: String) + + /// The download did not match the digest its manifest published. + case digestMismatch + } +} diff --git a/SF50 TOLD/Localizable.xcstrings b/SF50 TOLD/Localizable.xcstrings index 7ca77b6..1495f77 100644 --- a/SF50 TOLD/Localizable.xcstrings +++ b/SF50 TOLD/Localizable.xcstrings @@ -1722,7 +1722,7 @@ "comment" : "A phrase that indicates that the requested operation is not", "isCommentAutoGenerated" : true }, - "This process usually takes a few minutes. It must be done the first time the app launches, and approximately once a month as new navigation data is released. You can switch away while it runs; %@ shows its progress and keeps it going." : { + "This must be done the first time the app launches, and approximately once a month as new navigation data is released. You can switch away while it runs; %@ shows its progress and keeps it going." : { }, "Time Zone Display" : { diff --git a/SF50 TOLD/Views/Loading/LoadingConsentView.swift b/SF50 TOLD/Views/Loading/LoadingConsentView.swift index 3de7b9c..13168e4 100644 --- a/SF50 TOLD/Views/Loading/LoadingConsentView.swift +++ b/SF50 TOLD/Views/Loading/LoadingConsentView.swift @@ -24,7 +24,7 @@ struct LoadingConsentView: View { .multilineTextAlignment(.center) Text( - "This process usually takes a few minutes. It must be done the first time the app launches, and approximately once a month as new navigation data is released. You can switch away while it runs; \(localizedModel()) shows its progress and keeps it going." + "This must be done the first time the app launches, and approximately once a month as new navigation data is released. You can switch away while it runs; \(localizedModel()) shows its progress and keeps it going." ) .font(.footnote) .padding(.horizontal, 20)