diff --git a/Yattee/AppDelegate.swift b/Yattee/AppDelegate.swift index d8563f32..eefb141a 100644 --- a/Yattee/AppDelegate.swift +++ b/Yattee/AppDelegate.swift @@ -16,6 +16,39 @@ class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // Open the SwiftData stores (TipKit's and the app's) here rather than in + // `YatteeApp.init()`, which also runs during prewarm launches that iOS + // suspends before `UIApplicationMain` (0xdead10cc if an SQLite lock is + // held). The assertion keeps a background launch running until the + // opens, including any WAL recovery, have finished and released locks. + DataManager.didFinishLaunching = true + var storeTask: UIBackgroundTaskIdentifier = .invalid + storeTask = application.beginBackgroundTask(withName: "OpenDataStore") { + LoggingService.shared.warning("Background task OpenDataStore expired", category: .diagnostics) + if storeTask != .invalid { + application.endBackgroundTask(storeTask) + storeTask = .invalid + } + } + LoggingService.shared.info( + "didFinishLaunching", + category: .diagnostics, + details: "applicationState: \(application.applicationState.rawValue), " + + "prewarm: \(ProcessInfo.processInfo.environment["ActivePrewarm"] == "1"), " + + "launchOptions: \(launchOptions?.keys.map(\.rawValue).sorted() ?? []), " + // `.invalid` means iOS granted no background time. The stores + // are opened anyway — deferring would only move the same + // unprotected open to the first caller (e.g. a silent-push + // sync) — but the log makes that launch identifiable. + + "assertion: \(storeTask != .invalid)" + ) + YatteeApp.configureTips() + AppEnvironment.shared.dataManager.openStore(trigger: "didFinishLaunching") + if storeTask != .invalid { + application.endBackgroundTask(storeTask) + storeTask = .invalid + } + // Required for CKSyncEngine to receive silent push notifications for remote changes. // The remote-notification background mode is already in Info.plist. LoggingService.shared.logCloudKit("Requesting remote notification registration...") diff --git a/Yattee/Data/DataManager.swift b/Yattee/Data/DataManager.swift index 9fb8e686..0280ce56 100644 --- a/Yattee/Data/DataManager.swift +++ b/Yattee/Data/DataManager.swift @@ -14,9 +14,28 @@ import SwiftData final class DataManager { // MARK: - Properties - let modelContainer: ModelContainer - let modelContext: ModelContext - + /// The SwiftData container, opened on first access. + /// + /// The on-disk store lives in the app-group container. Opening it takes an + /// SQLite lock (and runs WAL recovery after an unclean exit), and iOS kills + /// an app suspended while holding such a lock with 0xdead10cc. `DataManager` + /// is built from `YatteeApp.init()`, which also runs during prewarm launches + /// that the system suspends before `UIApplicationMain`, so the store must not + /// be opened there. On iOS `AppDelegate` opens it from + /// `didFinishLaunching` inside a background-task assertion via `openStore`. + var modelContainer: ModelContainer { openedStore().container } + + /// The main-actor context of `modelContainer`, opened on first access. + var modelContext: ModelContext { openedStore().context } + + @ObservationIgnored private var store: (container: ModelContainer, context: ModelContext)? + @ObservationIgnored private let isInMemory: Bool + @ObservationIgnored private let launchedAt = Date() + + /// Set by `AppDelegate` once `didFinishLaunching` runs, so the open log can + /// tell a deferred open from one that still happened during launch. + static var didFinishLaunching = false + /// Weak reference to settings manager for accessing search history limit. weak var settingsManager: SettingsManager? @@ -34,7 +53,12 @@ final class DataManager { /// to the sync engine — otherwise it only fetches changes made since the /// old tokens and records that did not change since (subscriptions, /// bookmarks) never come back. - private(set) var storeWasCreated: Bool = false + var storeWasCreated: Bool { + _ = openedStore() + return storeWasCreatedValue + } + + @ObservationIgnored private var storeWasCreatedValue = false /// Cached set of bookmarked video IDs for fast O(1) lookup. /// Updated when bookmarks are added/removed locally or via CloudKit sync. @@ -56,49 +80,21 @@ final class DataManager { // MARK: - Initialization init(inMemory: Bool = false, iCloudSyncEnabled _: Bool = false) throws { - let configuration: ModelConfiguration - - // IMPORTANT: We intentionally do NOT use SwiftData's built-in CloudKit sync. - // SwiftData CloudKit sync uses internal UUIDs which causes duplicates when - // the same data is created on multiple devices (each device generates different IDs). - // - // Instead, we use CKSyncEngine directly via CloudKitSyncEngine for iCloud sync. - // This approach: - // 1. Uses business identifiers (channelID, videoID) for deduplication - // 2. Gives explicit control over sync timing and conflict resolution - // 3. Avoids the duplicate data problem inherent in SwiftData CloudKit sync - if inMemory { - configuration = ModelConfiguration( - schema: Self.schema, - isStoredInMemoryOnly: true, - cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync - ) - } else { - configuration = ModelConfiguration( - schema: Self.schema, - isStoredInMemoryOnly: false, - cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync - ) - } - - let storeExisted = inMemory || FileManager.default.fileExists(atPath: configuration.url.path) - self.modelContainer = try ModelContainer( - for: Self.schema, - configurations: [configuration] - ) - self.storeWasCreated = !storeExisted - self.modelContext = modelContainer.mainContext - self.modelContext.autosaveEnabled = true + self.isInMemory = inMemory self.isCloudKitEnabled = false - LoggingService.shared.logCloudKit( - "SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)", - details: inMemory ? "in-memory" : "store: \(configuration.url.path), existed: \(storeExisted)" - ) - - // Initialize bookmark cache - refreshBookmarkCache() - + // In-memory stores hold no file lock, so tests and previews open + // immediately and keep the synchronous behavior they rely on. + // macOS has no suspend-time lock kill and tvOS has no crash evidence, + // so both keep opening at init until there is a reason to change them. + #if os(iOS) + if inMemory { + openStore(trigger: "in-memory init") + } + #else + openStore(trigger: "init") + #endif + // Listen for bookmark changes from CloudKit sync to refresh cache NotificationCenter.default.addObserver( forName: .bookmarksDidChange, @@ -111,7 +107,109 @@ final class DataManager { } } } - + + // MARK: - Store Opening + + /// Opens the store if it is not open yet. Safe to call repeatedly. + /// + /// - Parameter trigger: Who asked, recorded in the open log so a store + /// opened before `didFinishLaunching` can be traced to its caller. + func openStore(trigger: String) { + guard store == nil else { return } + + #if DEBUG && os(iOS) + // Enforces the lifecycle boundary in development, before SQLite takes + // any lock: anything that touches the store from `YatteeApp.init()` / + // `AppEnvironment.init` would reintroduce the pre-UIApplicationMain + // open (0xdead10cc). + if !isInMemory && !Self.didFinishLaunching { + LoggingService.shared.warning( + "SwiftData store opening before didFinishLaunching", + category: .diagnostics, + details: Thread.callStackSymbols.prefix(12).joined(separator: "\n") + ) + assertionFailure("SwiftData store opening before didFinishLaunching (trigger: \(trigger))") + } + #endif + + let start = Date() + let opened = Self.makeStore(inMemory: isInMemory) + store = (opened.container, opened.container.mainContext) + store?.context.autosaveEnabled = true + storeWasCreatedValue = opened.created + + if !isInMemory { + let duration = Date().timeIntervalSince(start) + let sinceLaunch = start.timeIntervalSince(launchedAt) + let prewarm = ProcessInfo.processInfo.environment["ActivePrewarm"] == "1" + LoggingService.shared.info( + "SwiftData store opened", + category: .diagnostics, + details: "trigger: \(trigger), duration: \(String(format: "%.3f", duration))s, " + + "afterInit: \(String(format: "%.3f", sinceLaunch))s, " + + "didFinishLaunching: \(Self.didFinishLaunching), prewarm: \(prewarm), " + + "existed: \(!opened.created), store: \(opened.url?.path ?? "in-memory")" + ) + } + + refreshBookmarkCache() + } + + private func openedStore() -> (container: ModelContainer, context: ModelContext) { + if store == nil { + openStore(trigger: "lazy access") + } + guard let store else { preconditionFailure("openStore always sets the store") } + return store + } + + /// Builds the container, falling back to in-memory storage if the + /// persistent store cannot be opened. + private static func makeStore(inMemory: Bool) -> (container: ModelContainer, created: Bool, url: URL?) { + // IMPORTANT: We intentionally do NOT use SwiftData's built-in CloudKit sync. + // SwiftData CloudKit sync uses internal UUIDs which causes duplicates when + // the same data is created on multiple devices (each device generates different IDs). + // + // Instead, we use CKSyncEngine directly via CloudKitSyncEngine for iCloud sync. + // This approach: + // 1. Uses business identifiers (channelID, videoID) for deduplication + // 2. Gives explicit control over sync timing and conflict resolution + // 3. Avoids the duplicate data problem inherent in SwiftData CloudKit sync + let memoryConfiguration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync + ) + if inMemory { + // swiftlint:disable:next force_try + let container = try! ModelContainer(for: schema, configurations: [memoryConfiguration]) + return (container, false, nil) + } + + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: false, + cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync + ) + let storeExisted = FileManager.default.fileExists(atPath: configuration.url.path) + do { + let container = try ModelContainer(for: schema, configurations: [configuration]) + LoggingService.shared.logCloudKit( + "SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)", + details: "store: \(configuration.url.path), existed: \(storeExisted)" + ) + return (container, !storeExisted, configuration.url) + } catch { + LoggingService.shared.logCloudKitError( + "Persistent store unavailable, falling back to in-memory storage", + error: error + ) + // swiftlint:disable:next force_try + let container = try! ModelContainer(for: schema, configurations: [memoryConfiguration]) + return (container, false, nil) + } + } + /// Refreshes the cached set of bookmarked video IDs. /// Called at init and when bookmarks change via CloudKit sync. private func refreshBookmarkCache() { diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index aabed646..b40379d7 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -46,13 +46,23 @@ struct YatteeApp: App { // Ambiguous-link prompts are stored on NavigationCoordinator so they can // be dual-hosted by the root app view (here) and the expanded player sheet. + #if !os(tvOS) + /// Configures TipKit. Called once per launch. + static func configureTips() { + try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)]) + } + #endif + init() { // Configure Nuke image loading pipeline ImageLoadingService.shared.configure() - #if !os(tvOS) - // TipKit: one-time hints such as the video info "tap thumbnail to play" tip - try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)]) + // TipKit: one-time hints such as the video info "tap thumbnail to play" tip. + // TipKit opens its own SwiftData store, so on iOS it is configured from + // `AppDelegate.didFinishLaunching` together with the app store, never + // here: this init also runs during prewarm launches (0xdead10cc). + #if os(macOS) + Self.configureTips() #endif // Register background tasks early — Apple requires BGTaskScheduler.register()