13 KiB
App Extensions
Share extensions, widgets, Quick Look previews, and Shortcuts for macOS.
<share_extension>
- File > New > Target > Share Extension
- Configure activation rules in Info.plist
- 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 viewimport 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>
- File > New > Target > Quick Look Preview Extension
- 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>
- 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>