Files
2025-11-29 18:28:37 +08:00

13 KiB

App Extensions

Share extensions, widgets, Quick Look previews, and Shortcuts for macOS.

<share_extension>

  1. File > New > Target > Share Extension
  2. Configure activation rules in Info.plist
  3. Implement share view controller

Info.plist activation rules:

<key>NSExtension</key>
<dict>
    <key>NSExtensionAttributes</key>
    <dict>
        <key>NSExtensionActivationRule</key>
        <dict>
            <key>NSExtensionActivationSupportsText</key>
            <true/>
            <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
            <integer>1</integer>
            <key>NSExtensionActivationSupportsImageWithMaxCount</key>
            <integer>10</integer>
        </dict>
    </dict>
    <key>NSExtensionPointIdentifier</key>
    <string>com.apple.share-services</string>
    <key>NSExtensionPrincipalClass</key>
    <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>

<share_view_controller>

import Cocoa
import Social

class ShareViewController: SLComposeServiceViewController {
    override func loadView() {
        super.loadView()
        // Customize title
        title = "Save to MyApp"
    }

    override func didSelectPost() {
        // Get shared items
        guard let extensionContext = extensionContext else { return }

        for item in extensionContext.inputItems as? [NSExtensionItem] ?? [] {
            for provider in item.attachments ?? [] {
                if provider.hasItemConformingToTypeIdentifier("public.url") {
                    provider.loadItem(forTypeIdentifier: "public.url") { [weak self] url, error in
                        if let url = url as? URL {
                            self?.saveURL(url)
                        }
                    }
                }

                if provider.hasItemConformingToTypeIdentifier("public.image") {
                    provider.loadItem(forTypeIdentifier: "public.image") { [weak self] image, error in
                        if let image = image as? NSImage {
                            self?.saveImage(image)
                        }
                    }
                }
            }
        }

        extensionContext.completeRequest(returningItems: nil)
    }

    override func isContentValid() -> Bool {
        // Validate content before allowing post
        return !contentText.isEmpty
    }

    override func didSelectCancel() {
        extensionContext?.cancelRequest(withError: NSError(domain: "ShareExtension", code: 0))
    }

    private func saveURL(_ url: URL) {
        // Save to shared container
        let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.myapp")
        var urls = sharedDefaults?.array(forKey: "savedURLs") as? [String] ?? []
        urls.append(url.absoluteString)
        sharedDefaults?.set(urls, forKey: "savedURLs")
    }

    private func saveImage(_ image: NSImage) {
        // Save to shared container
        guard let data = image.tiffRepresentation,
              let rep = NSBitmapImageRep(data: data),
              let pngData = rep.representation(using: .png, properties: [:]) else { return }

        let containerURL = FileManager.default.containerURL(
            forSecurityApplicationGroupIdentifier: "group.com.yourcompany.myapp"
        )!
        let imageURL = containerURL.appendingPathComponent(UUID().uuidString + ".png")
        try? pngData.write(to: imageURL)
    }
}

</share_view_controller>

<app_groups> Share data between app and extension:

<!-- Entitlements for both app and extension -->
<key>com.apple.security.application-groups</key>
<array>
    <string>group.com.yourcompany.myapp</string>
</array>
// Shared UserDefaults
let shared = UserDefaults(suiteName: "group.com.yourcompany.myapp")

// Shared container
let containerURL = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.yourcompany.myapp"
)

</app_groups> </share_extension>

1. File > New > Target > Widget Extension 2. Define timeline provider 3. Create widget view
import WidgetKit
import SwiftUI

// Timeline entry
struct ItemEntry: TimelineEntry {
    let date: Date
    let items: [Item]
}

// Timeline provider
struct ItemProvider: TimelineProvider {
    func placeholder(in context: Context) -> ItemEntry {
        ItemEntry(date: Date(), items: [.placeholder])
    }

    func getSnapshot(in context: Context, completion: @escaping (ItemEntry) -> Void) {
        let entry = ItemEntry(date: Date(), items: loadItems())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<ItemEntry>) -> Void) {
        let items = loadItems()
        let entry = ItemEntry(date: Date(), items: items)

        // Refresh every hour
        let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
        let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))

        completion(timeline)
    }

    private func loadItems() -> [Item] {
        // Load from shared container
        let shared = UserDefaults(suiteName: "group.com.yourcompany.myapp")
        // ... deserialize items
        return []
    }
}

// Widget view
struct ItemWidgetView: View {
    var entry: ItemEntry

    var body: some View {
        VStack(alignment: .leading) {
            Text("Recent Items")
                .font(.headline)

            ForEach(entry.items.prefix(3)) { item in
                HStack {
                    Image(systemName: item.icon)
                    Text(item.name)
                }
                .font(.caption)
            }
        }
        .padding()
    }
}

// Widget configuration
@main
struct ItemWidget: Widget {
    let kind = "ItemWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: ItemProvider()) { entry in
            ItemWidgetView(entry: entry)
        }
        .configurationDisplayName("Recent Items")
        .description("Shows your most recent items")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

</widget_extension>

<widget_deep_links>

struct ItemWidgetView: View {
    var entry: ItemEntry

    var body: some View {
        VStack {
            ForEach(entry.items) { item in
                Link(destination: URL(string: "myapp://item/\(item.id)")!) {
                    Text(item.name)
                }
            }
        }
        .widgetURL(URL(string: "myapp://widget"))
    }
}

// Handle in main app
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    handleURL(url)
                }
        }
    }

    func handleURL(_ url: URL) {
        // Parse myapp://item/123
        if url.host == "item", let id = url.pathComponents.last {
            // Navigate to item
        }
    }
}

