Skip to content
C.W.K.
Stream
Lesson 02 of 06 · published

The Share Extension: Offered, Loaded, Written, and Never Silent

~18 min · beyond-the-app, share-extension, nsitemprovider, activation-rule, uniform-type-identifiers

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"An extension must not be able to fail silently in any direction."

Four Places a Share Can Vanish

A share extension is how a photo, a PDF or a link from another app becomes a capture. It has four stages, and the family has lost shares at every one of them, each time with no visible error: the system must offer the extension for that content, the extension must decide what each attachment is, it must load it, and it must write it somewhere the app will import.

Offered: The Activation Rule

The training app's extension never appeared for a check-up PDF shared from Mail. It was installed and registered: simctl spawn … pluginkit -m -i <extension id> listed it. It simply was not offered, because its Info.plist used the dictionary form of NSExtensionActivationRule (supports text, one web URL, eight images, four files), and "file" in that form did not cover a PDF handed over as com.adobe.pdf data. The predicate form Apple's extension guide documents, a SUBQUERY over the attachments with UTI-CONFORMS-TO, fixed it. Registered but absent from the sheet means the rule, not the code. And never ship TRUEPREDICATE: Apple's guide says an app whose extension contains it will be rejected.

What: Every File Is Also a URL

Measured with NSItemProvider(contentsOf:), a photo advertises public.jpeg, public.file-url and public.url. Every file-borne share conforms to public.url, because a file URL is a URL. So an extension that asks "is it a link?" first, in an else if chain, calls the photo a link, drops it, and leaves Post disabled. The discriminator is public.file-url: a web link is the only thing that is a URL without it. The shared code makes the decision a ranking rather than a check order, so no caller can reintroduce the bug by reordering its own conditions. And no ranked kind is not the same as not shareable: a zip is still a file the app may have promised to accept.

Loaded: A Deadline on Every Load

From the proof track: the same extension, once offered, sat alive for an hour because loadItem for Mail's PDF never called its completion on the device. Files go through loadFileRepresentation, reading the bytes inside its completion because the temporary URL is valid only there; files are copied, not decoded, to stay inside an extension's memory budget; and every load has a deadline that turns into a visible failure.

Written: No Success on Failure

An extension that swallows its save errors with try? and then calls completeRequest reports success for every failure. Throw, show the reason, and end with cancelRequest(withError:). The opposite silence exists too: the prose editor's shares had been landing for four builds while the owner believed they were dead, because the sheet simply closed. The fix there was an acknowledgment card. A share that worked says so.

Code

project.yml: an activation rule that is actually offered for PDFs, images, links and text·yaml
  SparkShare:
    type: app-extension
    platform: iOS
    info:
      path: ShareExtension/Info.plist
      properties:
        NSExtension:
          NSExtensionPointIdentifier: com.apple.share-services
          NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
          NSExtensionAttributes:
            # The dictionary form (NSExtensionActivationSupportsFileWithMaxCount...) was never
            # offered for a PDF from Safari or Mail. A predicate says what it means.
            # Never TRUEPREDICATE: Apple rejects an app whose extension contains it.
            NSExtensionActivationRule: >-
              SUBQUERY(extensionItems, $extensionItem,
                $extensionItem.attachments.@count >= 1 AND $extensionItem.attachments.@count <= 8 AND
                SUBQUERY($extensionItem.attachments, $attachment,
                  ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" OR
                  ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf" OR
                  ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" OR
                  ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text"
                ).@count == $extensionItem.attachments.@count
              ).@count >= 1
Rank what a provider is before loading it, with public.file-url as the discriminator·swift
import Foundation
import UniformTypeIdentifiers

enum ShareKind: Int, Comparable, Sendable {
    case image, pdf, file, link, text      // a RANKING: the first match wins
    static func < (a: ShareKind, b: ShareKind) -> Bool { a.rawValue < b.rawValue }
}

/// Decide what a provider IS before loading anything. Every file-borne share also conforms to
/// public.url (because public.file-url does), so "is it a link?" must never be asked first.
func shareKind(ofTypes identifiers: [String]) -> ShareKind? {
    let types = identifiers.compactMap(UTType.init)
    func has(_ type: UTType) -> Bool { types.contains { $0.conforms(to: type) } }
    let isFile = has(.fileURL)
    let candidates: [ShareKind?] = [
        has(.image) ? .image : nil,
        has(.pdf) ? .pdf : nil,
        isFile ? .file : nil,                    // a zip or a .docx: still a file the app may take
        has(.url) && !isFile ? .link : nil,      // a web link is the only url WITHOUT file-url
        has(.plainText) ? .text : nil,
    ]
    return candidates.compactMap { $0 }.min()
}

// Measured on macOS with NSItemProvider(contentsOf:) and a web link object:
//   photo.jpg    [public.jpeg, public.file-url, public.url]      -> image
//   doc.pdf      [com.adobe.pdf, public.file-url, public.url]    -> pdf
//   archive.zip  [public.zip-archive, public.file-url, public.url] -> file
//   web link     [public.url]                                    -> link

External links

Exercise

Add the predicate rule to SparkShare and verify three things in a simulator without a device: xcrun simctl spawn <device> pluginkit -m -i com.example.spark.mobile.share lists the extension, Safari's share sheet offers Spark for a PDF served from python3 -m http.server, and after Post the capture folder appears in the App Group container. Then write a hosted test that reads the built extension's Info.plist through Bundle.main.builtInPlugInsURL and asserts the rule mentions com.adobe.pdf and does not contain TRUEPREDICATE.
Hint
Open the PDF with xcrun simctl openurl <device> http://127.0.0.1:8000/file.pdf. The container path comes from xcrun simctl get_app_container <device> com.example.spark.mobile group.com.example.spark, and ls there is the ground truth.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.