Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions Sources/App/ViewModels/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/App/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +16,14 @@ struct ContentView: View {
OnboardingView()
case .workspace:
WorkspaceView()
.environment(
\.whatsNew,
WhatsNewEnvironment(
versionStore: UserDefaultsWhatsNewVersionStore(),
whatsNewCollection: WhatsNewProvider.collection
)
)
.whatsNewSheet()
}
}
}
Expand Down
56 changes: 56 additions & 0 deletions Sources/App/Views/WhatsNew/WhatsNewProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2026 Alejandro Modroño Vara <amodrono@alu.icai.comillas.edu>
//
// 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
)
)
]
}
182 changes: 177 additions & 5 deletions Sources/FileProviderExtension/FileProviderExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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()
}

Expand All @@ -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()
}

Expand All @@ -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()
}
}
21 changes: 18 additions & 3 deletions Sources/FileProviderExtension/FileProviderItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading