From 8124921b0f019739630ce293ea1030e87788d490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Modro=C3=B1o=20Vara?= Date: Mon, 1 Jun 2026 23:19:21 +0200 Subject: [PATCH 1/2] Store the canonical database in the App Group container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app and File Provider extension kept the live SQLite database inside the File Provider domain's state directory (NSFileProviderManager.stateDirectoryURL). fileproviderd deletes that directory on every domain remove/re-add — which the app triggers on Sparkle updates, session recovery, and manual resets. After such an event the app kept writing through the now-unlinked file handle, so the next sync failed with "saveItems step failed: disk I/O error". Move the canonical database, and user-created local file content, to the App Group container. Both processes already reach it via the application-groups entitlement and fileproviderd never reclaims it, so the handle stays valid across domain resets and re-registration no longer needs to snapshot and re-seed. The bundled MCP helper now reads live data instead of a stale snapshot, since it already resolves to the container path. A one-time migration copies any surviving per-site data from the old state-directory database into the container. --- Sources/App/ViewModels/AppState.swift | 299 ++++++------------ .../FileProviderExtension.swift | 55 +--- Sources/Persistence/Database.swift | 61 ++-- 3 files changed, 144 insertions(+), 271 deletions(-) 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/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) } From a59174604751695c033213c4395ed67eddc5e8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Modro=C3=B1o=20Vara?= Date: Mon, 1 Jun 2026 23:19:27 +0200 Subject: [PATCH 2/2] Show What's New to everyone who hasn't seen the current release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check treated an empty last-seen version as a fresh install and recorded the current version without showing the showcase. That key is empty for every upgrader too (earlier versions never wrote it), so the sheet was suppressed for exactly its intended audience. Show it whenever the last-seen version differs from the current one — fresh installs included — and stamp the version on dismiss. The sheet is only attached to the workspace, so onboarding is never interrupted. --- Sources/App/Views/ContentView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 } }