fix(share): close CloudKit sync gap from iOS share extension#24
Merged
Conversation
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>
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
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:
extensionContext.open("tossinger://share-complete")after the local save, which briefly foregrounds the main app soNSPersistentCloudKitContainercan 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
NSPersistentCloudKitContainercannot 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 whatextensionContext.open(_:)does. DirectCKModifyRecordsOperationfrom the extension would technically work but requires reverse-engineering the privateCD_TossCKRecord schema and is fragile across iOS releases — skipped.Architecture notes
UserDefaults.standardof the main app (per-process). NewAppSettings.sharedDefaultsstatic points atgroup.lutra-labs.toss(reusingTossPersistenceStack.appGroupIdentifier, already public). Existingbiometric_enabledandlayout_modestay inUserDefaults.standardso installed users don't lose preferences on upgrade.UserDefaultsdirectly, notAppSettings.AppSettingsis a@MainActor ObservableObjectwith SwiftUI dependencies that would be awkward to instantiate in a UIKit extension. One-lineUserDefaults(suiteName:)?.bool(forKey:)read at instance-init time is enough..onOpenURLis 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.Files changed
toss/Services/AppSettings.swift— newsharedDefaultsstatic +autoOpenAfterSharingpropertytoss/Info.plist—CFBundleURLTypesregisteringtossinger://toss/tossApp.swift— iOS-only.onOpenURL { _ in }no-op on root scenetoss/Views/Settings/SettingsView.swift— new iOS-onlysharingSectionbetween Security and AbouttossShare/ShareViewController.swift— reads preference, computes honest success copy, newfinishAfterSuccess()helper routes throughextensionContext.openwhen opt-in is onTest plan
Manual device test (simulators don't exercise CloudKit sync between devices):
xcrun simctl openurl booted tossinger://share-completeopens Tossinger (no crash, no Safari fallback).🤖 Generated with Claude Code