diff --git a/Sources/App/ViewModels/AppState.swift b/Sources/App/ViewModels/AppState.swift index ffdcf38..8f7124f 100644 --- a/Sources/App/ViewModels/AppState.swift +++ b/Sources/App/ViewModels/AppState.swift @@ -43,7 +43,6 @@ final class AppState: ObservableObject { private let moodleClient = MoodleClient() private var database: Database? - private var databaseSecurityScopedURL: URL? /// On-disk path of the shared database, passed to the bundled MCP helper when /// registering it with Claude so it reads the right App Group container. @@ -127,120 +126,107 @@ final class AppState: ObservableObject { } - private struct SharedDatabaseLocation { - let securityScopedDirectoryURL: URL - let databaseURL: URL - } - private func configureInitialDatabase() throws { if let siteID = userDefaults.string(forKey: Self.currentSiteIDKey) { - // Try to open the shared database directly first. - if let sharedDatabase = try openSharedDatabase(siteID: siteID, seedFrom: nil) { - database = sharedDatabase - return - } - // Shared database needs seeding — bootstrap from the app group database - // so the File Provider picks up existing data on relaunch. - let bootstrapDatabase = try Database() - if let sharedDatabase = try openSharedDatabase(siteID: siteID, seedFrom: bootstrapDatabase) { - database = sharedDatabase - } else { - database = bootstrapDatabase - } + database = try openCanonicalDatabase(siteID: siteID) } else { database = try Database() } } - private func openSharedDatabase(siteID: String, seedFrom sourceDatabase: Database?) throws -> Database? { - guard let location = try sharedDatabaseLocation(siteID: siteID) else { return nil } + /// Opens the canonical database in the App Group container — the single + /// source of truth shared by the app, the File Provider extension, and the + /// MCP helper. + /// + /// Earlier builds kept this database inside the File Provider domain's state + /// directory, which `fileproviderd` deletes on every domain remove/re-add + /// (Sparkle update, session recovery, manual reset). The app then kept + /// writing through the now-unlinked handle, surfacing as + /// "saveItems step failed: disk I/O error". The App Group container is never + /// reclaimed, so the handle stays valid across domain resets. + private func openCanonicalDatabase(siteID: String) throws -> Database { + migrateLegacyStateDirectoryDatabaseIfNeeded(siteID: siteID) + let database = try Database() + userDefaults.set(siteID, forKey: Self.currentSiteIDKey) + return database + } - let didStartAccessing = location.securityScopedDirectoryURL.startAccessingSecurityScopedResource() - var adoptedScope = false - defer { - if didStartAccessing && !adoptedScope { - location.securityScopedDirectoryURL.stopAccessingSecurityScopedResource() - } - } + /// One-time migration for users upgrading from a build that stored the + /// database in the File Provider state directory: copy any surviving per-site + /// data into the App Group container so courses, items, and sync cursors + /// carry over. Safe to skip when the legacy database is already gone (e.g. the + /// domain was reset before this build first ran). + private func migrateLegacyStateDirectoryDatabaseIfNeeded(siteID: String) { + let flagKey = "didMigrateStateDirDB.\(siteID)" + guard !userDefaults.bool(forKey: flagKey) else { return } - if try sharedDatabaseNeedsSeeding(siteID: siteID, databaseURL: location.databaseURL) { - guard let sourceDatabase else { return nil } - try seedSharedDatabase(from: sourceDatabase, siteID: siteID, destinationURL: location.databaseURL) + guard #available(macOS 15.0, *) else { + userDefaults.set(true, forKey: flagKey) + return } - let sharedDatabase = try Database(path: location.databaseURL.path) - databaseSecurityScopedURL?.stopAccessingSecurityScopedResource() - databaseSecurityScopedURL = didStartAccessing ? location.securityScopedDirectoryURL : nil - adoptedScope = true - userDefaults.set(siteID, forKey: Self.currentSiteIDKey) - return sharedDatabase - } - - private func sharedDatabaseLocation(siteID: String) throws -> SharedDatabaseLocation? { let domainID = NSFileProviderDomainIdentifier(BundleIdentifiers.fileProviderDomainID(siteID: siteID)) let domain = NSFileProviderDomain(identifier: domainID, displayName: siteID) - guard let manager = NSFileProviderManager(for: domain) else { return nil } - - guard #available(macOS 15.0, *) else { return nil } - let storageRootURL = try manager.stateDirectoryURL() + guard let manager = NSFileProviderManager(for: domain), + let stateRoot = try? manager.stateDirectoryURL() else { return } - let databaseURL = storageRootURL + let legacyURL = stateRoot .appendingPathComponent(".FoodleState", isDirectory: true) .appendingPathComponent("Foodle", isDirectory: true) .appendingPathComponent("foodle.db") - return SharedDatabaseLocation( - securityScopedDirectoryURL: storageRootURL, - databaseURL: databaseURL - ) - } + guard FileManager.default.fileExists(atPath: legacyURL.path) else { + // Nothing to migrate (fresh install, or the state dir was reclaimed). + userDefaults.set(true, forKey: flagKey) + return + } - private func sharedDatabaseNeedsSeeding(siteID: String, databaseURL: URL) throws -> Bool { - guard FileManager.default.fileExists(atPath: databaseURL.path) else { return true } + let didStart = stateRoot.startAccessingSecurityScopedResource() + defer { if didStart { stateRoot.stopAccessingSecurityScopedResource() } } - let sharedDatabase = try Database(path: databaseURL.path) - let hasSite = try sharedDatabase.fetchSite(id: siteID) != nil - let hasConnectedAccount = try sharedDatabase.fetchAccounts().contains { - $0.siteID == siteID && $0.state.isConnected + do { + let legacy = try Database(path: legacyURL.path) + guard try legacy.fetchSite(id: siteID) != nil else { + userDefaults.set(true, forKey: flagKey) + return + } + let container = try Database() + // Don't clobber newer container data: only migrate when the legacy + // database holds more items for this site than the container does. + let containerItems = (try? container.fetchAllItems(siteID: siteID))?.count ?? 0 + let legacyItems = try legacy.fetchAllItems(siteID: siteID).count + if legacyItems > containerItems { + try copyData(from: legacy, to: container, siteID: siteID) + logger.info("Migrated \(legacyItems) item(s) from the legacy state-directory database into the App Group container for site \(siteID, privacy: .public)") + } + userDefaults.set(true, forKey: flagKey) + } catch { + // Leave the flag unset so a later launch can retry once readable. + logger.warning("Legacy state-directory database migration skipped: \(error.localizedDescription, privacy: .public)") } - - return !(hasSite && hasConnectedAccount) } - private func seedSharedDatabase(from sourceDatabase: Database, siteID: String, destinationURL: URL) throws { - try FileManager.default.createDirectory( - at: destinationURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - - let sharedDatabase = try Database(path: destinationURL.path) - try sharedDatabase.deleteAllData() - - if let site = try sourceDatabase.fetchSite(id: siteID) { - try sharedDatabase.saveSite(site) + /// Copies one site's rows (site, accounts, courses, items, sync cursors) from + /// one database to another. Upserts only — never deletes — so it is safe to + /// run against a populated destination. + private func copyData(from source: Database, to destination: Database, siteID: String) throws { + if let site = try source.fetchSite(id: siteID) { + try destination.saveSite(site) } - - let siteAccounts = try sourceDatabase.fetchAccounts().filter { $0.siteID == siteID } - for account in siteAccounts { - try sharedDatabase.saveAccount(account) + for account in try source.fetchAccounts().filter({ $0.siteID == siteID }) { + try destination.saveAccount(account) } - - let courses = try sourceDatabase.fetchCourses(siteID: siteID) + let courses = try source.fetchCourses(siteID: siteID) if !courses.isEmpty { - try sharedDatabase.saveCourses(courses) + try destination.saveCourses(courses) } - - let items = try sourceDatabase.fetchAllItems(siteID: siteID) + let items = try source.fetchAllItems(siteID: siteID) if !items.isEmpty { - try sharedDatabase.saveItems(items) + try destination.saveItems(items) } - - let cursors = try sourceDatabase.fetchAllSyncCursors(siteID: siteID) - for cursor in cursors { - try sharedDatabase.saveSyncCursor(cursor) + for cursor in try source.fetchAllSyncCursors(siteID: siteID) { + try destination.saveSyncCursor(cursor) } - - logger.info("Seeded File Provider state database for site \(siteID, privacy: .public)") } // MARK: - Account Management @@ -401,9 +387,7 @@ final class AppState: ObservableObject { // should still succeed so the user can access courses in the app. do { try await setupFileProviderDomain(site: site) - if let sharedDatabase = try openSharedDatabase(siteID: site.id, seedFrom: db) { - database = sharedDatabase - } + database = try openCanonicalDatabase(siteID: site.id) await resolveFileProviderAuthentication(for: site) await pinToFinderSidebar(site: site) } catch { @@ -474,16 +458,7 @@ final class AppState: ObservableObject { // Re-enable the extension — macOS disables it when Sparkle replaces the bundle. reenableFileProviderExtension() - // 1. Snapshot current data so re-seeding restores everything. - if let sourceDatabase = database { - do { - try snapshotCurrentDataToBootstrap(from: sourceDatabase, siteID: site.id) - } catch { - logger.error("Snapshot before re-registration failed: \(error.localizedDescription, privacy: .public)") - } - } - - // 2. Re-store the keychain token under the current signing context. + // 1. Re-store the keychain token under the current signing context. // This ensures the File Provider extension (which may have a refreshed // code signature after Sparkle replaced the bundle) can read the token. if let token = currentToken, @@ -496,7 +471,10 @@ final class AppState: ObservableObject { } } - // 3. Remove and re-add the domain to force macOS to reload the extension. + // 2. Remove and re-add the domain to force macOS to reload the extension. + // The canonical database lives in the App Group container, so it + // survives the domain removal — no snapshot or re-seed is needed, and + // the live `database`/`syncEngine` handles stay valid. let domainID = NSFileProviderDomainIdentifier(BundleIdentifiers.fileProviderDomainID(siteID: site.id)) let domain = NSFileProviderDomain(identifier: domainID, displayName: site.displayName) @@ -512,32 +490,6 @@ final class AppState: ObservableObject { } catch { logger.error("Failed to re-add domain during re-registration: \(error.localizedDescription, privacy: .public)") } - - // 4. Retry seeding the shared database with backoff — fileproviderd may - // need time to initialize the new extension and state directory. - var seeded = false - for attempt in 1...5 { - try? await Task.sleep(for: .seconds(TimeInterval(attempt))) - - do { - let bootstrapDatabase = try Database() - if let sharedDatabase = try openSharedDatabase(siteID: site.id, seedFrom: bootstrapDatabase) { - self.database = sharedDatabase - syncEngine = SyncEngine(provider: moodleClient, database: sharedDatabase) - seeded = true - logger.info("Re-seeded shared database on attempt \(attempt)") - break - } else { - logger.info("Shared database not ready on attempt \(attempt)") - } - } catch { - logger.warning("Shared database seeding attempt \(attempt) failed: \(error.localizedDescription, privacy: .public)") - } - } - - if !seeded { - logger.error("Failed to re-seed shared database after 5 attempts") - } } /// Re-enable the File Provider extension via `pluginkit`. @@ -1209,14 +1161,10 @@ final class AppState: ObservableObject { // Clear Keychain try? KeychainManager.shared.deleteToken(forAccount: account.id) - // Clear database before revoking security-scoped access + // Clear the canonical database in the App Group container, then reopen a + // fresh empty handle for the onboarding screen. try? database?.deleteAllData() - if let bootstrapDatabase = try? Database() { - try? bootstrapDatabase.deleteAllData() - } database = nil - databaseSecurityScopedURL?.stopAccessingSecurityScopedResource() - databaseSecurityScopedURL = nil database = try? Database() userDefaults.removeObject(forKey: Self.currentSiteIDKey) @@ -1274,14 +1222,6 @@ final class AppState: ObservableObject { reenableFileProviderExtension() - if let sourceDatabase = database { - do { - try snapshotCurrentDataToBootstrap(from: sourceDatabase, siteID: site.id) - } catch { - logger.error("Snapshot before reset failed: \(error.localizedDescription, privacy: .public)") - } - } - // Re-store the keychain token to guarantee accessibility after reset. if let token = currentToken, let account = accounts.first(where: { $0.state.isConnected }) { @@ -1307,39 +1247,24 @@ final class AppState: ObservableObject { logger.error("Failed to re-add domain during reset: \(error.localizedDescription, privacy: .public)") } - // Retry seeding with backoff — fileproviderd needs time to initialize. - var seeded = false - for attempt in 1...5 { - try? await Task.sleep(for: .seconds(TimeInterval(attempt))) - + // The canonical database in the App Group container is untouched by the + // domain reset; just rebuild the in-memory session handles so they point + // at it again. + if let token = currentToken { do { - let bootstrapDatabase = try Database() - if let sharedDatabase = try openSharedDatabase(siteID: site.id, seedFrom: bootstrapDatabase) { - database = sharedDatabase - if let token = currentToken { - currentSite = site - currentToken = token - sites = [site] - syncEngine = SyncEngine(provider: moodleClient, database: sharedDatabase) - - courses = try sharedDatabase.fetchCourses(siteID: site.id) - reloadCourseTags() - } - seeded = true - logger.info("Re-seeded shared database after reset on attempt \(attempt)") - break - } else { - logger.info("Shared database not ready after reset on attempt \(attempt)") - } + let database = try openCanonicalDatabase(siteID: site.id) + self.database = database + currentSite = site + currentToken = token + sites = [site] + syncEngine = SyncEngine(provider: moodleClient, database: database) + courses = try database.fetchCourses(siteID: site.id) + reloadCourseTags() } catch { - logger.warning("Reset seeding attempt \(attempt) failed: \(error.localizedDescription, privacy: .public)") + logger.error("Failed to reopen database after reset: \(error.localizedDescription, privacy: .public)") } } - if !seeded { - logger.error("Failed to re-seed shared database after reset (5 attempts)") - } - await resolveFileProviderAuthentication(for: site) } @@ -1356,14 +1281,11 @@ final class AppState: ObservableObject { ) { let activeDatabase: Database do { - if let sharedDatabase = try openSharedDatabase(siteID: site.id, seedFrom: database) { - activeDatabase = sharedDatabase - self.database = sharedDatabase - } else { - activeDatabase = database - } + let canonical = try openCanonicalDatabase(siteID: site.id) + activeDatabase = canonical + self.database = canonical } catch { - logger.error("Failed to adopt File Provider database: \(error.localizedDescription, privacy: .public)") + logger.error("Failed to adopt canonical database: \(error.localizedDescription, privacy: .public)") activeDatabase = database } @@ -1409,35 +1331,6 @@ final class AppState: ObservableObject { } } - private func snapshotCurrentDataToBootstrap(from sourceDatabase: Database, siteID: String) throws { - let bootstrapDatabase = try Database() - try bootstrapDatabase.deleteAllData() - - if let site = try sourceDatabase.fetchSite(id: siteID) { - try bootstrapDatabase.saveSite(site) - } - - let siteAccounts = try sourceDatabase.fetchAccounts().filter { $0.siteID == siteID } - for account in siteAccounts { - try bootstrapDatabase.saveAccount(account) - } - - let courses = try sourceDatabase.fetchCourses(siteID: siteID) - if !courses.isEmpty { - try bootstrapDatabase.saveCourses(courses) - } - - let items = try sourceDatabase.fetchAllItems(siteID: siteID) - if !items.isEmpty { - try bootstrapDatabase.saveItems(items) - } - - let cursors = try sourceDatabase.fetchAllSyncCursors(siteID: siteID) - for cursor in cursors { - try bootstrapDatabase.saveSyncCursor(cursor) - } - } - private func observeSyncSettings() { // UserDefaults.didChangeNotification fires on every defaults write // across the app (launch counters, prompt state, etc.), so check diff --git a/Sources/App/Views/ContentView.swift b/Sources/App/Views/ContentView.swift index f042e6d..d4f09be 100644 --- a/Sources/App/Views/ContentView.swift +++ b/Sources/App/Views/ContentView.swift @@ -31,10 +31,11 @@ struct ContentView: View { } private func evaluateWhatsNew() { - if lastWhatsNewVersion.isEmpty { - // Fresh install — record the version without showing the sheet. - lastWhatsNewVersion = WhatsNewRelease.current.version - } else if lastWhatsNewVersion != WhatsNewRelease.current.version { + // Show the showcase whenever the user hasn't seen the current release — + // fresh installs included. The sheet is only attached to the workspace, + // so onboarding is never interrupted, and `lastWhatsNewVersion` is + // stamped to the current version when the sheet is dismissed. + if lastWhatsNewVersion != WhatsNewRelease.current.version { showWhatsNew = true } } diff --git a/Sources/FileProviderExtension/FileProviderExtension.swift b/Sources/FileProviderExtension/FileProviderExtension.swift index 7a6e61e..4411c1e 100644 --- a/Sources/FileProviderExtension/FileProviderExtension.swift +++ b/Sources/FileProviderExtension/FileProviderExtension.swift @@ -16,7 +16,6 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { let logger = Logger(subsystem: "es.amodrono.foodle.file-provider", category: "Extension") private let stateLock = NSLock() private var _database: Database? - private var databaseSecurityScopedURL: URL? private var rootContainerName: String { "Findle-\(FileNameSanitizer.sanitize(domain.displayName))" } @@ -32,7 +31,7 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { /// Lazily resolve the database, retrying until the main app has finished seeding it. /// /// File Provider requests can arrive concurrently, so the resolution path is - /// serialized to avoid racing on the cached database and security-scoped URL. + /// serialized to avoid racing on the cached database handle. var database: Database? { stateLock.lock() defer { stateLock.unlock() } @@ -53,31 +52,16 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { private func resolveDatabaseLocked() -> Database? { do { - let stateDirectoryURL = try Self.stateDirectoryURL(for: domain) - let databaseURL = Self.databaseURL(in: stateDirectoryURL) - - guard FileManager.default.fileExists(atPath: databaseURL.path) else { - return nil - } - - let didStart = stateDirectoryURL.startAccessingSecurityScopedResource() - var adoptedScope = false - defer { - if didStart && !adoptedScope { - stateDirectoryURL.stopAccessingSecurityScopedResource() - } - } - - let db = try Database(path: databaseURL.path) + // The canonical database lives in the App Group container — reachable + // directly via the app-group entitlement (no security-scoped bookmark) + // and never reclaimed by fileproviderd, unlike the domain's state + // directory. + let db = try Database() guard try databaseIsReady(db) else { logger.info("Database exists but is not seeded yet for domain: \(self.domain.identifier.rawValue, privacy: .public)") return nil } - // Adopt security-scoped access, releasing any previous scope - databaseSecurityScopedURL?.stopAccessingSecurityScopedResource() - databaseSecurityScopedURL = didStart ? stateDirectoryURL : nil - adoptedScope = true _database = db logger.info("Database resolved for domain: \(self.domain.identifier.rawValue, privacy: .public)") return db @@ -104,30 +88,10 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { func invalidate() { stateLock.lock() defer { stateLock.unlock() } - databaseSecurityScopedURL?.stopAccessingSecurityScopedResource() - databaseSecurityScopedURL = nil _database = nil logger.info("File Provider extension invalidated") } - private static func stateDirectoryURL(for domain: NSFileProviderDomain) throws -> URL { - guard let manager = NSFileProviderManager(for: domain) else { - throw NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError) - } - - guard #available(macOS 15.0, *) else { - throw NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError) - } - return try manager.stateDirectoryURL() - } - - private static func databaseURL(in stateDirectoryURL: URL) -> URL { - stateDirectoryURL - .appendingPathComponent(".FoodleState", isDirectory: true) - .appendingPathComponent("Foodle", isDirectory: true) - .appendingPathComponent("foodle.db") - } - // MARK: - Item Lookup func item( @@ -241,10 +205,11 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { // MARK: - Local Content Storage /// Directory for storing user-created local file content. + /// + /// Kept in the App Group container alongside the database so it survives File + /// Provider domain remove/re-add — the state directory does not. private func localContentDirectory() throws -> URL { - let stateDir = try Self.stateDirectoryURL(for: domain) - let dir = stateDir - .appendingPathComponent(".FoodleState", isDirectory: true) + let dir = try Database.appGroupSupportDirectory() .appendingPathComponent("LocalContent", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir diff --git a/Sources/Persistence/Database.swift b/Sources/Persistence/Database.swift index 3d14084..38db79a 100644 --- a/Sources/Persistence/Database.swift +++ b/Sources/Persistence/Database.swift @@ -50,29 +50,8 @@ public final class Database: @unchecked Sendable { if let path = path { self.path = path } else { - let fm = FileManager.default - let appSupport: URL - if let groupURL = fm.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) { - let preferredAppSupport = groupURL.appendingPathComponent("Application Support", isDirectory: true) - let legacyAppSupport = groupURL - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Application Support", isDirectory: true) - - try Self.migrateLegacyDatabaseIfNeeded( - from: legacyAppSupport, - to: preferredAppSupport, - fileManager: fm - ) - - appSupport = preferredAppSupport - } else { - appSupport = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Application Support", isDirectory: true) - } - let dbDir = appSupport.appendingPathComponent("Foodle", isDirectory: true) - try FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true) - self.path = dbDir.appendingPathComponent("foodle.db").path + self.path = try Self.appGroupSupportDirectory() + .appendingPathComponent("foodle.db").path } var dbPointer: OpaquePointer? @@ -116,6 +95,42 @@ public final class Database: @unchecked Sendable { } } + /// The App Group "Application Support/Foodle" directory that holds the + /// canonical database and other shared on-disk state (e.g. user-created local + /// file content). + /// + /// This lives in the App Group container — accessible to the app, the File + /// Provider extension, and the MCP helper via the app-group entitlement — + /// rather than the File Provider domain's state directory, which + /// `fileproviderd` deletes on every domain remove/re-add. Storing the live + /// database here keeps the handle valid across domain resets, which otherwise + /// surface as "saveItems step failed: disk I/O error". + public static func appGroupSupportDirectory() throws -> URL { + let fm = FileManager.default + let appSupport: URL + if let groupURL = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) { + let preferredAppSupport = groupURL.appendingPathComponent("Application Support", isDirectory: true) + let legacyAppSupport = groupURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + + try migrateLegacyDatabaseIfNeeded( + from: legacyAppSupport, + to: preferredAppSupport, + fileManager: fm + ) + + appSupport = preferredAppSupport + } else { + appSupport = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + } + let dir = appSupport.appendingPathComponent("Foodle", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + private static func databaseDirectory(in appSupport: URL) -> URL { appSupport.appendingPathComponent("Foodle", isDirectory: true) }