From 9e490f725f1f612030a80c0112e82d08036d5b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Modro=C3=B1o=20Vara?= Date: Wed, 18 Mar 2026 12:37:21 +0100 Subject: [PATCH 1/3] Allow users to create local files and directories in File Provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local items (prefixed with `local-`) are fully editable — users can create, rename, move, and delete files alongside synced Moodle content. These items are stored only on disk and are never uploaded or synced. - Add `isLocal` flag to LocalItem and database schema (v8 migration) - Implement createItem, modifyItem, deleteItem for local items - Add allowsAddingSubItems capability to all directories and root - Sync engine skips local items during diff - Bulk delete operations preserve local items --- .../FileProviderExtension.swift | 182 +++++++++++++++++- .../FileProviderItem.swift | 21 +- Sources/Persistence/Database.swift | 65 ++++++- Sources/SharedDomain/State/SyncState.swift | 7 +- Sources/SyncEngine/SyncEngine.swift | 2 +- 5 files changed, 257 insertions(+), 20 deletions(-) diff --git a/Sources/FileProviderExtension/FileProviderExtension.swift b/Sources/FileProviderExtension/FileProviderExtension.swift index e0aaf7c..45fc8c6 100644 --- a/Sources/FileProviderExtension/FileProviderExtension.swift +++ b/Sources/FileProviderExtension/FileProviderExtension.swift @@ -211,6 +211,12 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } } + // Local items have no remote — if their content is missing, report an error. + if localItem.isLocal { + completionHandler(nil, nil, NSFileProviderError(.noSuchItem)) + return progress + } + do { try FileDownloader.startDownload( item: localItem, @@ -227,7 +233,24 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { // Download logic moved to FileDownloader for Sendable compliance. - // MARK: - Modification (Read-Only for now) + // MARK: - Local Content Storage + + /// Directory for storing user-created local file content. + private func localContentDirectory() throws -> URL { + let stateDir = try Self.stateDirectoryURL(for: domain) + let dir = stateDir + .appendingPathComponent(".FoodleState", isDirectory: true) + .appendingPathComponent("LocalContent", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// Returns the on-disk URL for a local item's content. + private func localContentURL(itemID: String) throws -> URL { + try localContentDirectory().appendingPathComponent(itemID) + } + + // MARK: - Modification func createItem( basedOn itemTemplate: NSFileProviderItem, @@ -237,8 +260,62 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void ) -> Progress { - // Moodle content is read-only for now - completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + guard let db = database, let siteID else { + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + // Determine the parent. Items at the root have parentID = nil. + let parentID: String? + if itemTemplate.parentItemIdentifier == .rootContainer { + parentID = nil + } else { + parentID = itemTemplate.parentItemIdentifier.rawValue + } + + // Infer courseID from parent item (local items inherit their parent's course). + let courseID: Int + if let parentID, let parentItem = try? db.fetchItem(id: parentID) { + courseID = parentItem.courseID + } else { + courseID = 0 // Root-level local item + } + + let itemID = "local-\(UUID().uuidString)" + let now = Date() + let isDirectory = itemTemplate.contentType == .folder + + var localItem = LocalItem( + id: itemID, + parentID: parentID, + siteID: siteID, + courseID: courseID, + remoteID: 0, + filename: itemTemplate.filename, + isDirectory: isDirectory, + contentType: isDirectory ? nil : itemTemplate.contentType?.preferredMIMEType, + fileSize: (itemTemplate.documentSize ?? nil)?.int64Value ?? 0, + creationDate: now, + modificationDate: now, + syncState: .materialized, + isLocal: true + ) + + do { + // Persist file content for non-directory items. + if !isDirectory, let contentURL = url { + let dest = try localContentURL(itemID: itemID) + try FileManager.default.copyItem(at: contentURL, to: dest) + localItem.localPath = dest.path + } + + try db.saveItems([localItem]) + completionHandler(FileProviderItem(localItem: localItem), [], false, nil) + } catch { + logger.error("createItem failed: \(error.localizedDescription, privacy: .public)") + completionHandler(nil, [], false, error) + } + return Progress() } @@ -251,7 +328,75 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void ) -> Progress { - completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + guard let db = database else { + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + guard var localItem = try? db.fetchItem(id: item.itemIdentifier.rawValue) else { + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + return Progress() + } + + // Only allow modifications to local items. + guard localItem.isLocal else { + completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + return Progress() + } + + do { + var newFilename = localItem.filename + var newParentID = localItem.parentID + var newLocalPath = localItem.localPath + var newFileSize = localItem.fileSize + var newTagData = localItem.tagData + + if changedFields.contains(.filename) { + newFilename = item.filename + } + if changedFields.contains(.parentItemIdentifier) { + newParentID = item.parentItemIdentifier == .rootContainer ? nil : item.parentItemIdentifier.rawValue + } + if changedFields.contains(.contents), let newContents { + let dest = try localContentURL(itemID: localItem.id) + try? FileManager.default.removeItem(at: dest) + try FileManager.default.copyItem(at: newContents, to: dest) + newLocalPath = dest.path + let attrs = try? FileManager.default.attributesOfItem(atPath: dest.path) + newFileSize = (attrs?[.size] as? Int64) ?? 0 + } + if changedFields.contains(.tagData) { + newTagData = item.tagData ?? nil + } + + let updated = LocalItem( + id: localItem.id, + parentID: newParentID, + siteID: localItem.siteID, + courseID: localItem.courseID, + remoteID: localItem.remoteID, + filename: newFilename, + isDirectory: localItem.isDirectory, + contentType: localItem.contentType, + fileSize: newFileSize, + creationDate: localItem.creationDate, + modificationDate: Date(), + syncState: localItem.syncState, + isPinned: localItem.isPinned, + localPath: newLocalPath, + remoteURL: localItem.remoteURL, + contentVersion: "\(Date().timeIntervalSince1970)", + tagData: newTagData, + isLocal: true + ) + + try db.saveItems([updated]) + completionHandler(FileProviderItem(localItem: updated), [], false, nil) + } catch { + logger.error("modifyItem failed: \(error.localizedDescription, privacy: .public)") + completionHandler(nil, [], false, error) + } + return Progress() } @@ -262,7 +407,34 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { request: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void ) -> Progress { - completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + guard let db = database else { + completionHandler(NSFileProviderError(.notAuthenticated)) + return Progress() + } + + guard let localItem = try? db.fetchItem(id: identifier.rawValue) else { + completionHandler(NSFileProviderError(.noSuchItem)) + return Progress() + } + + // Only allow deletion of local items. + guard localItem.isLocal else { + completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + return Progress() + } + + do { + // Remove stored content. + if let path = localItem.localPath { + try? FileManager.default.removeItem(atPath: path) + } + try db.deleteItemAndChildren(id: localItem.id) + completionHandler(nil) + } catch { + logger.error("deleteItem failed: \(error.localizedDescription, privacy: .public)") + completionHandler(error) + } + return Progress() } } diff --git a/Sources/FileProviderExtension/FileProviderItem.swift b/Sources/FileProviderExtension/FileProviderItem.swift index 337da36..46059b4 100644 --- a/Sources/FileProviderExtension/FileProviderItem.swift +++ b/Sources/FileProviderExtension/FileProviderItem.swift @@ -27,8 +27,19 @@ final class FileProviderItem: NSObject, NSFileProviderItem { } var capabilities: NSFileProviderItemCapabilities { + if localItem.isLocal { + var caps: NSFileProviderItemCapabilities = [ + .allowsReading, .allowsWriting, .allowsRenaming, + .allowsReparenting, .allowsDeleting + ] + if localItem.isDirectory { + caps.insert(.allowsContentEnumerating) + caps.insert(.allowsAddingSubItems) + } + return caps + } if localItem.isDirectory { - return [.allowsReading, .allowsContentEnumerating] + return [.allowsReading, .allowsContentEnumerating, .allowsAddingSubItems] } return [.allowsReading] } @@ -77,13 +88,17 @@ final class FileProviderItem: NSObject, NSFileProviderItem { } var isUploaded: Bool { - true // read-only content is always "uploaded" + true // Local items are never uploaded; remote items are always "uploaded" } var isUploading: Bool { false } + var isMostRecentVersionDownloaded: Bool { + localItem.syncState == .materialized + } + var tagData: Data? { localItem.tagData } @@ -111,7 +126,7 @@ final class RootContainerItem: NSObject, NSFileProviderItem { var parentItemIdentifier: NSFileProviderItemIdentifier { .rootContainer } var filename: String { rootName } var contentType: UTType { .folder } - var capabilities: NSFileProviderItemCapabilities { [.allowsReading, .allowsContentEnumerating] } + var capabilities: NSFileProviderItemCapabilities { [.allowsReading, .allowsContentEnumerating, .allowsAddingSubItems] } var itemVersion: NSFileProviderItemVersion { let versionData = Data(rootName.utf8) return NSFileProviderItemVersion(contentVersion: versionData, metadataVersion: versionData) diff --git a/Sources/Persistence/Database.swift b/Sources/Persistence/Database.swift index 9f8433a..99051a8 100644 --- a/Sources/Persistence/Database.swift +++ b/Sources/Persistence/Database.swift @@ -17,7 +17,7 @@ public final class Database: @unchecked Sendable { private let path: String public var filePath: String { path } - public static let schemaVersion = 7 + public static let schemaVersion = 8 public init(path: String? = nil) throws { if let path = path { @@ -298,6 +298,14 @@ public final class Database: @unchecked Sendable { logger.info("Migrated database schema to version 7 (custom course icons)") } + if currentVersion < 8 { + let itemColumns = try existingColumns(table: "items") + if !itemColumns.contains("is_local") { + try execute("ALTER TABLE items ADD COLUMN is_local INTEGER NOT NULL DEFAULT 0") + } + logger.info("Migrated database schema to version 8 (local items)") + } + try execute("PRAGMA user_version = \(Self.schemaVersion)") } @@ -730,8 +738,8 @@ extension Database { let sql = """ INSERT OR REPLACE INTO items (id, parent_id, site_id, course_id, remote_id, filename, is_directory, content_type, file_size, creation_date, - modification_date, sync_state, is_pinned, local_path, remote_url, content_version, tag_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + modification_date, sync_state, is_pinned, local_path, remote_url, content_version, tag_data, is_local) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ try queue.sync { try executeUnsafe("BEGIN TRANSACTION") @@ -760,6 +768,7 @@ extension Database { } else { sqlite3_bind_null(stmt, 17) } + sqlite3_bind_int(stmt, 18, item.isLocal ? 1 : 0) _ = sqlite3_step(stmt) } @@ -892,14 +901,14 @@ extension Database { try queue.sync { // Clear stale pending deletions from previous cycles before recording new ones. try executeUnsafe("DELETE FROM pending_deletions") - // Record IDs for the File Provider to report as deletions. - let insertStmt = try prepareStatement("INSERT INTO pending_deletions (item_id) SELECT id FROM items WHERE course_id = ? AND site_id = ?") + // Record IDs for the File Provider to report as deletions (skip local items). + let insertStmt = try prepareStatement("INSERT INTO pending_deletions (item_id) SELECT id FROM items WHERE course_id = ? AND site_id = ? AND is_local = 0") defer { sqlite3_finalize(insertStmt) } sqlite3_bind_int(insertStmt, 1, Int32(courseID)) sqlite3_bind_text(insertStmt, 2, (siteID as NSString).utf8String, -1, nil) _ = sqlite3_step(insertStmt) - let deleteStmt = try prepareStatement("DELETE FROM items WHERE course_id = ? AND site_id = ?") + let deleteStmt = try prepareStatement("DELETE FROM items WHERE course_id = ? AND site_id = ? AND is_local = 0") defer { sqlite3_finalize(deleteStmt) } sqlite3_bind_int(deleteStmt, 1, Int32(courseID)) sqlite3_bind_text(deleteStmt, 2, (siteID as NSString).utf8String, -1, nil) @@ -911,18 +920,50 @@ extension Database { try queue.sync { try executeUnsafe("DELETE FROM pending_deletions") - let insertStmt = try prepareStatement("INSERT INTO pending_deletions (item_id) SELECT id FROM items WHERE site_id = ?") + let insertStmt = try prepareStatement("INSERT INTO pending_deletions (item_id) SELECT id FROM items WHERE site_id = ? AND is_local = 0") defer { sqlite3_finalize(insertStmt) } sqlite3_bind_text(insertStmt, 1, (siteID as NSString).utf8String, -1, nil) _ = sqlite3_step(insertStmt) - let deleteStmt = try prepareStatement("DELETE FROM items WHERE site_id = ?") + let deleteStmt = try prepareStatement("DELETE FROM items WHERE site_id = ? AND is_local = 0") defer { sqlite3_finalize(deleteStmt) } sqlite3_bind_text(deleteStmt, 1, (siteID as NSString).utf8String, -1, nil) _ = sqlite3_step(deleteStmt) } } + /// Delete a single item and all its children by ID. + public func deleteItemAndChildren(id: String) throws { + try queue.sync { + // Delete children first (recursive via parent_id chain). + // For simplicity, collect all descendant IDs then delete them. + var idsToDelete = [id] + var frontier = [id] + + while !frontier.isEmpty { + var nextFrontier: [String] = [] + for parentID in frontier { + let stmt = try prepareStatement("SELECT id FROM items WHERE parent_id = ?") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, (parentID as NSString).utf8String, -1, nil) + while sqlite3_step(stmt) == SQLITE_ROW { + let childID = String(cString: sqlite3_column_text(stmt, 0)) + idsToDelete.append(childID) + nextFrontier.append(childID) + } + } + frontier = nextFrontier + } + + for itemID in idsToDelete { + let stmt = try prepareStatement("DELETE FROM items WHERE id = ?") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, (itemID as NSString).utf8String, -1, nil) + _ = sqlite3_step(stmt) + } + } + } + public func fetchPendingDeletions() throws -> [String] { let sql = "SELECT item_id FROM pending_deletions ORDER BY deleted_at" return try queue.sync { @@ -949,8 +990,9 @@ extension Database { } private func readItem(from stmt: OpaquePointer) -> LocalItem { + let colCount = sqlite3_column_count(stmt) + let tagData: Data? = { - let colCount = sqlite3_column_count(stmt) guard colCount > 16, sqlite3_column_type(stmt, 16) != SQLITE_NULL else { return nil } let bytes = sqlite3_column_blob(stmt, 16) let length = sqlite3_column_bytes(stmt, 16) @@ -958,6 +1000,8 @@ extension Database { return Data(bytes: bytes, count: Int(length)) }() + let isLocal = colCount > 17 ? sqlite3_column_int(stmt, 17) == 1 : false + return LocalItem( id: String(cString: sqlite3_column_text(stmt, 0)), parentID: sqlite3_column_text(stmt, 1).map { String(cString: $0) }, @@ -975,7 +1019,8 @@ extension Database { localPath: sqlite3_column_text(stmt, 13).map { String(cString: $0) }, remoteURL: sqlite3_column_text(stmt, 14).flatMap { URL(string: String(cString: $0)) }, contentVersion: sqlite3_column_text(stmt, 15).map { String(cString: $0) }, - tagData: tagData + tagData: tagData, + isLocal: isLocal ) } } diff --git a/Sources/SharedDomain/State/SyncState.swift b/Sources/SharedDomain/State/SyncState.swift index e631bd3..d3a6b67 100644 --- a/Sources/SharedDomain/State/SyncState.swift +++ b/Sources/SharedDomain/State/SyncState.swift @@ -68,6 +68,9 @@ public struct LocalItem: Sendable, Codable, Equatable, Identifiable { public var remoteURL: URL? public var contentVersion: String? public var tagData: Data? + /// When `true`, the item was created locally by the user and should never be + /// synced to Moodle. The sync engine skips local items entirely. + public var isLocal: Bool public init( id: String = UUID().uuidString, @@ -86,7 +89,8 @@ public struct LocalItem: Sendable, Codable, Equatable, Identifiable { localPath: String? = nil, remoteURL: URL? = nil, contentVersion: String? = nil, - tagData: Data? = nil + tagData: Data? = nil, + isLocal: Bool = false ) { self.id = id self.parentID = parentID @@ -105,5 +109,6 @@ public struct LocalItem: Sendable, Codable, Equatable, Identifiable { self.remoteURL = remoteURL self.contentVersion = contentVersion self.tagData = tagData + self.isLocal = isLocal } } diff --git a/Sources/SyncEngine/SyncEngine.swift b/Sources/SyncEngine/SyncEngine.swift index 5436082..e28573b 100644 --- a/Sources/SyncEngine/SyncEngine.swift +++ b/Sources/SyncEngine/SyncEngine.swift @@ -133,7 +133,7 @@ public actor SyncEngine { syncProgress[course.id]?.totalItems = allItems.count // Diff against existing items - let existingItems = try database.fetchAllItems(siteID: site.id).filter { $0.courseID == course.id } + let existingItems = try database.fetchAllItems(siteID: site.id).filter { $0.courseID == course.id && !$0.isLocal } let changes = diffItems(existing: existingItems, incoming: allItems) From df2835d2ccb63fa4594b8eed8b58a2c86d798f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Modro=C3=B1o=20Vara?= Date: Wed, 18 Mar 2026 12:37:27 +0100 Subject: [PATCH 2/3] Add WhatsNewKit for version-aware release notes Integrates WhatsNewKit to automatically show a "What's New" sheet when the user updates to a new version. Version history is tracked via UserDefaults so each release is shown only once. --- Sources/App/Views/ContentView.swift | 9 +++ .../App/Views/WhatsNew/WhatsNewProvider.swift | 56 +++++++++++++++++++ project.yml | 5 ++ 3 files changed, 70 insertions(+) create mode 100644 Sources/App/Views/WhatsNew/WhatsNewProvider.swift diff --git a/Sources/App/Views/ContentView.swift b/Sources/App/Views/ContentView.swift index ae2dd78..79a1673 100644 --- a/Sources/App/Views/ContentView.swift +++ b/Sources/App/Views/ContentView.swift @@ -4,6 +4,7 @@ // You may obtain a copy of the License in the LICENSE file at the root of this repository. import SwiftUI +import WhatsNewKit struct ContentView: View { @EnvironmentObject var appState: AppState @@ -17,5 +18,13 @@ struct ContentView: View { WorkspaceView() } } + .environment( + \.whatsNew, + WhatsNewEnvironment( + versionStore: UserDefaultsWhatsNewVersionStore(), + whatsNewCollection: WhatsNewProvider.collection + ) + ) + .whatsNewSheet() } } diff --git a/Sources/App/Views/WhatsNew/WhatsNewProvider.swift b/Sources/App/Views/WhatsNew/WhatsNewProvider.swift new file mode 100644 index 0000000..be8ed66 --- /dev/null +++ b/Sources/App/Views/WhatsNew/WhatsNewProvider.swift @@ -0,0 +1,56 @@ +// Copyright 2026 Alejandro Modroño Vara +// +// Licensed under the Apache License, Version 2.0. +// You may obtain a copy of the License in the LICENSE file at the root of this repository. + +import SwiftUI +import WhatsNewKit + +/// Central registry of all What's New entries shown after app updates. +enum WhatsNewProvider { + nonisolated(unsafe) static let collection: WhatsNewCollection = [ + WhatsNew( + version: "1.0.0", + title: "Welcome to Findle", + features: [ + WhatsNew.Feature( + image: .init( + systemName: "folder.fill", + foregroundColor: .accentColor + ), + title: "Your Courses in Finder", + subtitle: "Browse and open Moodle files directly from the Finder sidebar — no browser needed." + ), + WhatsNew.Feature( + image: .init( + systemName: "arrow.down.circle.fill", + foregroundColor: .green + ), + title: "On-Demand Downloads", + subtitle: "Files download only when you open them and can be evicted to save disk space." + ), + WhatsNew.Feature( + image: .init( + systemName: "lock.shield.fill", + foregroundColor: .orange + ), + title: "SSO & Direct Login", + subtitle: "Sign in with your university's SSO provider or with username and password." + ), + WhatsNew.Feature( + image: .init( + systemName: "doc.badge.plus", + foregroundColor: .purple + ), + title: "Local Files", + subtitle: "Create your own notes and files alongside course content — they stay local and never sync." + ) + ], + primaryAction: WhatsNew.PrimaryAction( + title: "Continue", + backgroundColor: .accentColor, + foregroundColor: .white + ) + ) + ] +} diff --git a/project.yml b/project.yml index f5be90d..f481e7e 100644 --- a/project.yml +++ b/project.yml @@ -79,6 +79,9 @@ packages: Sparkle: url: https://github.com/sparkle-project/Sparkle from: 2.6.0 + WhatsNewKit: + url: https://github.com/SvenTiigi/WhatsNewKit + from: 2.2.1 targets: Foodle: @@ -107,6 +110,8 @@ targets: product: Airlock - package: Sparkle product: Sparkle + - package: WhatsNewKit + product: WhatsNewKit - sdk: CoreSpotlight.framework settings: base: From 34f09418837855509dbb06c95b918383acc0c176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Modro=C3=B1o=20Vara?= Date: Wed, 18 Mar 2026 12:46:38 +0100 Subject: [PATCH 3/3] Fix WhatsNewSheet showing during onboarding and improve File Provider recovery - Move WhatsNewKit environment and sheet to the workspace branch only, so the "What's New" popup never appears during the onboarding flow. - Make configureInitialDatabase fall back to re-seeding the shared File Provider database from the bootstrap database on app relaunch. Previously, if the shared database needed seeding but no source was provided, the app silently fell back to the app-group database, leaving the File Provider state directory stale. --- Sources/App/ViewModels/AppState.swift | 17 ++++++++++++++--- Sources/App/Views/ContentView.swift | 16 ++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Sources/App/ViewModels/AppState.swift b/Sources/App/ViewModels/AppState.swift index 65bc195..a40598f 100644 --- a/Sources/App/ViewModels/AppState.swift +++ b/Sources/App/ViewModels/AppState.swift @@ -88,9 +88,20 @@ final class AppState: ObservableObject { } private func configureInitialDatabase() throws { - if let siteID = userDefaults.string(forKey: Self.currentSiteIDKey), - let sharedDatabase = try openSharedDatabase(siteID: siteID, seedFrom: nil) { - database = sharedDatabase + 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 + } } else { database = try Database() } diff --git a/Sources/App/Views/ContentView.swift b/Sources/App/Views/ContentView.swift index 79a1673..b99095d 100644 --- a/Sources/App/Views/ContentView.swift +++ b/Sources/App/Views/ContentView.swift @@ -16,15 +16,15 @@ struct ContentView: View { OnboardingView() case .workspace: WorkspaceView() + .environment( + \.whatsNew, + WhatsNewEnvironment( + versionStore: UserDefaultsWhatsNewVersionStore(), + whatsNewCollection: WhatsNewProvider.collection + ) + ) + .whatsNewSheet() } } - .environment( - \.whatsNew, - WhatsNewEnvironment( - versionStore: UserDefaultsWhatsNewVersionStore(), - whatsNewCollection: WhatsNewProvider.collection - ) - ) - .whatsNewSheet() } }