Skip to content

fix(share): close CloudKit sync gap from iOS share extension#24

Merged
pseudobun merged 1 commit into
mainfrom
feat/share-sync-fix
Apr 14, 2026
Merged

fix(share): close CloudKit sync gap from iOS share extension#24
pseudobun merged 1 commit into
mainfrom
feat/share-sync-fix

Conversation

@pseudobun
Copy link
Copy Markdown
Owner

Summary

Fixes a silent CloudKit sync gap in the iOS share extension. When you share from Safari to Tossinger, the record has been saving locally on the phone but never reaching iCloud until the next time you manually launch the main app — so the Mac sat there with nothing new. The old success label ("Tossed and syncing…") was actively dishonest about this.

Two changes in one PR:

  1. Always-on copy fix. Success label now reads "Tossed. Open app to sync." and is displayed ~0.8s so users can actually read it. Informational only — no behavior change beyond the text.
  2. Opt-in auto-open (new iOS Settings toggle). New Sharing → Auto-open app after sharing toggle (default OFF). When enabled, the share extension calls extensionContext.open("tossinger://share-complete") after the local save, which briefly foregrounds the main app so NSPersistentCloudKitContainer can run its setup → export cycle. Tradeoff (sharing interrupts whatever the user was doing) is explained in the section footer. Label changes to "Tossed. Opening app…" when opt-in is on.

Why this approach

NSPersistentCloudKitContainer cannot complete a CloudKit export from inside a short-lived share-extension process — confirmed on Apple Developer Forums, reproduced across iOS 17/18/26. The supported workaround is to foreground the containing app briefly, which is what extensionContext.open(_:) does. Direct CKModifyRecordsOperation from the extension would technically work but requires reverse-engineering the private CD_Toss CKRecord schema and is fragile across iOS releases — skipped.

Architecture notes

  • App-group UserDefaults for the one new flag only. Extensions can't read UserDefaults.standard of the main app (per-process). New AppSettings.sharedDefaults static points at group.lutra-labs.toss (reusing TossPersistenceStack.appGroupIdentifier, already public). Existing biometric_enabled and layout_mode stay in UserDefaults.standard so installed users don't lose preferences on upgrade.
  • Extension reads UserDefaults directly, not AppSettings. AppSettings is a @MainActor ObservableObject with SwiftUI dependencies that would be awkward to instantiate in a UIKit extension. One-line UserDefaults(suiteName:)?.bool(forKey:) read at instance-init time is enough.
  • .onOpenURL is a no-op stub. The sole purpose of the URL scheme is to force iOS to foreground Tossinger; we don't do anything with the URL. Launching is what triggers CloudKit export. Comment in the handler explains why it's intentionally empty.
  • Default OFF for auto-open. Making it on would silently start yanking users out of Safari on every share — hostile surprise. Opt-in only.
  • macOS untouched. No share extension on macOS, no equivalent sync gap (the menu-bar app process is typically always running so exports complete normally).

Files changed

  • toss/Services/AppSettings.swift — new sharedDefaults static + autoOpenAfterSharing property
  • toss/Info.plistCFBundleURLTypes registering tossinger://
  • toss/tossApp.swift — iOS-only .onOpenURL { _ in } no-op on root scene
  • toss/Views/Settings/SettingsView.swift — new iOS-only sharingSection between Security and About
  • tossShare/ShareViewController.swift — reads preference, computes honest success copy, new finishAfterSuccess() helper routes through extensionContext.open when opt-in is on

Test plan

Manual device test (simulators don't exercise CloudKit sync between devices):

  • Force-quit Tossinger on iPhone, share from Safari — label reads "Tossed. Open app to sync." for ~0.8s.
  • Mac doesn't receive the toss yet (baseline sync gap still present, expected — Change A is informational only).
  • Open Tossinger on iPhone once — Mac receives the toss within a few seconds.
  • Enable Settings → Sharing → Auto-open app after sharing.
  • Force-quit Tossinger, share again — label reads "Tossed. Opening app…", iPhone briefly foregrounds Tossinger, Mac receives the toss within ~5s without manual iOS launch.
  • Quit and relaunch Tossinger — toggle state persists (confirms app-group UserDefaults is sticking).
  • Toggle off — back to informational copy.
  • URL scheme sanity: xcrun simctl openurl booted tossinger://share-complete opens Tossinger (no crash, no Safari fallback).
  • macOS Settings regression: "Sharing" section does NOT appear on macOS.

🤖 Generated with Claude Code

The iOS share extension writes new tosses to the shared SwiftData store
and exits within ~0.35s, but NSPersistentCloudKitContainer cannot
complete a CloudKit export from inside the short-lived extension
process. Records have been landing locally on the phone and staying
there until the main Tossinger app is next launched, meaning the Mac
never saw them in the meantime — exactly the "toss from Safari, read
on Mac later" flow Tossinger is built around. The previous success
label ("Tossed and syncing…") was actively misleading.

Two changes ship together:

1. Always-on copy fix: the success label now reads
   "Tossed. Open app to sync." and is displayed for ~0.8s so users
   actually have time to read it. Honest about what just happened.

2. Opt-in auto-open: a new iOS Settings toggle "Auto-open app after
   sharing" (default OFF) under a Sharing section. When enabled, the
   share extension calls extensionContext.open(tossinger://share-complete)
   after saving, which briefly foregrounds the main app so
   NSPersistentCloudKitContainer runs its setup → export cycle. The
   tradeoff (interrupting whatever the user was in) is explained in
   the section footer.

The preference lives in the app-group UserDefaults suite so the
share extension can read it directly without instantiating
SwiftUI's AppSettings. Existing AppSettings properties stay in
UserDefaults.standard so installed users don't lose their biometric
or layout-mode preferences on upgrade.

Registers the `tossinger://` URL scheme in the iOS app Info.plist
and adds an intentionally empty .onOpenURL handler on the root
scene so iOS routes the URL to Tossinger instead of Safari. The
handler does nothing with the URL — the launch alone is what
triggers CloudKit export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pseudobun pseudobun merged commit 8dfeb7f into main Apr 14, 2026
3 checks passed
@pseudobun pseudobun deleted the feat/share-sync-fix branch April 14, 2026 10:31
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