diff --git a/.github/homebrew-tap-setup.md b/.github/homebrew-tap-setup.md index 44b9c4d..bd934a2 100644 --- a/.github/homebrew-tap-setup.md +++ b/.github/homebrew-tap-setup.md @@ -16,7 +16,7 @@ The repo just needs to exist — the release workflow will populate it automatic ## 2. GitHub Secrets -Add these secrets to the **Foodle** repository (Settings > Secrets and variables > Actions): +Add these secrets to the **Findle** repository (Settings > Secrets and variables > Actions): | Secret | Description | |---|---| @@ -26,8 +26,8 @@ Add these secrets to the **Foodle** repository (Settings > Secrets and variables | `APPLE_ID` | Your Apple ID email (for notarization) | | `NOTARIZATION_PASSWORD` | App-specific password for notarization. Generate at appleid.apple.com > Sign-In and Security > App-Specific Passwords | | `HOMEBREW_TAP_TOKEN` | A GitHub Personal Access Token (classic) with `repo` scope, so the workflow can push to the `homebrew-tap` repo | -| `APP_PROVISION_PROFILE_BASE64` | Base64-encoded Developer ID provisioning profile for `es.amodrono.foodle`. Create at developer.apple.com > Profiles, then: `base64 -i profile.provisionprofile \| pbcopy` | -| `FILEPROVIDER_PROVISION_PROFILE_BASE64` | Base64-encoded Developer ID provisioning profile for `es.amodrono.foodle.file-provider` | +| `APP_PROVISION_PROFILE_BASE64` | Base64-encoded Developer ID provisioning profile for `es.amodrono.findle`. Create at developer.apple.com > Profiles, then: `base64 -i profile.provisionprofile \| pbcopy` | +| `FILEPROVIDER_PROVISION_PROFILE_BASE64` | Base64-encoded Developer ID provisioning profile for `es.amodrono.findle.file-provider` | | `SPARKLE_PRIVATE_ED_KEY` | Sparkle EdDSA private key for signing updates. Generate with `generate_keys` from Sparkle's tools | | `SPARKLE_PUBLIC_ED_KEY` | Sparkle EdDSA public key (embedded in Info.plist via build settings) | diff --git a/.github/workflows/pr-nightly.yml b/.github/workflows/pr-nightly.yml index c996870..88bb036 100644 --- a/.github/workflows/pr-nightly.yml +++ b/.github/workflows/pr-nightly.yml @@ -11,7 +11,7 @@ permissions: env: APP_NAME: Findle Nightly - SCHEME: Foodle-Nightly + SCHEME: Findle-Nightly CONFIGURATION: Nightly jobs: @@ -30,7 +30,7 @@ jobs: - name: Build run: | xcodebuild build \ - -project Foodle.xcodeproj \ + -project Findle.xcodeproj \ -scheme "$SCHEME" \ -configuration "$CONFIGURATION" \ -derivedDataPath "$RUNNER_TEMP/DerivedData" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5198587..dcd21bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,8 @@ permissions: env: APP_NAME: Findle - SCHEME: Foodle - BUNDLE_ID: es.amodrono.foodle + SCHEME: Findle + BUNDLE_ID: es.amodrono.findle jobs: build-and-release: @@ -95,7 +95,7 @@ jobs: # The bundled MCP helper (FindleMCP) ships inside the app's Contents/MacOS. # It must be Developer ID-signed with the same team as the embedded # frameworks, or the hardened runtime's library validation refuses to - # load SharedDomain/FoodlePersistence at runtime (and notarization + # load SharedDomain/FindlePersistence at runtime (and notarization # rejects an ad-hoc nested executable). A CLI tool needs no provisioning # profile. Overwrites the gitignored local include used for dev builds. cat > Config/MCP.xcconfig <Developer ID Application provisioningProfiles - es.amodrono.foodle + es.amodrono.findle ${APP_PROFILE_UUID} - es.amodrono.foodle.file-provider + es.amodrono.findle.file-provider ${FP_PROFILE_UUID} @@ -148,7 +148,7 @@ jobs: EOF xcodebuild -exportArchive \ - -archivePath "$RUNNER_TEMP/Foodle.xcarchive" \ + -archivePath "$RUNNER_TEMP/Findle.xcarchive" \ -exportPath "$RUNNER_TEMP/export" \ -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" @@ -251,7 +251,7 @@ jobs: ${PUB_DATE} ${BUILD} ${VERSION} - 14.0 + 15.0

- macOS + macOS Swift Build Status License @@ -72,7 +72,7 @@ brew install --cask findle ### Requirements -- macOS 14.0 (Sonoma) or later +- macOS 15.0 (Sequoia) or later - Xcode 16.0 or later - Swift 6.0 - [XcodeGen](https://github.com/yonaskolb/XcodeGen) (for generating the Xcode project) @@ -91,25 +91,25 @@ brew install --cask findle 3. **Open the project:** ```bash - open Foodle.xcodeproj + open Findle.xcodeproj ``` 4. **Configure Code Signing:** The File Provider extension requires code signing with a valid Apple Development Team. Go into the Xcode project settings and select your own Development Team for all targets before building. -5. Select the `Foodle` scheme, build, and run. +5. Select the `Findle` scheme, build, and run. -> **Note:** The Xcode project and scheme are named `Foodle` for historical reasons, but the built app is called **Findle**. +> **Note:** The Xcode project and scheme are named `Findle` for historical reasons, but the built app is called **Findle**. ### Nightly Validation -Before shipping, use the Release-like `Foodle-Nightly` scheme. It keeps optimization and hardened runtime behavior close to Release while still allowing tests to run, and produces a separate "Findle Nightly" app that can be installed side-by-side with the production build. +Before shipping, use the Release-like `Findle-Nightly` scheme. It keeps optimization and hardened runtime behavior close to Release while still allowing tests to run, and produces a separate "Findle Nightly" app that can be installed side-by-side with the production build. ```bash xcodegen generate xcodebuild build \ - -project Foodle.xcodeproj \ - -scheme Foodle-Nightly \ + -project Findle.xcodeproj \ + -scheme Findle-Nightly \ -configuration Nightly \ CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_ALLOWED=NO @@ -117,12 +117,12 @@ xcodebuild build \ This builds an unsigned optimized app suitable for fast validation of optimized code paths. It is not suitable for File Provider registration or end-to-end SSO/Finder checks because the extension is not code signed. -For real pre-ship runtime validation, build a signed Nightly app from Xcode by selecting the `Foodle-Nightly` scheme with your Development Team configured, or pass it from the command line: +For real pre-ship runtime validation, build a signed Nightly app from Xcode by selecting the `Findle-Nightly` scheme with your Development Team configured, or pass it from the command line: ```bash xcodebuild build \ - -project Foodle.xcodeproj \ - -scheme Foodle-Nightly \ + -project Findle.xcodeproj \ + -scheme Findle-Nightly \ -configuration Nightly \ DEVELOPMENT_TEAM=ABCDE12345 ``` @@ -138,10 +138,10 @@ Findle is split into a modular set of frameworks and targets. This keeps the sep | Module | Purpose | |--------|---------| | `SharedDomain` | Core types, models, state machines, and shared error handling. | -| `FoodleNetworking` | The Moodle API client, authentication logic, and Keychain integration. | -| `FoodlePersistence` | The SQLite database layer, metadata cache, and sync cursors. | -| `FoodleSyncEngine` | Orchestrates the sync process, computes diffs, and manages downloads. | -| `FoodleFileProvider`| The Apple File Provider extension that hooks directly into Finder. | +| `FindleNetworking` | The Moodle API client, authentication logic, and Keychain integration. | +| `FindlePersistence` | The SQLite database layer, metadata cache, and sync cursors. | +| `FindleSyncEngine` | Orchestrates the sync process, computes diffs, and manages downloads. | +| `FindleFileProvider`| The Apple File Provider extension that hooks directly into Finder. | | `Findle` (App) | The main SwiftUI app shell, handling onboarding, diagnostics, and settings. | ### Data Flow Overview @@ -159,8 +159,8 @@ graph LR Reliability is maintained with unit and integration tests. You can run the test suites directly in Xcode using `Cmd+U`, or via the command line: ```bash -xcodebuild test -project Foodle.xcodeproj -scheme SharedDomainTests -xcodebuild test -project Foodle.xcodeproj -scheme PersistenceTests +xcodebuild test -project Findle.xcodeproj -scheme SharedDomainTests +xcodebuild test -project Findle.xcodeproj -scheme PersistenceTests ``` Mock API responses and test fixtures can be found in the `Fixtures/` directory. diff --git a/Resources/Entitlements/FileProvider.entitlements b/Resources/Entitlements/FileProvider.entitlements index f5aef09..bb35778 100644 --- a/Resources/Entitlements/FileProvider.entitlements +++ b/Resources/Entitlements/FileProvider.entitlements @@ -12,7 +12,7 @@ keychain-access-groups - $(AppIdentifierPrefix)$(FOODLE_BUNDLE_PREFIX) + $(AppIdentifierPrefix)$(FINDLE_BUNDLE_PREFIX) diff --git a/Resources/Entitlements/Foodle.entitlements b/Resources/Entitlements/Findle.entitlements similarity index 95% rename from Resources/Entitlements/Foodle.entitlements rename to Resources/Entitlements/Findle.entitlements index d8742f5..b4f6501 100644 --- a/Resources/Entitlements/Foodle.entitlements +++ b/Resources/Entitlements/Findle.entitlements @@ -14,7 +14,7 @@ keychain-access-groups - $(AppIdentifierPrefix)$(FOODLE_BUNDLE_PREFIX) + $(AppIdentifierPrefix)$(FINDLE_BUNDLE_PREFIX) com.apple.security.temporary-exception.mach-lookup.global-name diff --git a/Resources/Info/App-Info.plist b/Resources/Info/App-Info.plist index 4cf74b6..27859d3 100644 --- a/Resources/Info/App-Info.plist +++ b/Resources/Info/App-Info.plist @@ -2,48 +2,48 @@ - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - LSApplicationCategoryType - public.app-category.education - NSPrincipalClass - NSApplication - LSUIElement - - SUFeedURL - https://github.com/alexmodrono/Findle/releases/latest/download/appcast.xml - SUEnableInstallerLauncherService - - SUPublicEDKey - 0J4Dy2ctl0ueMVmU3Cf+/n1Dz2EO6MmwtyfqiwUnQa0= - CFBundleURLTypes - - - CFBundleURLName - es.amodrono.findle.sso-callback - CFBundleURLSchemes - - findle - - CFBundleTypeRole - Viewer - - + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + es.amodrono.findle.sso-callback + CFBundleURLSchemes + + findle + + + + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.education + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSPrincipalClass + NSApplication + SUEnableInstallerLauncherService + + SUFeedURL + $(FINDLE_APPCAST_URL) + SUPublicEDKey + 0J4Dy2ctl0ueMVmU3Cf+/n1Dz2EO6MmwtyfqiwUnQa0= diff --git a/Resources/Info/FileProvider-Info.plist b/Resources/Info/FileProvider-Info.plist index 0725e58..0c1b31d 100644 --- a/Resources/Info/FileProvider-Info.plist +++ b/Resources/Info/FileProvider-Info.plist @@ -34,7 +34,15 @@ NSExtensionFileProviderActionActivationRule TRUEPREDICATE NSExtensionFileProviderActionIdentifier - $(FOODLE_BUNDLE_PREFIX).action.open-in-moodle + $(FINDLE_BUNDLE_PREFIX).action.sync-now + NSExtensionFileProviderActionName + Sync Now + + + NSExtensionFileProviderActionActivationRule + TRUEPREDICATE + NSExtensionFileProviderActionIdentifier + $(FINDLE_BUNDLE_PREFIX).action.open-in-moodle NSExtensionFileProviderActionName Open in Moodle @@ -42,7 +50,7 @@ NSExtensionFileProviderActionActivationRule TRUEPREDICATE NSExtensionFileProviderActionIdentifier - $(FOODLE_BUNDLE_PREFIX).action.copy-moodle-link + $(FINDLE_BUNDLE_PREFIX).action.copy-moodle-link NSExtensionFileProviderActionName Copy Moodle Link @@ -50,7 +58,7 @@ NSExtensionFileProviderActionActivationRule TRUEPREDICATE NSExtensionFileProviderActionIdentifier - $(FOODLE_BUNDLE_PREFIX).action.open-course-page + $(FINDLE_BUNDLE_PREFIX).action.open-course-page NSExtensionFileProviderActionName Open Course Page @@ -58,7 +66,7 @@ NSExtensionFileProviderActionActivationRule TRUEPREDICATE NSExtensionFileProviderActionIdentifier - $(FOODLE_BUNDLE_PREFIX).action.keep-downloaded + $(FINDLE_BUNDLE_PREFIX).action.keep-downloaded NSExtensionFileProviderActionName Keep Downloaded @@ -66,7 +74,7 @@ NSExtensionFileProviderActionActivationRule TRUEPREDICATE NSExtensionFileProviderActionIdentifier - $(FOODLE_BUNDLE_PREFIX).action.remove-download + $(FINDLE_BUNDLE_PREFIX).action.remove-download NSExtensionFileProviderActionName Remove Download diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 696a7fa..02b713a 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -8,7 +8,7 @@ import FileProvider import OSLog final class AppDelegate: NSObject, NSApplicationDelegate, @unchecked Sendable { - private let logger = Logger(subsystem: "es.amodrono.foodle", category: "AppDelegate") + private let logger = Logger(subsystem: "es.amodrono.findle", category: "AppDelegate") private var windowObservers: [NSObjectProtocol] = [] func applicationDidFinishLaunching(_ notification: Notification) { diff --git a/Sources/App/FoodleApp.swift b/Sources/App/FindleApp.swift similarity index 97% rename from Sources/App/FoodleApp.swift rename to Sources/App/FindleApp.swift index deb7cf6..346a595 100644 --- a/Sources/App/FoodleApp.swift +++ b/Sources/App/FindleApp.swift @@ -8,10 +8,10 @@ import CoreSpotlight import Sparkle import OSLog -private let logger = Logger(subsystem: "es.amodrono.foodle", category: "App") +private let logger = Logger(subsystem: "es.amodrono.findle", category: "App") @main -struct FoodleApp: App { +struct FindleApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var appState = AppState() @StateObject private var updateController = UpdateController() diff --git a/Sources/App/LoginItemController.swift b/Sources/App/LoginItemController.swift index 3b78ea7..89d15e9 100644 --- a/Sources/App/LoginItemController.swift +++ b/Sources/App/LoginItemController.swift @@ -15,7 +15,7 @@ final class LoginItemController: ObservableObject { @Published private(set) var isEnabled: Bool = false @Published private(set) var lastError: String? - private let logger = Logger(subsystem: "es.amodrono.foodle", category: "LoginItem") + private let logger = Logger(subsystem: "es.amodrono.findle", category: "LoginItem") init() { refresh() diff --git a/Sources/App/Services/ClaudeIntegration.swift b/Sources/App/Services/ClaudeIntegration.swift index bbee27a..efba97e 100644 --- a/Sources/App/Services/ClaudeIntegration.swift +++ b/Sources/App/Services/ClaudeIntegration.swift @@ -7,9 +7,10 @@ import Foundation import AppKit import OSLog import Security +import SharedDomain /// Registers Findle's bundled MCP server with Claude Desktop and Claude Code by -/// merging a `findle` entry into their JSON config files. +/// merging this build's entry into their JSON config files. /// /// The app is sandboxed, so two things matter: writes to these well-known paths /// are permitted by a temporary-exception home-relative-path entitlement, and @@ -18,7 +19,7 @@ import Security /// copying a ready-to-paste snippet to the clipboard and revealing the config. @MainActor enum ClaudeIntegration { - private static let logger = Logger(subsystem: "es.amodrono.foodle", category: "ClaudeIntegration") + private static let logger = Logger(subsystem: "es.amodrono.findle", category: "ClaudeIntegration") enum Target: CaseIterable, Hashable { case desktop @@ -84,7 +85,7 @@ enum ClaudeIntegration { return parts.joined(separator: " ") } - /// Whether a `findle` MCP server is already registered with `target`. Returns + /// Whether this build's MCP server is already registered with `target`. Returns /// `false` if the config is missing or unreadable. static func isInstalled(_ target: Target) -> Bool { guard let data = try? Data(contentsOf: target.configURL), @@ -92,7 +93,7 @@ enum ClaudeIntegration { let servers = root["mcpServers"] as? [String: Any] else { return false } - return servers["findle"] != nil + return servers[BundleIdentifiers.mcpServerKey] != nil } /// Register the bundled MCP server with `target`, passing the app's actual @@ -115,6 +116,40 @@ enum ClaudeIntegration { } } + /// Remove this build's MCP server entry from `target`, leaving every other + /// server untouched. + /// + /// Because the key is build-specific, uninstalling Nightly cannot disturb + /// the release app's registration (or vice versa) — which is what makes a + /// side-by-side Nightly safe to remove at any time. + @discardableResult + static func uninstall(_ target: Target) -> Bool { + let url = target.configURL + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + var root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + var servers = root["mcpServers"] as? [String: Any], + servers[BundleIdentifiers.mcpServerKey] != nil else { + return false + } + + servers.removeValue(forKey: BundleIdentifiers.mcpServerKey) + root["mcpServers"] = servers + + do { + let output = try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + try output.write(to: url, options: .atomic) + logger.info("Removed MCP server from \(target.displayName, privacy: .public)") + return true + } catch { + logger.warning("Failed to remove MCP server from \(target.displayName, privacy: .public): \(error.localizedDescription, privacy: .public)") + return false + } + } + // MARK: - Internals private static func serverEntry(databasePath: String?) -> [String: Any] { @@ -123,7 +158,7 @@ enum ClaudeIntegration { return entry } - /// Read the existing config (if any), set `mcpServers.findle`, and write back + /// Read the existing config (if any), set this build's `mcpServers` entry, and write back /// atomically — preserving every other key. Never overwrites a config that /// exists but can't be parsed. private static func merge(entry: [String: Any], into url: URL) throws { @@ -139,7 +174,7 @@ enum ClaudeIntegration { } var servers = root["mcpServers"] as? [String: Any] ?? [:] - servers["findle"] = entry + servers[BundleIdentifiers.mcpServerKey] = entry root["mcpServers"] = servers let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) @@ -148,7 +183,7 @@ enum ClaudeIntegration { /// A standalone `mcpServers` snippet for the clipboard fallback. private static func snippet(entry: [String: Any]) -> String { - let wrapper: [String: Any] = ["mcpServers": ["findle": entry]] + let wrapper: [String: Any] = ["mcpServers": [BundleIdentifiers.mcpServerKey: entry]] let data = (try? JSONSerialization.data(withJSONObject: wrapper, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])) ?? Data() return String(data: data, encoding: .utf8) ?? "{}" } diff --git a/Sources/App/Services/SpotlightIndexer.swift b/Sources/App/Services/SpotlightIndexer.swift index 3620a37..d63f966 100644 --- a/Sources/App/Services/SpotlightIndexer.swift +++ b/Sources/App/Services/SpotlightIndexer.swift @@ -6,14 +6,14 @@ import CoreSpotlight import UniformTypeIdentifiers import SharedDomain -import FoodlePersistence +import FindlePersistence import OSLog /// Indexes Moodle courses and files into CoreSpotlight for system-wide search. final class SpotlightIndexer: @unchecked Sendable { private static let domainPrefix = BundleIdentifiers.spotlightPrefix private static let lastIndexedDomainsKey = "spotlightLastIndexedDomains" - private let logger = Logger(subsystem: "es.amodrono.foodle", category: "SpotlightIndexer") + private let logger = Logger(subsystem: "es.amodrono.findle", category: "SpotlightIndexer") static let shared = SpotlightIndexer() diff --git a/Sources/App/UpdateController.swift b/Sources/App/UpdateController.swift index 44abb16..b6da529 100644 --- a/Sources/App/UpdateController.swift +++ b/Sources/App/UpdateController.swift @@ -14,21 +14,35 @@ final class UpdateController: ObservableObject { private let updaterController: SPUStandardUpdaterController let updater: SPUUpdater + /// Whether this build has an update feed at all. + /// + /// Nightly builds are distributed as unsigned CI artifacts with no appcast, + /// so `SUFeedURL` is empty there. Starting Sparkle without a feed makes it + /// raise a configuration error, so the updater stays stopped instead and the + /// UI hides the update controls. + let updatesSupported: Bool + @Published var canCheckForUpdates = false init() { + let feedURL = (Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String) ?? "" + let supported = !feedURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + self.updatesSupported = supported + self.updaterController = SPUStandardUpdaterController( - startingUpdater: true, + startingUpdater: supported, updaterDelegate: nil, userDriverDelegate: nil ) self.updater = updaterController.updater + guard supported else { return } updater.publisher(for: \.canCheckForUpdates) .assign(to: &$canCheckForUpdates) } func checkForUpdates() { + guard updatesSupported else { return } updater.checkForUpdates() } } diff --git a/Sources/App/ViewModels/AppState.swift b/Sources/App/ViewModels/AppState.swift index 0f5b40d..7a31414 100644 --- a/Sources/App/ViewModels/AppState.swift +++ b/Sources/App/ViewModels/AppState.swift @@ -10,13 +10,13 @@ import AppKit import CoreSpotlight import UserNotifications import SharedDomain -import FoodleNetworking -import FoodlePersistence -import FoodleSyncEngine +import FindleNetworking +import FindlePersistence +import FindleSyncEngine @preconcurrency import FileProvider import OSLog -/// The central observable state for the Foodle app. +/// The central observable state for the Findle app. @MainActor final class AppState: ObservableObject { @Published var currentScreen: AppScreen = .onboarding @@ -57,15 +57,21 @@ final class AppState: ObservableObject { private var automaticSyncTask: Task? private var lastAppliedSyncInterval: Double = -1 private var sessionBootstrapTask: Task? + private var trackingRefreshTask: Task? + private var isDatabaseTransitioning = false private var syncSettingsObserver: NSObjectProtocol? - private let logger = Logger(subsystem: "es.amodrono.foodle", category: "AppState") + private var syncNowObserver: DarwinNotification.Token? + /// Guards against a burst of "Sync Now" requests (the extension posts twice + /// when it has to cold-launch the app) turning into overlapping syncs. + private var lastSyncNowRequest: Date? + private let logger = Logger(subsystem: "es.amodrono.findle", category: "AppState") private let userDefaults: UserDefaults #if DEBUG /// When `true`, sign-in completes normally but the token is not persisted, /// so the next launch always shows the onboarding flow. /// Enable only when you explicitly want ephemeral debug sessions. - static let skipTokenPersistence = ProcessInfo.processInfo.environment["FOODLE_SKIP_TOKEN_PERSISTENCE"] == "1" + static let skipTokenPersistence = ProcessInfo.processInfo.environment["FINDLE_SKIP_TOKEN_PERSISTENCE"] == "1" #endif enum AppScreen: Hashable { @@ -107,6 +113,11 @@ final class AppState: ObservableObject { private static let syncOnLaunchKey = "syncOnLaunch" private static let syncIntervalMinutesKey = "syncIntervalMinutes" + /// The File Provider extension cannot fetch on its own — it only reads rows + /// this app writes — so this interval is the upper bound on how stale Finder + /// can be. Keep it short enough that new coursework shows up on its own, and + /// pair it with the "Sync Now" Finder action for an immediate refresh. + static let defaultSyncIntervalMinutes = 5.0 private static let currentSiteIDKey = "currentSiteID" private static let lastKnownAppVersionKey = "lastKnownAppVersion" @@ -114,12 +125,13 @@ final class AppState: ObservableObject { self.userDefaults = userDefaults userDefaults.register(defaults: [ Self.syncOnLaunchKey: true, - Self.syncIntervalMinutesKey: 30.0 + Self.syncIntervalMinutesKey: Self.defaultSyncIntervalMinutes ]) do { try configureInitialDatabase() observeSyncSettings() + observeSyncNowRequests() loadAccounts() } catch { logger.error("Failed to initialize database: \(error.localizedDescription, privacy: .public)") @@ -177,6 +189,37 @@ final class AppState: ObservableObject { return sharedDatabase } + /// fileproviderd can take a few seconds to expose a new state directory. + /// Keep the app on its bootstrap database only until that window is + /// exhausted, rather than starting a sync against a database Finder cannot + /// see. + private func adoptSharedDatabaseWithRetry(siteID: String, seedFrom sourceDatabase: Database?) async -> Database? { + let wasTransitioning = isDatabaseTransitioning + isDatabaseTransitioning = true + defer { isDatabaseTransitioning = wasTransitioning } + + for attempt in 1...5 { + do { + try Task.checkCancellation() + if let sharedDatabase = try openSharedDatabase(siteID: siteID, seedFrom: sourceDatabase) { + return sharedDatabase + } + } catch is CancellationError { + return nil + } catch { + logger.warning("Shared database adoption attempt \(attempt) failed: \(error.localizedDescription, privacy: .public)") + } + + guard attempt < 5 else { break } + do { + try await Task.sleep(for: .seconds(TimeInterval(attempt))) + } catch { + return nil + } + } + return nil + } + private func sharedDatabaseLocation(siteID: String) throws -> SharedDatabaseLocation? { let domainID = NSFileProviderDomainIdentifier(BundleIdentifiers.fileProviderDomainID(siteID: siteID)) let domain = NSFileProviderDomain(identifier: domainID, displayName: siteID) @@ -186,9 +229,9 @@ final class AppState: ObservableObject { let storageRootURL = try manager.stateDirectoryURL() let databaseURL = storageRootURL - .appendingPathComponent(".FoodleState", isDirectory: true) - .appendingPathComponent("Foodle", isDirectory: true) - .appendingPathComponent("foodle.db") + .appendingPathComponent(".FindleState", isDirectory: true) + .appendingPathComponent("Findle", isDirectory: true) + .appendingPathComponent("findle.db") return SharedDatabaseLocation( securityScopedDirectoryURL: storageRootURL, @@ -200,6 +243,7 @@ final class AppState: ObservableObject { guard FileManager.default.fileExists(atPath: databaseURL.path) else { return true } let sharedDatabase = try Database(path: databaseURL.path) + guard try sharedDatabase.isSeedComplete() else { return true } let hasSite = try sharedDatabase.fetchSite(id: siteID) != nil let hasConnectedAccount = try sharedDatabase.fetchAccounts().contains { $0.siteID == siteID && $0.state.isConnected @@ -214,32 +258,88 @@ final class AppState: ObservableObject { withIntermediateDirectories: true ) + // Snapshot before touching the destination. During recovery the source + // can already be the state-directory database; deleting it first would + // erase the only copy and leave Finder permanently empty. + let sourcePath = URL(fileURLWithPath: sourceDatabase.filePath).standardizedFileURL.path + let destinationPath = URL(fileURLWithPath: destinationURL.path).standardizedFileURL.path let sharedDatabase = try Database(path: destinationURL.path) + let destinationCounter = try sharedDatabase.currentChangeCounter() + try sharedDatabase.setSeedComplete(false) + + // If recovery is asked to seed a database from itself, prefer the + // bootstrap snapshot. If that snapshot is unavailable, fail closed + // rather than marking an unknown partial state as ready. + let snapshotSource: Database + if sourcePath == destinationPath { + guard let bootstrap = try? Database(), + URL(fileURLWithPath: bootstrap.filePath).standardizedFileURL.path != destinationPath, + try bootstrap.isSeedComplete(), + try bootstrap.fetchSite(id: siteID) != nil, + try bootstrap.fetchAccounts().contains(where: { $0.siteID == siteID && $0.state.isConnected }) else { + throw FindleError.databaseError(detail: "Cannot safely reseed the active File Provider database") + } + snapshotSource = bootstrap + } else { + guard try sourceDatabase.isSeedComplete() else { + throw FindleError.databaseError(detail: "Cannot seed from an incomplete database snapshot") + } + snapshotSource = sourceDatabase + } + let seedCounter = max(destinationCounter, try snapshotSource.currentChangeCounter()) + + let site = try snapshotSource.fetchSite(id: siteID) + let siteAccounts = try snapshotSource.fetchAccounts().filter { $0.siteID == siteID } + let courses = try snapshotSource.fetchCourses(siteID: siteID) + let outlines = try snapshotSource.fetchCourseOutlines(siteID: siteID) + let sourceItems = try snapshotSource.fetchAllItems(siteID: siteID) + let cursors = try snapshotSource.fetchAllSyncCursors(siteID: siteID) + let pendingDeletions = try snapshotSource.fetchPendingDeletions(siteID: siteID) + let tags = try snapshotSource.fetchAllCourseTags(siteID: siteID) + let assignments = try snapshotSource.fetchAssignments(siteID: siteID) + let grades = try snapshotSource.fetchGradeItems(siteID: siteID) + let quizzes = try snapshotSource.fetchQuizzes(siteID: siteID) + let attempts = try snapshotSource.fetchQuizAttempts(siteID: siteID) + try sharedDatabase.deleteAllData() + try sharedDatabase.preserveChangeCounter(atLeast: seedCounter) - if let site = try sourceDatabase.fetchSite(id: siteID) { + if let site { try sharedDatabase.saveSite(site) } - let siteAccounts = try sourceDatabase.fetchAccounts().filter { $0.siteID == siteID } for account in siteAccounts { try sharedDatabase.saveAccount(account) } - let courses = try sourceDatabase.fetchCourses(siteID: siteID) if !courses.isEmpty { try sharedDatabase.saveCourses(courses) } - let items = try sourceDatabase.fetchAllItems(siteID: siteID) + let items = try remapLocalContent( + in: sourceItems, + toDatabaseURL: destinationURL + ) if !items.isEmpty { try sharedDatabase.saveItems(items) } - let cursors = try sourceDatabase.fetchAllSyncCursors(siteID: siteID) for cursor in cursors { try sharedDatabase.saveSyncCursor(cursor) } + for outline in outlines { + try sharedDatabase.saveCourseOutline(outline, siteID: siteID) + } + try sharedDatabase.savePendingDeletions(pendingDeletions, siteID: siteID) + + for (courseID, courseTags) in tags { + try sharedDatabase.saveCourseTags(courseTags, courseID: courseID, siteID: siteID) + } + try sharedDatabase.saveAssignments(assignments, siteID: siteID) + try sharedDatabase.saveGradeItems(grades, siteID: siteID) + try sharedDatabase.saveQuizzes(quizzes, siteID: siteID) + try sharedDatabase.saveQuizAttempts(attempts, siteID: siteID) + try sharedDatabase.setSeedComplete(true) logger.info("Seeded File Provider state database for site \(siteID, privacy: .public)") } @@ -283,7 +383,7 @@ final class AppState: ObservableObject { onProgress: @escaping @MainActor (SiteValidationProgress) -> Void ) async throws -> MoodleSite { guard let normalizedURL = normalizedValidationURL(from: urlString) else { - throw FoodleError.siteUnreachable(url: URL(string: "https://invalid")!) + throw FindleError.siteUnreachable(url: URL(string: "https://invalid")!) } let cacheKey = normalizedURL.absoluteString @@ -393,6 +493,7 @@ final class AppState: ObservableObject { state: .authenticated(userID: user.id) ) try db.saveAccount(account) + try db.setSeedComplete(true) accounts = [account] sites = [site] @@ -411,8 +512,10 @@ 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) { + if let sharedDatabase = await adoptSharedDatabaseWithRetry(siteID: site.id, seedFrom: db) { database = sharedDatabase + } else { + logger.warning("File Provider domain was added but its shared database was not ready; continuing with bootstrap storage") } await resolveFileProviderAuthentication(for: site) await pinToFinderSidebar(site: site) @@ -479,18 +582,30 @@ final class AppState: ObservableObject { /// is to call `pluginkit -e use` to re-enable the extension, then re-seed the /// shared database so the extension has up-to-date state. private func reregisterFileProviderDomain(site: MoodleSite) async { + let wasTransitioning = isDatabaseTransitioning + isDatabaseTransitioning = true + defer { isDatabaseTransitioning = wasTransitioning } + logger.info("App version changed — re-registering File Provider domain for \(site.displayName, privacy: .public)") // Re-enable the extension — macOS disables it when Sparkle replaces the bundle. reenableFileProviderExtension() + if let engine = syncEngine { + await engine.stopAllSyncs() + } + await cancelTrackingRefresh() + // 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)") - } + guard let sourceDatabase = database else { + logger.error("Cannot re-register File Provider without an active database") + return + } + do { + try snapshotCurrentDataToBootstrap(from: sourceDatabase, siteID: site.id) + } catch { + logger.error("Snapshot before re-registration failed: \(error.localizedDescription, privacy: .public)") + return } // 2. Re-store the keychain token under the current signing context. @@ -538,7 +653,13 @@ final class AppState: ObservableObject { // 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 { + try await Task.sleep(for: .seconds(TimeInterval(attempt))) + try Task.checkCancellation() + } catch { + logger.info("Cancelled File Provider re-registration") + return + } do { let bootstrapDatabase = try Database() @@ -598,7 +719,7 @@ final class AppState: ObservableObject { } catch let error as NSError { logger.error("Failed to add File Provider domain: \(error.localizedDescription, privacy: .public) [\(error.domain, privacy: .public):\(error.code)]") let detail = "\(error.localizedDescription) (\(error.domain):\(error.code))" - throw FoodleError.domainSetupFailed(detail: detail) + throw FindleError.domainSetupFailed(detail: detail) } } @@ -645,7 +766,7 @@ final class AppState: ObservableObject { seedCourseSyncStatesFromCursors(siteID: site.id) loadCourseCovers() logger.info("Loaded \(self.courses.count) courses") - } catch let error as FoodleError where error.requiresReauthentication { + } catch let error as FindleError where error.requiresReauthentication { logger.error("Failed to load courses: session expired") handleSessionExpired() } catch { @@ -795,6 +916,7 @@ final class AppState: ObservableObject { // Remove items from the File Provider and Spotlight when disabling sync if !enabled { try db.deleteItems(courseID: course.id, siteID: site.id) + try db.deleteCourseOutline(courseID: course.id, siteID: site.id) SpotlightIndexer.shared.removeItems(forCourse: course.id, siteID: course.siteID) signalFileProviderChanges() } @@ -911,6 +1033,10 @@ final class AppState: ObservableObject { // MARK: - Sync func syncAll() async { + guard !isDatabaseTransitioning else { + logger.info("Skipping sync while the shared database is transitioning") + return + } guard let site = currentSite, let token = currentToken, let engine = syncEngine else { return } errorMessage = nil @@ -948,8 +1074,28 @@ final class AppState: ObservableObject { } defer { syncProgressDetail = nil } + // Publish each course to Finder the moment it lands in the database. + // Signalling only after the whole run meant the first course's files + // stayed invisible until the last course finished. + let signalDomainID = BundleIdentifiers.fileProviderDomainID(siteID: site.id) + let signalDisplayName = site.displayName + let signalLogger = logger + do { - try await engine.syncAllCourses(site: site, token: token, courses: enabledCourses) + try await engine.syncAllCourses( + site: site, + token: token, + courses: enabledCourses, + onCourseSynced: { _ in + Task.detached { + await Self.performSignalEnumerators( + domainID: signalDomainID, + displayName: signalDisplayName, + logger: signalLogger + ) + } + } + ) progressTask.cancel() let finalProgress = await engine.allProgress() for course in enabledCourses { @@ -962,8 +1108,11 @@ final class AppState: ObservableObject { notifySyncCompletedIfEnabled(courseCount: enabledCourses.count) // Refresh deadlines/grades/quizzes in the background so it doesn't // delay the content sync's completion. Best-effort. - Task { await refreshTracking() } - } catch let error as FoodleError where error.requiresReauthentication { + trackingRefreshTask?.cancel() + trackingRefreshTask = Task { [weak self] in + await self?.refreshTracking() + } + } catch let error as FindleError where error.requiresReauthentication { progressTask.cancel() handleSessionExpired() } catch is CancellationError { @@ -979,8 +1128,21 @@ final class AppState: ObservableObject { } } - func syncCourse(_ course: MoodleCourse) async { - guard let site = currentSite, let token = currentToken, let engine = syncEngine else { return } + private func cancelTrackingRefresh() async { + if let task = trackingRefreshTask { + task.cancel() + await task.value + } + trackingRefreshTask = nil + } + + @discardableResult + func syncCourse(_ course: MoodleCourse) async -> Bool { + guard !isDatabaseTransitioning else { + logger.info("Skipping course sync while the shared database is transitioning") + return false + } + guard let site = currentSite, let token = currentToken, let engine = syncEngine else { return false } errorMessage = nil syncStatus = .syncing(progress: 0) @@ -992,15 +1154,19 @@ final class AppState: ObservableObject { syncStatus = .completed lastSyncDate = Date() signalFileProviderChanges() - } catch let error as FoodleError where error.requiresReauthentication { + return true + } catch let error as FindleError where error.requiresReauthentication { handleSessionExpired() + return false } catch is CancellationError { courseSyncStates[course.id] = .stale syncStatus = .idle + return false } catch { courseSyncStates[course.id] = .error syncStatus = .error(error.localizedDescription) errorMessage = error.localizedDescription + return false } } @@ -1091,7 +1257,7 @@ final class AppState: ObservableObject { private func handleSessionExpired() { logger.warning("Session expired — prompting user to reconnect") sessionExpired = true - syncStatus = .error(FoodleError.tokenExpired.localizedDescription) + syncStatus = .error(FindleError.tokenExpired.localizedDescription) automaticSyncTask?.cancel() automaticSyncTask = nil lastAppliedSyncInterval = -1 @@ -1211,9 +1377,19 @@ final class AppState: ObservableObject { guard let account = accounts.first else { return } // Cancel all background work before touching shared resources - automaticSyncTask?.cancel() + if let engine = syncEngine { + await engine.stopAllSyncs() + } + await cancelTrackingRefresh() + if let task = automaticSyncTask { + task.cancel() + await task.value + } automaticSyncTask = nil - sessionBootstrapTask?.cancel() + if let task = sessionBootstrapTask { + task.cancel() + _ = try? await task.value + } sessionBootstrapTask = nil syncEngine = nil @@ -1307,14 +1483,31 @@ final class AppState: ObservableObject { func resetProvider() async { guard let site = currentSite else { return } + let wasTransitioning = isDatabaseTransitioning + isDatabaseTransitioning = true + defer { isDatabaseTransitioning = wasTransitioning } + reenableFileProviderExtension() - if let sourceDatabase = database { - do { - try snapshotCurrentDataToBootstrap(from: sourceDatabase, siteID: site.id) - } catch { - logger.error("Snapshot before reset failed: \(error.localizedDescription, privacy: .public)") - } + if let engine = syncEngine { + await engine.stopAllSyncs() + } + await cancelTrackingRefresh() + if let task = automaticSyncTask { + task.cancel() + await task.value + } + automaticSyncTask = nil + + guard let sourceDatabase = database else { + logger.error("Cannot reset File Provider without an active database") + return + } + do { + try snapshotCurrentDataToBootstrap(from: sourceDatabase, siteID: site.id) + } catch { + logger.error("Snapshot before reset failed: \(error.localizedDescription, privacy: .public)") + return } // Re-store the keychain token to guarantee accessibility after reset. @@ -1353,7 +1546,13 @@ final class AppState: ObservableObject { // 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))) + do { + try await Task.sleep(for: .seconds(TimeInterval(attempt))) + try Task.checkCancellation() + } catch { + logger.info("Cancelled File Provider reset") + return + } do { let bootstrapDatabase = try Database() @@ -1433,9 +1632,19 @@ final class AppState: ObservableObject { await self.ensureFileProviderDomain(site: site) } + // Re-adopt after registration, including the normal relaunch path. + // The initial synchronous attempt can race fileproviderd while it + // restores the domain, so do not let the first sync use bootstrap + // storage when the state database becomes available moments later. + if let sourceDatabase = self.database, + let sharedDatabase = await self.adoptSharedDatabaseWithRetry(siteID: site.id, seedFrom: sourceDatabase) { + self.database = sharedDatabase + self.syncEngine = SyncEngine(provider: self.moodleClient, database: sharedDatabase) + } + // Clear any downloads left mid-flight by a previous run so they // don't appear stuck in-progress. - try? self.database?.resetStaleDownloads(siteID: site.id) + _ = try? self.database?.resetStaleDownloads(siteID: site.id) try Task.checkCancellation() await self.resolveFileProviderAuthentication(for: site) @@ -1452,33 +1661,164 @@ final class AppState: ObservableObject { } } + private func localContentDirectory(for databaseURL: URL) -> URL { + let databaseDirectory = databaseURL.deletingLastPathComponent() + if databaseDirectory.lastPathComponent == "Findle", + databaseDirectory.deletingLastPathComponent().lastPathComponent == ".FindleState" { + return databaseDirectory + .deletingLastPathComponent() + .appendingPathComponent("LocalContent", isDirectory: true) + } + return databaseDirectory.appendingPathComponent("LocalContent", isDirectory: true) + } + + /// Copy provider-owned local bytes while a database is being moved. The + /// metadata path is absolute, so copying rows without remapping it would + /// leave local project files pointing into a deleted File Provider state + /// directory after reset or update. + private func remapLocalContent(in items: [LocalItem], toDatabaseURL databaseURL: URL) throws -> [LocalItem] { + let destinationDirectory = localContentDirectory(for: databaseURL) + try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) + + var remapped = items + for index in remapped.indices { + guard remapped[index].isLocal, let sourcePath = remapped[index].localPath else { continue } + + let sourceURL = URL(fileURLWithPath: sourcePath) + let destinationURL = destinationDirectory.appendingPathComponent(remapped[index].id) + guard FileManager.default.fileExists(atPath: sourceURL.path) else { + logger.warning("Local content missing during database move: \(sourcePath, privacy: .public)") + remapped[index].localPath = nil + remapped[index].syncState = .error + continue + } + + let source = sourceURL.standardizedFileURL.path + let destination = destinationURL.standardizedFileURL.path + if source != destination { + try? FileManager.default.removeItem(at: destinationURL) + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + } + remapped[index].localPath = destinationURL.path + } + return remapped + } + private func snapshotCurrentDataToBootstrap(from sourceDatabase: Database, siteID: String) throws { let bootstrapDatabase = try Database() + let sourcePath = URL(fileURLWithPath: sourceDatabase.filePath).standardizedFileURL.path + let bootstrapPath = URL(fileURLWithPath: bootstrapDatabase.filePath).standardizedFileURL.path + + // The bootstrap database can already be the active handle when a + // provider update races state-directory recovery. Never clear the + // source before taking a snapshot in that case. + guard sourcePath != bootstrapPath else { return } + + let snapshotCounter = max( + try bootstrapDatabase.currentChangeCounter(), + try sourceDatabase.currentChangeCounter() + ) + + let site = try sourceDatabase.fetchSite(id: siteID) + let siteAccounts = try sourceDatabase.fetchAccounts().filter { $0.siteID == siteID } + let courses = try sourceDatabase.fetchCourses(siteID: siteID) + let outlines = try sourceDatabase.fetchCourseOutlines(siteID: siteID) + let sourceItems = try sourceDatabase.fetchAllItems(siteID: siteID) + let cursors = try sourceDatabase.fetchAllSyncCursors(siteID: siteID) + let pendingDeletions = try sourceDatabase.fetchPendingDeletions(siteID: siteID) + let tags = try sourceDatabase.fetchAllCourseTags(siteID: siteID) + let assignments = try sourceDatabase.fetchAssignments(siteID: siteID) + let grades = try sourceDatabase.fetchGradeItems(siteID: siteID) + let quizzes = try sourceDatabase.fetchQuizzes(siteID: siteID) + let attempts = try sourceDatabase.fetchQuizAttempts(siteID: siteID) + try bootstrapDatabase.deleteAllData() + try bootstrapDatabase.preserveChangeCounter(atLeast: snapshotCounter) - if let site = try sourceDatabase.fetchSite(id: siteID) { + if let site { 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) + let items = try remapLocalContent( + in: sourceItems, + toDatabaseURL: URL(fileURLWithPath: bootstrapDatabase.filePath) + ) if !items.isEmpty { try bootstrapDatabase.saveItems(items) } - let cursors = try sourceDatabase.fetchAllSyncCursors(siteID: siteID) for cursor in cursors { try bootstrapDatabase.saveSyncCursor(cursor) } + for outline in outlines { + try bootstrapDatabase.saveCourseOutline(outline, siteID: siteID) + } + try bootstrapDatabase.savePendingDeletions(pendingDeletions, siteID: siteID) + + for (courseID, courseTags) in tags { + try bootstrapDatabase.saveCourseTags(courseTags, courseID: courseID, siteID: siteID) + } + try bootstrapDatabase.saveAssignments(assignments, siteID: siteID) + try bootstrapDatabase.saveGradeItems(grades, siteID: siteID) + try bootstrapDatabase.saveQuizzes(quizzes, siteID: siteID) + try bootstrapDatabase.saveQuizAttempts(attempts, siteID: siteID) + try bootstrapDatabase.setSeedComplete(true) + } + + /// Listen for the "Sync Now" action the File Provider extension exposes in + /// Finder. The extension can't sync by itself, so it posts a Darwin + /// notification and we do the work here. + private func observeSyncNowRequests() { + syncNowObserver = DarwinNotification.addObserver( + for: BundleIdentifiers.syncNowRequestNotification + ) { [weak self] in + Task { @MainActor in + self?.handleSyncNowRequest() + } + } + } + + private func handleSyncNowRequest() { + // The extension posts a second time after cold-launching the app, and + // Finder lets the user invoke the action repeatedly. Collapse those. + let now = Date() + if let last = lastSyncNowRequest, now.timeIntervalSince(last) < 5 { + logger.debug("Ignoring duplicate Sync Now request") + return + } + lastSyncNowRequest = now + + if case .syncing = syncStatus { + logger.info("Sync Now requested while a sync is already running — ignoring") + return + } + + logger.info("Sync Now requested from Finder") + Task { await performRequestedSync() } + } + + /// Run a user-requested sync, tolerating the case where the app was + /// cold-launched by the request itself and is still restoring its session. + private func performRequestedSync() async { + for attempt in 0..<10 { + if currentSite != nil, currentToken != nil, syncEngine != nil { + await syncAll() + return + } + if attempt == 0 { + logger.info("Sync Now: waiting for the session to finish loading") + } + try? await Task.sleep(for: .seconds(1)) + } + logger.warning("Sync Now: no active session after waiting — skipping") } private func observeSyncSettings() { diff --git a/Sources/App/Views/Connect/MCPConnectView.swift b/Sources/App/Views/Connect/MCPConnectView.swift index 0b72f7f..d89187f 100644 --- a/Sources/App/Views/Connect/MCPConnectView.swift +++ b/Sources/App/Views/Connect/MCPConnectView.swift @@ -5,6 +5,7 @@ import SwiftUI import AppKit +import SharedDomain /// A dedicated, reusable window for connecting Findle's bundled MCP server to /// assistants — one-click for Claude (Desktop/Code), and a friendly, guided @@ -17,7 +18,8 @@ struct MCPConnectView: View { @State private var token = ClaudeIntegration.generateToken() @State private var toast: String? - private let port = 8765 + // Release and Nightly bind different loopback ports so both can run at once. + private let port = Int(BundleIdentifiers.mcpPort) var body: some View { VStack(spacing: 0) { @@ -89,7 +91,7 @@ struct MCPConnectView: View { switch ClaudeIntegration.install(target, databasePath: appState.databaseFilePath) { case .installed: installed.insert(target) - showToast("\(wasAdded ? "Updated" : "Added to") \(target.displayName) — restart it to load Findle.") + showToast("\(wasAdded ? "Updated" : "Added to") \(target.displayName) — restart it to load \(BundleIdentifiers.appDisplayName).") case .copiedToClipboard: showToast("Couldn't write the config — it's copied to your clipboard.") } @@ -104,6 +106,18 @@ struct MCPConnectView: View { .tint(isAdded ? .green : .accentColor) .disabled(!target.isLikelyInstalled) .help(target.isLikelyInstalled ? "" : "\(target.displayName) doesn't appear to be installed") + // Removal only touches this build's own entry, so a Nightly install can + // be undone without disturbing the release app's registration. + .contextMenu { + if isAdded { + Button("Remove from \(target.displayName)", role: .destructive) { + if ClaudeIntegration.uninstall(target) { + installed.remove(target) + showToast("Removed from \(target.displayName) — restart it to apply.") + } + } + } + } } // MARK: - ChatGPT diff --git a/Sources/App/Views/Onboarding/EmbeddedSSOView.swift b/Sources/App/Views/Onboarding/EmbeddedSSOView.swift index fa5f8b0..de09aa8 100644 --- a/Sources/App/Views/Onboarding/EmbeddedSSOView.swift +++ b/Sources/App/Views/Onboarding/EmbeddedSSOView.swift @@ -6,7 +6,7 @@ import SwiftUI import WebKit import SharedDomain -import FoodleNetworking +import FindleNetworking /// A SwiftUI wrapper around the `EmbeddedAuthCoordinator`'s WKWebView. /// Presented as a sheet during onboarding when the site uses `SiteLoginType.embedded`. diff --git a/Sources/App/Views/Onboarding/OnboardingState.swift b/Sources/App/Views/Onboarding/OnboardingState.swift index 4eaa4bb..ae81586 100644 --- a/Sources/App/Views/Onboarding/OnboardingState.swift +++ b/Sources/App/Views/Onboarding/OnboardingState.swift @@ -5,7 +5,7 @@ import SwiftUI import SharedDomain -import FoodleNetworking +import FindleNetworking @MainActor final class OnboardingState: ObservableObject { diff --git a/Sources/App/Views/Onboarding/OnboardingView.swift b/Sources/App/Views/Onboarding/OnboardingView.swift index 3cf239e..04ad234 100644 --- a/Sources/App/Views/Onboarding/OnboardingView.swift +++ b/Sources/App/Views/Onboarding/OnboardingView.swift @@ -6,7 +6,7 @@ import SwiftUI import Airlock import SharedDomain -import FoodleNetworking +import FindleNetworking struct OnboardingView: View { @EnvironmentObject private var appState: AppState diff --git a/Sources/App/Views/Onboarding/Steps/SetupStepView.swift b/Sources/App/Views/Onboarding/Steps/SetupStepView.swift index b0855b7..2e09fd1 100644 --- a/Sources/App/Views/Onboarding/Steps/SetupStepView.swift +++ b/Sources/App/Views/Onboarding/Steps/SetupStepView.swift @@ -113,9 +113,9 @@ struct SetupStepView: View { return } - guard let site = appState.currentSite, - let token = appState.currentToken, - let engine = appState.syncEngine else { + guard appState.currentSite != nil, + appState.currentToken != nil, + appState.syncEngine != nil else { currentActivity = "Sync engine not available" errorMessage = "Could not start sync. You can sync later from the workspace." isComplete = true @@ -126,10 +126,9 @@ struct SetupStepView: View { for course in courses { currentActivity = "Syncing \(course.shortName)…" - do { - try await engine.syncCourse(site: site, token: token, course: course) - - let progress = await engine.progress(forCourse: course.id) + let didSync = await appState.syncCourse(course) + if didSync { + let progress = await appState.syncEngine?.progress(forCourse: course.id) let itemCount = progress?.totalItems ?? 0 syncedCourses.append(SyncedCourseInfo( @@ -137,7 +136,7 @@ struct SetupStepView: View { name: course.fullName, itemCount: itemCount )) - } catch { + } else { syncedCourses.append(SyncedCourseInfo( id: course.id, name: course.fullName, diff --git a/Sources/App/Views/Onboarding/Steps/SignInStepView.swift b/Sources/App/Views/Onboarding/Steps/SignInStepView.swift index 504992c..d0a23b5 100644 --- a/Sources/App/Views/Onboarding/Steps/SignInStepView.swift +++ b/Sources/App/Views/Onboarding/Steps/SignInStepView.swift @@ -6,7 +6,7 @@ import SwiftUI import Airlock import SharedDomain -import FoodleNetworking +import FindleNetworking struct SignInStepView: View { @EnvironmentObject private var appState: AppState @@ -201,7 +201,7 @@ struct SignInStepView: View { onboardingState.showEmbeddedSSO = false onboardingState.embeddedAuthCoordinator = nil navigator?.setContinueEnabled(true) - } catch let error as FoodleError where error.isCancelled { + } catch let error as FindleError where error.isCancelled { onboardingState.showEmbeddedSSO = false onboardingState.embeddedAuthCoordinator = nil navigator?.setContinueEnabled(true) @@ -214,8 +214,8 @@ struct SignInStepView: View { } private func handleSSOError(_ error: Error) { - if let foodleError = error as? FoodleError { - switch foodleError { + if let findleError = error as? FindleError { + switch findleError { case .ssoLaunchURLUnavailable: onboardingState.errorMessage = "This site requires sign-in through its identity provider, but Findle could not build a valid launch URL." case .ssoLaunchURLInvalid: @@ -225,7 +225,7 @@ struct SignInStepView: View { case .ssoCallbackInvalid: onboardingState.errorMessage = "The sign-in callback from the site was invalid or incomplete. Please try again." default: - onboardingState.errorMessage = foodleError.localizedDescription + onboardingState.errorMessage = findleError.localizedDescription } } else { onboardingState.errorMessage = error.localizedDescription diff --git a/Sources/App/Views/Settings/SettingsView.swift b/Sources/App/Views/Settings/SettingsView.swift index 82ec698..13c6d2b 100644 --- a/Sources/App/Views/Settings/SettingsView.swift +++ b/Sources/App/Views/Settings/SettingsView.swift @@ -12,7 +12,7 @@ struct SettingsView: View { @StateObject private var loginItem = LoginItemController() @AppStorage("showMenuBarIcon") private var showMenuBarIcon = true @AppStorage("notifyOnSyncComplete") private var notifyOnSyncComplete = false - @AppStorage("syncIntervalMinutes") private var syncInterval: Double = 30 + @AppStorage("syncIntervalMinutes") private var syncInterval: Double = AppState.defaultSyncIntervalMinutes @AppStorage("syncOnLaunch") private var syncOnLaunch = true @AppStorage("enableVerboseLogging") private var verboseLogging = false @@ -39,17 +39,25 @@ struct SettingsView: View { .foregroundStyle(.red) } Toggle("Show in menu bar", isOn: $showMenuBarIcon) - Toggle( - "Automatically check for updates", - isOn: Binding( - get: { updateController.updater.automaticallyChecksForUpdates }, - set: { updateController.updater.automaticallyChecksForUpdates = $0 } + // Nightly builds have no appcast, so hide update controls there + // rather than show a toggle that can never do anything. + if updateController.updatesSupported { + Toggle( + "Automatically check for updates", + isOn: Binding( + get: { updateController.updater.automaticallyChecksForUpdates }, + set: { updateController.updater.automaticallyChecksForUpdates = $0 } + ) ) - ) - Button("Check for Updates…") { - updateController.checkForUpdates() + Button("Check for Updates…") { + updateController.checkForUpdates() + } + .disabled(!updateController.canCheckForUpdates) + } else { + Text("This build updates manually — download a newer Nightly to upgrade.") + .font(.caption) + .foregroundStyle(.secondary) } - .disabled(!updateController.canCheckForUpdates) if showMenuBarIcon { Text("Findle stays accessible from the menu bar when you close the window.") .font(.caption) @@ -119,6 +127,7 @@ struct SettingsView: View { Toggle("Sync when Findle launches", isOn: $syncOnLaunch) Picker("Sync cadence", selection: $syncInterval) { + Text("5 minutes").tag(5.0) Text("15 minutes").tag(15.0) Text("30 minutes").tag(30.0) Text("1 hour").tag(60.0) diff --git a/Sources/FileProviderExtension/FileDownloader.swift b/Sources/FileProviderExtension/FileDownloader.swift index f4319ef..0d5c830 100644 --- a/Sources/FileProviderExtension/FileDownloader.swift +++ b/Sources/FileProviderExtension/FileDownloader.swift @@ -6,15 +6,15 @@ import Foundation import FileProvider import SharedDomain -import FoodleNetworking -import FoodlePersistence +import FindleNetworking +import FindlePersistence import OSLog /// Handles file downloads for the File Provider extension. /// Uses URLSession's callback API so File Provider completion handlers stay in the /// framework's callback world instead of crossing Swift concurrency executors. enum FileDownloader { - private static let logger = Logger(subsystem: "es.amodrono.foodle.file-provider", category: "Download") + private static let logger = Logger(subsystem: "es.amodrono.findle.file-provider", category: "Download") /// Reset an item to `.placeholder` after a failed download so the next /// fetch retries cleanly. A failure here would otherwise strand the item in @@ -30,6 +30,7 @@ enum FileDownloader { item: LocalItem, database: Database, progress: Progress, + temporaryDirectory: URL? = nil, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void ) throws { let completionBridge = FileDownloadCompletionBridge( @@ -38,12 +39,12 @@ enum FileDownloader { ) guard let remoteURL = item.remoteURL else { - throw FoodleError.downloadFailed(itemID: item.id, reason: "No remote URL available") + throw FindleError.downloadFailed(itemID: item.id, reason: "No remote URL available") } let tokenAccountID = try database.fetchAccounts().first(where: { $0.siteID == item.siteID })?.id ?? item.siteID guard let tokenString = try KeychainManager.shared.retrieveToken(forAccount: tokenAccountID) else { - throw FoodleError.authenticationRequired + throw FindleError.authenticationRequired } // Send the token in the POST body instead of the URL query so it @@ -54,52 +55,21 @@ enum FileDownloader { authenticatedRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") authenticatedRequest.httpBody = formEncodeToken(tokenString) - let destinationURL = makeTemporaryDestinationURL(for: item) + let destinationURL = makeTemporaryDestinationURL(for: item, in: temporaryDirectory) try database.updateItemSyncState(id: item.id, state: .downloading) - let task = URLSession.shared.downloadTask(with: authenticatedRequest) { downloadedURL, response, error in - if let error { - resetToPlaceholder(itemID: item.id, database: database) - completionBridge.fail(error) - return - } - - guard let downloadedURL, - let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - resetToPlaceholder(itemID: item.id, database: database) - completionBridge.fail(FoodleError.downloadFailed(itemID: item.id, reason: "Download failed")) - return - } - - do { - if FileManager.default.fileExists(atPath: destinationURL.path) { - try FileManager.default.removeItem(at: destinationURL) - } - try FileManager.default.moveItem(at: downloadedURL, to: destinationURL) - try database.updateItemSyncState( - id: item.id, - state: .materialized, - localPath: destinationURL.path - ) - - var updatedItem = item - updatedItem.syncState = .materialized - updatedItem.localPath = destinationURL.path - completionBridge.succeed( - url: destinationURL, - item: FileProviderItem(localItem: updatedItem) - ) - } catch { - resetToPlaceholder(itemID: item.id, database: database) - completionBridge.fail(error) - } - } + DownloadCoordinator.shared.start( + request: authenticatedRequest, + item: item, + database: database, + destination: destinationURL, + progress: progress, + bridge: completionBridge + ) + } - progress.cancellationHandler = { - task.cancel() - } - task.resume() + fileprivate static func resetToPlaceholderIfNeeded(itemID: String, database: Database) { + resetToPlaceholder(itemID: itemID, database: database) } /// Form-encode just the token value for the download body. Using a @@ -112,15 +82,171 @@ enum FileDownloader { return "token=\(encoded)".data(using: .utf8) } - private static func makeTemporaryDestinationURL(for item: LocalItem) -> URL { + private static func makeTemporaryDestinationURL(for item: LocalItem, in directory: URL?) -> URL { let pathExtension = (item.filename as NSString).pathExtension let baseName = item.id.replacingOccurrences(of: "/", with: "_") - let tempDir = FileManager.default.temporaryDirectory + let tempDir = directory ?? FileManager.default.temporaryDirectory let fileName = pathExtension.isEmpty ? baseName : "\(baseName).\(pathExtension)" return tempDir.appendingPathComponent(fileName) } } +/// Runs File Provider materializations on one shared delegate-backed session. +/// +/// A delegate is what makes byte-level progress observable: the completion-handler +/// form of `downloadTask` reports nothing until it finishes, so Finder showed an +/// indeterminate spinner for the whole transfer — a 75 MB lecture PDF looked +/// identical to a hung download. Sharing a single session (rather than creating +/// one per file) preserves connection reuse to the Moodle host. +private final class DownloadCoordinator: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { + static let shared = DownloadCoordinator() + + private struct Context { + let item: LocalItem + let database: Database + let destination: URL + let progress: Progress + let bridge: FileDownloadCompletionBridge + /// Moodle reports each file's size in the course contents, so we can + /// still show a real bar when a response omits `Content-Length`. + let expectedSize: Int64 + } + + private let logger = Logger(subsystem: "es.amodrono.findle.file-provider", category: "Download") + private let lock = NSLock() + private var contexts: [Int: Context] = [:] + + private lazy var session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.waitsForConnectivity = true + return URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + }() + + func start( + request: URLRequest, + item: LocalItem, + database: Database, + destination: URL, + progress: Progress, + bridge: FileDownloadCompletionBridge + ) { + let task = session.downloadTask(with: request) + let context = Context( + item: item, + database: database, + destination: destination, + progress: progress, + bridge: bridge, + expectedSize: item.fileSize + ) + + // Register before `resume()` so no delegate callback can arrive first. + lock.lock() + contexts[task.taskIdentifier] = context + lock.unlock() + + progress.cancellationHandler = { task.cancel() } + task.resume() + } + + private func context(for taskIdentifier: Int) -> Context? { + lock.lock() + defer { lock.unlock() } + return contexts[taskIdentifier] + } + + @discardableResult + private func removeContext(for taskIdentifier: Int) -> Context? { + lock.lock() + defer { lock.unlock() } + return contexts.removeValue(forKey: taskIdentifier) + } + + // MARK: - URLSessionDownloadDelegate + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + guard let context = context(for: downloadTask.taskIdentifier) else { return } + + let expected = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : context.expectedSize + // With no size from either source, leave the caller's placeholder units + // alone rather than reporting a fraction we'd be inventing. + guard expected > 0 else { return } + + context.progress.totalUnitCount = expected + context.progress.completedUnitCount = min(totalBytesWritten, expected) + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + guard let context = context(for: downloadTask.taskIdentifier) else { return } + + guard let httpResponse = downloadTask.response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + removeContext(for: downloadTask.taskIdentifier) + FileDownloader.resetToPlaceholderIfNeeded(itemID: context.item.id, database: context.database) + context.bridge.fail( + FindleError.downloadFailed(itemID: context.item.id, reason: "Download failed") + ) + return + } + + // The temporary file is deleted as soon as this method returns, so the + // move has to happen here rather than in didCompleteWithError. + do { + if FileManager.default.fileExists(atPath: context.destination.path) { + try FileManager.default.removeItem(at: context.destination) + } + try FileManager.default.moveItem(at: location, to: context.destination) + try context.database.updateItemSyncState( + id: context.item.id, + state: .materialized, + localPath: context.destination.path + ) + + var updatedItem = context.item + updatedItem.syncState = .materialized + updatedItem.localPath = context.destination.path + + removeContext(for: downloadTask.taskIdentifier) + context.bridge.succeed( + url: context.destination, + item: FileProviderItem(localItem: updatedItem) + ) + } catch { + removeContext(for: downloadTask.taskIdentifier) + FileDownloader.resetToPlaceholderIfNeeded(itemID: context.item.id, database: context.database) + context.bridge.fail(error) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + // A successful download already consumed its context in + // didFinishDownloadingTo, so reaching here with one means a failure. + guard let context = removeContext(for: task.taskIdentifier) else { return } + + let failure = error ?? FindleError.downloadFailed( + itemID: context.item.id, + reason: "Download ended without producing a file" + ) + logger.error("Download failed for \(context.item.id, privacy: .public): \(failure.localizedDescription, privacy: .public)") + FileDownloader.resetToPlaceholderIfNeeded(itemID: context.item.id, database: context.database) + context.bridge.fail(failure) + } +} + final class FileDownloadCompletionBridge: @unchecked Sendable { private let lock = NSLock() private let progress: Progress @@ -135,20 +261,42 @@ final class FileDownloadCompletionBridge: @unchecked Sendable { } func succeed(url: URL, item: NSFileProviderItem) { - progress.completedUnitCount = progress.totalUnitCount - takeCompletionHandler()?(url, item, nil) + finish(progressState: .succeeded) { handler in + handler(url, item, nil) + } } func fail(_ error: Error) { - takeCompletionHandler()?(nil, nil, error) + // A failed materialization must leave a terminal Progress state. An + // incomplete progress object makes Finder keep showing the item as + // downloading even though its completion handler already failed. + finish(progressState: .failed) { handler in + handler(nil, nil, error) + } } - private func takeCompletionHandler() -> ((URL?, NSFileProviderItem?, Error?) -> Void)? { - lock.lock() - defer { lock.unlock() } + private enum TerminalProgressState { + case succeeded + case failed + } - let handler = completionHandler + private func finish( + progressState: TerminalProgressState, + calling callback: (((URL?, NSFileProviderItem?, Error?) -> Void) -> Void) + ) { + lock.lock() + guard let handler = completionHandler else { + lock.unlock() + return + } completionHandler = nil - return handler + switch progressState { + case .succeeded: + progress.completedUnitCount = progress.totalUnitCount + case .failed: + progress.cancel() + } + lock.unlock() + callback(handler) } } diff --git a/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift b/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift index 8ad278a..6799305 100644 --- a/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift +++ b/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift @@ -6,7 +6,7 @@ import FileProvider import AppKit import SharedDomain -import FoodlePersistence +import FindlePersistence import OSLog extension FileProviderExtension: NSFileProviderCustomAction { @@ -16,6 +16,7 @@ extension FileProviderExtension: NSFileProviderCustomAction { private static let openCoursePageIdentifier = BundleIdentifiers.actionOpenCoursePage private static let keepDownloadedIdentifier = BundleIdentifiers.actionKeepDownloaded private static let removeDownloadIdentifier = BundleIdentifiers.actionRemoveDownload + private static let syncNowIdentifier = BundleIdentifiers.actionSyncNow func performAction( identifier actionIdentifier: NSFileProviderExtensionActionIdentifier, @@ -57,6 +58,10 @@ extension FileProviderExtension: NSFileProviderCustomAction { switch actionIdentifier.rawValue { case Self.openInMoodleIdentifier: + guard localItem?.isLocal != true else { + completionHandler(NSFileProviderError(.cannotSynchronize)) + break + } let url: URL if let localItem { url = MoodleURLBuilder.webURL( @@ -74,6 +79,10 @@ extension FileProviderExtension: NSFileProviderCustomAction { completionHandler(nil) case Self.copyMoodleLinkIdentifier: + guard localItem?.isLocal != true else { + completionHandler(NSFileProviderError(.cannotSynchronize)) + break + } let url: URL if let localItem { url = MoodleURLBuilder.webURL( @@ -93,6 +102,10 @@ extension FileProviderExtension: NSFileProviderCustomAction { completionHandler(nil) case Self.openCoursePageIdentifier: + guard localItem?.isLocal != true else { + completionHandler(NSFileProviderError(.cannotSynchronize)) + break + } let courseURL: URL if let localItem { courseURL = MoodleURLBuilder.courseURL( @@ -132,6 +145,13 @@ extension FileProviderExtension: NSFileProviderCustomAction { completionHandler(error) } + case Self.syncNowIdentifier: + // The extension has no sync engine of its own — it only reads rows + // the app writes — so a manual refresh means waking the app up. + logger.info("Sync Now requested from Finder") + requestSyncFromApp() + completionHandler(nil) + default: completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) } @@ -144,4 +164,47 @@ extension FileProviderExtension: NSFileProviderCustomAction { guard let manager = NSFileProviderManager(for: domain) else { return } manager.signalEnumerator(for: .workingSet) { _ in } } + + /// Ask the main app to sync immediately, launching it first if it isn't + /// running. Without the launch step "Sync Now" would silently do nothing + /// whenever the app is closed — which is precisely when content is stalest. + private func requestSyncFromApp() { + let appBundleID = BundleIdentifiers.prefix + let notificationName = BundleIdentifiers.syncNowRequestNotification + let log = logger + + // Covers the common case where the app is already listening. + DarwinNotification.post(notificationName) + + Task { @MainActor in + let isRunning = NSWorkspace.shared.runningApplications.contains { + $0.bundleIdentifier == appBundleID + } + guard !isRunning else { return } + + guard let appURL = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: appBundleID + ) else { + log.warning("Sync Now: could not locate \(appBundleID, privacy: .public) to launch") + return + } + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + + do { + _ = try await NSWorkspace.shared.openApplication( + at: appURL, + configuration: configuration + ) + // A cold-launched app registers its observer during startup, so + // the notification posted above arrived too early for it. Give + // it a moment to come up, then post again. + try? await Task.sleep(for: .seconds(2)) + DarwinNotification.post(notificationName) + } catch { + log.warning("Sync Now: failed to launch app: \(error.localizedDescription, privacy: .public)") + } + } + } } diff --git a/Sources/FileProviderExtension/FileProviderExtension.swift b/Sources/FileProviderExtension/FileProviderExtension.swift index 7a6e61e..8182cf2 100644 --- a/Sources/FileProviderExtension/FileProviderExtension.swift +++ b/Sources/FileProviderExtension/FileProviderExtension.swift @@ -5,20 +5,23 @@ import FileProvider import SharedDomain -import FoodleNetworking -import FoodlePersistence +import FindleNetworking +import FindlePersistence import OSLog /// The File Provider extension that exposes Moodle course content in Finder. /// Uses the replicated extension model for modern macOS cloud-file behavior. final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { let domain: NSFileProviderDomain - let logger = Logger(subsystem: "es.amodrono.foodle.file-provider", category: "Extension") + let logger = Logger(subsystem: "es.amodrono.findle.file-provider", category: "Extension") private let stateLock = NSLock() private var _database: Database? private var databaseSecurityScopedURL: URL? + /// Name of the folder this domain mounts as under `~/Library/CloudStorage`. + /// Built from the product name so a Nightly domain for the same Moodle site + /// can't collide with the release one. private var rootContainerName: String { - "Findle-\(FileNameSanitizer.sanitize(domain.displayName))" + "\(BundleIdentifiers.appDisplayName)-\(FileNameSanitizer.sanitize(domain.displayName))" } /// Extract the site ID from the domain identifier (format: `.domain.`). @@ -56,10 +59,6 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { 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 { @@ -68,8 +67,15 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } } + // The state directory may be security-scoped. Checking the file + // before acquiring that scope can report a false negative and make + // Finder treat a healthy domain as disconnected. + guard FileManager.default.fileExists(atPath: databaseURL.path) else { + return nil + } + let db = try Database(path: databaseURL.path) - guard try databaseIsReady(db) else { + guard try db.isSeedComplete(), try databaseIsReady(db) else { logger.info("Database exists but is not seeded yet for domain: \(self.domain.identifier.rawValue, privacy: .public)") return nil } @@ -123,9 +129,9 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { private static func databaseURL(in stateDirectoryURL: URL) -> URL { stateDirectoryURL - .appendingPathComponent(".FoodleState", isDirectory: true) - .appendingPathComponent("Foodle", isDirectory: true) - .appendingPathComponent("foodle.db") + .appendingPathComponent(".FindleState", isDirectory: true) + .appendingPathComponent("Findle", isDirectory: true) + .appendingPathComponent("findle.db") } // MARK: - Item Lookup @@ -176,12 +182,16 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return ItemEnumerator( containerIdentifier: containerItemIdentifier, - database: db + database: db, + siteID: siteID ) } private func isAuthenticated(using database: Database) -> Bool { - guard let account = try? database.fetchAccounts().last(where: { $0.state.isConnected }) else { + guard let siteID, + let account = try? database.fetchAccounts().last(where: { + $0.siteID == siteID && $0.state.isConnected + }) else { return false } @@ -203,6 +213,7 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { guard let db = database, let localItem = try? db.fetchItem(id: itemIdentifier.rawValue) else { completionHandler(nil, nil, NSFileProviderError(.noSuchItem)) + progress.cancel() return progress } @@ -219,17 +230,27 @@ 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)) + progress.cancel() return progress } do { + let providerTemporaryDirectory: URL? + if #available(macOS 15.0, *), + let manager = NSFileProviderManager(for: domain) { + providerTemporaryDirectory = try? manager.temporaryDirectoryURL() + } else { + providerTemporaryDirectory = nil + } try FileDownloader.startDownload( item: localItem, database: db, progress: progress, + temporaryDirectory: providerTemporaryDirectory, completionHandler: completionHandler ) } catch { + progress.cancel() completionHandler(nil, nil, error) } @@ -244,7 +265,7 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { private func localContentDirectory() throws -> URL { let stateDir = try Self.stateDirectoryURL(for: domain) let dir = stateDir - .appendingPathComponent(".FoodleState", isDirectory: true) + .appendingPathComponent(".FindleState", isDirectory: true) .appendingPathComponent("LocalContent", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir @@ -280,7 +301,15 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { // 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) { + if let parentID { + guard let parentItem = try? db.fetchItem(id: parentID) else { + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + return Progress() + } + guard parentItem.isLocal else { + completionHandler(nil, [], false, NSFileProviderError(.cannotSynchronize)) + return Progress() + } courseID = parentItem.courseID } else { courseID = 0 // Root-level local item @@ -485,6 +514,12 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { guard let newParentID else { return true } if newParentID == itemID { return false } + // Keep user-created content in the provider's local workspace. Remote + // Moodle folders are read-only and may disappear on a later sync. + guard let newParent = try? db.fetchItem(id: newParentID), newParent.isLocal else { + return false + } + // Walk the ancestor chain of the proposed parent. If it leads back to // the item being moved, the move would create a cycle. A missing // ancestor means the chain is orphaned, so reject that too. `visited` diff --git a/Sources/FileProviderExtension/FileProviderItem.swift b/Sources/FileProviderExtension/FileProviderItem.swift index 46059b4..9d88bf2 100644 --- a/Sources/FileProviderExtension/FileProviderItem.swift +++ b/Sources/FileProviderExtension/FileProviderItem.swift @@ -39,7 +39,10 @@ final class FileProviderItem: NSObject, NSFileProviderItem { return caps } if localItem.isDirectory { - return [.allowsReading, .allowsContentEnumerating, .allowsAddingSubItems] + // Remote Moodle folders are read-only. Local work belongs in a + // provider-root workspace so a remote course refresh cannot delete + // or orphan it. + return [.allowsReading, .allowsContentEnumerating] } return [.allowsReading] } @@ -72,10 +75,10 @@ final class FileProviderItem: NSObject, NSFileProviderItem { var itemVersion: NSFileProviderItemVersion { let contentVersion = localItem.contentVersion ?? "1" - let versionData = Data(contentVersion.utf8) + let metadataVersion = localItem.modificationDate.map { String($0.timeIntervalSince1970) } ?? contentVersion return NSFileProviderItemVersion( - contentVersion: versionData, - metadataVersion: versionData + contentVersion: Data(contentVersion.utf8), + metadataVersion: Data(metadataVersion.utf8) ) } @@ -88,7 +91,7 @@ final class FileProviderItem: NSObject, NSFileProviderItem { } var isUploaded: Bool { - true // Local items are never uploaded; remote items are always "uploaded" + !localItem.isLocal } var isUploading: Bool { diff --git a/Sources/FileProviderExtension/ItemEnumerator.swift b/Sources/FileProviderExtension/ItemEnumerator.swift index d3e6d00..1bb8940 100644 --- a/Sources/FileProviderExtension/ItemEnumerator.swift +++ b/Sources/FileProviderExtension/ItemEnumerator.swift @@ -6,7 +6,7 @@ import FileProvider import Foundation import SharedDomain -import FoodlePersistence +import FindlePersistence import OSLog /// Sync anchors are opaque to the system, but we use them as a decimal-encoded @@ -31,11 +31,13 @@ enum SyncAnchorCoding { final class ItemEnumerator: NSObject, NSFileProviderEnumerator { private let containerIdentifier: NSFileProviderItemIdentifier private let database: Database - private let logger = Logger(subsystem: "es.amodrono.foodle.file-provider", category: "Enumerator") + private let siteID: String? + private let logger = Logger(subsystem: "es.amodrono.findle.file-provider", category: "Enumerator") - init(containerIdentifier: NSFileProviderItemIdentifier, database: Database) { + init(containerIdentifier: NSFileProviderItemIdentifier, database: Database, siteID: String? = nil) { self.containerIdentifier = containerIdentifier self.database = database + self.siteID = siteID } func invalidate() {} @@ -54,7 +56,12 @@ final class ItemEnumerator: NSObject, NSFileProviderEnumerator { parentID = containerIdentifier.rawValue } - let items = try database.fetchItems(parentID: parentID) + let items: [LocalItem] + if let siteID { + items = try database.fetchItems(parentID: parentID, siteID: siteID) + } else { + items = try database.fetchItems(parentID: parentID) + } let providerItems = items.map { FileProviderItem(localItem: $0) } logger.debug("Enumerated \(providerItems.count, privacy: .public) items for \(self.containerIdentifier.rawValue, privacy: .public)") @@ -82,23 +89,38 @@ final class ItemEnumerator: NSObject, NSFileProviderEnumerator { } let fromCounter = SyncAnchorCoding.decode(anchor) + let upperBound = try database.currentChangeCounter() // Only items whose updated_at exceeds the incoming anchor; the // trigger on writes guarantees updated_at is strictly increasing. - let changedItems = try database.fetchItemsChangedSince(anchor: fromCounter, parentID: parentID) + let changedItems: [LocalItem] + if let siteID { + changedItems = try database.fetchItemsChangedSince( + anchor: fromCounter, + parentID: parentID, + siteID: siteID, + through: upperBound + ) + } else { + changedItems = try database.fetchItemsChangedSince(anchor: fromCounter, parentID: parentID, through: upperBound) + } let providerItems = changedItems.map { FileProviderItem(localItem: $0) } if !providerItems.isEmpty { observer.didUpdate(providerItems) } - let deletedIDs = try database.fetchPendingDeletionsSince(anchor: fromCounter) + let deletedIDs: [String] + if let siteID { + deletedIDs = try database.fetchPendingDeletionsSince(anchor: fromCounter, siteID: siteID, through: upperBound) + } else { + deletedIDs = try database.fetchPendingDeletionsSince(anchor: fromCounter, through: upperBound) + } if !deletedIDs.isEmpty { let identifiers = deletedIDs.map { NSFileProviderItemIdentifier($0) } observer.didDeleteItems(withIdentifiers: identifiers) } - let newCounter = try database.currentChangeCounter() - observer.finishEnumeratingChanges(upTo: SyncAnchorCoding.encode(newCounter), moreComing: false) + observer.finishEnumeratingChanges(upTo: SyncAnchorCoding.encode(upperBound), moreComing: false) } catch { logger.error("Change enumeration failed: \(error.localizedDescription, privacy: .public)") observer.finishEnumeratingWithError(error) @@ -115,7 +137,7 @@ final class ItemEnumerator: NSObject, NSFileProviderEnumerator { final class WorkingSetEnumerator: NSObject, NSFileProviderEnumerator { private let database: Database private let siteID: String? - private let logger = Logger(subsystem: "es.amodrono.foodle.file-provider", category: "WorkingSet") + private let logger = Logger(subsystem: "es.amodrono.findle.file-provider", category: "WorkingSet") init(database: Database, siteID: String? = nil) { self.database = database @@ -156,11 +178,12 @@ final class WorkingSetEnumerator: NSObject, NSFileProviderEnumerator { ) { do { let fromCounter = SyncAnchorCoding.decode(anchor) + let upperBound = try database.currentChangeCounter() let changedItems: [LocalItem] if let siteID { - changedItems = try database.fetchItemsChangedSince(anchor: fromCounter, siteID: siteID) + changedItems = try database.fetchItemsChangedSince(anchor: fromCounter, siteID: siteID, through: upperBound) } else { - changedItems = try database.fetchItemsChangedSince(anchor: fromCounter, parentID: nil) + changedItems = try database.fetchItemsChangedSince(anchor: fromCounter, parentID: nil, through: upperBound) } let providerItems = changedItems.map { FileProviderItem(localItem: $0) } @@ -169,14 +192,18 @@ final class WorkingSetEnumerator: NSObject, NSFileProviderEnumerator { observer.didUpdate(providerItems) } - let deletedIDs = try database.fetchPendingDeletionsSince(anchor: fromCounter) + let deletedIDs: [String] + if let siteID { + deletedIDs = try database.fetchPendingDeletionsSince(anchor: fromCounter, siteID: siteID, through: upperBound) + } else { + deletedIDs = try database.fetchPendingDeletionsSince(anchor: fromCounter, through: upperBound) + } if !deletedIDs.isEmpty { let identifiers = deletedIDs.map { NSFileProviderItemIdentifier($0) } observer.didDeleteItems(withIdentifiers: identifiers) } - let newCounter = try database.currentChangeCounter() - observer.finishEnumeratingChanges(upTo: SyncAnchorCoding.encode(newCounter), moreComing: false) + observer.finishEnumeratingChanges(upTo: SyncAnchorCoding.encode(upperBound), moreComing: false) } catch { observer.finishEnumeratingWithError(error) } diff --git a/Sources/FindleMCP/Catalog.swift b/Sources/FindleMCP/Catalog.swift index 8cf66c3..47e56e1 100644 --- a/Sources/FindleMCP/Catalog.swift +++ b/Sources/FindleMCP/Catalog.swift @@ -6,7 +6,7 @@ import Foundation import PDFKit import SharedDomain -import FoodlePersistence +import FindlePersistence /// Read-only data access for the MCP catalog tools. Wraps the shared Findle /// database (opened read-only) and renders results as JSON text for agents. @@ -37,6 +37,10 @@ struct Catalog { let tagsByCourse = (try? database.fetchAllCourseTags(siteID: siteID)) ?? [:] let cursors = (try? database.fetchAllSyncCursors(siteID: siteID)) ?? [] let cursorByCourse = Dictionary(uniqueKeysWithValues: cursors.map { ($0.courseID, $0) }) + let fileCounts = Dictionary( + grouping: try database.fetchAllItems(siteID: siteID).filter { !$0.isDirectory }, + by: \.courseID + ).mapValues(\.count) let out = courses.map { course -> CourseOut in let cursor = cursorByCourse[course.id] @@ -47,7 +51,7 @@ struct Catalog { folderName: course.effectiveFolderName, syncEnabled: course.isSyncEnabled, tags: (tagsByCourse[course.id] ?? []).map(\.name), - fileCount: cursor?.itemCount ?? 0, + fileCount: fileCounts[course.id] ?? 0, lastSynced: cursor.map { $0.lastSyncDate.ISO8601Format() } ) } @@ -57,6 +61,115 @@ struct Catalog { } } + /// Return the small amount of context an agent usually needs before it + /// asks for a specific document. The limits are deliberate: course + /// orientation should not require loading an entire Finder tree into the + /// model context. + func getCourseBrief(courseID: Int, maxSections: Int, maxFilesPerSection: Int, maxChars: Int) -> String { + guard let siteID else { return Self.noAccountJSON } + do { + guard let course = try database.fetchCourses(siteID: siteID).first(where: { $0.id == courseID }) else { + return Self.errorJSON(message: "No course with id \(courseID)") + } + + let items = try database.fetchItems(courseID: courseID, siteID: siteID) + guard let root = items.first(where: { $0.parentID == nil }) else { + return Self.errorJSON(message: "No synced contents for course \(courseID). It may not be sync-enabled.") + } + + let childrenByParent = Dictionary(grouping: items, by: { $0.parentID }) + // Older shared databases predate the outline table. The Finder + // projection remains useful even before the next sync backfills it. + let persistedOutline = try? database.fetchCourseOutline(courseID: courseID, siteID: siteID) + let outlineBySectionID = Dictionary( + uniqueKeysWithValues: (persistedOutline?.sections ?? []) + .map { ($0.id, $0) } + ) + let sectionLimit = max(1, min(maxSections, 50)) + let fileLimit = max(1, min(maxFilesPerSection, 25)) + let summaryLimit = max(200, min(maxChars, 8_000)) + let upcomingCutoff = Date().addingTimeInterval(-86_400) + let sections = (childrenByParent[root.id] ?? []) + .filter(\.isDirectory) + .sorted(by: Self.itemOrder) + + var truncated = sections.count > sectionLimit + let sectionOut = sections.prefix(sectionLimit).map { section in + let descendants = Self.descendants(of: section.id, childrenByParent: childrenByParent) + let files = descendants + .filter { !$0.isDirectory } + .sorted(by: Self.itemOrder) + truncated = truncated || files.count > fileLimit + let activities = (outlineBySectionID[section.remoteID]?.modules ?? []) + .filter(\.visible) + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + truncated = truncated || activities.count > 20 + return BriefSection( + id: section.id, + name: section.filename, + summary: outlineBySectionID[section.remoteID].flatMap { Self.compactText($0.summary ?? "", maxCharacters: 600) }, + fileCount: files.count, + files: files.prefix(fileLimit).map(BriefFile.init), + activities: activities.prefix(20).map(BriefActivity.init) + ) + } + + let assignments = try database.fetchAssignments(siteID: siteID) + .filter { $0.courseID == courseID && ($0.dueDate ?? .distantPast) >= upcomingCutoff } + .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + .prefix(5) + .map { + BriefDeadline( + type: "assignment", + id: $0.id, + name: $0.name, + due: $0.dueDate?.ISO8601Format() ?? "", + submitted: $0.submitted + ) + } + + let quizzes = try database.fetchQuizzes(siteID: siteID) + .filter { $0.courseID == courseID && ($0.closeDate ?? .distantPast) >= upcomingCutoff } + .sorted { ($0.closeDate ?? .distantFuture) < ($1.closeDate ?? .distantFuture) } + .prefix(5) + .map { + BriefDeadline( + type: "quiz", + id: $0.id, + name: $0.name, + due: $0.closeDate?.ISO8601Format() ?? "", + submitted: nil + ) + } + + let deadlines = (Array(assignments) + Array(quizzes)) + .sorted { $0.due < $1.due } + .prefix(5) + + let cursor = try database.fetchSyncCursor(courseID: courseID, siteID: siteID) + let fileCount = items.filter { !$0.isDirectory }.count + let downloadedCount = items.filter { !$0.isDirectory && $0.syncState == .materialized }.count + return Self.encode(CourseBriefOut( + course: BriefCourse( + id: course.id, + name: course.fullName, + shortName: course.shortName, + summary: Self.compactText(course.summary ?? "", maxCharacters: summaryLimit), + visible: course.visible, + syncEnabled: course.isSyncEnabled, + lastSynced: cursor?.lastSyncDate.ISO8601Format(), + fileCount: fileCount, + downloadedCount: downloadedCount + ), + sections: Array(sectionOut), + upcomingDeadlines: Array(deadlines), + truncated: truncated + )) + } catch { + return Self.errorJSON(error) + } + } + func getCourseContents(courseID: Int) -> String { guard let siteID else { return Self.noAccountJSON } do { @@ -131,7 +244,7 @@ struct Catalog { } } - func readItem(id: String, maxChars: Int) -> String { + func readItem(id: String, maxChars: Int, offset: Int) -> String { do { guard let item = try database.fetchItem(id: id) else { return Self.errorJSON(message: "No item with id \(id)") @@ -144,16 +257,22 @@ struct Catalog { case .failure(let message): return Self.errorJSON(message: message) case .success(let full): - let cap = max(1_000, maxChars) - let truncated = full.count > cap - let text = truncated ? String(full.prefix(cap)) : full + let characters = Array(full) + let cap = max(256, min(maxChars, 20_000)) + let start = max(0, min(offset, characters.count)) + let end = min(characters.count, start + cap) + let truncated = end < characters.count + let text = String(characters[start.. String { + var names: [String] = [] + var current: LocalItem? = item + var hops = 0 + while let node = current, hops < 64 { + names.insert(node.filename, at: 0) + current = node.parentID.flatMap { try? database.fetchItem(id: $0) } + hops += 1 + } + return names.joined(separator: "/") + } + + private func pathForItemID(_ itemID: String) -> String? { + guard let item = try? database.fetchItem(id: itemID) else { return nil } + return itemPath(for: item) + } + static func bestChildMatch(in directory: URL, name: String) -> URL? { let fm = FileManager.default guard let entries = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { @@ -538,6 +683,70 @@ struct Catalog { // MARK: - Output types + private struct CourseBriefOut: Codable { + let course: BriefCourse + let sections: [BriefSection] + let upcomingDeadlines: [BriefDeadline] + let truncated: Bool + } + + private struct BriefCourse: Codable { + let id: Int + let name: String + let shortName: String + let summary: String + let visible: Bool + let syncEnabled: Bool + let lastSynced: String? + let fileCount: Int + let downloadedCount: Int + } + + private struct BriefSection: Codable { + let id: String + let name: String + let summary: String? + let fileCount: Int + let files: [BriefFile] + let activities: [BriefActivity] + } + + private struct BriefActivity: Codable { + let id: Int + let name: String + let type: String + let fileCount: Int + + init(_ module: CourseOutlineModule) { + id = module.id + name = module.name + type = module.type + fileCount = module.files.count + } + } + + private struct BriefFile: Codable { + let id: String + let name: String + let size: Int64 + let downloaded: Bool + + init(_ item: LocalItem) { + id = item.id + name = item.filename + size = item.fileSize + downloaded = item.syncState == .materialized + } + } + + private struct BriefDeadline: Codable { + let type: String + let id: Int + let name: String + let due: String + let submitted: Bool? + } + private struct CourseOut: Codable { let id: Int let fullName: String @@ -613,6 +822,8 @@ struct Catalog { let filename: String let score: Float let passage: String + let contentVersion: String? + let path: String? } private struct SearchHit: Codable { @@ -620,6 +831,8 @@ struct Catalog { let courseID: Int let filename: String let snippet: String + let contentVersion: String? + let path: String? } private struct CourseContentsOut: Codable { @@ -642,8 +855,11 @@ struct Catalog { let filename: String let courseID: Int let characters: Int + let offset: Int + let nextOffset: Int? let truncated: Bool let text: String + let path: String } private struct ItemOut: Codable { @@ -674,11 +890,34 @@ struct Catalog { return a.filename.localizedCaseInsensitiveCompare(b.filename) == .orderedAscending } + private static func descendants( + of parentID: String, + childrenByParent: [String?: [LocalItem]] + ) -> [LocalItem] { + var result: [LocalItem] = [] + var frontier = childrenByParent[parentID] ?? [] + while !frontier.isEmpty { + result.append(contentsOf: frontier) + frontier = frontier.flatMap { childrenByParent[$0.id] ?? [] } + } + return result + } + + private static func compactText(_ text: String, maxCharacters: Int) -> String { + let withoutTags = text.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) + let compact = withoutTags + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + return String(compact.prefix(maxCharacters)) + } + // MARK: - Encoding helpers private static func encode(_ value: T) -> String { let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + // MCP responses are model input. Compact JSON avoids spending context + // on indentation while keeping deterministic key ordering for tests. + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] return (try? encoder.encode(value)).flatMap { String(data: $0, encoding: .utf8) } ?? #"{"error":"Failed to encode response"}"# } @@ -703,38 +942,67 @@ struct ArgReader { } extension Catalog { + static func toolResult(_ text: String) -> (text: String, isError: Bool) { + guard let object = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any] else { + return (text, false) + } + return (text, object["error"] != nil) + } + + static func missingArgument(_ name: String) -> (text: String, isError: Bool) { + (Self.errorJSON(message: "Missing required argument: \(name)"), true) + } + /// Run a tool by name. Shared by both transports; returns the JSON text and /// whether it represents an error. func callTool(named name: String, args: ArgReader) -> (text: String, isError: Bool) { switch name { case "list_courses": - return (listCourses(), false) + return Self.toolResult(listCourses()) + case "get_course_brief": + guard let course = args.int("course") else { return Self.missingArgument("course") } + return Self.toolResult(getCourseBrief( + courseID: course, + maxSections: args.int("max_sections") ?? 12, + maxFilesPerSection: args.int("max_files_per_section") ?? 8, + maxChars: args.int("max_chars") ?? 2_000 + )) case "get_course_contents": - return (getCourseContents(courseID: args.int("course") ?? -1), false) + guard let course = args.int("course") else { return Self.missingArgument("course") } + return Self.toolResult(getCourseContents(courseID: course)) case "search_items": - return (searchItems(query: args.string("query") ?? "", courseID: args.int("course"), limit: args.int("limit") ?? 20), false) + guard let query = args.string("query") else { return Self.missingArgument("query") } + return Self.toolResult(searchItems(query: query, courseID: args.int("course"), limit: args.int("limit") ?? 20)) case "search_text": - return (searchText(query: args.string("query") ?? "", courseID: args.int("course"), limit: args.int("limit") ?? 10), false) + guard let query = args.string("query") else { return Self.missingArgument("query") } + return Self.toolResult(searchText(query: query, courseID: args.int("course"), limit: args.int("limit") ?? 10)) case "semantic_search": - return (semanticSearch(query: args.string("query") ?? "", courseID: args.int("course"), k: args.int("k") ?? 6), false) + guard let query = args.string("query") else { return Self.missingArgument("query") } + return Self.toolResult(semanticSearch(query: query, courseID: args.int("course"), k: args.int("k") ?? 6)) case "index_course": - return (indexCourse(courseID: args.int("course") ?? -1), false) + guard let course = args.int("course") else { return Self.missingArgument("course") } + return Self.toolResult(indexCourse(courseID: course)) case "read_item": - return (readItem(id: args.string("id") ?? "", maxChars: args.int("max_chars") ?? 100_000), false) + guard let id = args.string("id") else { return Self.missingArgument("id") } + return Self.toolResult(readItem(id: id, maxChars: args.int("max_chars") ?? 8_000, offset: args.int("offset") ?? 0)) case "get_item": - return (getItem(id: args.string("id") ?? ""), false) + guard let id = args.string("id") else { return Self.missingArgument("id") } + return Self.toolResult(getItem(id: id)) case "get_moodle_url": - return (getMoodleURL(id: args.string("id") ?? ""), false) + guard let id = args.string("id") else { return Self.missingArgument("id") } + return Self.toolResult(getMoodleURL(id: id)) case "trigger_sync": - return (triggerSync(courseID: args.int("course")), false) + return Self.toolResult(triggerSync(courseID: args.int("course"))) case "list_deadlines": - return (listDeadlines(courseID: args.int("course"), withinDays: args.int("within_days")), false) + return Self.toolResult(listDeadlines(courseID: args.int("course"), withinDays: args.int("within_days"))) case "get_submission_status": - return (getSubmissionStatus(assignmentID: args.int("assignment_id") ?? -1), false) + guard let assignmentID = args.int("assignment_id") else { return Self.missingArgument("assignment_id") } + return Self.toolResult(getSubmissionStatus(assignmentID: assignmentID)) case "get_grades": - return (getGrades(courseID: args.int("course")), false) + return Self.toolResult(getGrades(courseID: args.int("course"))) case "get_quiz_attempts": - return (getQuizAttempts(quizID: args.int("quiz_id") ?? -1), false) + guard let quizID = args.int("quiz_id") else { return Self.missingArgument("quiz_id") } + return Self.toolResult(getQuizAttempts(quizID: quizID)) default: return ("Unknown tool: \(name)", true) } diff --git a/Sources/FindleMCP/FindleMCP.swift b/Sources/FindleMCP/FindleMCP.swift index b850544..a4ff805 100644 --- a/Sources/FindleMCP/FindleMCP.swift +++ b/Sources/FindleMCP/FindleMCP.swift @@ -5,7 +5,7 @@ import Foundation import MCP -import FoodlePersistence +import FindlePersistence /// Findle's MCP server: a local, stdio, read-only bridge that lets an agent /// query the user's synced Moodle coursework without manual uploads. @@ -16,10 +16,8 @@ import FoodlePersistence @main struct FindleMCP { static func main() async { - let dbPath = resolveDatabasePath() - - guard FileManager.default.fileExists(atPath: dbPath) else { - failStartup("database not found at \(dbPath)\nOpen Findle and sign in first.") + guard let dbPath = resolveDatabasePath() else { + failStartup("no complete database found. Open Findle and sign in first.") } let database: Database @@ -48,7 +46,7 @@ struct FindleMCP { let args = params.arguments let reader = ArgReader(string: { stringArg(args, $0) }, int: { intArg(args, $0) }) let result = catalog.callTool(named: params.name, args: reader) - return .init(content: [.text(result.text)], isError: result.isError) + return .init(content: [.text(text: result.text, annotations: nil, _meta: nil)], isError: result.isError) } // HTTP (Streamable HTTP + bearer token) when requested, otherwise stdio. @@ -94,6 +92,32 @@ struct FindleMCP { "properties": .object([:]) ]) ), + Tool( + name: "get_course_brief", + description: "Get a compact, bounded course overview in one call: summary, sync status, section outline, representative files, and upcoming deadlines. Prefer this over browsing the full tree.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "course": .object([ + "type": .string("integer"), + "description": .string("The course id from list_courses.") + ]), + "max_sections": .object([ + "type": .string("integer"), + "description": .string("Maximum sections to return (default 12).") + ]), + "max_files_per_section": .object([ + "type": .string("integer"), + "description": .string("Maximum representative files per section (default 8).") + ]), + "max_chars": .object([ + "type": .string("integer"), + "description": .string("Maximum characters for the course summary (default 2000).") + ]) + ]), + "required": .array([.string("course")]) + ]) + ), Tool( name: "search_items", description: "Find synced files by name across courses. Catalog/keyword match over file names (full-text search arrives in a later milestone).", @@ -142,7 +166,11 @@ struct FindleMCP { ]), "max_chars": .object([ "type": .string("integer"), - "description": .string("Maximum characters of text to return (default 100000).") + "description": .string("Maximum characters in this chunk (default 8000, maximum 20000).") + ]), + "offset": .object([ + "type": .string("integer"), + "description": .string("Character offset for paging through a long document (default 0).") ]) ]), "required": .array([.string("id")]) @@ -409,16 +437,36 @@ struct HTTPOptions { /// Resolve the database path: `FINDLE_DB_PATH` env, then a `--db-path` argument, /// then the default shared App Group container location. -private func resolveDatabasePath() -> String { +private func resolveDatabasePath() -> String? { let env = ProcessInfo.processInfo.environment - if let path = env["FINDLE_DB_PATH"], !path.isEmpty { + let args = CommandLine.arguments + let configuredPath = env["FINDLE_DB_PATH"].flatMap { $0.isEmpty ? nil : $0 } + ?? args.firstIndex(of: "--db-path").flatMap { index in + index + 1 < args.count ? args[index + 1] : nil + } + let defaultPath = Database.sharedContainerDatabasePath + let candidates = [configuredPath, defaultPath].compactMap { $0 } + + for path in candidates where isUsableDatabase(at: path) { return path } - let args = CommandLine.arguments - if let index = args.firstIndex(of: "--db-path"), index + 1 < args.count { - return args[index + 1] + + return nil +} + +private func isUsableDatabase(at path: String) -> Bool { + guard FileManager.default.fileExists(atPath: path), + let database = try? Database(path: path, readOnly: true), + let accounts = try? database.fetchAccounts(), + accounts.contains(where: { $0.state.isConnected }) else { + return false } - return Database.sharedContainerDatabasePath + + // State-directory databases are explicitly marked complete by the app. + // If an update/reset left the configured path stale or half-seeded, fall + // back to the bootstrap App Group snapshot instead of feeding an agent an + // empty or partial catalog. + return (try? database.isSeedComplete()) == true } private func stringArg(_ args: [String: Value]?, _ key: String) -> String? { diff --git a/Sources/FindleMCP/HTTPServer.swift b/Sources/FindleMCP/HTTPServer.swift index c62fa96..753ba1c 100644 --- a/Sources/FindleMCP/HTTPServer.swift +++ b/Sources/FindleMCP/HTTPServer.swift @@ -18,7 +18,7 @@ final class HTTPServer: @unchecked Sendable { private let token: String private let handler: @Sendable (Data) -> Data? private var listener: NWListener? - private let queue = DispatchQueue(label: "es.amodrono.foodle.mcp.http", attributes: .concurrent) + private let queue = DispatchQueue(label: "es.amodrono.findle.mcp.http", attributes: .concurrent) init(port: UInt16, token: String, handler: @escaping @Sendable (Data) -> Data?) { self.port = NWEndpoint.Port(rawValue: port) ?? 8080 diff --git a/Sources/FindleMCP/IndexStore.swift b/Sources/FindleMCP/IndexStore.swift index b75ee47..3cc9317 100644 --- a/Sources/FindleMCP/IndexStore.swift +++ b/Sources/FindleMCP/IndexStore.swift @@ -16,13 +16,14 @@ private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.sel /// so repeat reads don't re-open the PDF. final class IndexStore: @unchecked Sendable { private var db: OpaquePointer? - private let queue = DispatchQueue(label: "es.amodrono.foodle.mcp.index") + private let queue = DispatchQueue(label: "es.amodrono.findle.mcp.index") struct Hit { let itemID: String let courseID: Int let filename: String let snippet: String + let contentVersion: String? } /// Opens the index database. `path` is injectable for tests; production uses @@ -87,6 +88,7 @@ final class IndexStore: @unchecked Sendable { let filename: String let chunkText: String let vector: [Float] + let contentVersion: String? } /// Whether `itemID` already has embeddings stored at the given content version. @@ -127,7 +129,7 @@ final class IndexStore: @unchecked Sendable { /// for brute-force cosine ranking. func fetchEmbeddings(language: String, courseID: Int?) -> [EmbeddingHit] { queue.sync { - var sql = "SELECT item_id, course_id, filename, chunk_text, vector FROM embeddings WHERE language = ?" + var sql = "SELECT item_id, course_id, filename, chunk_text, vector, content_version FROM embeddings WHERE language = ?" if courseID != nil { sql += " AND course_id = ?" } var stmt: OpaquePointer? @@ -149,7 +151,8 @@ final class IndexStore: @unchecked Sendable { courseID: Int(sqlite3_column_int64(stmt, 1)), filename: sqlite3_column_text(stmt, 2).map { String(cString: $0) } ?? "", chunkText: sqlite3_column_text(stmt, 3).map { String(cString: $0) } ?? "", - vector: vector + vector: vector, + contentVersion: sqlite3_column_text(stmt, 5).map { String(cString: $0) } )) } return hits @@ -194,7 +197,7 @@ final class IndexStore: @unchecked Sendable { guard !match.isEmpty else { return [] } return queue.sync { - var sql = "SELECT item_id, course_id, filename, snippet(item_text, 3, '«', '»', '…', 14) FROM item_text WHERE item_text MATCH ?" + var sql = "SELECT item_id, course_id, filename, snippet(item_text, 3, '«', '»', '…', 14), content_version FROM item_text WHERE item_text MATCH ?" // CAST: course_id is an FTS5 UNINDEXED column; its stored affinity is // unreliable, so compare as an integer to keep scoped search correct. if courseID != nil { sql += " AND CAST(course_id AS INTEGER) = ?" } @@ -215,7 +218,8 @@ final class IndexStore: @unchecked Sendable { itemID: sqlite3_column_text(stmt, 0).map { String(cString: $0) } ?? "", courseID: Int(sqlite3_column_int64(stmt, 1)), filename: sqlite3_column_text(stmt, 2).map { String(cString: $0) } ?? "", - snippet: sqlite3_column_text(stmt, 3).map { String(cString: $0) } ?? "" + snippet: sqlite3_column_text(stmt, 3).map { String(cString: $0) } ?? "", + contentVersion: sqlite3_column_text(stmt, 4).map { String(cString: $0) } )) } return hits diff --git a/Sources/Networking/Auth/EmbeddedAuthCoordinator.swift b/Sources/Networking/Auth/EmbeddedAuthCoordinator.swift index db709ad..1e516eb 100644 --- a/Sources/Networking/Auth/EmbeddedAuthCoordinator.swift +++ b/Sources/Networking/Auth/EmbeddedAuthCoordinator.swift @@ -22,7 +22,7 @@ public struct EmbeddedAuthResult: Sendable { @MainActor public final class EmbeddedAuthCoordinator: NSObject { - private let logger = Logger(subsystem: "es.amodrono.foodle.networking", category: "EmbeddedAuth") + private let logger = Logger(subsystem: "es.amodrono.findle.networking", category: "EmbeddedAuth") /// The WKWebView managed by this coordinator. Callers embed this in their view hierarchy. public private(set) var webView: WKWebView! @@ -110,7 +110,7 @@ public final class EmbeddedAuthCoordinator: NSObject { cleanUpWebData() let pending = continuation continuation = nil - pending?.resume(throwing: FoodleError.cancelled) + pending?.resume(throwing: FindleError.cancelled) } // MARK: - Private @@ -207,7 +207,7 @@ extension EmbeddedAuthCoordinator: WKNavigationDelegate { cleanUpWebData() let pending = continuation continuation = nil - pending?.resume(throwing: FoodleError.ssoCallbackInvalid( + pending?.resume(throwing: FindleError.ssoCallbackInvalid( detail: "The embedded sign-in page failed to load: \(error.localizedDescription)" )) } diff --git a/Sources/Networking/Auth/KeychainManager.swift b/Sources/Networking/Auth/KeychainManager.swift index 3511510..b74bc9a 100644 --- a/Sources/Networking/Auth/KeychainManager.swift +++ b/Sources/Networking/Auth/KeychainManager.swift @@ -12,7 +12,7 @@ import SharedDomain public final class KeychainManager: Sendable { public static let shared = KeychainManager() - private let logger = Logger(subsystem: "es.amodrono.foodle.networking", category: "Keychain") + private let logger = Logger(subsystem: "es.amodrono.findle.networking", category: "Keychain") private let service = BundleIdentifiers.keychainService private init() {} diff --git a/Sources/Networking/Auth/MoodleSSOLaunchURLBuilder.swift b/Sources/Networking/Auth/MoodleSSOLaunchURLBuilder.swift index a838588..424235c 100644 --- a/Sources/Networking/Auth/MoodleSSOLaunchURLBuilder.swift +++ b/Sources/Networking/Auth/MoodleSSOLaunchURLBuilder.swift @@ -26,7 +26,7 @@ public struct MoodleSSOLaunchURLBuildResult: Sendable { public enum MoodleSSOLaunchURLBuilder { private static let logger = Logger( - subsystem: "es.amodrono.foodle.networking", + subsystem: "es.amodrono.findle.networking", category: "SSOLaunchURLBuilder" ) @@ -37,9 +37,9 @@ public enum MoodleSSOLaunchURLBuilder { /// - Parameters: /// - site: The Moodle site requiring SSO. /// - passport: The random passport nonce for callback verification. - /// - callbackScheme: The URL scheme registered for the SSO callback (default: `foodle`). + /// - callbackScheme: The URL scheme registered for the SSO callback (default: `findle`). /// - Returns: A build result containing the validated URL and its source. - /// - Throws: `FoodleError.ssoLaunchURLUnavailable` if no valid URL can be formed. + /// - Throws: `FindleError.ssoLaunchURLUnavailable` if no valid URL can be formed. public static func build( for site: MoodleSite, passport: String, @@ -71,7 +71,7 @@ public enum MoodleSSOLaunchURLBuilder { } logger.error("Could not build any valid SSO launch URL for \(site.displayName, privacy: .public)") - throw FoodleError.ssoLaunchURLUnavailable( + throw FindleError.ssoLaunchURLUnavailable( detail: "Neither the site-advertised launch URL nor the default endpoint could produce a valid URL." ) } diff --git a/Sources/Networking/Auth/WebAuthSession.swift b/Sources/Networking/Auth/WebAuthSession.swift index bb5ba8f..fee8385 100644 --- a/Sources/Networking/Auth/WebAuthSession.swift +++ b/Sources/Networking/Auth/WebAuthSession.swift @@ -24,7 +24,7 @@ extension ASWebAuthenticationSession: WebAuthenticationSessionProtocol {} /// a `WKWebView` in-app instead of opening the system browser. @MainActor public final class WebAuthSession: NSObject { - private let logger = Logger(subsystem: "es.amodrono.foodle.networking", category: "WebAuth") + private let logger = Logger(subsystem: "es.amodrono.findle.networking", category: "WebAuth") typealias SessionFactory = ( URL, String?, @@ -116,9 +116,9 @@ public final class WebAuthSession: NSObject { let nsError = error as NSError logger.error("SSO authentication session failed for \(site.displayName, privacy: .public) with \(nsError.domain, privacy: .public) (\(nsError.code, privacy: .public)): \(error.localizedDescription, privacy: .public)") if nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue { - continuation.resume(throwing: FoodleError.cancelled) + continuation.resume(throwing: FindleError.cancelled) } else { - continuation.resume(throwing: FoodleError.ssoCallbackInvalid( + continuation.resume(throwing: FindleError.ssoCallbackInvalid( detail: "SSO authentication failed: \(error.localizedDescription)" )) } @@ -126,7 +126,7 @@ public final class WebAuthSession: NSObject { } guard let url else { - continuation.resume(throwing: FoodleError.ssoCallbackInvalid( + continuation.resume(throwing: FindleError.ssoCallbackInvalid( detail: "No callback URL received from SSO." )) return @@ -150,7 +150,7 @@ public final class WebAuthSession: NSObject { logger.error("Ignoring duplicate ASWebAuthenticationSession start failure for \(site.displayName, privacy: .public)") return } - continuation.resume(throwing: FoodleError.ssoSessionStartFailed( + continuation.resume(throwing: FindleError.ssoSessionStartFailed( detail: "The browser authentication session could not be started for \(site.displayName)." )) } diff --git a/Sources/Networking/Client/MoodleClient.swift b/Sources/Networking/Client/MoodleClient.swift index a8807e0..f5e1f3b 100644 --- a/Sources/Networking/Client/MoodleClient.swift +++ b/Sources/Networking/Client/MoodleClient.swift @@ -11,7 +11,7 @@ import CommonCrypto /// Native Moodle web services client implementing the LMSProvider protocol. public final class MoodleClient: LMSProvider, Sendable { private let session: URLSession - private let logger = Logger(subsystem: "es.amodrono.foodle.networking", category: "MoodleClient") + private let logger = Logger(subsystem: "es.amodrono.findle.networking", category: "MoodleClient") public init(session: URLSession = .shared) { self.session = session @@ -38,7 +38,7 @@ public final class MoodleClient: LMSProvider, Sendable { return try await fetchSiteInfo(baseURL: normalizedURL, requestPolicy: .interactiveValidation) } catch is CancellationError { throw CancellationError() - } catch let error as FoodleError { + } catch let error as FindleError { guard shouldAttemptCompatibilityProbe(after: error) else { throw error } @@ -79,11 +79,11 @@ public final class MoodleClient: LMSProvider, Sendable { let (data, response) = try await performRequest(request, policy: requestPolicy) guard let httpResponse = response as? HTTPURLResponse else { - throw FoodleError.siteUnreachable(url: baseURL) + throw FindleError.siteUnreachable(url: baseURL) } guard (200...299).contains(httpResponse.statusCode) else { - throw FoodleError.requestFailed( + throw FindleError.requestFailed( statusCode: httpResponse.statusCode, detail: String(data: data, encoding: .utf8) ?? "Unknown" ) @@ -93,11 +93,11 @@ public final class MoodleClient: LMSProvider, Sendable { let first = responses.first { if let exception = first["exception"] as? [String: Any] { let detail = exception["message"] as? String ?? "Missing public site configuration." - throw FoodleError.siteIncompatible(reason: detail) + throw FindleError.siteIncompatible(reason: detail) } guard let resultData = first["data"] as? [String: Any] else { - throw FoodleError.invalidResponse(detail: "Missing public site configuration.") + throw FindleError.invalidResponse(detail: "Missing public site configuration.") } let siteName = resultData["sitename"] as? String @@ -177,7 +177,7 @@ public final class MoodleClient: LMSProvider, Sendable { ) } - throw FoodleError.invalidResponse(detail: "Could not decode Moodle public site configuration.") + throw FindleError.invalidResponse(detail: "Could not decode Moodle public site configuration.") } private func probeTokenEndpointCompatibility( @@ -194,20 +194,20 @@ public final class MoodleClient: LMSProvider, Sendable { let (data, response) = try await performRequest(request, policy: requestPolicy) guard let httpResponse = response as? HTTPURLResponse else { - throw FoodleError.siteUnreachable(url: baseURL) + throw FindleError.siteUnreachable(url: baseURL) } guard httpResponse.statusCode == 200 else { - throw FoodleError.siteIncompatible(reason: "Could not verify Moodle web services at this URL.") + throw FindleError.siteIncompatible(reason: "Could not verify Moodle web services at this URL.") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw FoodleError.siteIncompatible(reason: "Could not verify Moodle web services at this URL.") + throw FindleError.siteIncompatible(reason: "Could not verify Moodle web services at this URL.") } let errorCode = json["errorcode"] as? String if errorCode == "enablewsdescription" { - throw FoodleError.webServicesDisabled + throw FindleError.webServicesDisabled } let isValidMoodle = errorCode == "invalidlogin" @@ -215,7 +215,7 @@ public final class MoodleClient: LMSProvider, Sendable { || json["token"] != nil guard isValidMoodle else { - throw FoodleError.siteIncompatible(reason: "Could not verify Moodle web services at this URL.") + throw FindleError.siteIncompatible(reason: "Could not verify Moodle web services at this URL.") } logger.info("Token endpoint compatibility probe succeeded") @@ -230,7 +230,7 @@ public final class MoodleClient: LMSProvider, Sendable { ) } - private func shouldAttemptCompatibilityProbe(after error: FoodleError) -> Bool { + private func shouldAttemptCompatibilityProbe(after error: FindleError) -> Bool { switch error { case .networkUnavailable, .timeout, .siteUnreachable, .webServicesDisabled: return false @@ -261,13 +261,13 @@ public final class MoodleClient: LMSProvider, Sendable { if let error = tokenResponse.error { if error.contains("invalidlogin") || tokenResponse.errorcode == "invalidlogin" { - throw FoodleError.invalidCredentials + throw FindleError.invalidCredentials } - throw FoodleError.siteIncompatible(reason: error) + throw FindleError.siteIncompatible(reason: error) } guard let token = tokenResponse.token else { - throw FoodleError.invalidResponse(detail: "No token in authentication response.") + throw FindleError.invalidResponse(detail: "No token in authentication response.") } return AuthToken(token: token, privateToken: tokenResponse.privatetoken) @@ -279,7 +279,7 @@ public final class MoodleClient: LMSProvider, Sendable { logger.info("Parsing SSO callback") guard let base64String = Self.extractTokenParam(from: callbackURLString) else { - throw FoodleError.invalidResponse(detail: "SSO callback URL has unexpected format.") + throw FindleError.invalidResponse(detail: "SSO callback URL has unexpected format.") } return try decodeTokenPayload(base64String, site: site, passport: passport) @@ -287,7 +287,7 @@ public final class MoodleClient: LMSProvider, Sendable { /// Extract the base64 token parameter from a raw callback URL string. /// Handles both `scheme://token=` and `scheme://token?token=` formats, - /// and accepts any URL scheme (foodle, moodlemobile, openlms, etc.). + /// and accepts any URL scheme (findle, moodlemobile, openlms, etc.). static func extractTokenParam(from urlString: String) -> String? { // Format 1: scheme://token= — the token is everything after "://token=" if let range = urlString.range(of: "://token=") { @@ -321,13 +321,13 @@ public final class MoodleClient: LMSProvider, Sendable { guard let data = Data(base64Encoded: base64), let decoded = String(data: data, encoding: .utf8) else { - throw FoodleError.invalidResponse(detail: "Could not decode SSO token payload.") + throw FindleError.invalidResponse(detail: "Could not decode SSO token payload.") } // Moodle payload format: md5(siteURL + passport):::token[:::privatetoken] let parts = decoded.components(separatedBy: ":::") guard parts.count >= 2 else { - throw FoodleError.invalidResponse(detail: "SSO token payload has unexpected format.") + throw FindleError.invalidResponse(detail: "SSO token payload has unexpected format.") } let signature = parts[0] @@ -345,7 +345,7 @@ public final class MoodleClient: LMSProvider, Sendable { guard matched else { logger.error("SSO signature mismatch - none of the candidate URLs matched") - throw FoodleError.invalidResponse(detail: "SSO security verification failed.") + throw FindleError.invalidResponse(detail: "SSO security verification failed.") } logger.info("SSO token obtained successfully") @@ -619,7 +619,7 @@ public final class MoodleClient: LMSProvider, Sendable { guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - throw FoodleError.downloadFailed(itemID: url.lastPathComponent, reason: "HTTP \(statusCode)") + throw FindleError.downloadFailed(itemID: url.lastPathComponent, reason: "HTTP \(statusCode)") } let fileManager = FileManager.default @@ -671,11 +671,11 @@ public final class MoodleClient: LMSProvider, Sendable { let (data, response) = try await performRequest(request) guard let httpResponse = response as? HTTPURLResponse else { - throw FoodleError.invalidResponse(detail: "Non-HTTP response.") + throw FindleError.invalidResponse(detail: "Non-HTTP response.") } guard (200...299).contains(httpResponse.statusCode) else { - throw FoodleError.requestFailed( + throw FindleError.requestFailed( statusCode: httpResponse.statusCode, detail: String(data: data, encoding: .utf8) ?? "Unknown" ) @@ -685,9 +685,9 @@ public final class MoodleClient: LMSProvider, Sendable { if let errorResponse = try? Self.makeDecoder().decode(MoodleErrorResponse.self, from: data) { if errorResponse.errorcode != nil { if errorResponse.errorcode == "invalidtoken" || errorResponse.errorcode == "accessexception" { - throw FoodleError.tokenExpired + throw FindleError.tokenExpired } - throw FoodleError.requestFailed( + throw FindleError.requestFailed( statusCode: httpResponse.statusCode, detail: errorResponse.message ?? errorResponse.errorcode ?? "Unknown Moodle error" ) @@ -774,14 +774,14 @@ public final class MoodleClient: LMSProvider, Sendable { } } - throw lastError ?? FoodleError.networkUnavailable + throw lastError ?? FindleError.networkUnavailable } private func mapTransportError( _ error: URLError, requestURL: URL?, policy: RequestPolicy - ) -> FoodleError? { + ) -> FindleError? { switch error.code { case .notConnectedToInternet, .networkConnectionLost: return .networkUnavailable diff --git a/Sources/Networking/FoodleNetworking.swift b/Sources/Networking/FindleNetworking.swift similarity index 83% rename from Sources/Networking/FoodleNetworking.swift rename to Sources/Networking/FindleNetworking.swift index 13123c6..992de1a 100644 --- a/Sources/Networking/FoodleNetworking.swift +++ b/Sources/Networking/FindleNetworking.swift @@ -3,8 +3,8 @@ // 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. -/// FoodleNetworking module. +/// FindleNetworking module. /// Contains the Moodle/Open LMS API client, authentication, and Keychain management. -public enum FoodleNetworkingVersion { +public enum FindleNetworkingVersion { public static let current = "1.0.0" } diff --git a/Sources/Persistence/Database.swift b/Sources/Persistence/Database.swift index 3d14084..2b574b9 100644 --- a/Sources/Persistence/Database.swift +++ b/Sources/Persistence/Database.swift @@ -14,12 +14,12 @@ import SharedDomain // autoreleased NSString.utf8String buffer or a transient Swift String. private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) -/// SQLite database manager for Foodle's local persistence. +/// SQLite database manager for Findle's local persistence. public final class Database: @unchecked Sendable { private static let appGroupIdentifier = BundleIdentifiers.appGroup private var db: OpaquePointer? - private let queue = DispatchQueue(label: "es.amodrono.foodle.persistence.db", qos: .userInitiated) - private let logger = Logger(subsystem: "es.amodrono.foodle.persistence", category: "Database") + private let queue = DispatchQueue(label: "es.amodrono.findle.persistence.db", qos: .userInitiated) + private let logger = Logger(subsystem: "es.amodrono.findle.persistence", category: "Database") private let path: String public var filePath: String { path } @@ -28,11 +28,11 @@ public final class Database: @unchecked Sendable { /// (the MCP server) can locate it without the App Group entitlement. public static var sharedContainerDatabasePath: String { URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library/Group Containers/\(appGroupIdentifier)/Application Support/Foodle/foodle.db") + .appendingPathComponent("Library/Group Containers/\(appGroupIdentifier)/Application Support/Findle/findle.db") .path } - public static let schemaVersion = 13 + public static let schemaVersion = 15 /// Opens the database. /// @@ -66,13 +66,16 @@ public final class Database: @unchecked Sendable { appSupport = preferredAppSupport } else { - appSupport = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Application Support", isDirectory: true) + // `NSHomeDirectory()` points at the real home directory even + // inside the sandbox. Use the resolved user-domain URL so a + // missing App Group entitlement cannot make sign-in attempt + // to write outside the app's container. + appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fm.temporaryDirectory.appendingPathComponent("Findle", isDirectory: true) } - let dbDir = appSupport.appendingPathComponent("Foodle", isDirectory: true) + let dbDir = appSupport.appendingPathComponent("Findle", isDirectory: true) try FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true) - self.path = dbDir.appendingPathComponent("foodle.db").path + self.path = dbDir.appendingPathComponent("findle.db").path } var dbPointer: OpaquePointer? @@ -82,7 +85,7 @@ public final class Database: @unchecked Sendable { let status = sqlite3_open_v2(self.path, &dbPointer, flags, nil) guard status == SQLITE_OK, let pointer = dbPointer else { let message = dbPointer.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "Unknown error" - throw FoodleError.databaseError(detail: "Could not open database: \(message)") + throw FindleError.databaseError(detail: "Could not open database: \(message)") } self.db = pointer @@ -117,11 +120,11 @@ public final class Database: @unchecked Sendable { } private static func databaseDirectory(in appSupport: URL) -> URL { - appSupport.appendingPathComponent("Foodle", isDirectory: true) + appSupport.appendingPathComponent("Findle", isDirectory: true) } private static func databaseURL(in appSupport: URL) -> URL { - databaseDirectory(in: appSupport).appendingPathComponent("foodle.db") + databaseDirectory(in: appSupport).appendingPathComponent("findle.db") } private static func migrateLegacyDatabaseIfNeeded( @@ -140,8 +143,8 @@ public final class Database: @unchecked Sendable { for suffix in ["", "-wal", "-shm"] { let sourceURL = legacyDatabaseURL.deletingLastPathComponent() - .appendingPathComponent("foodle.db\(suffix)") - let destinationURL = preferredDirectory.appendingPathComponent("foodle.db\(suffix)") + .appendingPathComponent("findle.db\(suffix)") + let destinationURL = preferredDirectory.appendingPathComponent("findle.db\(suffix)") guard fileManager.fileExists(atPath: sourceURL.path) else { continue } guard !fileManager.fileExists(atPath: destinationURL.path) else { continue } @@ -203,6 +206,16 @@ public final class Database: @unchecked Sendable { ) """) + try execute(""" + CREATE TABLE IF NOT EXISTS course_outlines ( + course_id INTEGER NOT NULL, + site_id TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at REAL NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (course_id, site_id) + ) + """) + try execute(""" CREATE TABLE IF NOT EXISTS items ( id TEXT PRIMARY KEY, @@ -307,6 +320,7 @@ public final class Database: @unchecked Sendable { try execute(""" CREATE TABLE IF NOT EXISTS pending_deletions ( item_id TEXT PRIMARY KEY, + site_id TEXT NOT NULL DEFAULT '', deleted_at REAL NOT NULL DEFAULT (strftime('%s', 'now')), deleted_at_counter INTEGER NOT NULL DEFAULT 0 ) @@ -319,6 +333,7 @@ public final class Database: @unchecked Sendable { ) """) try execute("INSERT OR IGNORE INTO system_metadata (key, value) VALUES ('change_counter', 0)") + try execute("INSERT OR IGNORE INTO system_metadata (key, value) VALUES ('seed_complete', 0)") try execute(""" CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id) @@ -530,6 +545,57 @@ public final class Database: @unchecked Sendable { logger.info("Migrated database schema to version 13 (coursework tracking)") } + if currentVersion < 14 { + // v13 -> v14: compact, token-safe Moodle activity outlines. The + // table is created above for new databases and backfilled during + // the next course sync for existing installations. + logger.info("Migrated database schema to version 14 (course outlines)") + } + + if currentVersion < 15 { + // v14 -> v15: associate deletion tombstones with their File + // Provider domain so one site's enumerator cannot receive another + // site's deleted identifiers. Existing rows retain an empty site + // ID and are intentionally excluded from site-scoped queries. + let pendingColumns = try existingColumns(table: "pending_deletions") + if !pendingColumns.contains("site_id") { + try execute("ALTER TABLE pending_deletions ADD COLUMN site_id TEXT NOT NULL DEFAULT ''") + } + // Remote item identifiers include the site UUID. Recover ownership + // for legacy tombstones before site-scoped enumerators start using + // the new column; ambiguous rows remain empty and are not emitted + // into an unrelated domain. + try execute(""" + UPDATE pending_deletions + SET site_id = ( + SELECT sites.id + FROM sites + WHERE instr(pending_deletions.item_id, 'course-' || sites.id || '-') = 1 + OR instr(pending_deletions.item_id, 'section-' || sites.id || '-') = 1 + OR instr(pending_deletions.item_id, 'file-' || sites.id || '-') = 1 + OR instr(pending_deletions.item_id, 'folder-' || sites.id || '-') = 1 + OR instr(pending_deletions.item_id, 'url-' || sites.id || '-') = 1 + OR instr(pending_deletions.item_id, 'page-' || sites.id || '-') = 1 + LIMIT 1 + ) + WHERE site_id = '' + """) + logger.info("Migrated database schema to version 15 (site-scoped tombstones)") + } + + // Databases created before the seed marker was introduced were already + // authoritative app databases. Promote only those legacy databases + // that contain a session; a partial v14+ snapshot must remain false. + if currentVersion < 14 { + try execute(""" + UPDATE system_metadata + SET value = 1 + WHERE key = 'seed_complete' + AND EXISTS (SELECT 1 FROM sites) + AND EXISTS (SELECT 1 FROM accounts WHERE state LIKE 'authenticated%') + """) + } + try execute("PRAGMA user_version = \(Self.schemaVersion)") } @@ -608,7 +674,7 @@ public final class Database: @unchecked Sendable { if status != SQLITE_OK { let message = errorMessage.map { String(cString: $0) } ?? "Unknown error" sqlite3_free(errorMessage) - throw FoodleError.databaseError(detail: message) + throw FindleError.databaseError(detail: message) } } @@ -618,7 +684,7 @@ public final class Database: @unchecked Sendable { let status = sqlite3_prepare_v2(db, sql, -1, &stmt, nil) guard status == SQLITE_OK, let statement = stmt else { let message = String(cString: sqlite3_errmsg(db)) - throw FoodleError.databaseError(detail: "Prepare failed: \(message)") + throw FindleError.databaseError(detail: "Prepare failed: \(message)") } return statement } @@ -655,7 +721,7 @@ extension Database { let status = sqlite3_step(stmt) guard status == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "Failed to save site: \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "Failed to save site: \(String(cString: sqlite3_errmsg(db)))") } } } @@ -720,7 +786,7 @@ extension Database { let status = sqlite3_step(stmt) guard status == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "Failed to save account") + throw FindleError.databaseError(detail: "Failed to save account") } } } @@ -832,7 +898,7 @@ extension Database { } guard sqlite3_step(stmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "saveCourses step failed: \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "saveCourses step failed: \(String(cString: sqlite3_errmsg(db)))") } } try executeUnsafe("COMMIT") @@ -924,7 +990,7 @@ extension Database { let status = sqlite3_step(stmt) guard status == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "Failed to update custom folder name") + throw FindleError.databaseError(detail: "Failed to update custom folder name") } } } @@ -944,7 +1010,70 @@ extension Database { let status = sqlite3_step(stmt) guard status == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "Failed to update custom icon name") + throw FindleError.databaseError(detail: "Failed to update custom icon name") + } + } + } +} + +// MARK: - Course Outline Operations + +extension Database { + public func saveCourseOutline(_ outline: CourseOutlineSnapshot, siteID: String) throws { + let payload = try JSONEncoder().encode(outline) + try queue.sync { + let stmt = try prepareStatement("INSERT OR REPLACE INTO course_outlines (course_id, site_id, payload, updated_at) VALUES (?, ?, ?, ?)") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int64(stmt, 1, Int64(outline.courseID)) + sqlite3_bind_text(stmt, 2, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) + _ = payload.withUnsafeBytes { bytes in + sqlite3_bind_text(stmt, 3, bytes.bindMemory(to: CChar.self).baseAddress, Int32(payload.count), SQLITE_TRANSIENT) + } + sqlite3_bind_double(stmt, 4, Date().timeIntervalSince1970) + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw FindleError.databaseError(detail: "Failed to save course outline") + } + } + } + + public func fetchCourseOutline(courseID: Int, siteID: String) throws -> CourseOutlineSnapshot? { + try queue.sync { + let stmt = try prepareStatement("SELECT payload FROM course_outlines WHERE course_id = ? AND site_id = ?") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int64(stmt, 1, Int64(courseID)) + sqlite3_bind_text(stmt, 2, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) + guard sqlite3_step(stmt) == SQLITE_ROW, + let payload = sqlite3_column_text(stmt, 0) else { return nil } + return try JSONDecoder().decode( + CourseOutlineSnapshot.self, + from: Data(bytes: payload, count: Int(sqlite3_column_bytes(stmt, 0))) + ) + } + } + + public func fetchCourseOutlines(siteID: String) throws -> [CourseOutlineSnapshot] { + try queue.sync { + let stmt = try prepareStatement("SELECT payload FROM course_outlines WHERE site_id = ? ORDER BY course_id") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) + var outlines: [CourseOutlineSnapshot] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let payload = sqlite3_column_text(stmt, 0) else { continue } + let data = Data(bytes: payload, count: Int(sqlite3_column_bytes(stmt, 0))) + outlines.append(try JSONDecoder().decode(CourseOutlineSnapshot.self, from: data)) + } + return outlines + } + } + + public func deleteCourseOutline(courseID: Int, siteID: String) throws { + try queue.sync { + let stmt = try prepareStatement("DELETE FROM course_outlines WHERE course_id = ? AND site_id = ?") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int64(stmt, 1, Int64(courseID)) + sqlite3_bind_text(stmt, 2, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw FindleError.databaseError(detail: "Failed to delete course outline") } } } @@ -963,7 +1092,7 @@ extension Database { sqlite3_bind_int64(deleteStmt, 1, Int64(courseID)) sqlite3_bind_text(deleteStmt, 2, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) guard sqlite3_step(deleteStmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "saveCourseTags delete failed: \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "saveCourseTags delete failed: \(String(cString: sqlite3_errmsg(db)))") } let insertSQL = "INSERT INTO course_tags (course_id, site_id, tag_name, tag_color) VALUES (?, ?, ?, ?)" @@ -975,7 +1104,7 @@ extension Database { sqlite3_bind_text(stmt, 3, (tag.name as NSString).utf8String, -1, SQLITE_TRANSIENT) sqlite3_bind_int(stmt, 4, Int32(tag.color.rawValue)) guard sqlite3_step(stmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "saveCourseTags insert failed: \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "saveCourseTags insert failed: \(String(cString: sqlite3_errmsg(db)))") } } @@ -1039,7 +1168,19 @@ extension Database { try queue.sync { try executeUnsafe("BEGIN TRANSACTION") do { + let clearTombstoneStmt = try prepareStatement("DELETE FROM pending_deletions WHERE item_id = ?") + defer { sqlite3_finalize(clearTombstoneStmt) } + for item in items { + // A reintroduced remote item must not retain the tombstone + // from its previous deletion. Otherwise a later change + // enumeration can delete the newly-created Finder item. + sqlite3_reset(clearTombstoneStmt) + sqlite3_bind_text(clearTombstoneStmt, 1, (item.id as NSString).utf8String, -1, SQLITE_TRANSIENT) + guard sqlite3_step(clearTombstoneStmt) == SQLITE_DONE else { + throw FindleError.databaseError(detail: "saveItems tombstone cleanup failed") + } + let stmt = try prepareStatement(sql) defer { sqlite3_finalize(stmt) } @@ -1067,7 +1208,7 @@ extension Database { sqlite3_bind_int(stmt, 18, item.isLocal ? 1 : 0) guard sqlite3_step(stmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "saveItems step failed: \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "saveItems step failed: \(String(cString: sqlite3_errmsg(db)))") } } try executeUnsafe("COMMIT") @@ -1092,6 +1233,25 @@ extension Database { } } + /// Return children in a single File Provider domain. Root enumeration must + /// be scoped as well as working-set enumeration; otherwise stale data from + /// another Moodle site can leak into this domain's Finder root. + public func fetchItems(parentID: String?, siteID: String) throws -> [LocalItem] { + try queue.sync { + let stmt: OpaquePointer + if let parentID { + stmt = try prepareStatement("SELECT * FROM items WHERE parent_id = ? AND site_id = ? ORDER BY is_directory DESC, filename") + sqlite3_bind_text(stmt, 1, (parentID as NSString).utf8String, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 2, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) + } else { + stmt = try prepareStatement("SELECT * FROM items WHERE parent_id IS NULL AND site_id = ? ORDER BY is_directory DESC, filename") + sqlite3_bind_text(stmt, 1, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) + } + defer { sqlite3_finalize(stmt) } + return try readItems(from: stmt) + } + } + public func fetchItem(id: String) throws -> LocalItem? { let sql = "SELECT * FROM items WHERE id = ?" return try queue.sync { @@ -1126,21 +1286,22 @@ extension Database { } public func updateItemFilename(id: String, filename: String) throws { - let sql = "UPDATE items SET filename = ? WHERE id = ?" + let sql = "UPDATE items SET filename = ?, modification_date = ? WHERE id = ?" try queue.sync { let stmt = try prepareStatement(sql) defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, (filename as NSString).utf8String, -1, SQLITE_TRANSIENT) - sqlite3_bind_text(stmt, 2, (id as NSString).utf8String, -1, SQLITE_TRANSIENT) + sqlite3_bind_double(stmt, 2, Date().timeIntervalSince1970) + sqlite3_bind_text(stmt, 3, (id as NSString).utf8String, -1, SQLITE_TRANSIENT) let status = sqlite3_step(stmt) guard status == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "Failed to update item filename") + throw FindleError.databaseError(detail: "Failed to update item filename") } } } public func updateItemTagData(id: String, tagData: Data?) throws { - let sql = "UPDATE items SET tag_data = ? WHERE id = ?" + let sql = "UPDATE items SET tag_data = ?, modification_date = ? WHERE id = ?" try queue.sync { let stmt = try prepareStatement(sql) defer { sqlite3_finalize(stmt) } @@ -1149,10 +1310,11 @@ extension Database { } else { sqlite3_bind_null(stmt, 1) } - sqlite3_bind_text(stmt, 2, (id as NSString).utf8String, -1, SQLITE_TRANSIENT) + sqlite3_bind_double(stmt, 2, Date().timeIntervalSince1970) + sqlite3_bind_text(stmt, 3, (id as NSString).utf8String, -1, SQLITE_TRANSIENT) let status = sqlite3_step(stmt) guard status == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "Failed to update item tag data") + throw FindleError.databaseError(detail: "Failed to update item tag data") } } } @@ -1193,7 +1355,7 @@ extension Database { sqlite3_bind_text(stmt, 2, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) sqlite3_bind_text(stmt, 3, (ItemSyncState.downloading.rawValue as NSString).utf8String, -1, SQLITE_TRANSIENT) guard sqlite3_step(stmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "Failed to reset stale downloads") + throw FindleError.databaseError(detail: "Failed to reset stale downloads") } return Int(sqlite3_changes(db)) } @@ -1208,7 +1370,7 @@ extension Database { sqlite3_bind_text(stmt, 2, (id as NSString).utf8String, -1, SQLITE_TRANSIENT) let status = sqlite3_step(stmt) guard status == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "Failed to update item pinned state") + throw FindleError.databaseError(detail: "Failed to update item pinned state") } } } @@ -1232,58 +1394,17 @@ extension Database { } public func deleteItems(courseID: Int, siteID: String) throws { - try queue.sync { - try executeUnsafe("BEGIN TRANSACTION") - do { - // Record IDs for the File Provider to report as deletions (skip local items). - // Append to any pending deletions from prior cycles that haven't drained yet — - // do NOT wipe the table blindly or earlier course deletions would be lost. - let insertStmt = try prepareStatement("INSERT OR IGNORE 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, SQLITE_TRANSIENT) - guard sqlite3_step(insertStmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "deleteItems pending insert failed") - } - - 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, SQLITE_TRANSIENT) - guard sqlite3_step(deleteStmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "deleteItems delete failed") - } - try executeUnsafe("COMMIT") - } catch { - try? executeUnsafe("ROLLBACK") - throw error - } - } + let ids = try fetchItems(courseID: courseID, siteID: siteID) + .filter { !$0.isLocal } + .map(\.id) + try deleteItemsWithTombstone(ids: ids) } public func deleteAllItems(siteID: String) throws { - try queue.sync { - try executeUnsafe("BEGIN TRANSACTION") - do { - let insertStmt = try prepareStatement("INSERT OR IGNORE 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, SQLITE_TRANSIENT) - guard sqlite3_step(insertStmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "deleteAllItems pending insert failed") - } - - 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, SQLITE_TRANSIENT) - guard sqlite3_step(deleteStmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "deleteAllItems delete failed") - } - try executeUnsafe("COMMIT") - } catch { - try? executeUnsafe("ROLLBACK") - throw error - } - } + let ids = try fetchAllItems(siteID: siteID) + .filter { !$0.isLocal } + .map(\.id) + try deleteItemsWithTombstone(ids: ids) } /// Delete the given items (and their descendants) and record a tombstone @@ -1295,19 +1416,53 @@ extension Database { try queue.sync { try executeUnsafe("BEGIN TRANSACTION") do { - var idsToDelete = Set(ids) - var frontier = Array(ids) + // Remote folders can contain user-created local items. Keep + // those items, but move them to the deleted remote item's + // parent so a Moodle refresh cannot destroy user work. + var idsToDelete = Set() + var frontier: [String] = [] + var parentByID: [String: String] = [:] + var siteByID: [String: String] = [:] + let hierarchyStmt = try prepareStatement("SELECT id, parent_id, site_id FROM items") + defer { sqlite3_finalize(hierarchyStmt) } + while sqlite3_step(hierarchyStmt) == SQLITE_ROW { + let id = String(cString: sqlite3_column_text(hierarchyStmt, 0)) + parentByID[id] = sqlite3_column_text(hierarchyStmt, 1).map { String(cString: $0) } ?? "" + siteByID[id] = String(cString: sqlite3_column_text(hierarchyStmt, 2)) + } + + let initialStmt = try prepareStatement("SELECT id, is_local, site_id FROM items WHERE id = ?") + defer { sqlite3_finalize(initialStmt) } + for id in ids { + sqlite3_reset(initialStmt) + sqlite3_bind_text(initialStmt, 1, (id as NSString).utf8String, -1, SQLITE_TRANSIENT) + guard sqlite3_step(initialStmt) == SQLITE_ROW else { continue } + guard sqlite3_column_int(initialStmt, 1) == 0 else { continue } + siteByID[id] = String(cString: sqlite3_column_text(initialStmt, 2)) + if idsToDelete.insert(id).inserted { + frontier.append(id) + } + } + + let childStmt = try prepareStatement("SELECT id, is_local, site_id FROM items WHERE parent_id = ?") + defer { sqlite3_finalize(childStmt) } + let reparentStmt = try prepareStatement("UPDATE items SET parent_id = ?, modification_date = ? WHERE id = ?") + defer { sqlite3_finalize(reparentStmt) } + var localReparents: [(id: String, parentID: String?)] = [] while !frontier.isEmpty { var nextFrontier: [String] = [] - let childStmt = try prepareStatement("SELECT id FROM items WHERE parent_id = ?") - defer { sqlite3_finalize(childStmt) } for parentID in frontier { sqlite3_reset(childStmt) sqlite3_bind_text(childStmt, 1, (parentID as NSString).utf8String, -1, SQLITE_TRANSIENT) while sqlite3_step(childStmt) == SQLITE_ROW { let childID = String(cString: sqlite3_column_text(childStmt, 0)) - if idsToDelete.insert(childID).inserted { + let childIsLocal = sqlite3_column_int(childStmt, 1) == 1 + siteByID[childID] = String(cString: sqlite3_column_text(childStmt, 2)) + if childIsLocal { + let parentOfDeletedItem = parentByID[parentID].flatMap { $0.isEmpty ? nil : $0 } + localReparents.append((id: childID, parentID: parentOfDeletedItem)) + } else if idsToDelete.insert(childID).inserted { nextFrontier.append(childID) } } @@ -1315,7 +1470,30 @@ extension Database { frontier = nextFrontier } - let tombstoneStmt = try prepareStatement("INSERT OR IGNORE INTO pending_deletions (item_id) VALUES (?)") + // Resolve each local item's new parent only after the complete + // remote deletion set is known. This avoids leaving local work + // under another remote ancestor that is also being removed. + for local in localReparents { + var target = local.parentID + var visited: Set = [] + while let candidate = target, idsToDelete.contains(candidate), visited.insert(candidate).inserted { + target = parentByID[candidate].flatMap { $0.isEmpty ? nil : $0 } + } + + sqlite3_reset(reparentStmt) + if let target { + sqlite3_bind_text(reparentStmt, 1, (target as NSString).utf8String, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(reparentStmt, 1) + } + sqlite3_bind_double(reparentStmt, 2, Date().timeIntervalSince1970) + sqlite3_bind_text(reparentStmt, 3, (local.id as NSString).utf8String, -1, SQLITE_TRANSIENT) + guard sqlite3_step(reparentStmt) == SQLITE_DONE else { + throw FindleError.databaseError(detail: "deleteItemsWithTombstone local reparent failed") + } + } + + let tombstoneStmt = try prepareStatement("INSERT OR IGNORE INTO pending_deletions (item_id, site_id) VALUES (?, ?)") defer { sqlite3_finalize(tombstoneStmt) } let deleteStmt = try prepareStatement("DELETE FROM items WHERE id = ?") defer { sqlite3_finalize(deleteStmt) } @@ -1323,13 +1501,14 @@ extension Database { for itemID in idsToDelete { sqlite3_reset(tombstoneStmt) sqlite3_bind_text(tombstoneStmt, 1, (itemID as NSString).utf8String, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(tombstoneStmt, 2, ((siteByID[itemID] ?? "") as NSString).utf8String, -1, SQLITE_TRANSIENT) guard sqlite3_step(tombstoneStmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "deleteItemsWithTombstone tombstone failed") + throw FindleError.databaseError(detail: "deleteItemsWithTombstone tombstone failed") } sqlite3_reset(deleteStmt) sqlite3_bind_text(deleteStmt, 1, (itemID as NSString).utf8String, -1, SQLITE_TRANSIENT) guard sqlite3_step(deleteStmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "deleteItemsWithTombstone delete failed") + throw FindleError.databaseError(detail: "deleteItemsWithTombstone delete failed") } } try executeUnsafe("COMMIT") @@ -1385,6 +1564,20 @@ extension Database { } } + public func fetchPendingDeletions(siteID: String) throws -> [String] { + let sql = "SELECT item_id FROM pending_deletions WHERE site_id = ? ORDER BY deleted_at" + return try queue.sync { + let stmt = try prepareStatement(sql) + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) + var ids: [String] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + ids.append(String(cString: sqlite3_column_text(stmt, 0))) + } + return ids + } + } + /// Return the global monotonic change counter. The value is bumped by SQL /// triggers on writes to `items` and `pending_deletions`, so it's safe to /// use as a sync anchor handed to NSFileProviderChangeObserver. @@ -1397,23 +1590,87 @@ extension Database { } } + /// Keep a replicated database's change counter at or above a previous + /// domain anchor before restoring rows. Subsequent item/tombstone triggers + /// then stamp restored rows with counters newer than Finder's anchor. + public func preserveChangeCounter(atLeast counter: Int64) throws { + try queue.sync { + let stmt = try prepareStatement("UPDATE system_metadata SET value = MAX(value, ?) WHERE key = 'change_counter'") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int64(stmt, 1, counter) + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw FindleError.databaseError(detail: "Failed to preserve change counter") + } + } + } + /// Return items in a container whose `updated_at` is strictly greater than `anchor`. /// Pass `parentID == nil` for root items, or a parent ID for a single container. public func fetchItemsChangedSince(anchor: Int64, parentID: String?) throws -> [LocalItem] { + try fetchItemsChangedSince(anchor: anchor, parentID: parentID, through: nil) + } + + /// Return changes in a container up to a captured counter. Bounding the + /// query and the returned anchor prevents writes that happen during + /// enumeration from being skipped permanently. + public func fetchItemsChangedSince(anchor: Int64, parentID: String?, through upperBound: Int64?) throws -> [LocalItem] { let sql: String if parentID == nil { - sql = "SELECT * FROM items WHERE parent_id IS NULL AND updated_at > ? ORDER BY updated_at" + sql = upperBound == nil + ? "SELECT * FROM items WHERE parent_id IS NULL AND updated_at > ? ORDER BY updated_at" + : "SELECT * FROM items WHERE parent_id IS NULL AND updated_at > ? AND updated_at <= ? ORDER BY updated_at" } else { - sql = "SELECT * FROM items WHERE parent_id = ? AND updated_at > ? ORDER BY updated_at" + sql = upperBound == nil + ? "SELECT * FROM items WHERE parent_id = ? AND updated_at > ? ORDER BY updated_at" + : "SELECT * FROM items WHERE parent_id = ? AND updated_at > ? AND updated_at <= ? ORDER BY updated_at" } return try queue.sync { let stmt = try prepareStatement(sql) defer { sqlite3_finalize(stmt) } + var index: Int32 = 1 if let parentID { - sqlite3_bind_text(stmt, 1, (parentID as NSString).utf8String, -1, SQLITE_TRANSIENT) - sqlite3_bind_int64(stmt, 2, anchor) - } else { - sqlite3_bind_int64(stmt, 1, anchor) + sqlite3_bind_text(stmt, index, (parentID as NSString).utf8String, -1, SQLITE_TRANSIENT) + index += 1 + } + sqlite3_bind_int64(stmt, index, anchor) + index += 1 + if let upperBound { + sqlite3_bind_int64(stmt, index, upperBound) + } + return try readItems(from: stmt) + } + } + + public func fetchItemsChangedSince( + anchor: Int64, + parentID: String?, + siteID: String, + through upperBound: Int64? + ) throws -> [LocalItem] { + let sql: String + if parentID == nil { + sql = upperBound == nil + ? "SELECT * FROM items WHERE parent_id IS NULL AND site_id = ? AND updated_at > ? ORDER BY updated_at" + : "SELECT * FROM items WHERE parent_id IS NULL AND site_id = ? AND updated_at > ? AND updated_at <= ? ORDER BY updated_at" + } else { + sql = upperBound == nil + ? "SELECT * FROM items WHERE parent_id = ? AND site_id = ? AND updated_at > ? ORDER BY updated_at" + : "SELECT * FROM items WHERE parent_id = ? AND site_id = ? AND updated_at > ? AND updated_at <= ? ORDER BY updated_at" + } + return try queue.sync { + let stmt = try prepareStatement(sql) + defer { sqlite3_finalize(stmt) } + var index: Int32 = 1 + if let parentID { + sqlite3_bind_text(stmt, index, (parentID as NSString).utf8String, -1, SQLITE_TRANSIENT) + index += 1 + } + sqlite3_bind_text(stmt, index, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) + index += 1 + sqlite3_bind_int64(stmt, index, anchor) + index += 1 + if let upperBound { + sqlite3_bind_int64(stmt, index, upperBound) } return try readItems(from: stmt) } @@ -1421,12 +1678,21 @@ extension Database { /// Return all items for a site whose `updated_at` is strictly greater than `anchor`. public func fetchItemsChangedSince(anchor: Int64, siteID: String) throws -> [LocalItem] { - let sql = "SELECT * FROM items WHERE site_id = ? AND updated_at > ? ORDER BY updated_at" + try fetchItemsChangedSince(anchor: anchor, siteID: siteID, through: nil) + } + + public func fetchItemsChangedSince(anchor: Int64, siteID: String, through upperBound: Int64?) throws -> [LocalItem] { + let sql = upperBound == nil + ? "SELECT * FROM items WHERE site_id = ? AND updated_at > ? ORDER BY updated_at" + : "SELECT * FROM items WHERE site_id = ? AND updated_at > ? AND updated_at <= ? ORDER BY updated_at" return try queue.sync { let stmt = try prepareStatement(sql) defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) sqlite3_bind_int64(stmt, 2, anchor) + if let upperBound { + sqlite3_bind_int64(stmt, 3, upperBound) + } return try readItems(from: stmt) } } @@ -1435,11 +1701,33 @@ extension Database { /// than `anchor`. Returned in counter order so the enumerator can derive /// the next anchor from the last entry. public func fetchPendingDeletionsSince(anchor: Int64) throws -> [String] { - let sql = "SELECT item_id FROM pending_deletions WHERE deleted_at_counter > ? ORDER BY deleted_at_counter" + try fetchPendingDeletionsSince(anchor: anchor, through: nil) + } + + public func fetchPendingDeletionsSince(anchor: Int64, through upperBound: Int64?) throws -> [String] { + try fetchPendingDeletionsSince(anchor: anchor, filterSiteID: nil, through: upperBound) + } + + public func fetchPendingDeletionsSince(anchor: Int64, siteID: String, through upperBound: Int64?) throws -> [String] { + try fetchPendingDeletionsSince(anchor: anchor, filterSiteID: siteID, through: upperBound) + } + + private func fetchPendingDeletionsSince(anchor: Int64, filterSiteID: String?, through upperBound: Int64?) throws -> [String] { + let siteClause = filterSiteID == nil ? "" : " AND site_id = ?" + let upperBoundClause = upperBound == nil ? "" : " AND deleted_at_counter <= ?" + let sql = "SELECT item_id FROM pending_deletions WHERE deleted_at_counter > ?\(siteClause)\(upperBoundClause) ORDER BY deleted_at_counter" return try queue.sync { let stmt = try prepareStatement(sql) defer { sqlite3_finalize(stmt) } sqlite3_bind_int64(stmt, 1, anchor) + var index: Int32 = 2 + if let filterSiteID { + sqlite3_bind_text(stmt, index, (filterSiteID as NSString).utf8String, -1, SQLITE_TRANSIENT) + index += 1 + } + if let upperBound { + sqlite3_bind_int64(stmt, index, upperBound) + } var ids: [String] = [] while sqlite3_step(stmt) == SQLITE_ROW { ids.append(String(cString: sqlite3_column_text(stmt, 0))) @@ -1452,6 +1740,55 @@ extension Database { try execute("DELETE FROM pending_deletions") } + /// Recreate pending tombstones during a domain seed. New counters are + /// assigned by the destination database so its change anchor remains + /// monotonic after replication. + public func savePendingDeletions(_ itemIDs: [String], siteID: String? = nil) throws { + guard !itemIDs.isEmpty else { return } + try queue.sync { + try executeUnsafe("BEGIN TRANSACTION") + do { + let stmt = try prepareStatement("INSERT OR IGNORE INTO pending_deletions (item_id, site_id) VALUES (?, ?)") + defer { sqlite3_finalize(stmt) } + for itemID in itemIDs { + sqlite3_reset(stmt) + sqlite3_bind_text(stmt, 1, (itemID as NSString).utf8String, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 2, ((siteID ?? "") as NSString).utf8String, -1, SQLITE_TRANSIENT) + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw FindleError.databaseError(detail: "Failed to restore pending deletion") + } + } + try executeUnsafe("COMMIT") + } catch { + try? executeUnsafe("ROLLBACK") + throw error + } + } + } + + /// Whether a replicated File Provider database contains a complete seed. + /// The app writes this marker last, after all copied rows are present, so + /// the extension never adopts a partially-seeded database. + public func isSeedComplete() throws -> Bool { + try queue.sync { + let stmt = try prepareStatement("SELECT value FROM system_metadata WHERE key = 'seed_complete'") + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW else { return false } + return sqlite3_column_int(stmt, 0) == 1 + } + } + + public func setSeedComplete(_ complete: Bool) throws { + try queue.sync { + let stmt = try prepareStatement("INSERT OR REPLACE INTO system_metadata (key, value) VALUES ('seed_complete', ?)") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int(stmt, 1, complete ? 1 : 0) + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw FindleError.databaseError(detail: "Failed to update seed state") + } + } + } + private func readItems(from stmt: OpaquePointer) throws -> [LocalItem] { var items: [LocalItem] = [] while sqlite3_step(stmt) == SQLITE_ROW { @@ -1471,7 +1808,11 @@ extension Database { return Data(bytes: bytes, count: Int(length)) }() - let isLocal = colCount > 17 ? sqlite3_column_int(stmt, 17) == 1 : false + // `is_local` was added before `updated_at` in older databases but + // after it in newly-created databases. Resolve it by name so the + // decoded value stays correct across both column layouts. + let isLocalIndex = columnIndex(stmt, named: "is_local") + let isLocal = isLocalIndex.map { sqlite3_column_int(stmt, $0) == 1 } ?? false return LocalItem( id: String(cString: sqlite3_column_text(stmt, 0)), @@ -1494,6 +1835,17 @@ extension Database { isLocal: isLocal ) } + + private func columnIndex(_ stmt: OpaquePointer, named name: String) -> Int32? { + let count = sqlite3_column_count(stmt) + for index in 0.. +// +// 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 Foundation + +/// A payload-free notification that crosses process and sandbox boundaries. +/// +/// The File Provider extension runs in its own XPC-hosted sandbox and shares no +/// notification center with the app. Darwin notifications are the one channel +/// that works in both directions without extra entitlements or a shared +/// container, at the cost of carrying no user info — receivers must derive any +/// state they need from the database. +public enum DarwinNotification { + + /// Keeps an observer registered for as long as the token is retained. + public final class Token { + private let name: String + private let id: UUID + + init(name: String, id: UUID) { + self.name = name + self.id = id + } + + deinit { + DarwinNotification.removeObserver(name: name, id: id) + } + } + + private static let lock = NSLock() + nonisolated(unsafe) private static var handlers: [String: [UUID: @Sendable () -> Void]] = [:] + + /// Post `name` to every process listening for it, including this one. + public static func post(_ name: String) { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(name as CFString), + nil, + nil, + true + ) + } + + /// Invoke `handler` whenever `name` is posted by any process. + /// + /// The handler runs on an arbitrary thread — hop to the isolation you need. + /// Observation stops when the returned token is released, so callers must + /// retain it. + public static func addObserver( + for name: String, + handler: @escaping @Sendable () -> Void + ) -> Token { + let id = UUID() + + lock.lock() + let isFirstForName = handlers[name] == nil + handlers[name, default: [:]][id] = handler + lock.unlock() + + // CFNotificationCenter keeps its own registration per (observer, name) + // pair, so register with the Darwin center only once per name and fan + // out to the individual handlers ourselves. + if isFirstForName { + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + nil, + darwinNotificationCallback, + name as CFString, + nil, + .deliverImmediately + ) + } + + return Token(name: name, id: id) + } + + fileprivate static func dispatch(_ name: String) { + lock.lock() + let matching = handlers[name]?.values.map { $0 } ?? [] + lock.unlock() + + for handler in matching { + handler() + } + } + + fileprivate static func removeObserver(name: String, id: UUID) { + lock.lock() + handlers[name]?.removeValue(forKey: id) + let isEmpty = handlers[name]?.isEmpty ?? true + if isEmpty { + handlers.removeValue(forKey: name) + } + lock.unlock() + + guard isEmpty else { return } + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + nil, + CFNotificationName(name as CFString), + nil + ) + } +} + +/// Trampoline from the C callback back into `DarwinNotification`. It cannot +/// capture context, so the posted name is the only routing information +/// available — which is why registration is keyed by name. +private func darwinNotificationCallback( + center: CFNotificationCenter?, + observer: UnsafeMutableRawPointer?, + name: CFNotificationName?, + object: UnsafeRawPointer?, + userInfo: CFDictionary? +) { + guard let name else { return } + DarwinNotification.dispatch(name.rawValue as String) +} diff --git a/Sources/SharedDomain/Errors/FoodleError.swift b/Sources/SharedDomain/Errors/FindleError.swift similarity index 98% rename from Sources/SharedDomain/Errors/FoodleError.swift rename to Sources/SharedDomain/Errors/FindleError.swift index 64388cd..d8c2243 100644 --- a/Sources/SharedDomain/Errors/FoodleError.swift +++ b/Sources/SharedDomain/Errors/FindleError.swift @@ -6,7 +6,7 @@ import Foundation /// Top-level error domain for the Findle app. -public enum FoodleError: Error, Sendable, LocalizedError { +public enum FindleError: Error, Sendable, LocalizedError { // Authentication case invalidCredentials case tokenExpired diff --git a/Sources/SharedDomain/Models/CourseOutline.swift b/Sources/SharedDomain/Models/CourseOutline.swift new file mode 100644 index 0000000..e466a48 --- /dev/null +++ b/Sources/SharedDomain/Models/CourseOutline.swift @@ -0,0 +1,137 @@ +// 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 Foundation + +/// A compact, token-safe snapshot of Moodle's course structure. It deliberately +/// omits authenticated URLs and full document contents while preserving the +/// section/activity metadata that the Finder projection does not need. +public struct CourseOutlineSnapshot: Sendable, Codable, Equatable { + public let courseID: Int + public let capturedAt: Date + public let sections: [CourseOutlineSection] + + public init( + courseID: Int, + capturedAt: Date = Date(), + sections: [CourseOutlineSection] + ) { + self.courseID = courseID + self.capturedAt = capturedAt + self.sections = sections + } + + public init(courseID: Int, sections: [MoodleSection], capturedAt: Date = Date()) { + self.init( + courseID: courseID, + capturedAt: capturedAt, + sections: sections.map(CourseOutlineSection.init) + ) + } +} + +public struct CourseOutlineSection: Sendable, Codable, Equatable, Identifiable { + public let id: Int + public let name: String + public let summary: String? + public let sectionNumber: Int + public let visible: Bool + public let modules: [CourseOutlineModule] + + public init( + id: Int, + name: String, + summary: String? = nil, + sectionNumber: Int, + visible: Bool = true, + modules: [CourseOutlineModule] = [] + ) { + self.id = id + self.name = name + self.summary = summary + self.sectionNumber = sectionNumber + self.visible = visible + self.modules = modules + } + + init(_ section: MoodleSection) { + self.init( + id: section.id, + name: section.name, + summary: section.summary, + sectionNumber: section.sectionNumber, + visible: section.visible, + modules: section.modules.map(CourseOutlineModule.init) + ) + } +} + +public struct CourseOutlineModule: Sendable, Codable, Equatable, Identifiable { + public let id: Int + public let name: String + public let type: String + public let visible: Bool + public let files: [CourseOutlineFile] + + public init( + id: Int, + name: String, + type: String, + visible: Bool = true, + files: [CourseOutlineFile] = [] + ) { + self.id = id + self.name = name + self.type = type + self.visible = visible + self.files = files + } + + init(_ module: MoodleModule) { + self.init( + id: module.id, + name: module.name, + type: module.modName, + visible: module.visible, + files: module.contents.map(CourseOutlineFile.init) + ) + } +} + +public struct CourseOutlineFile: Sendable, Codable, Equatable { + public let name: String + public let type: String + public let size: Int64 + public let mimeType: String? + public let modified: Date? + public let sortOrder: Int? + + public init( + name: String, + type: String, + size: Int64 = 0, + mimeType: String? = nil, + modified: Date? = nil, + sortOrder: Int? = nil + ) { + self.name = name + self.type = type + self.size = size + self.mimeType = mimeType + self.modified = modified + self.sortOrder = sortOrder + } + + init(_ content: MoodleFileContent) { + self.init( + name: content.fileName, + type: content.type, + size: content.fileSize, + mimeType: content.mimeType, + modified: content.timeModified, + sortOrder: content.sortOrder + ) + } +} diff --git a/Sources/SharedDomain/Models/MoodleSite.swift b/Sources/SharedDomain/Models/MoodleSite.swift index 228560f..4466f9d 100644 --- a/Sources/SharedDomain/Models/MoodleSite.swift +++ b/Sources/SharedDomain/Models/MoodleSite.swift @@ -56,7 +56,6 @@ public struct MoodleSite: Sendable, Codable, Equatable, Identifiable { /// navigation delegate intercepts them instead of the system URL handler. public static let acceptedCallbackSchemes: Set = [ "findle", - "foodle", "moodlemobile", "openlms", "ltgopenlmsapp", diff --git a/Sources/SharedDomain/SharedDomain.swift b/Sources/SharedDomain/SharedDomain.swift index e6a4729..9e9c2ee 100644 --- a/Sources/SharedDomain/SharedDomain.swift +++ b/Sources/SharedDomain/SharedDomain.swift @@ -3,7 +3,7 @@ // 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. -/// SharedDomain module for Foodle. +/// SharedDomain module for Findle. /// Contains models, identifiers, state machines, and error types shared across all modules. public enum SharedDomainVersion { public static let current = "1.0.0" diff --git a/Sources/SyncEngine/FoodleSyncEngine.swift b/Sources/SyncEngine/FindleSyncEngine.swift similarity index 85% rename from Sources/SyncEngine/FoodleSyncEngine.swift rename to Sources/SyncEngine/FindleSyncEngine.swift index 88c0601..21b1547 100644 --- a/Sources/SyncEngine/FoodleSyncEngine.swift +++ b/Sources/SyncEngine/FindleSyncEngine.swift @@ -3,9 +3,9 @@ // 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. -/// FoodleSyncEngine module. +/// FindleSyncEngine module. /// Orchestrates sync between remote Moodle and local state, including diffing, /// scheduling, download orchestration, and offline pinning. -public enum FoodleSyncEngineVersion { +public enum FindleSyncEngineVersion { public static let current = "1.0.0" } diff --git a/Sources/SyncEngine/SyncEngine.swift b/Sources/SyncEngine/SyncEngine.swift index 37f4c68..0772ff0 100644 --- a/Sources/SyncEngine/SyncEngine.swift +++ b/Sources/SyncEngine/SyncEngine.swift @@ -6,17 +6,18 @@ import Foundation import OSLog import SharedDomain -import FoodleNetworking -import FoodlePersistence +import FindleNetworking +import FindlePersistence /// Orchestrates synchronization between Moodle servers and local state. public actor SyncEngine { private let provider: LMSProvider private let database: Database - private let logger = Logger(subsystem: "es.amodrono.foodle.sync", category: "SyncEngine") + private let logger = Logger(subsystem: "es.amodrono.findle.sync", category: "SyncEngine") private var activeTasks: [Int: Task] = [:] private var syncProgress: [Int: SyncProgress] = [:] + private var isStopping = false public struct SyncProgress: Sendable { public let courseID: Int @@ -37,13 +38,37 @@ public actor SyncEngine { // MARK: - Course Sync + /// How many courses may be fetched from the server at once. Course syncs are + /// independent, so running them serially made a full sync cost the sum of + /// every round trip. The cap keeps us from opening an unbounded number of + /// connections to a shared institutional Moodle. + public static let defaultCourseConcurrency = 4 + + /// The terminal outcome of a `syncAllCourses` run. + private enum RunOutcome { + case ok + case authenticationFailed(Error) + case cancelled + } + /// Sync all subscribed courses for a site. /// + /// Courses are fetched with bounded concurrency. `onCourseSynced` fires as + /// soon as each individual course lands in the database, so callers can + /// publish that course's files immediately instead of waiting for the whole + /// run — a slow course no longer hides the ones that already finished. + /// /// Throws if a course fails with an authentication error (`tokenExpired` /// etc.) — such failures affect every course, so we stop early and let the /// caller prompt the user to reconnect. Per-course non-auth failures are /// logged and the sync continues with the remaining courses. - public func syncAllCourses(site: MoodleSite, token: AuthToken, courses: [MoodleCourse]) async throws { + public func syncAllCourses( + site: MoodleSite, + token: AuthToken, + courses: [MoodleCourse], + maxConcurrentCourses: Int = SyncEngine.defaultCourseConcurrency, + onCourseSynced: (@Sendable (Int) -> Void)? = nil + ) async throws { logger.info("Starting sync for \(courses.count) courses on \(site.displayName, privacy: .public)") // Reset progress up front so a re-sync doesn't briefly report the @@ -57,40 +82,126 @@ public actor SyncEngine { ) } - for course in courses { - guard activeTasks[course.id] == nil else { - logger.debug("Skipping course \(course.id) - sync already in progress") - continue + guard !courses.isEmpty else { return } + let limit = max(1, min(maxConcurrentCourses, courses.count)) + + let outcome = await withTaskGroup( + of: (Int, Error?).self, + returning: RunOutcome.self + ) { group in + var outcome = RunOutcome.ok + var next = 0 + + while next < limit { + let course = courses[next] + group.addTask { [self] in + await syncCourseCapturingError(site: site, token: token, course: course) + } + next += 1 } - let task = Task { - try await syncCourse(site: site, token: token, course: course) + while let (courseID, error) = await group.next() { + switch error { + case nil: + logger.info("Sync completed for course \(courseID, privacy: .public)") + onCourseSynced?(courseID) + + case let authError as FindleError where authError.requiresReauthentication: + logger.error("Authentication failed during sync of course \(courseID, privacy: .public) — aborting") + syncProgress[courseID]?.state = .stale + // An auth failure affects every course, so stop scheduling + // more work. It also outranks the cancellations it causes. + if case .ok = outcome { outcome = .authenticationFailed(authError) } + group.cancelAll() + + case is CancellationError: + if case .ok = outcome { outcome = .cancelled } + group.cancelAll() + + case .some(let error): + logger.error("Sync failed for course \(courseID): \(error.localizedDescription, privacy: .public)") + // Mark as no-longer-syncing so progress observers advance past it. + syncProgress[courseID]?.state = .stale + } + + // Only refill the window while the run is still healthy. + guard case .ok = outcome, next < courses.count else { continue } + let course = courses[next] + group.addTask { [self] in + await syncCourseCapturingError(site: site, token: token, course: course) + } + next += 1 } - activeTasks[course.id] = task - do { + return outcome + } + + switch outcome { + case .authenticationFailed(let error): throw error + case .cancelled: throw CancellationError() + case .ok: break + } + + try Task.checkCancellation() + + // Download pinned items once for the whole run. Doing this per course + // re-scanned and re-downloaded the site's entire pinned set N times. + try await downloadPinnedItems(site: site, token: token) + } + + /// Run one course sync and surface its failure as a value so a task group + /// can decide how the run as a whole should react. + private func syncCourseCapturingError( + site: MoodleSite, + token: AuthToken, + course: MoodleCourse + ) async -> (Int, Error?) { + do { + try await syncCourseTracked(site: site, token: token, course: course) + return (course.id, nil) + } catch { + return (course.id, error) + } + } + + /// Sync a single course, then materialize any pinned items that still need + /// downloading. Batch runs use `syncCourseTracked` directly and download + /// pinned items once at the end instead. + public func syncCourse(site: MoodleSite, token: AuthToken, course: MoodleCourse) async throws { + try await syncCourseTracked(site: site, token: token, course: course) + try await downloadPinnedItems(site: site, token: token) + } + + /// Sync a single course: enumerate content, diff against local state, update database. + private func syncCourseTracked(site: MoodleSite, token: AuthToken, course: MoodleCourse) async throws { + if isStopping { + throw CancellationError() + } + + if let activeTask = activeTasks[course.id] { + try await activeTask.value + return + } + + let task = Task { [self] in + try await performSyncCourse(site: site, token: token, course: course) + } + activeTasks[course.id] = task + + do { + try await withTaskCancellationHandler { try await task.value - logger.info("Sync completed for course \(course.id, privacy: .public)") - } catch let error as FoodleError where error.requiresReauthentication { - logger.error("Authentication failed during sync of course \(course.id, privacy: .public) — aborting") - syncProgress[course.id]?.state = .stale - activeTasks[course.id] = nil - throw error - } catch is CancellationError { - activeTasks[course.id] = nil - throw CancellationError() - } catch { - logger.error("Sync failed for course \(course.id): \(error.localizedDescription, privacy: .public)") - // Mark as no-longer-syncing so progress observers advance past it. - syncProgress[course.id]?.state = .stale + } onCancel: { + task.cancel() } - + } catch { activeTasks[course.id] = nil + throw error } + activeTasks[course.id] = nil } - /// Sync a single course: enumerate content, diff against local state, update database. - public func syncCourse(site: MoodleSite, token: AuthToken, course: MoodleCourse) async throws { + private func performSyncCourse(site: MoodleSite, token: AuthToken, course: MoodleCourse) async throws { logger.info("Syncing course: \(course.fullName, privacy: .public)") syncProgress[course.id] = SyncProgress( @@ -164,8 +275,10 @@ public actor SyncEngine { try Task.checkCancellation() - // Diff against existing items - let existingItems = try database.fetchAllItems(siteID: site.id).filter { $0.courseID == course.id && !$0.isLocal } + // Diff against existing items. Scope the query to this course so the + // `idx_items_course` index does the filtering — loading every row for + // the site and filtering in memory made a full sync quadratic. + let existingItems = try database.fetchItems(courseID: course.id, siteID: site.id).filter { !$0.isLocal } let changes = diffItems(existing: existingItems, incoming: allItems) @@ -192,18 +305,18 @@ public actor SyncEngine { itemCount: allItems.count ) try database.saveSyncCursor(cursor) + try database.saveCourseOutline( + CourseOutlineSnapshot(courseID: course.id, sections: sections), + siteID: site.id + ) syncProgress[course.id]?.processedItems = allItems.count syncProgress[course.id]?.state = .synced - - // Auto-download pinned items that aren't yet materialized - await downloadPinnedItems(site: site, token: token) - logger.info("Course \(course.id) sync complete: \(allItems.count) items") } /// Download all pinned items that are not yet materialized. - private func downloadPinnedItems(site: MoodleSite, token: AuthToken) async { + private func downloadPinnedItems(site: MoodleSite, token: AuthToken) async throws { do { let pinnedItems = try database.fetchPinnedItems(siteID: site.id) let pending = pinnedItems.filter { $0.syncState != .materialized } @@ -212,6 +325,7 @@ public actor SyncEngine { logger.info("Downloading \(pending.count) pinned items") for item in pending { + try Task.checkCancellation() guard item.remoteURL != nil else { continue } // Use item.id as the temp filename to avoid collisions between // different items that share a basename (e.g. "Slides.pdf"). @@ -227,11 +341,19 @@ public actor SyncEngine { token: token, destination: destination ) + } catch is CancellationError { + throw CancellationError() } catch { + if Task.isCancelled { + throw CancellationError() + } logger.error("Failed to download pinned item \(item.id, privacy: .public): \(error.localizedDescription, privacy: .public)") } } } catch { + if error is CancellationError || Task.isCancelled { + throw CancellationError() + } logger.error("Failed to fetch pinned items: \(error.localizedDescription, privacy: .public)") } } @@ -403,19 +525,24 @@ public actor SyncEngine { destination: URL ) async throws { guard let item = try database.fetchItem(id: itemID) else { - throw FoodleError.itemNotFound(itemID: itemID) + throw FindleError.itemNotFound(itemID: itemID) } guard let remoteURL = item.remoteURL else { - throw FoodleError.downloadFailed(itemID: itemID, reason: "No remote URL") + throw FindleError.downloadFailed(itemID: itemID, reason: "No remote URL") } try database.updateItemSyncState(id: itemID, state: .downloading) do { try await provider.downloadFile(url: remoteURL, token: token, destination: destination) + try Task.checkCancellation() try database.updateItemSyncState(id: itemID, state: .materialized, localPath: destination.path) logger.info("Downloaded item \(itemID, privacy: .public)") + } catch is CancellationError { + try? FileManager.default.removeItem(at: destination) + try? database.updateItemSyncState(id: itemID, state: .placeholder) + throw CancellationError() } catch { // Don't leave a half-written file behind for the next download to trip on. try? FileManager.default.removeItem(at: destination) @@ -440,6 +567,24 @@ public actor SyncEngine { activeTasks.removeAll() } + /// Cancel and await every in-flight course task before callers replace or + /// delete the database those tasks may still be writing. + public func stopAllSyncs() async { + isStopping = true + let tasks = Array(activeTasks.values) + for task in tasks { + task.cancel() + } + for task in tasks { + _ = try? await task.value + } + for id in activeTasks.keys { + syncProgress[id]?.state = .stale + } + activeTasks.removeAll() + isStopping = false + } + // MARK: - Progress public func progress(forCourse courseID: Int) -> SyncProgress? { diff --git a/Tests/FileProviderTests/FileDownloadCompletionBridgeTests.swift b/Tests/FileProviderTests/FileDownloadCompletionBridgeTests.swift index 7418863..610fc49 100644 --- a/Tests/FileProviderTests/FileDownloadCompletionBridgeTests.swift +++ b/Tests/FileProviderTests/FileDownloadCompletionBridgeTests.swift @@ -61,6 +61,7 @@ final class FileDownloadCompletionBridgeTests: XCTestCase { XCTAssertEqual(state.invocationCount, 1) XCTAssertEqual(progress.completedUnitCount, 0) + XCTAssertTrue(progress.isCancelled) XCTAssertNotNil(state.lastError) XCTAssertNil(state.lastURL) } diff --git a/Tests/FindleMCPTests/CatalogTests.swift b/Tests/FindleMCPTests/CatalogTests.swift index 8baf4c4..08a5187 100644 --- a/Tests/FindleMCPTests/CatalogTests.swift +++ b/Tests/FindleMCPTests/CatalogTests.swift @@ -4,7 +4,7 @@ // You may obtain a copy of the License in the LICENSE file at the root of this repository. import XCTest -import FoodlePersistence +import FindlePersistence import SharedDomain final class CatalogTests: XCTestCase { @@ -44,6 +44,28 @@ final class CatalogTests: XCTestCase { XCTAssertEqual(children.count, 2) } + func testCourseBriefIsBoundedAndIncludesDeadlines() throws { + let object = parseJSON(try makeCatalog().getCourseBrief( + courseID: 100, + maxSections: 1, + maxFilesPerSection: 1, + maxChars: 200 + )) as! [String: Any] + + let course = object["course"] as! [String: Any] + XCTAssertEqual(course["fileCount"] as? Int, 2) + XCTAssertEqual(course["downloadedCount"] as? Int, 1) + + let sections = object["sections"] as! [[String: Any]] + XCTAssertEqual(sections.count, 1) + XCTAssertEqual((sections[0]["files"] as! [[String: Any]]).count, 1) + XCTAssertEqual(sections[0]["summary"] as? String, "Evaluación final y material de repaso.") + XCTAssertEqual((sections[0]["activities"] as! [[String: Any]]).first?["type"] as? String, "resource") + + let deadlines = object["upcomingDeadlines"] as! [[String: Any]] + XCTAssertTrue(deadlines.contains { $0["name"] as? String == "Entrega 1" }) + } + func testGetItemAndMoodleURL() throws { let catalog = try makeCatalog() let item = parseJSON(catalog.getItem(id: "f1")) as! [String: Any] @@ -95,6 +117,12 @@ final class CatalogTests: XCTestCase { let search = catalog.callTool(named: "search_items", args: ArgReader(string: { $0 == "query" ? "examen" : nil }, int: { _ in nil })) XCTAssertFalse(search.isError) XCTAssertTrue(search.text.contains("Exámenes")) + + let missing = catalog.callTool(named: "get_course_brief", args: empty) + XCTAssertTrue(missing.isError) + + let invalid = catalog.callTool(named: "search_items", args: ArgReader(string: { _ in "" }, int: { _ in nil })) + XCTAssertTrue(invalid.isError) } func testNoAccountYieldsError() throws { diff --git a/Tests/FindleMCPTests/TestSupport.swift b/Tests/FindleMCPTests/TestSupport.swift index 541e17c..bda5b09 100644 --- a/Tests/FindleMCPTests/TestSupport.swift +++ b/Tests/FindleMCPTests/TestSupport.swift @@ -4,7 +4,7 @@ // You may obtain a copy of the License in the LICENSE file at the root of this repository. import Foundation -import FoodlePersistence +import FindlePersistence import SharedDomain /// Builds a throwaway database seeded with a small, known fixture, so the MCP @@ -33,6 +33,25 @@ enum TestDB { let file2 = LocalItem(id: "f2", parentID: section.id, siteID: siteID, courseID: 100, remoteID: 3, filename: "Tema 1 Derivadas.pdf", fileSize: 2000, syncState: .placeholder) try db.saveItems([root, section, file1, file2]) try db.saveSyncCursor(SyncCursor(courseID: 100, siteID: siteID, lastSyncDate: Date(timeIntervalSince1970: 1_700_000_000), itemCount: 2)) + try db.saveCourseOutline( + CourseOutlineSnapshot( + courseID: 100, + sections: [ + CourseOutlineSection( + id: 1, + name: "Exámenes", + summary: "Evaluación final y material de repaso.", + sectionNumber: 1, + modules: [ + CourseOutlineModule(id: 2, name: "Examen Final", type: "resource", files: [ + CourseOutlineFile(name: "Examen Final.pdf", type: "file", size: 1000) + ]) + ] + ) + ] + ), + siteID: siteID + ) try db.saveAssignments([ MoodleAssignment(id: 901, courseID: 100, name: "Entrega 1", dueDate: Date().addingTimeInterval(3 * 86400)), diff --git a/Tests/NetworkingTests/MoodleClientTests.swift b/Tests/NetworkingTests/MoodleClientTests.swift index 1979877..25fa3e0 100644 --- a/Tests/NetworkingTests/MoodleClientTests.swift +++ b/Tests/NetworkingTests/MoodleClientTests.swift @@ -4,7 +4,7 @@ // You may obtain a copy of the License in the LICENSE file at the root of this repository. import XCTest -@testable import FoodleNetworking +@testable import FindleNetworking @testable import SharedDomain final class MoodleClientTests: XCTestCase { @@ -227,7 +227,7 @@ final class MoodleClientTests: XCTestCase { do { _ = try await client.validateSite(url: URL(string: "https://moodle.example.edu")!) XCTFail("Expected validation to fail") - } catch let error as FoodleError { + } catch let error as FindleError { guard case .webServicesDisabled = error else { XCTFail("Expected webServicesDisabled, got \(error)") return @@ -252,7 +252,7 @@ final class MoodleClientTests: XCTestCase { do { _ = try await client.validateSite(url: URL(string: "https://missing.example.edu")!) XCTFail("Expected validation to fail") - } catch let error as FoodleError { + } catch let error as FindleError { guard case .siteUnreachable = error else { XCTFail("Expected siteUnreachable, got \(error)") return @@ -434,8 +434,8 @@ final class MoodleClientTests: XCTestCase { XCTAssertThrowsError( try client.parseTokenFromSSOCallback(callbackURLString: callbackString, site: site, passport: "p") ) { error in - guard let foodleError = error as? FoodleError, - case .invalidResponse(let detail) = foodleError else { + guard let findleError = error as? FindleError, + case .invalidResponse(let detail) = findleError else { XCTFail("Expected invalidResponse error") return } @@ -458,7 +458,7 @@ final class MoodleClientTests: XCTestCase { passport: "rawpassport" ) ) { error in - XCTAssertTrue(error is FoodleError) + XCTAssertTrue(error is FindleError) } } @@ -582,7 +582,7 @@ final class MoodleClientTests: XCTestCase { passport: "anypassport" ) ) { error in - XCTAssertTrue(error is FoodleError) + XCTAssertTrue(error is FindleError) } } @@ -597,7 +597,7 @@ final class MoodleClientTests: XCTestCase { passport: "anypassport" ) ) { error in - XCTAssertTrue(error is FoodleError) + XCTAssertTrue(error is FindleError) } } diff --git a/Tests/NetworkingTests/MoodleSSOLaunchURLBuilderTests.swift b/Tests/NetworkingTests/MoodleSSOLaunchURLBuilderTests.swift index 19255da..6b455b9 100644 --- a/Tests/NetworkingTests/MoodleSSOLaunchURLBuilderTests.swift +++ b/Tests/NetworkingTests/MoodleSSOLaunchURLBuilderTests.swift @@ -4,7 +4,7 @@ // You may obtain a copy of the License in the LICENSE file at the root of this repository. import XCTest -@testable import FoodleNetworking +@testable import FindleNetworking @testable import SharedDomain final class MoodleSSOLaunchURLBuilderTests: XCTestCase { diff --git a/Tests/NetworkingTests/WebAuthSessionTests.swift b/Tests/NetworkingTests/WebAuthSessionTests.swift index 3b80150..017f9eb 100644 --- a/Tests/NetworkingTests/WebAuthSessionTests.swift +++ b/Tests/NetworkingTests/WebAuthSessionTests.swift @@ -5,7 +5,7 @@ import XCTest import AuthenticationServices -@testable import FoodleNetworking +@testable import FindleNetworking @testable import SharedDomain @MainActor diff --git a/Tests/PersistenceTests/DatabaseTests.swift b/Tests/PersistenceTests/DatabaseTests.swift index 48a7bd3..19a2be2 100644 --- a/Tests/PersistenceTests/DatabaseTests.swift +++ b/Tests/PersistenceTests/DatabaseTests.swift @@ -5,7 +5,7 @@ import XCTest import SQLite3 -@testable import FoodlePersistence +@testable import FindlePersistence @testable import SharedDomain final class DatabaseTests: XCTestCase { @@ -13,7 +13,7 @@ final class DatabaseTests: XCTestCase { var tempPath: String! override func setUp() async throws { - tempPath = NSTemporaryDirectory() + "foodle_test_\(UUID().uuidString).db" + tempPath = NSTemporaryDirectory() + "findle_test_\(UUID().uuidString).db" database = try Database(path: tempPath) } @@ -120,6 +120,27 @@ final class DatabaseTests: XCTestCase { XCTAssertNil(result) } + func testSaveFetchCourseOutline() throws { + let outline = CourseOutlineSnapshot( + courseID: 101, + sections: [ + CourseOutlineSection( + id: 4, + name: "Week 1", + summary: "Introduction", + sectionNumber: 1, + modules: [CourseOutlineModule(id: 8, name: "Reading", type: "resource")] + ) + ] + ) + + try database.saveCourseOutline(outline, siteID: "site-1") + + let fetched = try database.fetchCourseOutline(courseID: 101, siteID: "site-1") + XCTAssertEqual(fetched, outline) + XCTAssertEqual(try database.fetchCourseOutlines(siteID: "site-1").count, 1) + } + // MARK: - Schema v3 Fields (wwwroot, httpswwwroot, showLoginForm) func testSaveFetchSiteWithDiscoveredRoots() throws { @@ -270,6 +291,21 @@ final class DatabaseTests: XCTestCase { XCTAssertNil(notFound) } + func testFetchItemPreservesLocalFlagOnCurrentSchema() throws { + let item = LocalItem( + id: "local-flag", + siteID: "site-1", + courseID: 101, + remoteID: 0, + filename: "workspace", + isDirectory: true, + isLocal: true + ) + try database.saveItems([item]) + + XCTAssertTrue(try database.fetchItem(id: item.id)?.isLocal == true) + } + func testUpdateItemSyncState() throws { let item = LocalItem( id: "item-state", @@ -323,6 +359,16 @@ final class DatabaseTests: XCTestCase { XCTAssertTrue(fetched.isEmpty) } + func testSeedCompletionIsExplicitAndResetByDeleteAllData() throws { + XCTAssertFalse(try database.isSeedComplete()) + try database.setSeedComplete(true) + XCTAssertTrue(try database.isSeedComplete()) + + try database.deleteAllData() + + XCTAssertFalse(try database.isSeedComplete()) + } + // MARK: - Change Counter / Sync Anchor func testOpeningVersion10DatabaseMigratesChangeCounterColumns() throws { @@ -352,11 +398,11 @@ final class DatabaseTests: XCTestCase { XCTAssertEqual(try database.fetchItem(id: "materialized-item")?.syncState, .materialized) } - private func makeItem(id: String, parentID: String? = nil, courseID: Int = 1) -> LocalItem { + private func makeItem(id: String, parentID: String? = nil, courseID: Int = 1, siteID: String = "site-1") -> LocalItem { LocalItem( id: id, parentID: parentID, - siteID: "site-1", + siteID: siteID, courseID: courseID, remoteID: 0, filename: id, @@ -505,6 +551,29 @@ final class DatabaseTests: XCTestCase { XCTAssertEqual(changedIDs, Set(["new-1", "new-2"])) } + func testFetchItemsChangedSinceCanBeBoundToCapturedCounter() throws { + try database.saveItems([makeItem(id: "old-1")]) + let anchor = try database.currentChangeCounter() + try database.saveItems([makeItem(id: "new-1")]) + let upperBound = try database.currentChangeCounter() + try database.saveItems([makeItem(id: "late-1")]) + + let changed = try database.fetchItemsChangedSince(anchor: anchor, siteID: "site-1", through: upperBound) + XCTAssertEqual(Set(changed.map(\.id)), Set(["new-1"])) + } + + func testPreservingChangeCounterKeepsRestoredRowsAfterExistingAnchor() throws { + try database.saveItems([makeItem(id: "before-reset")]) + let oldAnchor = try database.currentChangeCounter() + + try database.deleteAllData() + try database.preserveChangeCounter(atLeast: oldAnchor) + try database.saveItems([makeItem(id: "restored")]) + + let changed = try database.fetchItemsChangedSince(anchor: oldAnchor, siteID: "site-1") + XCTAssertEqual(changed.map(\.id), ["restored"]) + } + func testFetchPendingDeletionsSinceFiltersByAnchor() throws { try database.saveItems([ makeItem(id: "to-keep"), @@ -523,4 +592,46 @@ final class DatabaseTests: XCTestCase { let nowAnchor = try database.currentChangeCounter() XCTAssertTrue(try database.fetchPendingDeletionsSince(anchor: nowAnchor).isEmpty) } + + func testPendingDeletionsAreScopedBySite() throws { + try database.saveItems([ + makeItem(id: "site-a-item", siteID: "site-a"), + makeItem(id: "site-b-item", siteID: "site-b"), + ]) + + try database.deleteItemsWithTombstone(ids: ["site-a-item", "site-b-item"]) + + let anchor: Int64 = 0 + XCTAssertEqual( + try database.fetchPendingDeletionsSince(anchor: anchor, siteID: "site-a", through: nil), + ["site-a-item"] + ) + XCTAssertEqual( + try database.fetchPendingDeletions(siteID: "site-b"), + ["site-b-item"] + ) + } + + func testReintroducedItemClearsItsOldTombstone() throws { + try database.saveItems([makeItem(id: "reintroduced")]) + let anchor = try database.currentChangeCounter() + + try database.deleteItemsWithTombstone(ids: ["reintroduced"]) + try database.saveItems([makeItem(id: "reintroduced")]) + + XCTAssertTrue(try database.fetchPendingDeletionsSince(anchor: anchor).isEmpty) + } + + func testRemoteDeletionPreservesLocalDescendantsAtSurvivingParent() throws { + let root = LocalItem(id: "remote-root", siteID: "site-1", courseID: 1, remoteID: 1, filename: "Course", isDirectory: true) + let section = LocalItem(id: "remote-section", parentID: root.id, siteID: "site-1", courseID: 1, remoteID: 2, filename: "Section", isDirectory: true) + let local = LocalItem(id: "local-work", parentID: section.id, siteID: "site-1", courseID: 1, remoteID: 0, filename: "Project", isDirectory: true, isLocal: true) + try database.saveItems([root, section, local]) + + try database.deleteItemsWithTombstone(ids: [root.id]) + + XCTAssertEqual(try database.fetchItem(id: local.id)?.parentID, nil) + XCTAssertNotNil(try database.fetchItem(id: local.id)) + XCTAssertEqual(Set(try database.fetchPendingDeletions()), Set([root.id, section.id])) + } } diff --git a/Tests/SharedDomainTests/ModelTests.swift b/Tests/SharedDomainTests/ModelTests.swift index 9ea7f3b..4c9f3f7 100644 --- a/Tests/SharedDomainTests/ModelTests.swift +++ b/Tests/SharedDomainTests/ModelTests.swift @@ -98,12 +98,12 @@ final class ModelTests: XCTestCase { } func testRequiresReauthentication() { - XCTAssertTrue(FoodleError.tokenExpired.requiresReauthentication) - XCTAssertTrue(FoodleError.tokenRefreshFailed(underlying: "x").requiresReauthentication) - XCTAssertTrue(FoodleError.authenticationRequired.requiresReauthentication) + XCTAssertTrue(FindleError.tokenExpired.requiresReauthentication) + XCTAssertTrue(FindleError.tokenRefreshFailed(underlying: "x").requiresReauthentication) + XCTAssertTrue(FindleError.authenticationRequired.requiresReauthentication) - XCTAssertFalse(FoodleError.networkUnavailable.requiresReauthentication) - XCTAssertFalse(FoodleError.timeout.requiresReauthentication) - XCTAssertFalse(FoodleError.invalidCredentials.requiresReauthentication) + XCTAssertFalse(FindleError.networkUnavailable.requiresReauthentication) + XCTAssertFalse(FindleError.timeout.requiresReauthentication) + XCTAssertFalse(FindleError.invalidCredentials.requiresReauthentication) } } diff --git a/Tests/SyncEngineTests/SyncEngineConcurrencyTests.swift b/Tests/SyncEngineTests/SyncEngineConcurrencyTests.swift new file mode 100644 index 0000000..0df6519 --- /dev/null +++ b/Tests/SyncEngineTests/SyncEngineConcurrencyTests.swift @@ -0,0 +1,242 @@ +// 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 XCTest +@testable import SharedDomain +@testable import FindleNetworking +@testable import FindlePersistence +@testable import FindleSyncEngine + +/// Covers the bounded-concurrency batch sync and its per-course completion +/// callback — the mechanism that lets Finder show a course's files as soon as +/// that course lands instead of waiting for the whole run. +final class SyncEngineConcurrencyTests: XCTestCase { + private var database: Database! + private var tempPath: String! + + private let site = MoodleSite( + id: "site-1", + displayName: "Example", + baseURL: URL(string: "https://moodle.example.edu")!, + capabilities: SiteCapabilities( + supportsWebServices: true, + supportsMobileAPI: true, + supportsFileDownload: true + ) + ) + private let token = AuthToken(token: "token") + + override func setUp() async throws { + tempPath = NSTemporaryDirectory() + "findle_concurrency_test_\(UUID().uuidString).db" + database = try Database(path: tempPath) + } + + override func tearDown() async throws { + database = nil + try? FileManager.default.removeItem(atPath: tempPath) + } + + private func makeCourses(_ count: Int) -> [MoodleCourse] { + (1...count).map { + MoodleCourse(id: $0, shortName: "C\($0)", fullName: "Course \($0)", siteID: site.id) + } + } + + func testEveryCourseReportsCompletionExactlyOnce() async throws { + let courses = makeCourses(8) + let provider = RecordingLMSProvider() + let engine = SyncEngine(provider: provider, database: database) + let synced = SyncedCourseRecorder() + + try await engine.syncAllCourses( + site: site, + token: token, + courses: courses, + onCourseSynced: { synced.record($0) } + ) + + XCTAssertEqual(synced.ids.sorted(), courses.map(\.id)) + XCTAssertEqual(synced.ids.count, Set(synced.ids).count, "A course reported completion more than once") + } + + func testCoursesFetchConcurrentlyUpToTheLimit() async throws { + let courses = makeCourses(8) + let provider = RecordingLMSProvider(fetchDelay: .milliseconds(50)) + let engine = SyncEngine(provider: provider, database: database) + + try await engine.syncAllCourses( + site: site, + token: token, + courses: courses, + maxConcurrentCourses: 4 + ) + + XCTAssertEqual(provider.completedFetchCount, courses.count) + // The whole point of the change: more than one course in flight at once, + // but never more than the cap. + XCTAssertGreaterThan(provider.peakConcurrency, 1, "Course fetches never overlapped") + XCTAssertLessThanOrEqual(provider.peakConcurrency, 4, "Exceeded the concurrency limit") + } + + func testConcurrencyLimitOfOneKeepsFetchesSerial() async throws { + let courses = makeCourses(4) + let provider = RecordingLMSProvider(fetchDelay: .milliseconds(20)) + let engine = SyncEngine(provider: provider, database: database) + + try await engine.syncAllCourses( + site: site, + token: token, + courses: courses, + maxConcurrentCourses: 1 + ) + + XCTAssertEqual(provider.peakConcurrency, 1) + XCTAssertEqual(provider.completedFetchCount, courses.count) + } + + func testFailedCourseDoesNotReportCompletion() async throws { + let courses = makeCourses(4) + // Course 3 fails with a non-auth error; the rest must still complete. + let provider = RecordingLMSProvider(failingCourseIDs: [3], failure: .networkUnavailable) + let engine = SyncEngine(provider: provider, database: database) + let synced = SyncedCourseRecorder() + + try await engine.syncAllCourses( + site: site, + token: token, + courses: courses, + onCourseSynced: { synced.record($0) } + ) + + XCTAssertEqual(synced.ids.sorted(), [1, 2, 4]) + } +} + +private final class SyncedCourseRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Int] = [] + + var ids: [Int] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func record(_ id: Int) { + lock.lock() + defer { lock.unlock() } + storage.append(id) + } +} + +/// Tracks how many `fetchCourseContents` calls are in flight simultaneously so +/// tests can assert on the concurrency window. +private final class RecordingLMSProvider: LMSProvider, @unchecked Sendable { + private let fetchDelay: Duration? + private let failingCourseIDs: Set + private let failure: FindleError + + private let lock = NSLock() + private var inFlight = 0 + private var _peakConcurrency = 0 + private var _completedFetchCount = 0 + + init( + fetchDelay: Duration? = nil, + failingCourseIDs: Set = [], + failure: FindleError = .networkUnavailable + ) { + self.fetchDelay = fetchDelay + self.failingCourseIDs = failingCourseIDs + self.failure = failure + } + + var peakConcurrency: Int { + lock.lock() + defer { lock.unlock() } + return _peakConcurrency + } + + var completedFetchCount: Int { + lock.lock() + defer { lock.unlock() } + return _completedFetchCount + } + + // NSLock's lock/unlock are unavailable directly inside an async function, so + // the bookkeeping lives in these synchronous helpers. + private func beginFetch() { + lock.lock() + defer { lock.unlock() } + inFlight += 1 + _peakConcurrency = max(_peakConcurrency, inFlight) + } + + private func endFetch() { + lock.lock() + defer { lock.unlock() } + inFlight -= 1 + } + + private func recordCompletedFetch() { + lock.lock() + defer { lock.unlock() } + _completedFetchCount += 1 + } + + func fetchCourseContents(site: MoodleSite, token: AuthToken, courseID: Int) async throws -> [MoodleSection] { + beginFetch() + defer { endFetch() } + + if let fetchDelay { + try await Task.sleep(for: fetchDelay) + } + + if failingCourseIDs.contains(courseID) { + throw failure + } + + recordCompletedFetch() + + return [ + MoodleSection( + id: 1, + courseID: courseID, + name: "Section 1", + sectionNumber: 0, + visible: true, + modules: [] + ) + ] + } + + // MARK: - Unused by these tests + + func validateSite(url: URL) async throws -> MoodleSite { + MoodleSite(displayName: url.host ?? "Test", baseURL: url) + } + + func authenticate(site: MoodleSite, username: String, password: String) async throws -> AuthToken { + AuthToken(token: "test-token") + } + + func parseTokenFromSSOCallback(callbackURLString: String, site: MoodleSite, passport: String) throws -> AuthToken { + AuthToken(token: "test-token") + } + + func fetchUserInfo(site: MoodleSite, token: AuthToken) async throws -> MoodleUser { + MoodleUser(id: 1, username: "test", fullName: "Test User", siteID: site.id) + } + + func fetchCourses(site: MoodleSite, token: AuthToken, userID: Int) async throws -> [MoodleCourse] { + [] + } + + func downloadFile(url: URL, token: AuthToken, destination: URL) async throws {} + + func authenticatedFileRequest(fileURL: URL, token: AuthToken) -> URLRequest { + URLRequest(url: fileURL) + } +} diff --git a/Tests/SyncEngineTests/SyncEngineCourseScopeTests.swift b/Tests/SyncEngineTests/SyncEngineCourseScopeTests.swift index dfad376..8aa6593 100644 --- a/Tests/SyncEngineTests/SyncEngineCourseScopeTests.swift +++ b/Tests/SyncEngineTests/SyncEngineCourseScopeTests.swift @@ -5,16 +5,16 @@ import XCTest @testable import SharedDomain -@testable import FoodleNetworking -@testable import FoodlePersistence -@testable import FoodleSyncEngine +@testable import FindleNetworking +@testable import FindlePersistence +@testable import FindleSyncEngine final class SyncEngineCourseScopeTests: XCTestCase { var database: Database! var tempPath: String! override func setUp() async throws { - tempPath = NSTemporaryDirectory() + "foodle_sync_test_\(UUID().uuidString).db" + tempPath = NSTemporaryDirectory() + "findle_sync_test_\(UUID().uuidString).db" database = try Database(path: tempPath) } diff --git a/Tests/SyncEngineTests/SyncEngineReauthTests.swift b/Tests/SyncEngineTests/SyncEngineReauthTests.swift index a5422d4..fc508cf 100644 --- a/Tests/SyncEngineTests/SyncEngineReauthTests.swift +++ b/Tests/SyncEngineTests/SyncEngineReauthTests.swift @@ -5,9 +5,9 @@ import XCTest @testable import SharedDomain -@testable import FoodleNetworking -@testable import FoodlePersistence -@testable import FoodleSyncEngine +@testable import FindleNetworking +@testable import FindlePersistence +@testable import FindleSyncEngine final class SyncEngineReauthTests: XCTestCase { var database: Database! @@ -26,7 +26,7 @@ final class SyncEngineReauthTests: XCTestCase { private let token = AuthToken(token: "token") override func setUp() async throws { - tempPath = NSTemporaryDirectory() + "foodle_reauth_test_\(UUID().uuidString).db" + tempPath = NSTemporaryDirectory() + "findle_reauth_test_\(UUID().uuidString).db" database = try Database(path: tempPath) } @@ -43,7 +43,7 @@ final class SyncEngineReauthTests: XCTestCase { do { try await engine.syncAllCourses(site: site, token: token, courses: courses) XCTFail("Expected syncAllCourses to rethrow the authentication failure") - } catch let error as FoodleError { + } catch let error as FindleError { XCTAssertTrue(error.requiresReauthentication) } } @@ -63,7 +63,7 @@ final class SyncEngineReauthTests: XCTestCase { } private struct ThrowingLMSProvider: LMSProvider { - let error: FoodleError + let error: FindleError func validateSite(url: URL) async throws -> MoodleSite { MoodleSite(displayName: url.host ?? "Test", baseURL: url) diff --git a/beta-channel-and-signing-plan.md b/beta-channel-and-signing-plan.md index 4002dd8..930514e 100644 --- a/beta-channel-and-signing-plan.md +++ b/beta-channel-and-signing-plan.md @@ -10,9 +10,9 @@ Decisions locked: **signed + notarized** beta · triggered by **prerelease tags* ## What already exists (good foundation) -- A **`Nightly` config + `Foodle-Nightly` scheme** with isolated bundle IDs (`es.amodrono.foodle.nightly[.file-provider]`) and app group (`group.es.amodrono.foodle.nightly`). -- **`BundleIdentifiers.swift`** derives app group, keychain service, File Provider domain ID, Spotlight prefix, and action IDs from `Bundle.main.bundleIdentifier` at runtime — so those are **already variant-isolated**. The hardcoded `es.amodrono.foodle` strings elsewhere are only Logger subsystems (cosmetic). -- **Entitlements** use `$(APP_GROUP_IDENTIFIER)` / `$(FOODLE_BUNDLE_PREFIX)` / `$(PRODUCT_BUNDLE_IDENTIFIER)`, so app group, keychain group, and Sparkle XPC names vary per variant automatically. +- A **`Nightly` config + `Findle-Nightly` scheme** with isolated bundle IDs (`es.amodrono.findle.nightly[.file-provider]`) and app group (`group.es.amodrono.findle.nightly`). +- **`BundleIdentifiers.swift`** derives app group, keychain service, File Provider domain ID, Spotlight prefix, and action IDs from `Bundle.main.bundleIdentifier` at runtime — so those are **already variant-isolated**. The hardcoded `es.amodrono.findle` strings elsewhere are only Logger subsystems (cosmetic). +- **Entitlements** use `$(APP_GROUP_IDENTIFIER)` / `$(FINDLE_BUNDLE_PREFIX)` / `$(PRODUCT_BUNDLE_IDENTIFIER)`, so app group, keychain group, and Sparkle XPC names vary per variant automatically. - A **production `release.yml`** (tag `v*`): import cert → install profiles → archive → export developer-id → notarize → DMG/ZIP → Sparkle-sign → appcast → GitHub Release → Homebrew cask. - A **`pr-nightly.yml`** that builds the Nightly scheme **unsigned** on PRs. @@ -38,7 +38,7 @@ A gitignored `Config/Signing.local.xcconfig` holds `DEVELOPMENT_TEAM`, `#include **Implemented:** - `Config/Signing.local.xcconfig` (gitignored) + `Config/Signing.local.xcconfig.example` (committed) - `Config/Debug-App.xcconfig`, `Config/Debug-FileProvider.xcconfig` (new) + `#include?` added to Nightly/Release app+FP xcconfigs -- `project.yml`: Debug `configFiles` for `Foodle` and `FoodleFileProvider` +- `project.yml`: Debug `configFiles` for `Findle` and `FindleFileProvider` - `.gitignore`: `Config/Signing.local.xcconfig` **Your one action:** paste your Team ID into `Config/Signing.local.xcconfig`: @@ -80,9 +80,9 @@ A new workflow mirroring `release.yml`, triggered on **`v*-beta.*`** tags, build Key differences from `release.yml`: - `on: push: tags: ['v*-beta.*']` -- `SCHEME: Foodle-Nightly`, `-configuration Nightly`, `APP_NAME: "Findle Beta"`, `BUNDLE_ID: es.amodrono.foodle.nightly` +- `SCHEME: Findle-Nightly`, `-configuration Nightly`, `APP_NAME: "Findle Beta"`, `BUNDLE_ID: es.amodrono.findle.nightly` - Install **nightly** provisioning profiles (new secrets) and write the **`Nightly-App` / `Nightly-FileProvider`** signing xcconfigs (manual, `Developer ID Application`, nightly profile UUIDs). -- `ExportOptions.plist` `provisioningProfiles` maps `es.amodrono.foodle.nightly` and `es.amodrono.foodle.nightly.file-provider`. +- `ExportOptions.plist` `provisioningProfiles` maps `es.amodrono.findle.nightly` and `es.amodrono.findle.nightly.file-provider`. - Notarize → DMG/ZIP named `Findle Beta` → Sparkle-sign → generate **beta** appcast. - `gh release create … --prerelease` (versioned, marked prerelease). - **Skip the Homebrew cask update** (don't disturb the stable cask). Optional: a separate `findle-beta` cask later. @@ -103,9 +103,9 @@ The prod `release.yml` is unchanged. (Optional later: tighten its tag filter to This is the price of a signed beta where the File Provider works. In the [Apple Developer portal](https://developer.apple.com/account): 1. **Register App IDs** - - `es.amodrono.foodle.nightly` — enable **App Groups** capability - - `es.amodrono.foodle.nightly.file-provider` — enable **App Groups** -2. **Register App Group** `group.es.amodrono.foodle.nightly`, and associate both App IDs with it. + - `es.amodrono.findle.nightly` — enable **App Groups** capability + - `es.amodrono.findle.nightly.file-provider` — enable **App Groups** +2. **Register App Group** `group.es.amodrono.findle.nightly`, and associate both App IDs with it. 3. **Create Developer ID provisioning profiles** for both nightly App IDs (Developer ID distribution, including the app group entitlement). 4. **Export + add GitHub secrets** (base64 the `.provisionprofile` files): ```bash diff --git a/findle-mcp-design.md b/findle-mcp-design.md index 4c2486e..c2c9c9e 100644 --- a/findle-mcp-design.md +++ b/findle-mcp-design.md @@ -18,7 +18,7 @@ Turn the pile of synced Moodle files into something you can _talk to_: ask quest flowchart LR claude["Claude Desktop"] mcp["findle-mcp
(separate process)"] - db[("foodle.db
(WAL)")] + db[("findle.db
(WAL)")] app["Findle app"] moodle["Moodle / FP"] diff --git a/project.yml b/project.yml index cf96c44..dace0cb 100644 --- a/project.yml +++ b/project.yml @@ -1,8 +1,8 @@ -name: Foodle +name: Findle options: - bundleIdPrefix: es.amodrono.foodle + bundleIdPrefix: es.amodrono.findle deploymentTarget: - macOS: "14.0" + macOS: "15.0" xcodeVersion: "16.0" groupSortPosition: top generateEmptyDirectories: true @@ -11,17 +11,22 @@ options: settings: base: SWIFT_VERSION: "6.0" - MACOSX_DEPLOYMENT_TARGET: "14.0" + MACOSX_DEPLOYMENT_TARGET: "15.0" ENABLE_HARDENED_RUNTIME: true CODE_SIGN_STYLE: Automatic CODE_SIGN_IDENTITY: "Apple Development" SWIFT_STRICT_CONCURRENCY: complete - FOODLE_BUNDLE_PREFIX: es.amodrono.foodle - APP_GROUP_IDENTIFIER: group.es.amodrono.foodle + FINDLE_BUNDLE_PREFIX: es.amodrono.findle + APP_GROUP_IDENTIFIER: group.es.amodrono.findle + FINDLE_APPCAST_URL: https://github.com/alexmodrono/Findle/releases/latest/download/appcast.xml configs: Nightly: - FOODLE_BUNDLE_PREFIX: es.amodrono.foodle.nightly - APP_GROUP_IDENTIFIER: group.es.amodrono.foodle.nightly + FINDLE_BUNDLE_PREFIX: es.amodrono.findle.nightly + APP_GROUP_IDENTIFIER: group.es.amodrono.findle.nightly + # Nightly ships as an unsigned CI artifact and has no appcast of its own. + # Leaving the release feed here would let a Nightly install "update" + # itself into the production build. An empty feed disables Sparkle. + FINDLE_APPCAST_URL: "" configs: Debug: debug @@ -29,11 +34,11 @@ configs: Release: release schemes: - Foodle: + Findle: build: targets: - Foodle: all - FoodleFileProvider: all + Findle: all + FindleFileProvider: all run: config: Debug test: @@ -57,11 +62,11 @@ schemes: FindleMCP: all run: config: Debug - Foodle-Nightly: + Findle-Nightly: build: targets: - Foodle: all - FoodleFileProvider: all + Findle: all + FindleFileProvider: all run: config: Nightly test: @@ -95,7 +100,7 @@ packages: from: 0.12.1 targets: - Foodle: + Findle: type: application platform: macOS configFiles: @@ -112,10 +117,10 @@ targets: buildPhase: resources dependencies: - target: SharedDomain - - target: FoodleNetworking - - target: FoodlePersistence - - target: FoodleSyncEngine - - target: FoodleFileProvider + - target: FindleNetworking + - target: FindlePersistence + - target: FindleSyncEngine + - target: FindleFileProvider embed: true codeSign: true - target: FindleMCP @@ -130,9 +135,9 @@ targets: settings: base: PRODUCT_NAME: Findle - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle INFOPLIST_FILE: Resources/Info/App-Info.plist - CODE_SIGN_ENTITLEMENTS: Resources/Entitlements/Foodle.entitlements + CODE_SIGN_ENTITLEMENTS: Resources/Entitlements/Findle.entitlements ENABLE_APP_SANDBOX: YES COMBINE_HIDPI_IMAGES: true ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon @@ -141,7 +146,7 @@ targets: configs: Nightly: PRODUCT_NAME: Findle Nightly - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.nightly + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.nightly SharedDomain: type: framework @@ -153,11 +158,11 @@ targets: - path: Sources/SharedDomain settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.shared-domain + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.shared-domain INFOPLIST_FILE: Resources/Info/Framework-Info.plist CODE_SIGN_IDENTITY: "-" - FoodleNetworking: + FindleNetworking: type: framework platform: macOS configFiles: @@ -169,11 +174,11 @@ targets: - target: SharedDomain settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.networking + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.networking INFOPLIST_FILE: Resources/Info/Framework-Info.plist CODE_SIGN_IDENTITY: "-" - FoodlePersistence: + FindlePersistence: type: framework platform: macOS configFiles: @@ -185,11 +190,11 @@ targets: - target: SharedDomain settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.persistence + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.persistence INFOPLIST_FILE: Resources/Info/Framework-Info.plist CODE_SIGN_IDENTITY: "-" - FoodleSyncEngine: + FindleSyncEngine: type: framework platform: macOS configFiles: @@ -199,15 +204,15 @@ targets: - path: Sources/SyncEngine dependencies: - target: SharedDomain - - target: FoodleNetworking - - target: FoodlePersistence + - target: FindleNetworking + - target: FindlePersistence settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.sync-engine + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.sync-engine INFOPLIST_FILE: Resources/Info/Framework-Info.plist CODE_SIGN_IDENTITY: "-" - FoodleFileProvider: + FindleFileProvider: type: app-extension platform: macOS configFiles: @@ -222,12 +227,12 @@ targets: - path: Sources/FileProviderExtension dependencies: - target: SharedDomain - - target: FoodleNetworking - - target: FoodlePersistence - - target: FoodleSyncEngine + - target: FindleNetworking + - target: FindlePersistence + - target: FindleSyncEngine settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.file-provider + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.file-provider INFOPLIST_FILE: Resources/Info/FileProvider-Info.plist CODE_SIGN_ENTITLEMENTS: Resources/Entitlements/FileProvider.entitlements ENABLE_APP_SANDBOX: YES @@ -236,7 +241,7 @@ targets: - "@executable_path/../../../../Frameworks" configs: Nightly: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.nightly.file-provider + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.nightly.file-provider info: path: Resources/Info/FileProvider-Info.plist properties: @@ -249,19 +254,22 @@ targets: NSExtensionFileProviderDocumentGroup: "$(APP_GROUP_IDENTIFIER)" NSExtensionFileProviderSupportsEnumeration: true NSExtensionFileProviderActions: - - NSExtensionFileProviderActionIdentifier: $(FOODLE_BUNDLE_PREFIX).action.open-in-moodle + - NSExtensionFileProviderActionIdentifier: $(FINDLE_BUNDLE_PREFIX).action.sync-now + NSExtensionFileProviderActionName: Sync Now + NSExtensionFileProviderActionActivationRule: TRUEPREDICATE + - NSExtensionFileProviderActionIdentifier: $(FINDLE_BUNDLE_PREFIX).action.open-in-moodle NSExtensionFileProviderActionName: Open in Moodle NSExtensionFileProviderActionActivationRule: TRUEPREDICATE - - NSExtensionFileProviderActionIdentifier: $(FOODLE_BUNDLE_PREFIX).action.copy-moodle-link + - NSExtensionFileProviderActionIdentifier: $(FINDLE_BUNDLE_PREFIX).action.copy-moodle-link NSExtensionFileProviderActionName: Copy Moodle Link NSExtensionFileProviderActionActivationRule: TRUEPREDICATE - - NSExtensionFileProviderActionIdentifier: $(FOODLE_BUNDLE_PREFIX).action.open-course-page + - NSExtensionFileProviderActionIdentifier: $(FINDLE_BUNDLE_PREFIX).action.open-course-page NSExtensionFileProviderActionName: Open Course Page NSExtensionFileProviderActionActivationRule: TRUEPREDICATE - - NSExtensionFileProviderActionIdentifier: $(FOODLE_BUNDLE_PREFIX).action.keep-downloaded + - NSExtensionFileProviderActionIdentifier: $(FINDLE_BUNDLE_PREFIX).action.keep-downloaded NSExtensionFileProviderActionName: Keep Downloaded NSExtensionFileProviderActionActivationRule: TRUEPREDICATE - - NSExtensionFileProviderActionIdentifier: $(FOODLE_BUNDLE_PREFIX).action.remove-download + - NSExtensionFileProviderActionIdentifier: $(FINDLE_BUNDLE_PREFIX).action.remove-download NSExtensionFileProviderActionName: Remove Download NSExtensionFileProviderActionActivationRule: TRUEPREDICATE @@ -276,12 +284,12 @@ targets: - path: Sources/FindleMCP dependencies: - target: SharedDomain - - target: FoodlePersistence + - target: FindlePersistence - package: MCP product: MCP settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.mcp + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.mcp # The helper ships only as an embedded copy inside the app (via the app's # copy phase). Without this, the tool also installs to the archive's # Products/usr/local/bin, making the archive multi-product: Xcode then @@ -307,7 +315,7 @@ targets: - target: SharedDomain settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.shared-domain-tests + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.shared-domain-tests NetworkingTests: type: bundle.unit-test @@ -315,11 +323,11 @@ targets: sources: - path: Tests/NetworkingTests dependencies: - - target: FoodleNetworking + - target: FindleNetworking - target: SharedDomain settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.networking-tests + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.networking-tests PersistenceTests: type: bundle.unit-test @@ -327,11 +335,11 @@ targets: sources: - path: Tests/PersistenceTests dependencies: - - target: FoodlePersistence + - target: FindlePersistence - target: SharedDomain settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.persistence-tests + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.persistence-tests FileProviderTests: type: bundle.unit-test @@ -342,11 +350,11 @@ targets: - path: Sources/FileProviderExtension/FileProviderItem.swift dependencies: - target: SharedDomain - - target: FoodleNetworking - - target: FoodlePersistence + - target: FindleNetworking + - target: FindlePersistence settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.file-provider-tests + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.file-provider-tests SyncEngineTests: type: bundle.unit-test @@ -354,13 +362,13 @@ targets: sources: - path: Tests/SyncEngineTests dependencies: - - target: FoodleSyncEngine + - target: FindleSyncEngine - target: SharedDomain - - target: FoodleNetworking - - target: FoodlePersistence + - target: FindleNetworking + - target: FindlePersistence settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.sync-engine-tests + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.sync-engine-tests FindleMCPTests: type: bundle.unit-test @@ -373,7 +381,7 @@ targets: - path: Tests/FindleMCPTests dependencies: - target: SharedDomain - - target: FoodlePersistence + - target: FindlePersistence settings: base: - PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.foodle.mcp-tests + PRODUCT_BUNDLE_IDENTIFIER: es.amodrono.findle.mcp-tests diff --git a/scripts/staging-signed-build.sh b/scripts/staging-signed-build.sh index 8db4fd8..9cc5125 100755 --- a/scripts/staging-signed-build.sh +++ b/scripts/staging-signed-build.sh @@ -15,8 +15,8 @@ echo "==> Building signed Staging app" echo " This build is intended for real SSO, File Provider, and Finder validation." xcodebuild \ - -project "$ROOT_DIR/Foodle.xcodeproj" \ - -scheme "Foodle-Staging" \ + -project "$ROOT_DIR/Findle.xcodeproj" \ + -scheme "Findle-Staging" \ -configuration Staging \ -destination "$DESTINATION" \ -derivedDataPath "$DERIVED_DATA_PATH" \ diff --git a/scripts/staging-smoke-test.sh b/scripts/staging-smoke-test.sh index 7863bec..6330559 100755 --- a/scripts/staging-smoke-test.sh +++ b/scripts/staging-smoke-test.sh @@ -8,8 +8,8 @@ DESTINATION="${DESTINATION:-platform=macOS}" echo "==> Building Staging (unsigned optimized validation build)" xcodebuild \ - -project "$ROOT_DIR/Foodle.xcodeproj" \ - -scheme "Foodle-Staging" \ + -project "$ROOT_DIR/Findle.xcodeproj" \ + -scheme "Findle-Staging" \ -configuration Staging \ -destination "$DESTINATION" \ -derivedDataPath "$DERIVED_DATA_PATH" \ @@ -19,8 +19,8 @@ xcodebuild \ echo "==> Running Staging tests" xcodebuild \ - -project "$ROOT_DIR/Foodle.xcodeproj" \ - -scheme "Foodle-Staging" \ + -project "$ROOT_DIR/Findle.xcodeproj" \ + -scheme "Findle-Staging" \ -configuration Staging \ -destination "$DESTINATION" \ -derivedDataPath "$DERIVED_DATA_PATH" \ diff --git a/v0.2.0-ui-ux-plan.md b/v0.2.0-ui-ux-plan.md index 0b43ecc..643457e 100644 --- a/v0.2.0-ui-ux-plan.md +++ b/v0.2.0-ui-ux-plan.md @@ -110,7 +110,7 @@ Low–medium. Mostly plumbing data that's already computed; main care is keeping Three concrete HIG deviations found in the code: ### 3a. Settings is duplicated -There's a real `Settings { SettingsView() }` scene (`FoodleApp.swift:49`) — so ⌘, *should* open a standard Settings window — **but** `SettingsView` is *also* a sidebar destination inside `WorkspaceView` (`WorkspaceView.swift:226`, `:321`). Same screen, two homes. +There's a real `Settings { SettingsView() }` scene (`FindleApp.swift:49`) — so ⌘, *should* open a standard Settings window — **but** `SettingsView` is *also* a sidebar destination inside `WorkspaceView` (`WorkspaceView.swift:226`, `:321`). Same screen, two homes. **Proposal:** Pick the standard one. Remove Settings from the sidebar; let it live in the Settings scene opened via the app menu / ⌘,. (Keeps the sidebar purely about courses.) @@ -130,7 +130,7 @@ The menu bar's "Open Findle…" is bound to ⌘, (`MenuBarView.swift:28`). On ma ### Files - `WorkspaceView.swift` — remove Settings/Diagnostics sidebar section; simplify `SidebarSelection` - `MenuBarView.swift` — rebind shortcut, maybe add Settings item -- `FoodleApp.swift` — possibly add `.commands` / Help menu entry for Diagnostics +- `FindleApp.swift` — possibly add `.commands` / Help menu entry for Diagnostics ### Effort Low. These are mostly deletions + rebinding. Highest ratio of polish-per-line.