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)