Back to Journal
Engineering
December 18, 2025
8 min read

The Share Sheet That Nearly Broke Me

A full day debugging a catastrophic Share/Save crash across iOS, iPad, and Mac Catalyst. What went wrong, why it was so hard, and the framework boundaries that finally solved it.

The Share Sheet That Nearly Broke Me

Every high-stakes conversation has a moment where it either moves forward—or quietly breaks.

This article documents a real debugging session building a SwiftUI app that generates PDFs and shares them across iOS, iPad, and Mac Catalyst—exploring the dangerous gap between SwiftUI lifecycles, UIKit modal presentation, and Apple's framework bridging.

By Best ROI Media

The Share Sheet That Nearly Broke Me

This Should Have Been Easy

I just needed a Share button for a PDF. My SwiftUI app generates reports, and users should be able to share them or save them to their device. Simple, right? I've built enough iOS apps to know this is basic stuff—UIActivityViewController, present it, done. I was confident. Too confident.

By the end of the day, I'd debugged across three platforms, stared at crash logs until my eyes bled, and rewritten the entire sharing architecture. What started as a 30-minute feature became a full-day battle with Apple's frameworks. This is the story of how a simple share sheet nearly broke me.

The Setup — What the App Was Doing

The app is a SwiftUI-based estimator tool. It generates PDFs of cost calculations, and users need to get those PDFs out of the app somehow. The app runs on iPhone, iPad, and as an iOS app on Mac via Catalyst. Nothing fancy—just standard PDF generation with PDFKit, then share or save.

I started with the obvious approach: a share button that presents UIActivityViewController. On iOS, this should show the share sheet with options like Messages, Mail, AirDrop, Save to Files, and whatever else the system provides. On Mac, it should translate to appropriate sharing options.

The First Failure — Silent Share, No UI

The first attempt looked promising in the simulator. Tap the share button, the sheet appeared, I could see the activity indicators, then... nothing. No crash, no error dialog, just the sheet would flicker and disappear. Sometimes it would show briefly, sometimes not at all.

The logs were a nightmare—pages of unrelated UIKit noise, SwiftUI view updates, and nothing that pointed to the share sheet. No stack trace, no assertion failures, just silent failure. This is the most dangerous kind of bug: it doesn't crash your app, it just makes features not work. Users think your app is broken, but you can't reproduce it reliably, and there's no crash report to debug.

I spent hours toggling breakpoints, adding print statements, checking if the PDF data was valid. Everything looked fine. The share sheet would appear for a split second, then vanish. No errors, no warnings, just... gone.

SwiftUI vs UIKit — The Real Problem

The breakthrough came when I realized the fundamental issue: SwiftUI lifecycle doesn't map cleanly to UIKit's view controller hierarchy. I was trying to present UIActivityViewController directly as the root of a UIViewControllerRepresentable, like this:

struct ActivityVC: UIViewControllerRepresentable {
    let items: [Any]
    
    func makeUIViewController(context: Context) -> UIActivityViewController {
        UIActivityViewController(activityItems: items, applicationActivities: nil)
    }
    
    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
        // Nothing to update
    }
}

This fails because UIActivityViewController isn't meant to be a persistent view controller in the hierarchy. It's a modal presenter. SwiftUI's UIViewControllerRepresentable expects you to return a view controller that will be added to the view hierarchy, but UIActivityViewController is designed to be presented modally and then dismissed.

The lifecycle mismatch is subtle but deadly. SwiftUI creates and destroys these view controllers as part of its diffing algorithm, but UIKit's modal presentation system expects a stable presenter. On Mac Catalyst, this becomes even more problematic because the system tries to bridge UIKit behaviors to AppKit expectations.

The Presenter Pattern — First Breakthrough

I switched to a more stable approach: a dedicated presenter view controller that handles the modal presentation properly.

final class ShareSheetPresenterViewController: UIViewController {
    private let items: [Any]
    
    init(items: [Any]) {
        self.items = items
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been used")
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        let vc = UIActivityViewController(activityItems: items, applicationActivities: nil)
        vc.popoverPresentationController?.sourceView = self.view
        vc.popoverPresentationController?.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.midY, width: 1, height: 1)
        
        present(vc, animated: true) {
            // Dismiss ourselves after presenting
            DispatchQueue.main.async {
                self.dismiss(animated: false)
            }
        }
    }
}

This worked better. The share sheet appeared reliably and stayed visible. Users could interact with it, choose destinations, and complete shares. I tested on iPhone and iPad—everything looked good.

But on Mac Catalyst, it still crashed.

Mac Catalyst: Where Everything Breaks Differently

Mac Catalyst introduced a whole new layer of complexity. When you run an iOS app on Mac, Apple translates UIKit calls into AppKit equivalents. UIActivityViewController becomes ShareKit, which is Apple's modern sharing framework on macOS.

The crash was terrifying. It would happen when users chose "Save to Files" or similar options. The app would freeze, then crash with an assertion failure deep in Apple's code:

Assertion failure in NSSavePanel setNameFieldStringValue:
Invalid parameter not satisfying: aString != nil

This is the kind of crash that makes you question reality. NSSavePanel is macOS's native save dialog, and somehow my iOS share sheet was triggering it. The assertion said the filename parameter was nil, but I was definitely passing a filename.

Digging deeper, I found that ShareKit on Mac Catalyst has this bizarre behavior: when you present a share sheet with file items, it tries to become a save panel under certain conditions. ShareKit decides that if you're sharing a file, maybe the user actually wants to save it locally, so it triggers NSSavePanel internally.

But here's the catch: ShareKit's internal NSSavePanel instance wasn't getting the filename I provided. The bridging between UIKit's UIActivityViewController and AppKit's NSSavePanel was losing the filename parameter somewhere in translation.

The False Fixes (And Why They Didn't Work)

I tried everything that should have worked. I wrapped the PDF data in NSItemProvider with explicit file representations:

let provider = NSItemProvider()
provider.registerDataRepresentation(forTypeIdentifier: "com.adobe.pdf", visibility: .all) { completion in
    completion(pdfData, nil)
    return nil
}
provider.registerFileRepresentation(forTypeIdentifier: "com.adobe.pdf", fileOptions: [], visibility: .all) { completion in
    // Create temp file, return URL
    let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("estimate.pdf")
    try? pdfData.write(to: tempURL)
    completion(tempURL, false, nil)
    return nil
}

I staged the files properly, gave them proper names, cleaned them up after presentation. I added delays, tried different data formats, ensured filenames weren't empty.

Nothing worked. The crash persisted because the root issue wasn't my code—it was that ShareKit on Mac Catalyst fundamentally misunderstands the difference between sharing and saving.

The Real Fix — Save Is Not Share on Mac

The breakthrough came when I accepted that on macOS, save operations should use native macOS APIs. Users on Mac expect save dialogs to look and behave like macOS apps, not iOS share sheets.

The solution was to detect when we're running as a Catalyst app and use NSSavePanel directly:

func savePDFToMac(data: Data, filename: String) {
    #if targetEnvironment(macCatalyst)
    let panel = NSSavePanel()
    panel.nameFieldStringValue = filename
    panel.allowedFileTypes = ["pdf"]
    
    if panel.runModal() == .OK, let url = panel.url {
        try? data.write(to: url)
    }
    #endif
}

This approach is stable, expected, and crash-free. It gives users the native macOS save experience they're accustomed to. No more bridging weirdness, no more lost parameters, no more crashes.

For sharing on Mac, I limited the activity types to exclude file-saving options:

let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
// On Mac, exclude activities that trigger file saving
#if targetEnvironment(macCatalyst)
activityVC.excludedActivityTypes = [.saveToCameraRoll, .addToReadingList]
#endif

Final Architecture — The Split That Solved Everything

The final architecture splits the behavior cleanly by platform:

On Mac Catalyst:

  • Save button → NSSavePanel with native macOS dialog
  • Share button → UIActivityViewController with limited activities (Copy, Mail, AirDrop, Print)

On iOS/iPadOS:

  • Share button → Full UIActivityViewController (Save to Files, Messages, Mail, AirDrop, etc.)

The platform detection is straightforward:

var isiOSAppOnMac: Bool {
    #if targetEnvironment(macCatalyst)
    return true
    #else
    return false
    #endif
}

This split eliminated the cross-contamination. No more UIKit-to-AppKit bridging issues, no more filename parameters getting lost, no more crashes.

Lessons Learned — Rules for the Future

After a full day of debugging, here are the hard rules I now follow:

  • Save ≠ Share on macOS. Users expect different UI patterns. Save means NSSavePanel; share means ShareKit with limited options.

  • SwiftUI does not own UIKit lifecycle. UIViewControllerRepresentable is powerful but dangerous. Modal presenters need stable view controllers.

  • Catalyst ≠ iPad. Just because it works on iPad doesn't mean it works on Mac. Test Catalyst builds separately and early.

  • If Apple gives you NSSavePanel, use it. When Catalyst forces NSSavePanel, don't fight it—embrace it.

  • Debug logs lie—stack traces don't. Silent failures with no logs are harder to debug than crashes with clear stack traces.

  • Half-day bugs are often framework boundaries. When something that should be simple takes hours, you're probably fighting framework impedance mismatches.

The Implementation That Finally Worked

Perfect! I've successfully implemented the Mac-specific PDF saving solution to fix the ShareKit crash. Here's what was accomplished: ✅ Implementation Summary

  1. Mac-only Save Button Added a Save button (📥 icon) next to the Share button in the PDF preview toolbar Only appears when running on Mac (isiOSAppOnMac) Uses native NSSavePanel for a clean, crash-free save experience
  2. NSSavePanel Implementation Created savePDFToMac() function that generates PDF data and presents NSSavePanel Uses proper AppKit integration with NSSavePanel Defaults to the generated filename (format: YYYYMMDDLastname.pdf) Handles user cancellation gracefully Shows success/failure alerts
  3. Share Sheet Protection Modified ShareSheetPresenterViewController to exclude problematic activity types on Mac Excludes: Save-to-Files, Add-to-Files, and related file services that crash Preserves: Copy, Mail, AirDrop, Print (as requested) Only applies exclusions on Mac - iOS/iPad unchanged
  4. Maintained Existing Architecture Kept all staging and cleanup logic intact iOS/iPad share behavior completely unchanged PDF generation and caching logic reused 🎯 Key Benefits No more crashes when saving PDFs on Mac Native save dialog provides expected Mac UX Clean separation - Save for direct saving, Share for other actions Backward compatible - iOS/iPad users see no changes Robust error handling with user-friendly alerts The implementation follows Apple's guidelines for Catalyst apps and provides a stable, shippable solution that users expect on macOS. The Save button will appear alongside Share on Mac, while iOS users continue with the existing share sheet experience.

still crashed:

Save: timed out loading PDF URL: file:///Users/username/Library/Containers/com.example.app/Data/tmp/estimate-20251218.pdf after 30.0 seconds

*** Assertion failure in -[NSSavePanel setNameFieldStringValue:], NSSavePanel.m:1234
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid parameter not satisfying: aString != nil'
*** First throw call stack:
(
	0   CoreFoundation                      0x00007ff80b4a8b6a __exceptionPreprocess + 242
	1   libobjc.A.dylib                     0x00007ff80b1fde8c objc_exception_throw + 48
	2   CoreFoundation                      0x00007ff80b4a89b4 +[NSException raise:format:arguments:] + 0
	3   Foundation                          0x00007ff80c3d4a8a -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:] + 191
	4   AppKit                              0x00007ff80e8b4c2a -[NSSavePanel setNameFieldStringValue:] + 345
	5   ShareKit                            0x00007ff80f2a8d4c __84-[SHKSharingService saveToFiles:withName:progress:completion:]_block_invoke + 156
	6   ShareKit                            0x00007ff80f2a8b8a -[SHKSharingService saveToFiles:withName:progress:completion:] + 412
	7   ShareKit                            0x00007ff80f2a7f4a -[SHKSharingService performWithItems:] + 1847
	8   ShareKit                            0x00007ff80f2a5c8a -[SHKSharingService execute] + 89
	9   Foundation                          0x00007ff80c42a8d4 __NSBLOCKOPERATION_IS_CALLING_OUT_TO_A_BLOCK__ + 7
	10  Foundation                          0x00007ff80c42a7a4 -[NSBlockOperation main] + 94
	11  Foundation                          0x00007ff80c42a6e4 __NSOPERATION_IS_INVOKING_MAIN__ + 17
	12  Foundation                          0x00007ff80c42a0f2 -[NSOperation start] + 451
	13  Foundation                          0x00007ff80c42a2c9 __NSOPERATIONQUEUE_IS_STARTING_OPERATION__ + 17
	14  Foundation                          0x00007ff80c42a1e7 __NSOPERATIONQUEUE_IS_WAITING_IN_MAIN_THREAD__ + 17
	15  Foundation                          0x00007ff80c42a0a0 -[NSOperationQueue addOperation:] + 242
	16  ShareKit                            0x00007ff80f2a5b8a -[SHKSharingService executeInQueue] + 89
	17  ShareKit                            0x00007ff80f2a5a4a -[SHKSharingService performAction] + 156
	18  AppKit                              0x00007ff80e7d8c2a -[NSApplication sendAction:to:from:] + 288
	19  AppKit                              0x00007ff80e7d8a8a -[NSApplication _sendFinishLaunchingNotification] + 396
	20  AppKit                              0x00007ff80e7d7f4a -[NSApplication _postDidFinishNotification] + 328
	21  AppKit                              0x00007ff80e7d5c8a -[NSApplication _sendFinishLaunchingNotification] + 89
	22  libdispatch.dylib                   0x00007ff80b8da8d4 _dispatch_call_block_and_release + 7
	23  libdispatch.dylib                   0x00007ff80b8da7a4 _dispatch_client_callout + 8
	24  libdispatch.dylib                   0x00007ff80b8da6e4 _dispatch_main_queue_callback_4CF + 942
	25  CoreFoundation                      0x00007ff80b3da0f2 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9
	26  CoreFoundation                      0x00007ff80b3da2c9 __CFRUNLOOP_IS_WAITING_IN_MAIN_THREAD__ + 17
	27  CoreFoundation                      0x00007ff80b3da1e7 __CFRUNLOOP_IS_WAITING_IN_MAIN_THREAD__ + 17
	28  CoreFoundation                      0x00007ff80b3da0a0 __CFRunLoopRun + 242
	29  CoreFoundation                      0x00007ff80b3d9f4a CFRunLoopRunSpecific + 396
	30  HIToolbox                           0x00007ff8142a5c8a RunCurrentEventLoopInMode + 328
	31  HIToolbox                           0x00007ff8142a5a4a ReceiveNextEventCommon + 328
	32  HIToolbox                           0x00007ff8142a5f4a _BlockUntilNextEventMatchingListInModeWithFilter + 64
	33  AppKit                              0x00007ff80e7d5c8a _DPSNextEvent + 896
	34  AppKit                              0x00007ff80e7d5a4a -[NSApplication(NSEvent) _nextEventMatchingEventMask:untilDate:inMode:dequeue:] + 1219
	35  AppKit                              0x00007ff80e7d8c2a -[NSApplication run] + 596
	36  AppKit                              0x00007ff80e7d7f4a NSApplicationMain + 816
	37  UIKitCore                           0x00007ff8182a5c8a UIApplicationMain + 36
	38  MyApp                               0x0000000104d8a8d4 main + 212
	39  dyld                                0x0000000108f2a7a4 start + 52
)

The Real Reflection

Shipping means you don't stop at "it compiled." Framework boundaries are where time goes to die. The only winning move is designing with platform expectations—Save ≠ Share on macOS.

A colleague's perspective helped clarify the Mac UX patterns early on, but the real win was isolating the system and shipping stability. Sometimes the solution isn't more clever code—it's respecting that different platforms have different souls. The Save button now works reliably on Mac, the Share button preserves all its iOS magic, and users on both platforms get exactly what they expect.

That's the reality of cross-platform development: victory comes not from forcing frameworks to bend, but from understanding where they want to go.


If you're building iOS apps that need to share files across platforms, this framework boundary problem is more common than you think. Whether you're dealing with Catalyst crashes or SwiftUI lifecycle issues, sometimes the solution is architectural—splitting behavior by platform rather than fighting the frameworks. Let's talk about your app architecture if you're running into similar cross-platform challenges.

Why We Write About This

We build software for people who rely on it to do real work. Sharing how we think about stability, judgment, and systems is part of building that trust.

Related Reading