From 1d187285fec535c46d1e347d6f201e75648e53a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Modro=C3=B1o=20Vara?= Date: Tue, 11 Aug 2026 18:15:18 +0200 Subject: [PATCH 1/4] Rename Foodle to Findle The project ships as Findle, but the codebase still carried the original Foodle name throughout: bundle identifiers, the App Group, the keychain service, the File Provider domain prefix, framework and target names, the on-disk database path, and the CI workflows. Rename every occurrence so the identifiers match the product. The bundle prefix becomes es.amodrono.findle, the App Group group.es.amodrono.findle, the framework targets FindleNetworking / FindlePersistence / FindleSyncEngine, and the state directory .FindleState/Findle/findle.db. The six Foodle-prefixed source files are renamed with their contents otherwise unchanged. --- .github/homebrew-tap-setup.md | 6 +- .github/workflows/pr-nightly.yml | 4 +- .github/workflows/release.yml | 22 ++-- .gitignore | 2 +- Config/MCP.xcconfig | 2 +- README.md | 30 ++--- .../Entitlements/FileProvider.entitlements | 2 +- ...oodle.entitlements => Findle.entitlements} | 2 +- Resources/Info/FileProvider-Info.plist | 10 +- Sources/App/AppDelegate.swift | 2 +- .../App/{FoodleApp.swift => FindleApp.swift} | 4 +- Sources/App/LoginItemController.swift | 2 +- Sources/App/Services/ClaudeIntegration.swift | 2 +- Sources/App/Services/SpotlightIndexer.swift | 4 +- Sources/App/ViewModels/AppState.swift | 30 ++--- .../Views/Onboarding/EmbeddedSSOView.swift | 2 +- .../Views/Onboarding/OnboardingState.swift | 2 +- .../App/Views/Onboarding/OnboardingView.swift | 2 +- .../Onboarding/Steps/SignInStepView.swift | 10 +- .../FileDownloader.swift | 12 +- .../FileProviderExtension+CustomActions.swift | 2 +- .../FileProviderExtension.swift | 14 +-- .../ItemEnumerator.swift | 6 +- Sources/FindleMCP/Catalog.swift | 2 +- Sources/FindleMCP/FindleMCP.swift | 2 +- Sources/FindleMCP/HTTPServer.swift | 2 +- Sources/FindleMCP/IndexStore.swift | 2 +- .../Auth/EmbeddedAuthCoordinator.swift | 6 +- Sources/Networking/Auth/KeychainManager.swift | 2 +- .../Auth/MoodleSSOLaunchURLBuilder.swift | 8 +- Sources/Networking/Auth/WebAuthSession.swift | 10 +- Sources/Networking/Client/MoodleClient.swift | 56 ++++----- ...etworking.swift => FindleNetworking.swift} | 4 +- Sources/Persistence/Database.swift | 72 +++++------ ...sistence.swift => FindlePersistence.swift} | 4 +- Sources/SharedDomain/BundleIdentifiers.swift | 4 +- .../{FoodleError.swift => FindleError.swift} | 2 +- Sources/SharedDomain/Models/MoodleSite.swift | 2 +- Sources/SharedDomain/SharedDomain.swift | 2 +- ...yncEngine.swift => FindleSyncEngine.swift} | 4 +- Sources/SyncEngine/SyncEngine.swift | 12 +- Tests/FindleMCPTests/CatalogTests.swift | 2 +- Tests/FindleMCPTests/TestSupport.swift | 2 +- Tests/NetworkingTests/MoodleClientTests.swift | 16 +-- .../MoodleSSOLaunchURLBuilderTests.swift | 2 +- .../NetworkingTests/WebAuthSessionTests.swift | 2 +- Tests/PersistenceTests/DatabaseTests.swift | 4 +- Tests/SharedDomainTests/ModelTests.swift | 12 +- .../SyncEngineCourseScopeTests.swift | 8 +- .../SyncEngineReauthTests.swift | 12 +- beta-channel-and-signing-plan.md | 18 +-- findle-mcp-design.md | 2 +- project.yml | 112 +++++++++--------- scripts/staging-signed-build.sh | 4 +- scripts/staging-smoke-test.sh | 8 +- v0.2.0-ui-ux-plan.md | 4 +- 56 files changed, 289 insertions(+), 289 deletions(-) rename Resources/Entitlements/{Foodle.entitlements => Findle.entitlements} (95%) rename Sources/App/{FoodleApp.swift => FindleApp.swift} (97%) rename Sources/Networking/{FoodleNetworking.swift => FindleNetworking.swift} (83%) rename Sources/Persistence/{FoodlePersistence.swift => FindlePersistence.swift} (83%) rename Sources/SharedDomain/Errors/{FoodleError.swift => FindleError.swift} (98%) rename Sources/SyncEngine/{FoodleSyncEngine.swift => FindleSyncEngine.swift} (85%) 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..ceb4f54 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" @@ -301,9 +301,9 @@ jobs: app "Findle.app" zap trash: [ - "~/Library/Group Containers/group.es.amodrono.foodle", - "~/Library/Containers/es.amodrono.foodle", - "~/Library/Containers/es.amodrono.foodle.file-provider", + "~/Library/Group Containers/group.es.amodrono.findle", + "~/Library/Containers/es.amodrono.findle", + "~/Library/Containers/es.amodrono.findle.file-provider", ] end CASK diff --git a/.gitignore b/.gitignore index 7601420..3a59a04 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,4 @@ Docs/ .cl[a]ude/ CLA[U]DE.md skills-lock.json -Resources/Icon/Foodle.icon/ +Resources/Icon/Findle.icon/ diff --git a/Config/MCP.xcconfig b/Config/MCP.xcconfig index 76e92d1..939922e 100644 --- a/Config/MCP.xcconfig +++ b/Config/MCP.xcconfig @@ -3,7 +3,7 @@ // Pulls in the local Team ID (gitignored) so the helper signs with the same // team as the app's embedded frameworks. The hardened runtime's library // validation refuses to load frameworks signed by a different team, so an -// ad-hoc helper inside a team-signed app can't load SharedDomain/FoodlePersistence. +// ad-hoc helper inside a team-signed app can't load SharedDomain/FindlePersistence. // // The include is optional: CI / contributors without a team build with // CODE_SIGNING_ALLOWED=NO, which skips signing entirely. diff --git a/README.md b/README.md index 4ba1048..20c9638 100644 --- a/README.md +++ b/README.md @@ -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/FileProvider-Info.plist b/Resources/Info/FileProvider-Info.plist index 0725e58..654d69a 100644 --- a/Resources/Info/FileProvider-Info.plist +++ b/Resources/Info/FileProvider-Info.plist @@ -34,7 +34,7 @@ NSExtensionFileProviderActionActivationRule TRUEPREDICATE NSExtensionFileProviderActionIdentifier - $(FOODLE_BUNDLE_PREFIX).action.open-in-moodle + $(FINDLE_BUNDLE_PREFIX).action.open-in-moodle NSExtensionFileProviderActionName Open in Moodle @@ -42,7 +42,7 @@ NSExtensionFileProviderActionActivationRule TRUEPREDICATE NSExtensionFileProviderActionIdentifier - $(FOODLE_BUNDLE_PREFIX).action.copy-moodle-link + $(FINDLE_BUNDLE_PREFIX).action.copy-moodle-link NSExtensionFileProviderActionName Copy Moodle Link @@ -50,7 +50,7 @@ NSExtensionFileProviderActionActivationRule TRUEPREDICATE NSExtensionFileProviderActionIdentifier - $(FOODLE_BUNDLE_PREFIX).action.open-course-page + $(FINDLE_BUNDLE_PREFIX).action.open-course-page NSExtensionFileProviderActionName Open Course Page @@ -58,7 +58,7 @@ NSExtensionFileProviderActionActivationRule TRUEPREDICATE NSExtensionFileProviderActionIdentifier - $(FOODLE_BUNDLE_PREFIX).action.keep-downloaded + $(FINDLE_BUNDLE_PREFIX).action.keep-downloaded NSExtensionFileProviderActionName Keep Downloaded @@ -66,7 +66,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..71c6aa2 100644 --- a/Sources/App/Services/ClaudeIntegration.swift +++ b/Sources/App/Services/ClaudeIntegration.swift @@ -18,7 +18,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 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/ViewModels/AppState.swift b/Sources/App/ViewModels/AppState.swift index 0f5b40d..126ca2c 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 @@ -58,14 +58,14 @@ final class AppState: ObservableObject { private var lastAppliedSyncInterval: Double = -1 private var sessionBootstrapTask: Task? private var syncSettingsObserver: NSObjectProtocol? - private let logger = Logger(subsystem: "es.amodrono.foodle", category: "AppState") + 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 { @@ -186,9 +186,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, @@ -283,7 +283,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 @@ -598,7 +598,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 +645,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 { @@ -963,7 +963,7 @@ final class AppState: ObservableObject { // 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 { + } catch let error as FindleError where error.requiresReauthentication { progressTask.cancel() handleSessionExpired() } catch is CancellationError { @@ -992,7 +992,7 @@ final class AppState: ObservableObject { syncStatus = .completed lastSyncDate = Date() signalFileProviderChanges() - } catch let error as FoodleError where error.requiresReauthentication { + } catch let error as FindleError where error.requiresReauthentication { handleSessionExpired() } catch is CancellationError { courseSyncStates[course.id] = .stale @@ -1091,7 +1091,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 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/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/FileProviderExtension/FileDownloader.swift b/Sources/FileProviderExtension/FileDownloader.swift index f4319ef..c947610 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 @@ -38,12 +38,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 @@ -68,7 +68,7 @@ enum FileDownloader { 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")) + completionBridge.fail(FindleError.downloadFailed(itemID: item.id, reason: "Download failed")) return } diff --git a/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift b/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift index 8ad278a..3931578 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 { diff --git a/Sources/FileProviderExtension/FileProviderExtension.swift b/Sources/FileProviderExtension/FileProviderExtension.swift index 7a6e61e..540497a 100644 --- a/Sources/FileProviderExtension/FileProviderExtension.swift +++ b/Sources/FileProviderExtension/FileProviderExtension.swift @@ -5,15 +5,15 @@ 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? @@ -123,9 +123,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 @@ -244,7 +244,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 diff --git a/Sources/FileProviderExtension/ItemEnumerator.swift b/Sources/FileProviderExtension/ItemEnumerator.swift index d3e6d00..1913d92 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,7 +31,7 @@ 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 logger = Logger(subsystem: "es.amodrono.findle.file-provider", category: "Enumerator") init(containerIdentifier: NSFileProviderItemIdentifier, database: Database) { self.containerIdentifier = containerIdentifier @@ -115,7 +115,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 diff --git a/Sources/FindleMCP/Catalog.swift b/Sources/FindleMCP/Catalog.swift index 8cf66c3..d1a8238 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. diff --git a/Sources/FindleMCP/FindleMCP.swift b/Sources/FindleMCP/FindleMCP.swift index b850544..35da80d 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. 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..432504b 100644 --- a/Sources/FindleMCP/IndexStore.swift +++ b/Sources/FindleMCP/IndexStore.swift @@ -16,7 +16,7 @@ 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 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..63913b8 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,7 +28,7 @@ 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 } @@ -70,9 +70,9 @@ public final class Database: @unchecked Sendable { .appendingPathComponent("Library", isDirectory: true) .appendingPathComponent("Application Support", 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 +82,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 +117,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 +140,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 } @@ -608,7 +608,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 +618,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 +655,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 +720,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 +832,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 +924,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 +944,7 @@ 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") } } } @@ -963,7 +963,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 +975,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)))") } } @@ -1067,7 +1067,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") @@ -1134,7 +1134,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 filename") + throw FindleError.databaseError(detail: "Failed to update item filename") } } } @@ -1152,7 +1152,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 tag data") + throw FindleError.databaseError(detail: "Failed to update item tag data") } } } @@ -1193,7 +1193,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 +1208,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") } } } @@ -1243,7 +1243,7 @@ extension Database { 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") + throw FindleError.databaseError(detail: "deleteItems pending insert failed") } let deleteStmt = try prepareStatement("DELETE FROM items WHERE course_id = ? AND site_id = ? AND is_local = 0") @@ -1251,7 +1251,7 @@ extension Database { 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") + throw FindleError.databaseError(detail: "deleteItems delete failed") } try executeUnsafe("COMMIT") } catch { @@ -1269,14 +1269,14 @@ extension Database { 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") + throw FindleError.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") + throw FindleError.databaseError(detail: "deleteAllItems delete failed") } try executeUnsafe("COMMIT") } catch { @@ -1324,12 +1324,12 @@ extension Database { sqlite3_reset(tombstoneStmt) sqlite3_bind_text(tombstoneStmt, 1, (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") @@ -1587,7 +1587,7 @@ extension Database { sqlite3_bind_int(stmt, 8, a.graded ? 1 : 0) bindOptionalText(stmt, 9, a.grade) guard sqlite3_step(stmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "saveAssignments: \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "saveAssignments: \(String(cString: sqlite3_errmsg(db)))") } } try executeUnsafe("COMMIT") @@ -1640,7 +1640,7 @@ extension Database { bindOptionalText(stmt, 6, g.percentage) bindOptionalText(stmt, 7, g.feedback) guard sqlite3_step(stmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "saveGradeItems: \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "saveGradeItems: \(String(cString: sqlite3_errmsg(db)))") } } try executeUnsafe("COMMIT") @@ -1691,7 +1691,7 @@ extension Database { bindOptionalDate(stmt, 6, q.closeDate) if let t = q.timeLimit { sqlite3_bind_int64(stmt, 7, Int64(t)) } else { sqlite3_bind_null(stmt, 7) } guard sqlite3_step(stmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "saveQuizzes: \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "saveQuizzes: \(String(cString: sqlite3_errmsg(db)))") } } try executeUnsafe("COMMIT") @@ -1743,7 +1743,7 @@ extension Database { bindOptionalDate(stmt, 7, a.startTime) bindOptionalDate(stmt, 8, a.finishTime) guard sqlite3_step(stmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "saveQuizAttempts: \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "saveQuizAttempts: \(String(cString: sqlite3_errmsg(db)))") } } try executeUnsafe("COMMIT") @@ -1782,7 +1782,7 @@ extension Database { defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, (siteID as NSString).utf8String, -1, SQLITE_TRANSIENT) guard sqlite3_step(stmt) == SQLITE_DONE else { - throw FoodleError.databaseError(detail: "deleteForSite(\(table)): \(String(cString: sqlite3_errmsg(db)))") + throw FindleError.databaseError(detail: "deleteForSite(\(table)): \(String(cString: sqlite3_errmsg(db)))") } } diff --git a/Sources/Persistence/FoodlePersistence.swift b/Sources/Persistence/FindlePersistence.swift similarity index 83% rename from Sources/Persistence/FoodlePersistence.swift rename to Sources/Persistence/FindlePersistence.swift index 88c4742..3624d5a 100644 --- a/Sources/Persistence/FoodlePersistence.swift +++ b/Sources/Persistence/FindlePersistence.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. -/// FoodlePersistence module. +/// FindlePersistence module. /// Contains SQLite-backed local storage for accounts, courses, items, and sync cursors. -public enum FoodlePersistenceVersion { +public enum FindlePersistenceVersion { public static let current = "1.0.0" } diff --git a/Sources/SharedDomain/BundleIdentifiers.swift b/Sources/SharedDomain/BundleIdentifiers.swift index 5e76bc4..250ba94 100644 --- a/Sources/SharedDomain/BundleIdentifiers.swift +++ b/Sources/SharedDomain/BundleIdentifiers.swift @@ -11,9 +11,9 @@ import Foundation /// correct values are used automatically based on which scheme built the app. public enum BundleIdentifiers { - /// Base identifier prefix (e.g., `es.amodrono.foodle` or `es.amodrono.foodle.nightly`). + /// Base identifier prefix (e.g., `es.amodrono.findle` or `es.amodrono.findle.nightly`). public static let prefix: String = { - guard let id = Bundle.main.bundleIdentifier else { return "es.amodrono.foodle" } + guard let id = Bundle.main.bundleIdentifier else { return "es.amodrono.findle" } // In the File Provider extension process, strip the ".file-provider" suffix. if id.hasSuffix(".file-provider") { return String(id.dropLast(".file-provider".count)) 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/MoodleSite.swift b/Sources/SharedDomain/Models/MoodleSite.swift index 228560f..8e92e16 100644 --- a/Sources/SharedDomain/Models/MoodleSite.swift +++ b/Sources/SharedDomain/Models/MoodleSite.swift @@ -56,7 +56,7 @@ public struct MoodleSite: Sendable, Codable, Equatable, Identifiable { /// navigation delegate intercepts them instead of the system URL handler. public static let acceptedCallbackSchemes: Set = [ "findle", - "foodle", + "findle", "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..cd98177 100644 --- a/Sources/SyncEngine/SyncEngine.swift +++ b/Sources/SyncEngine/SyncEngine.swift @@ -6,14 +6,14 @@ 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] = [:] @@ -71,7 +71,7 @@ public actor SyncEngine { do { try await task.value logger.info("Sync completed for course \(course.id, privacy: .public)") - } catch let error as FoodleError where error.requiresReauthentication { + } catch let error as FindleError 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 @@ -403,11 +403,11 @@ 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) diff --git a/Tests/FindleMCPTests/CatalogTests.swift b/Tests/FindleMCPTests/CatalogTests.swift index 8baf4c4..9d4e119 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 { diff --git a/Tests/FindleMCPTests/TestSupport.swift b/Tests/FindleMCPTests/TestSupport.swift index 541e17c..5aa5300 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 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..2258ae7 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) } 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/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..b3400df 100644 --- a/project.yml +++ b/project.yml @@ -1,6 +1,6 @@ -name: Foodle +name: Findle options: - bundleIdPrefix: es.amodrono.foodle + bundleIdPrefix: es.amodrono.findle deploymentTarget: macOS: "14.0" xcodeVersion: "16.0" @@ -16,12 +16,12 @@ settings: 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 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 configs: Debug: debug @@ -29,11 +29,11 @@ configs: Release: release schemes: - Foodle: + Findle: build: targets: - Foodle: all - FoodleFileProvider: all + Findle: all + FindleFileProvider: all run: config: Debug test: @@ -57,11 +57,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 +95,7 @@ packages: from: 0.12.1 targets: - Foodle: + Findle: type: application platform: macOS configFiles: @@ -112,10 +112,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 +130,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 +141,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 +153,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 +169,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 +185,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 +199,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 +222,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 +236,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 +249,19 @@ targets: NSExtensionFileProviderDocumentGroup: "$(APP_GROUP_IDENTIFIER)" NSExtensionFileProviderSupportsEnumeration: true NSExtensionFileProviderActions: - - NSExtensionFileProviderActionIdentifier: $(FOODLE_BUNDLE_PREFIX).action.open-in-moodle + - 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 +276,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 +307,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 +315,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 +327,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 +342,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 +354,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 +373,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. From 78690eeb70ccea07b546d51e6cf7e46b636343a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Modro=C3=B1o=20Vara?= Date: Tue, 11 Aug 2026 18:29:59 +0200 Subject: [PATCH 2/4] Add course outlines and MCP catalog tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP server could list and search synced items, but had no way to answer questions about a course's shape — which sections exist, what sits in each, and how a given file fits in. Answering that from the items table alone meant reconstructing the hierarchy on every call. Persist the section/module tree returned by core_course_get_contents as a course outline snapshot alongside the items, in a new course_outlines table (schema 15). The catalog builds a compact course brief from it and can read an item's extracted text directly, so an assistant can orient itself in a course without walking the file tree. Also scopes the File Provider enumerators and several database queries by site so a multi-site install can't leak items between domains, and lets sign-in fall back to the user-domain Application Support directory when the App Group container is unavailable. --- .github/workflows/release.yml | 2 +- README.md | 4 +- Sources/App/Services/ClaudeIntegration.swift | 7 +- Sources/App/ViewModels/AppState.swift | 311 +++++++++-- .../App/Views/Connect/MCPConnectView.swift | 1 + .../Onboarding/Steps/SetupStepView.swift | 15 +- .../FileDownloader.swift | 45 +- .../FileProviderExtension+CustomActions.swift | 12 + .../FileProviderExtension.swift | 48 +- .../FileProviderItem.swift | 13 +- .../ItemEnumerator.swift | 49 +- Sources/FindleMCP/Catalog.swift | 314 ++++++++++- Sources/FindleMCP/FindleMCP.swift | 72 ++- Sources/FindleMCP/IndexStore.swift | 12 +- Sources/Persistence/Database.swift | 519 +++++++++++++++--- .../SharedDomain/Models/CourseOutline.swift | 137 +++++ Sources/SharedDomain/Models/MoodleSite.swift | 1 - Sources/SyncEngine/SyncEngine.swift | 41 +- .../FileDownloadCompletionBridgeTests.swift | 1 + Tests/FindleMCPTests/CatalogTests.swift | 28 + Tests/FindleMCPTests/TestSupport.swift | 19 + Tests/PersistenceTests/DatabaseTests.swift | 115 +++- project.yml | 4 +- 23 files changed, 1567 insertions(+), 203 deletions(-) create mode 100644 Sources/SharedDomain/Models/CourseOutline.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ceb4f54..dcd21bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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) diff --git a/Sources/App/Services/ClaudeIntegration.swift b/Sources/App/Services/ClaudeIntegration.swift index 71c6aa2..a335822 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 @@ -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), @@ -123,7 +124,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 { diff --git a/Sources/App/ViewModels/AppState.swift b/Sources/App/ViewModels/AppState.swift index 126ca2c..b1e9489 100644 --- a/Sources/App/ViewModels/AppState.swift +++ b/Sources/App/ViewModels/AppState.swift @@ -57,6 +57,8 @@ 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.findle", category: "AppState") private let userDefaults: UserDefaults @@ -177,6 +179,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) @@ -200,6 +233,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 +248,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)") } @@ -393,6 +483,7 @@ final class AppState: ObservableObject { state: .authenticated(userID: user.id) ) try db.saveAccount(account) + try db.setSeedComplete(true) accounts = [account] sites = [site] @@ -411,8 +502,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 +572,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 +643,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() @@ -795,6 +906,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 +1023,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 @@ -962,7 +1078,10 @@ 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() } + trackingRefreshTask?.cancel() + trackingRefreshTask = Task { [weak self] in + await self?.refreshTracking() + } } catch let error as FindleError where error.requiresReauthentication { progressTask.cancel() handleSessionExpired() @@ -979,8 +1098,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 +1124,19 @@ final class AppState: ObservableObject { syncStatus = .completed lastSyncDate = Date() signalFileProviderChanges() + 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 } } @@ -1211,9 +1347,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 +1453,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 +1516,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 +1602,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,30 +1631,100 @@ 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) } diff --git a/Sources/App/Views/Connect/MCPConnectView.swift b/Sources/App/Views/Connect/MCPConnectView.swift index 0b72f7f..38ec167 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 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/FileProviderExtension/FileDownloader.swift b/Sources/FileProviderExtension/FileDownloader.swift index c947610..3c193f7 100644 --- a/Sources/FileProviderExtension/FileDownloader.swift +++ b/Sources/FileProviderExtension/FileDownloader.swift @@ -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( @@ -54,7 +55,7 @@ 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 @@ -112,10 +113,10 @@ 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) } @@ -135,20 +136,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 3931578..3d9116e 100644 --- a/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift +++ b/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift @@ -57,6 +57,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 +78,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 +101,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( diff --git a/Sources/FileProviderExtension/FileProviderExtension.swift b/Sources/FileProviderExtension/FileProviderExtension.swift index 540497a..0eb066e 100644 --- a/Sources/FileProviderExtension/FileProviderExtension.swift +++ b/Sources/FileProviderExtension/FileProviderExtension.swift @@ -56,10 +56,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 +64,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 } @@ -176,12 +179,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 +210,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 +227,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) } @@ -280,7 +298,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 +511,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 1913d92..1bb8940 100644 --- a/Sources/FileProviderExtension/ItemEnumerator.swift +++ b/Sources/FileProviderExtension/ItemEnumerator.swift @@ -31,11 +31,13 @@ enum SyncAnchorCoding { final class ItemEnumerator: NSObject, NSFileProviderEnumerator { private let containerIdentifier: NSFileProviderItemIdentifier private let database: Database + 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) @@ -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 d1a8238..47e56e1 100644 --- a/Sources/FindleMCP/Catalog.swift +++ b/Sources/FindleMCP/Catalog.swift @@ -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 35da80d..a4ff805 100644 --- a/Sources/FindleMCP/FindleMCP.swift +++ b/Sources/FindleMCP/FindleMCP.swift @@ -16,10 +16,8 @@ import FindlePersistence @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/IndexStore.swift b/Sources/FindleMCP/IndexStore.swift index 432504b..3cc9317 100644 --- a/Sources/FindleMCP/IndexStore.swift +++ b/Sources/FindleMCP/IndexStore.swift @@ -23,6 +23,7 @@ final class IndexStore: @unchecked Sendable { 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/Persistence/Database.swift b/Sources/Persistence/Database.swift index 63913b8..2b574b9 100644 --- a/Sources/Persistence/Database.swift +++ b/Sources/Persistence/Database.swift @@ -32,7 +32,7 @@ public final class Database: @unchecked Sendable { .path } - public static let schemaVersion = 13 + public static let schemaVersion = 15 /// Opens the database. /// @@ -66,9 +66,12 @@ 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("Findle", isDirectory: true) try FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true) @@ -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)") } @@ -950,6 +1016,69 @@ extension Database { } } +// 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") + } + } + } +} + // MARK: - Course Tag Operations extension Database { @@ -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) } @@ -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,12 +1286,13 @@ 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 FindleError.databaseError(detail: "Failed to update item filename") @@ -1140,7 +1301,7 @@ extension Database { } 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,7 +1310,8 @@ 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 FindleError.databaseError(detail: "Failed to update item tag data") @@ -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 FindleError.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 FindleError.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 FindleError.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 FindleError.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,6 +1501,7 @@ 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 FindleError.databaseError(detail: "deleteItemsWithTombstone tombstone failed") } @@ -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 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 8e92e16..4466f9d 100644 --- a/Sources/SharedDomain/Models/MoodleSite.swift +++ b/Sources/SharedDomain/Models/MoodleSite.swift @@ -55,7 +55,6 @@ public struct MoodleSite: Sendable, Codable, Equatable, Identifiable { /// mobile app configuration. These are registered with WKWebView so the /// navigation delegate intercepts them instead of the system URL handler. public static let acceptedCallbackSchemes: Set = [ - "findle", "findle", "moodlemobile", "openlms", diff --git a/Sources/SyncEngine/SyncEngine.swift b/Sources/SyncEngine/SyncEngine.swift index cd98177..9447dbf 100644 --- a/Sources/SyncEngine/SyncEngine.swift +++ b/Sources/SyncEngine/SyncEngine.swift @@ -17,6 +17,7 @@ public actor SyncEngine { private var activeTasks: [Int: Task] = [:] private var syncProgress: [Int: SyncProgress] = [:] + private var isStopping = false public struct SyncProgress: Sendable { public let courseID: Int @@ -192,18 +193,22 @@ 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) + try 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 +217,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 +233,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)") } } @@ -414,8 +428,13 @@ public actor SyncEngine { 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 +459,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 9d4e119..08a5187 100644 --- a/Tests/FindleMCPTests/CatalogTests.swift +++ b/Tests/FindleMCPTests/CatalogTests.swift @@ -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 5aa5300..bda5b09 100644 --- a/Tests/FindleMCPTests/TestSupport.swift +++ b/Tests/FindleMCPTests/TestSupport.swift @@ -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/PersistenceTests/DatabaseTests.swift b/Tests/PersistenceTests/DatabaseTests.swift index 2258ae7..19a2be2 100644 --- a/Tests/PersistenceTests/DatabaseTests.swift +++ b/Tests/PersistenceTests/DatabaseTests.swift @@ -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/project.yml b/project.yml index b3400df..468b79e 100644 --- a/project.yml +++ b/project.yml @@ -2,7 +2,7 @@ name: Findle options: bundleIdPrefix: es.amodrono.findle deploymentTarget: - macOS: "14.0" + macOS: "15.0" xcodeVersion: "16.0" groupSortPosition: top generateEmptyDirectories: true @@ -11,7 +11,7 @@ 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" From e95fc4ba7faf5e45b33793a0ad42ac9e5c27391d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Modro=C3=B1o=20Vara?= Date: Tue, 11 Aug 2026 18:29:59 +0200 Subject: [PATCH 3/4] Deliver new coursework to Finder promptly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New files could take many minutes to appear in Finder, and the delay was not the Moodle server: the File Provider extension only reads rows the app writes, so it can never fetch anything itself. Everything hung off the app's timer, which defaulted to 30 minutes, and the enumerators were signalled once after the whole run finished — so the first course's files stayed invisible until the last course had synced. Courses were also fetched strictly one at a time, the site-wide pinned set was re-scanned once per course, and the per-course diff loaded every item for the site and filtered in memory. Signal each course as soon as its rows land, sync courses through a task group capped at four, hoist the pinned-item download to run once per pass, and scope the diff query to the course so idx_items_course does the filtering. The default cadence drops to 5 minutes, and a "Sync Now" action in Finder asks the app to sync immediately, launching it first if it isn't running — the extension reaches it over a Darwin notification, which is the one channel that crosses its sandbox without extra entitlements. Materialization also reported no progress at all: the completion-handler form of downloadTask stays silent until it finishes, so Finder showed an indeterminate spinner for the whole transfer and a large lecture PDF looked identical to a hung download. Downloads now run on a shared delegate-backed session that reports bytes as they arrive, falling back to the file size Moodle already reports when a response omits Content-Length. --- Resources/Info/FileProvider-Info.plist | 8 + Sources/App/ViewModels/AppState.swift | 95 ++++++- Sources/App/Views/Settings/SettingsView.swift | 3 +- .../FileDownloader.swift | 209 ++++++++++++--- .../FileProviderExtension+CustomActions.swift | 51 ++++ Sources/SharedDomain/BundleIdentifiers.swift | 9 + Sources/SharedDomain/DarwinNotification.swift | 120 +++++++++ Sources/SyncEngine/SyncEngine.swift | 170 +++++++++--- .../SyncEngineConcurrencyTests.swift | 242 ++++++++++++++++++ project.yml | 3 + 10 files changed, 834 insertions(+), 76 deletions(-) create mode 100644 Sources/SharedDomain/DarwinNotification.swift create mode 100644 Tests/SyncEngineTests/SyncEngineConcurrencyTests.swift diff --git a/Resources/Info/FileProvider-Info.plist b/Resources/Info/FileProvider-Info.plist index 654d69a..0c1b31d 100644 --- a/Resources/Info/FileProvider-Info.plist +++ b/Resources/Info/FileProvider-Info.plist @@ -30,6 +30,14 @@ NSExtensionFileProviderActions + + NSExtensionFileProviderActionActivationRule + TRUEPREDICATE + NSExtensionFileProviderActionIdentifier + $(FINDLE_BUNDLE_PREFIX).action.sync-now + NSExtensionFileProviderActionName + Sync Now + NSExtensionFileProviderActionActivationRule TRUEPREDICATE diff --git a/Sources/App/ViewModels/AppState.swift b/Sources/App/ViewModels/AppState.swift index b1e9489..7a31414 100644 --- a/Sources/App/ViewModels/AppState.swift +++ b/Sources/App/ViewModels/AppState.swift @@ -60,6 +60,10 @@ final class AppState: ObservableObject { private var trackingRefreshTask: Task? private var isDatabaseTransitioning = false private var syncSettingsObserver: NSObjectProtocol? + 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 @@ -109,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" @@ -116,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)") @@ -1064,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 { @@ -1728,6 +1758,67 @@ final class AppState: ObservableObject { 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/Settings/SettingsView.swift b/Sources/App/Views/Settings/SettingsView.swift index 82ec698..47bcdfe 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 @@ -119,6 +119,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 3c193f7..0d5c830 100644 --- a/Sources/FileProviderExtension/FileDownloader.swift +++ b/Sources/FileProviderExtension/FileDownloader.swift @@ -58,49 +58,18 @@ enum FileDownloader { 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(FindleError.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 @@ -122,6 +91,162 @@ enum FileDownloader { } } +/// 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 diff --git a/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift b/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift index 3d9116e..6799305 100644 --- a/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift +++ b/Sources/FileProviderExtension/FileProviderExtension+CustomActions.swift @@ -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, @@ -144,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)) } @@ -156,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/SharedDomain/BundleIdentifiers.swift b/Sources/SharedDomain/BundleIdentifiers.swift index 250ba94..b3807d4 100644 --- a/Sources/SharedDomain/BundleIdentifiers.swift +++ b/Sources/SharedDomain/BundleIdentifiers.swift @@ -42,4 +42,13 @@ public enum BundleIdentifiers { public static let actionOpenCoursePage = "\(prefix).action.open-course-page" public static let actionKeepDownloaded = "\(prefix).action.keep-downloaded" public static let actionRemoveDownload = "\(prefix).action.remove-download" + public static let actionSyncNow = "\(prefix).action.sync-now" + + // MARK: - Cross-Process Notifications + + /// Darwin notification the File Provider extension posts to ask the main app + /// to sync immediately. Darwin notifications carry no payload and cross + /// sandbox boundaries without any shared-container or entitlement setup, + /// which the extension's XPC-hosted process needs. + public static let syncNowRequestNotification = "\(prefix).sync-now-requested" } diff --git a/Sources/SharedDomain/DarwinNotification.swift b/Sources/SharedDomain/DarwinNotification.swift new file mode 100644 index 0000000..99a269b --- /dev/null +++ b/Sources/SharedDomain/DarwinNotification.swift @@ -0,0 +1,120 @@ +// 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 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/SyncEngine/SyncEngine.swift b/Sources/SyncEngine/SyncEngine.swift index 9447dbf..0772ff0 100644 --- a/Sources/SyncEngine/SyncEngine.swift +++ b/Sources/SyncEngine/SyncEngine.swift @@ -38,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 @@ -58,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 FindleError 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( @@ -165,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) @@ -200,10 +312,6 @@ public actor SyncEngine { syncProgress[course.id]?.processedItems = allItems.count syncProgress[course.id]?.state = .synced - - // Auto-download pinned items that aren't yet materialized - try await downloadPinnedItems(site: site, token: token) - logger.info("Course \(course.id) sync complete: \(allItems.count) items") } 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/project.yml b/project.yml index 468b79e..68bee12 100644 --- a/project.yml +++ b/project.yml @@ -249,6 +249,9 @@ targets: NSExtensionFileProviderDocumentGroup: "$(APP_GROUP_IDENTIFIER)" NSExtensionFileProviderSupportsEnumeration: true NSExtensionFileProviderActions: + - 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 From a5767faa560f8c224ac3d196a73a391a6463d387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Modro=C3=B1o=20Vara?= Date: Tue, 11 Aug 2026 18:29:59 +0200 Subject: [PATCH 4/4] Separate Nightly from the release installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nightly already had its own bundle identifiers and App Group, but three things were still shared and would have made a side-by-side install misbehave. The File Provider root was named "Findle-" unconditionally, so both builds mounted the same folder under CloudStorage; the MCP helper's loopback port was a hardcoded 8765, so whichever app launched second failed to bind; and the Claude config entry was keyed "findle" for both, so installing Nightly silently overwrote the release app's registration. Worse, Nightly inherited the release appcast. Nightly ships as an unsigned CI artifact and publishes no appcast of its own, so a Nightly install would have followed the release feed and updated itself into the production build. Derive the mount name, MCP port and config key from the bundle prefix — which the File Provider extension resolves the same way, so the extension agrees with the app it ships inside — and give Nightly an empty feed. UpdateController leaves Sparkle unstarted when there is no feed rather than letting it raise a configuration error, and Settings shows a manual-update note in place of controls that could never work. ClaudeIntegration gains an uninstall path so a Nightly registration can be removed without touching the release entry. --- Resources/Info/App-Info.plist | 86 +++++++++---------- Sources/App/Services/ClaudeIntegration.swift | 40 ++++++++- Sources/App/UpdateController.swift | 16 +++- .../App/Views/Connect/MCPConnectView.swift | 17 +++- Sources/App/Views/Settings/SettingsView.swift | 26 ++++-- .../FileProviderExtension.swift | 5 +- Sources/SharedDomain/BundleIdentifiers.swift | 21 +++++ project.yml | 5 ++ 8 files changed, 157 insertions(+), 59 deletions(-) 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/Sources/App/Services/ClaudeIntegration.swift b/Sources/App/Services/ClaudeIntegration.swift index a335822..efba97e 100644 --- a/Sources/App/Services/ClaudeIntegration.swift +++ b/Sources/App/Services/ClaudeIntegration.swift @@ -93,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 @@ -116,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] { @@ -140,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]) @@ -149,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/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/Views/Connect/MCPConnectView.swift b/Sources/App/Views/Connect/MCPConnectView.swift index 38ec167..d89187f 100644 --- a/Sources/App/Views/Connect/MCPConnectView.swift +++ b/Sources/App/Views/Connect/MCPConnectView.swift @@ -18,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) { @@ -90,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.") } @@ -105,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/Settings/SettingsView.swift b/Sources/App/Views/Settings/SettingsView.swift index 47bcdfe..13c6d2b 100644 --- a/Sources/App/Views/Settings/SettingsView.swift +++ b/Sources/App/Views/Settings/SettingsView.swift @@ -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) diff --git a/Sources/FileProviderExtension/FileProviderExtension.swift b/Sources/FileProviderExtension/FileProviderExtension.swift index 0eb066e..8182cf2 100644 --- a/Sources/FileProviderExtension/FileProviderExtension.swift +++ b/Sources/FileProviderExtension/FileProviderExtension.swift @@ -17,8 +17,11 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { 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.`). diff --git a/Sources/SharedDomain/BundleIdentifiers.swift b/Sources/SharedDomain/BundleIdentifiers.swift index b3807d4..36eecae 100644 --- a/Sources/SharedDomain/BundleIdentifiers.swift +++ b/Sources/SharedDomain/BundleIdentifiers.swift @@ -21,9 +21,30 @@ public enum BundleIdentifiers { return id }() + /// Whether this is a Nightly build. + /// + /// Nightly and Release are separate applications that must coexist without + /// touching each other's data. Everything below that could otherwise collide + /// — the Finder mount name, the MCP port, the Claude config key — is keyed + /// off this rather than off a build flag, so the File Provider extension + /// (whose `prefix` is derived the same way) agrees with the app it ships in. + public static let isNightly = prefix.hasSuffix(".nightly") + + /// User-facing product name, matching `PRODUCT_NAME` for each config. + public static let appDisplayName = isNightly ? "Findle Nightly" : "Findle" + /// App group identifier for shared container access. public static let appGroup = "group.\(prefix)" + /// Loopback port for the MCP helper's HTTP transport. Release and Nightly + /// need different ports or whichever launches second fails to bind. + public static let mcpPort: UInt16 = isNightly ? 8766 : 8765 + + /// Key this build uses under `mcpServers` in an assistant's JSON config. + /// Distinct per build so installing Nightly can't clobber the entry that + /// points at the release app — and so removing one leaves the other intact. + public static let mcpServerKey = isNightly ? "findle-nightly" : "findle" + /// Keychain service name for credential storage. public static let keychainService = prefix diff --git a/project.yml b/project.yml index 68bee12..dace0cb 100644 --- a/project.yml +++ b/project.yml @@ -18,10 +18,15 @@ settings: SWIFT_STRICT_CONCURRENCY: complete 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: 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