Skip to content

Release 0.1.3: sync reliability, session recovery, Airlock 0.2.1#14

Merged
alexmodrono merged 9 commits into
mainfrom
worktree-release-0.1
May 29, 2026
Merged

Release 0.1.3: sync reliability, session recovery, Airlock 0.2.1#14
alexmodrono merged 9 commits into
mainfrom
worktree-release-0.1

Conversation

@alexmodrono

Copy link
Copy Markdown
Owner

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

  • The change-counter triggers and indexes were defined in both 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.
  • Tombstones are no longer cleared after being reported — with two enumerators reporting against independent anchors, the early clear could hide a deletion from the second enumerator.

Dependencies

  • Update Airlock to 0.2.1 (immersiveIntroOnlyhidesOtherAppsDuringIntro).

File Provider hardening

  • Reject reparenting a local item under a missing parent or one of its own descendants (prevents orphaned items and directory cycles).
  • Sweep LocalContent/ once per extension instance for files with no backing local item, reclaiming leaks from partial create/modify/delete failures.
  • Log (instead of silently swallowing) failures to reset an item's state after a failed download.

Reliability & UX

  • Surface sync failures in the workspace (dismissible banner) and menu bar instead of only in Diagnostics.
  • Detect authentication failures and present a "Reconnect" prompt, stopping the auto-sync loop rather than dead-ending on a generic error.
  • Reset items stranded in .downloading on launch so they don't appear perpetually in progress.
  • Show per-course progress ("Syncing X of Y") during a sync-all pass.
  • Wire up the previously-inert "Notify when sync completes" setting to a local notification.

Onboarding

  • Switch the window to .hiddenTitleBar so the title bar no longer floats over the transparent onboarding card (required by Airlock 0.2.x).

Testing

  • xcodebuild build succeeds.
  • Full test suite passes, including new coverage for resetStaleDownloads, FoodleError.requiresReauthentication, and syncAllCourses auth-abort / continue-past-non-auth behavior.

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.
@alexmodrono alexmodrono merged commit cea043c into main May 29, 2026
1 check passed
@github-actions

Copy link
Copy Markdown

Nightly Build

Download Findle Nightly (unsigned)

Built from 25f25ab.

Important

This build is unsigned. macOS will block it on first launch. To open it:

  1. Try to open the app normally — macOS will show a warning and refuse.
  2. Go to System Settings → Privacy & Security, scroll down, and click Open Anyway.
  3. The File Provider extension requires code signing and won't work in this build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant