Release 0.1.3: sync reliability, session recovery, Airlock 0.2.1#14
Merged
Conversation
The previous NSKeyedArchiver encoding for tagData was not understood by Finder, so tags written by the extension never appeared in the Finder UI. Switch to PropertyListSerialization (binary plist), which matches the com.apple.metadata:_kMDItemUserTags format Finder reads and writes. Adds a schema v9 migration that regenerates tag_data blobs for every course item from the course_tags table using the new encoding, and a modifyItem path that persists tag edits coming from Finder: course-root items are written back to course_tags so colors survive re-syncs, and other non-local items have their tagData stored on the item directly.
Without this, syncing a course would overwrite any Finder tags the user had set on individual items whenever the remote file changed (because the incoming LocalItem from the provider has no tagData). Mirror how isPinned is already preserved: copy the existing tagData onto the incoming item before saving. Adds SyncEngineCourseScopeTests.testSyncCoursePreservesItemTagDataWhenRemoteFileChanges to lock the behavior in.
SQLite layer - Bind text/blob with SQLITE_TRANSIENT instead of nil (= SQLITE_STATIC). The previous code passed autoreleased NSString.utf8String buffers, whose lifetime can end before sqlite3_step runs, silently corrupting writes. - Wrap saveCourses/saveCourseTags/saveItems/deleteItems/deleteAllItems transactions in do/catch with ROLLBACK on error and check SQLITE_DONE on each step so failures don't leave the DB wedged with an open txn. - Add sqlite3_busy_timeout(5000) so WAL contention with the File Provider extension is handled inside SQLite instead of surfacing as silent drops. - Schema v10: rebuild pending_deletions with item_id PRIMARY KEY so INSERT OR IGNORE can dedupe. Stop wiping pending_deletions blindly on every deleteItems call — earlier course deletions would be lost before the File Provider could drain them. Sync - SyncEngine.syncCourse now deletes removed items via the new deleteItemsWithTombstone and records pending_deletions, instead of downgrading them to .placeholder where they'd linger in Finder forever. - Add Task.checkCancellation() checkpoints around each phase of syncCourse so cancelSync actually stops in-flight work. - ItemEnumerator clears reported pending deletions after didDeleteItems so they don't replay on every subsequent change enumeration. - Pinned-item download destination keyed on item.id (not filename) to avoid collisions between items sharing a basename; clean up partial files on download error. Auth & errors - KeychainManager: store via SecItemUpdate first with SecItemAdd as fallback so credentials are never briefly absent between a delete and an add. Also reconciles older items stored with a different accessibility class. - FoodleError.isRetryable: only retry transient HTTP failures (5xx, 408, 429) instead of any requestFailed, which previously retried 4xx auth errors in a tight loop. Domain models - FileNameSanitizer: truncate by scalar boundaries (the old utf8 prefix could land mid-scalar and produce invalid strings); single-pass dash collapsing instead of O(n²); strip control chars and backslashes; map reserved "." and ".." to Untitled; only treat short alphanumeric suffixes as extensions so "My.Course.Name" doesn't lose stem text. - MoodleSite.webServiceURL / tokenURL: build through URLComponents instead of appendingPathComponent so trailing-slash variations on baseURL don't collapse the suffix. UI - SupportPrompt: gate by hasEvaluatedThisAppearance @State + a minLaunchesBetweenPrompts floor so the 30% random check no longer fires on every onAppear (tab switch, window reactivation) and the user can't see the alert multiple times in a single session. - UpdateController: retain SPUStandardUpdaterController as a stored property so Sparkle's user driver stays alive and update prompts work. - CourseDetailView: guard the sync Toggle onChange with oldValue != newValue && newValue != course.isSyncEnabled so loadCustomization()'s programmatic re-sync doesn't trigger a spurious setCourseSyncEnabled. Save the folder-name field when the course changes, not only on .onSubmit. - AppState: scope the UserDefaults.didChangeNotification observer to only re-arm the auto-sync task when syncIntervalMinutes actually changes; clamp the interval to [0, 24h] to prevent UInt64 overflow. - SignInStepView: trim whitespace from the username before submit so an auto-capitalised or pasted leading space doesn't return an opaque "invalid credentials". File Provider - FileProviderExtension.createItem: detect directories via contentType?.conforms(to: .directory) so bundle UTIs (.app, .pkg) are treated as folders; roll back the staged file in LocalContent/ when the DB write fails so orphans don't accumulate. - FileProviderExtension.modifyItem/deleteItem: reject unsupported edits with NSFileProviderError(.cannotSynchronize) instead of generic NSCocoaErrorDomain so the OS reverts the user's local change gracefully. Tag-edit error branches now call the completion handler exactly once with the real error. Test - Updated testSyncCourseOnlyDiffsItemsFromCurrentCourse to assert the new deletion contract: stale items are deleted and appear in pending_deletions; other courses' items are untouched.
Token in POST body
- MoodleClient.callWebService POSTs wstoken/wsfunction/params in the
body so the token doesn't end up in URLSession metrics, OSLog network
instrumentation, corporate proxy access logs, or crash reports.
- MoodleClient.authenticate body is built with an explicit formEncode
helper (restrictive allowed set: alphanumerics + -._~) so '+' in a
password isn't double-decoded as a space by Moodle's form parser.
- LMSProvider.authenticatedFileURL → authenticatedFileRequest: returns
a POST URLRequest with token in body. The URL stays clean of credentials,
preventing leaks via the Referer header on CDN redirects from
pluginfile.php to S3/CloudFront.
- FileDownloader builds the same POST-with-body request.
- WebAuthSession: demote the SSO launch URL log from .public to .private
(host kept public for debugging). The full URL embeds the passport
nonce used to sign the SSO callback.
Monotonic sync anchor (DB schema v11)
- Add items.updated_at INTEGER and pending_deletions.deleted_at_counter
INTEGER columns plus a system_metadata(change_counter) row.
- SQLite triggers on items INSERT, items UPDATE OF (relevant cols), and
pending_deletions INSERT atomically bump the counter and stamp the row.
WHEN NEW.<col> = 0 guards the trigger so it doesn't recurse.
- Add Database.currentChangeCounter(), fetchItemsChangedSince(...) (both
parentID and siteID variants), and fetchPendingDeletionsSince(anchor:).
- Migration v11 backfills updated_at for existing rows and seeds the
counter past their max so future writes get unique values.
- ItemEnumerator + WorkingSetEnumerator: anchors are now decimal-encoded
Int64s of the counter (SyncAnchorCoding). enumerateChanges filters to
only items/deletions with counter > anchor — no more O(items) re-emit
on every poll, and macOS sees a stable monotonic anchor instead of a
wall-clock value that thrashed change tracking.
- New DatabaseTests prove the counter increments on insert/update and
that fetchItemsChangedSince / fetchPendingDeletionsSince filter
correctly by anchor.
Login at login (SMAppService)
- New LoginItemController wraps SMAppService.mainApp.{register,unregister}
and reads the actual SMAppService.mainApp.status as source of truth.
- SettingsView's "Launch Findle at login" toggle now binds through the
controller instead of an @AppStorage UserDefaults bool that nothing
read. Refreshes on appear because System Settings > Login Items can
flip the registration state without notifying us. Surfaces the
underlying error in the UI when registration fails.
WhatsNew + Spotlight cleanup
- WhatsNewProvider.collection (a `static let` with nonisolated(unsafe))
→ makeCollection() @mainactor. The SwiftUI Color values stay on the
only actor that's allowed to touch them.
- ContentView constructs WhatsNewEnvironment once via @State instead of
re-allocating UserDefaultsWhatsNewVersionStore on every body redraw.
- SpotlightIndexer.indexCourses snapshots the indexed domain identifiers
in UserDefaults and on the next call diffs against the previous
snapshot to deleteSearchableItems for any courses that were unenrolled
or had sync disabled. Without this, dropped courses lingered in
Spotlight search results forever. removeItems(forCourse:) and
removeAllItems keep the snapshot consistent.
The change-counter triggers and their indexes were defined in both createSchema() and the v11 migration block. Since migrateSchema() runs every block on a fresh database (user_version starts at 0), the v11 block already creates them, so the createSchema() copies were redundant. Drop them and let the migration own the definitions. Stop clearing tombstones after reporting deletions: with two enumerators reporting against independent anchors, removing a tombstone once the first enumerator reports it would hide the deletion from the second. Counter-based filtering already prevents re-reporting, so the per-id clear (and its now-dead helper) can go. Drop the save-on-clicked-away handler for the custom folder name.
Bump the onboarding package from 0.1.1. AirlockConfiguration's immersiveIntroOnly parameter was renamed to hidesOtherAppsDuringIntro in 0.2.x; update the call site accordingly.
Reject reparenting a local item under a missing parent or one of its own descendants, which would orphan the item or create a directory cycle. Sweep LocalContent/ once per extension instance for files with no backing local item — partial failures during create/modify/delete could leak them and grow disk usage unbounded. Log (instead of silently swallowing) failures to reset an item's state after a failed download, so a stranded .downloading row is diagnosable.
Sync errors were only visible in Diagnostics. The workspace now shows a
dismissible error banner and the menu bar shows the actual failure, and
authentication failures (tokenExpired etc.) raise a 'Reconnect' prompt and
stop the auto-sync loop instead of dead-ending on a generic error.
syncAllCourses now throws on auth failure so the app can react.
Show per-course progress ("Syncing X of Y") by polling the engine while a
sync runs. Wire up the previously-inert "Notify when sync completes" setting
to a local notification. Clear items left in .downloading by a prior run on
launch so they don't appear stuck.
Add tests for resetStaleDownloads, requiresReauthentication, and the
auth-abort/continue behavior of syncAllCourses.
Airlock 0.2.x makes the onboarding window transparent and hides the traffic-light buttons via its WindowAccessor, but the titled chrome and app title still rendered on top of the intro card. Switch the window to .hiddenTitleBar, the configuration Airlock documents; the workspace toolbar still renders in the title area.
Nightly BuildDownload Findle Nightly (unsigned) Built from 25f25ab. Important This build is unsigned. macOS will block it on first launch. To open it:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Release 0.1.3. Hardens sync/File Provider reliability, surfaces failures that were previously invisible, adds session recovery and progress reporting, updates the Airlock onboarding dependency, and fixes the onboarding window chrome.
Changes
Sync anchor
createSchema()and the v11 migration; since the migration runs on fresh databases too, the duplicates are removed and the migration is the single source of truth.Dependencies
immersiveIntroOnly→hidesOtherAppsDuringIntro).File Provider hardening
LocalContent/once per extension instance for files with no backing local item, reclaiming leaks from partial create/modify/delete failures.Reliability & UX
.downloadingon launch so they don't appear perpetually in progress.Onboarding
.hiddenTitleBarso the title bar no longer floats over the transparent onboarding card (required by Airlock 0.2.x).Testing
xcodebuildbuild succeeds.resetStaleDownloads,FoodleError.requiresReauthentication, andsyncAllCoursesauth-abort / continue-past-non-auth behavior.