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) {