From 9e32b3bbdc5b2a6011b3acb4f5f81a6fc15d5067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urban=20Vidovic=CC=8C?= Date: Tue, 14 Apr 2026 12:29:01 +0200 Subject: [PATCH] fix(share): close CloudKit sync gap from iOS share extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- toss/Info.plist | 13 ++++++++ toss/Services/AppSettings.swift | 9 ++++++ toss/Views/Settings/SettingsView.swift | 30 ++++++++++++++++++ toss/tossApp.swift | 7 +++++ tossShare/ShareViewController.swift | 42 +++++++++++++++++++++++--- 5 files changed, 97 insertions(+), 4 deletions(-) diff --git a/toss/Info.plist b/toss/Info.plist index 4b09e0d..cb21b47 100644 --- a/toss/Info.plist +++ b/toss/Info.plist @@ -2,6 +2,19 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + lutra-labs.toss + CFBundleURLSchemes + + tossinger + + + NSAccessibilityUsageDescription Tossinger needs Accessibility access to capture selected text from other apps when you use the global shortcut (experimental). UIBackgroundModes diff --git a/toss/Services/AppSettings.swift b/toss/Services/AppSettings.swift index 2ede74d..40fcc2d 100644 --- a/toss/Services/AppSettings.swift +++ b/toss/Services/AppSettings.swift @@ -11,6 +11,12 @@ import TossKit @MainActor class AppSettings: ObservableObject { + // Shared with the iOS share extension via the app group so both processes + // see the same value for `autoOpenAfterSharing`. Existing single-process + // preferences below continue to live in UserDefaults.standard so they + // aren't wiped on upgrade. + static let sharedDefaults = UserDefaults(suiteName: TossPersistenceStack.appGroupIdentifier)! + @AppStorage("biometric_enabled") var isBiometricEnabled: Bool = false @AppStorage("layout_mode") private var layoutModeRaw: String = TossLayoutMode.grid.rawValue @@ -19,4 +25,7 @@ class AppSettings: ObservableObject { get { TossLayoutMode(rawValue: layoutModeRaw) ?? .grid } set { layoutModeRaw = newValue.rawValue } } + + @AppStorage("auto_open_after_share", store: AppSettings.sharedDefaults) + var autoOpenAfterSharing: Bool = false } diff --git a/toss/Views/Settings/SettingsView.swift b/toss/Views/Settings/SettingsView.swift index fd2222d..f870acc 100644 --- a/toss/Views/Settings/SettingsView.swift +++ b/toss/Views/Settings/SettingsView.swift @@ -271,6 +271,7 @@ struct SettingsView: View { NavigationStack { Form { securitySection + sharingSection aboutSection Section { @@ -422,6 +423,35 @@ struct SettingsView: View { Text("About") } } + + #if os(iOS) + private var sharingSection: some View { + Section { + Toggle(isOn: $appSettings.autoOpenAfterSharing) { + HStack(spacing: 12) { + Image(systemName: "square.and.arrow.up") + .foregroundStyle(.blue) + .font(.title3) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text("Auto-open app after sharing") + .font(.body) + Text("Ensures tosses sync to iCloud immediately") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } header: { + Text("Sharing") + } footer: { + Text( + "When off, tosses shared from other apps are saved locally and only reach iCloud the next time you open Tossinger. Turn on to briefly open Tossinger after each share so changes sync immediately." + ) + } + } + #endif } #Preview { diff --git a/toss/tossApp.swift b/toss/tossApp.swift index bb01d52..fc70f05 100644 --- a/toss/tossApp.swift +++ b/toss/tossApp.swift @@ -74,6 +74,13 @@ struct tossApp: App { break } } + #if os(iOS) + .onOpenURL { _ in + // Intentional no-op. The share extension opens `tossinger://share-complete` + // purely to foreground Tossinger so NSPersistentCloudKitContainer can + // complete its pending CloudKit export. The URL itself carries no data. + } + #endif } .modelContainer(container) #if os(macOS) diff --git a/tossShare/ShareViewController.swift b/tossShare/ShareViewController.swift index a826415..de44694 100644 --- a/tossShare/ShareViewController.swift +++ b/tossShare/ShareViewController.swift @@ -30,6 +30,26 @@ final class ShareViewController: UIViewController { private let successLabel = UILabel() private var didCompleteRequest = false + /// User-opt-in flag read from the app-group UserDefaults, written by the + /// main app's Settings screen. When true, the extension foregrounds the + /// main Tossinger app after saving so NSPersistentCloudKitContainer can + /// complete its pending CloudKit export (extensions cannot finish the + /// export themselves from a short-lived process). + private let autoOpenAfterSharing: Bool = { + UserDefaults(suiteName: TossPersistenceStack.appGroupIdentifier)? + .bool(forKey: "auto_open_after_share") ?? false + }() + + private var successMessage: String { + autoOpenAfterSharing + ? "Tossed. Opening app…" + : "Tossed. Open app to sync." + } + + private var successCloseDelay: TimeInterval { + autoOpenAfterSharing ? 0.35 : 0.8 + } + override func viewDidLoad() { super.viewDidLoad() setupUI() @@ -44,7 +64,7 @@ final class ShareViewController: UIViewController { view.backgroundColor = .systemBackground // Configure success label - successLabel.text = "Tossed and syncing..." + successLabel.text = successMessage successLabel.font = .systemFont(ofSize: 17, weight: .medium) successLabel.textAlignment = .center successLabel.textColor = .label @@ -167,7 +187,7 @@ final class ShareViewController: UIViewController { do { try context.save() - showMessage("Tossed and syncing...") + showMessage(successMessage) } catch { showMessageAndClose("Failed to save toss.") return @@ -189,7 +209,7 @@ final class ShareViewController: UIViewController { // Keep the initial save; metadata enrichment is best-effort. } - closeExtension(after: 0.35) + finishAfterSuccess() } private func applyMetadata(_ result: MetadataResult, to toss: Toss) { @@ -262,7 +282,21 @@ final class ShareViewController: UIViewController { } private func showSuccessAndClose() { - showMessageAndClose("Tossed and syncing...") + showMessage(successMessage) + finishAfterSuccess() + } + + /// Close path for a successful save. If the user opted into auto-open, the + /// extension foregrounds the containing app via its custom URL scheme so + /// NSPersistentCloudKitContainer can complete its CloudKit export. The + /// extension always tears itself down afterwards — `completeRequest` is + /// called unconditionally via `closeExtension(after:)` so the extension + /// exits even if `open` was denied by the system. + private func finishAfterSuccess() { + if autoOpenAfterSharing, let url = URL(string: "tossinger://share-complete") { + extensionContext?.open(url, completionHandler: nil) + } + closeExtension(after: successCloseDelay) } private func closeExtension(after delay: TimeInterval = 0) {