</widget_deep_links>

<update_widget>

// From main app, tell widget to refresh
import WidgetKit

func itemsChanged() {
    WidgetCenter.shared.reloadTimelines(ofKind: "ItemWidget")
}

// Reload all widgets
WidgetCenter.shared.reloadAllTimelines()

</update_widget>

<quick_look> <preview_extension>

  1. File > New > Target > Quick Look Preview Extension
  2. Implement preview view controller
import Cocoa
import Quartz

class PreviewViewController: NSViewController, QLPreviewingController {
    @IBOutlet var textView: NSTextView!

    func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) {
        do {
            let content = try loadDocument(at: url)
            textView.string = content.text
            handler(nil)
        } catch {
            handler(error)
        }
    }

    private func loadDocument(at url: URL) throws -> DocumentContent {
        let data = try Data(contentsOf: url)
        return try JSONDecoder().decode(DocumentContent.self, from: data)
    }
}

</preview_extension>

<thumbnail_extension>

  1. File > New > Target > Thumbnail Extension
import QuickLookThumbnailing

class ThumbnailProvider: QLThumbnailProvider {
    override func provideThumbnail(
        for request: QLFileThumbnailRequest,
        _ handler: @escaping (QLThumbnailReply?, Error?) -> Void
    ) {
        let size = request.maximumSize

        handler(QLThumbnailReply(contextSize: size) { context -> Bool in
            // Draw thumbnail
            let content = self.loadContent(at: request.fileURL)
            self.drawThumbnail(content, in: context, size: size)
            return true
        }, nil)
    }

    private func drawThumbnail(_ content: DocumentContent, in context: CGContext, size: CGSize) {
        // Draw background
        context.setFillColor(NSColor.white.cgColor)
        context.fill(CGRect(origin: .zero, size: size))

        // Draw content preview
        // ...
    }
}

</thumbnail_extension> </quick_look>

```swift import AppIntents

// Define intent struct CreateItemIntent: AppIntent { static var title: LocalizedStringResource = "Create Item" static var description = IntentDescription("Creates a new item in MyApp")

@Parameter(title: "Name")
var name: String

@Parameter(title: "Folder", optionsProvider: FolderOptionsProvider())
var folder: String?

func perform() async throws -> some IntentResult & ProvidesDialog {
    let item = Item(name: name)
    if let folderName = folder {
        item.folder = findFolder(named: folderName)
    }

    try await DataService.shared.save(item)

    return .result(dialog: "Created \(name)")
}

}

// Options provider struct FolderOptionsProvider: DynamicOptionsProvider { func results() async throws -> [String] { let folders = try await DataService.shared.fetchFolders() return folders.map { $0.name } } }

// Register shortcuts struct MyAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( intent: CreateItemIntent(), phrases: [ "Create item in (.applicationName)", "New (.applicationName) item" ], shortTitle: "Create Item", systemImageName: "plus.circle" ) } }

</app_intents>

<entity_queries>
```swift
// Define entity
struct ItemEntity: AppEntity {
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Item")

    var id: UUID
    var name: String

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "\(name)")
    }

    static var defaultQuery = ItemQuery()
}

// Define query
struct ItemQuery: EntityQuery {
    func entities(for identifiers: [UUID]) async throws -> [ItemEntity] {
        let items = try await DataService.shared.fetchItems(ids: identifiers)
        return items.map { ItemEntity(id: $0.id, name: $0.name) }
    }

    func suggestedEntities() async throws -> [ItemEntity] {
        let items = try await DataService.shared.recentItems(limit: 10)
        return items.map { ItemEntity(id: $0.id, name: $0.name) }
    }
}

// Use in intent
struct OpenItemIntent: AppIntent {
    static var title: LocalizedStringResource = "Open Item"

    @Parameter(title: "Item")
    var item: ItemEntity

    func perform() async throws -> some IntentResult {
        // Open item in app
        NotificationCenter.default.post(
            name: .openItem,
            object: nil,
            userInfo: ["id": item.id]
        )
        return .result()
    }
}

</entity_queries>

<action_extension>

import Cocoa

class ActionViewController: NSViewController {
    @IBOutlet var textView: NSTextView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // Get input items
        for item in extensionContext?.inputItems as? [NSExtensionItem] ?? [] {
            for provider in item.attachments ?? [] {
                if provider.hasItemConformingToTypeIdentifier("public.text") {
                    provider.loadItem(forTypeIdentifier: "public.text") { [weak self] text, _ in
                        DispatchQueue.main.async {
                            self?.textView.string = text as? String ?? ""
                        }
                    }
                }
            }
        }
    }

    @IBAction func done(_ sender: Any) {
        // Return modified content
        let outputItem = NSExtensionItem()
        outputItem.attachments = [
            NSItemProvider(item: textView.string as NSString, typeIdentifier: "public.text")
        ]

        extensionContext?.completeRequest(returningItems: [outputItem])
    }

    @IBAction func cancel(_ sender: Any) {
        extensionContext?.cancelRequest(withError: NSError(domain: "ActionExtension", code: 0))
    }
}

</action_extension>

<extension_best_practices>

  • Share data via App Groups
  • Keep extensions lightweight (memory limits)
  • Handle errors gracefully
  • Test in all contexts (Finder, Safari, etc.)
  • Update Info.plist activation rules carefully
  • Use WidgetCenter.shared.reloadTimelines() to update widgets
  • Define clear App Intents with good phrases </extension_best_practices>