spotlight-discovery

Spotlight and Content Discovery

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "spotlight-discovery" with this command: npx skills add bluewaves-creations/bluewaves-skills/bluewaves-creations-bluewaves-skills-spotlight-discovery

Spotlight and Content Discovery

Comprehensive guide to CoreSpotlight for content indexing, NSUserActivity for handoff and search, and making your app's content discoverable in iOS 26.

Prerequisites

  • iOS 9+ for CoreSpotlight (iOS 26 recommended)

  • Xcode 26+

Overview

Two Indexing Approaches

  • CoreSpotlight - Index any content at any time (comprehensive)

  • NSUserActivity - Index content user actually views (usage-based)

When to Use Each

Feature CoreSpotlight NSUserActivity

Timing Any time When user views content

Scope All content Viewed content

Handoff No Yes

Web indexing No Yes

Ranking Default Usage-boosted

CoreSpotlight

Import

import CoreSpotlight import MobileCoreServices

Basic Indexing

import CoreSpotlight

func indexNote(_ note: Note) { // Create searchable item attributes let attributes = CSSearchableItemAttributeSet(contentType: .text) attributes.title = note.title attributes.contentDescription = note.content attributes.lastUsedDate = note.modifiedAt attributes.keywords = note.tags

// Optional: thumbnail
if let thumbnailData = note.thumbnailData {
    attributes.thumbnailData = thumbnailData
}

// Create searchable item
let item = CSSearchableItem(
    uniqueIdentifier: note.id.uuidString,
    domainIdentifier: "com.yourapp.notes",
    attributeSet: attributes
)

// Optional: Set expiration
item.expirationDate = Date().addingTimeInterval(30 * 24 * 60 * 60) // 30 days

// Index the item
CSSearchableIndex.default().indexSearchableItems([item]) { error in
    if let error {
        print("Indexing failed: \(error)")
    }
}

}

Async Indexing

func indexNote(_ note: Note) async throws { let attributes = CSSearchableItemAttributeSet(contentType: .text) attributes.title = note.title attributes.contentDescription = note.content

let item = CSSearchableItem(
    uniqueIdentifier: note.id.uuidString,
    domainIdentifier: "com.yourapp.notes",
    attributeSet: attributes
)

try await CSSearchableIndex.default().indexSearchableItems([item])

}

Batch Indexing

func indexAllNotes(_ notes: [Note]) async throws { let items = notes.map { note -> CSSearchableItem in let attributes = CSSearchableItemAttributeSet(contentType: .text) attributes.title = note.title attributes.contentDescription = note.content attributes.lastUsedDate = note.modifiedAt

    return CSSearchableItem(
        uniqueIdentifier: note.id.uuidString,
        domainIdentifier: "com.yourapp.notes",
        attributeSet: attributes
    )
}

try await CSSearchableIndex.default().indexSearchableItems(items)

}

Rich Attribute Set

func createRichAttributes(for note: Note) -> CSSearchableItemAttributeSet { let attributes = CSSearchableItemAttributeSet(contentType: .text)

// Basic info
attributes.title = note.title
attributes.contentDescription = note.content
attributes.displayName = note.title

// Dates
attributes.contentCreationDate = note.createdAt
attributes.contentModificationDate = note.modifiedAt
attributes.lastUsedDate = note.lastViewedAt

// Keywords and categorization
attributes.keywords = note.tags
attributes.subject = note.category

// Media (if applicable)
if let imageData = note.thumbnailData {
    attributes.thumbnailData = imageData
}

// Contact info (for contact-related content)
attributes.authorNames = [note.author]
attributes.authorEmailAddresses = [note.authorEmail]

// Location (if applicable)
if let location = note.location {
    attributes.latitude = location.latitude as NSNumber
    attributes.longitude = location.longitude as NSNumber
    attributes.namedLocation = location.name
}

// Custom attributes
attributes.identifier = note.id.uuidString
attributes.relatedUniqueIdentifier = note.folder?.id.uuidString

return attributes

}

Deleting from Index

// Delete specific item func deleteFromIndex(noteId: UUID) async throws { try await CSSearchableIndex.default().deleteSearchableItems( withIdentifiers: [noteId.uuidString] ) }

// Delete by domain func deleteAllNotes() async throws { try await CSSearchableIndex.default().deleteSearchableItems( withDomainIdentifiers: ["com.yourapp.notes"] ) }

// Delete all indexed content func deleteAllIndexedContent() async throws { try await CSSearchableIndex.default().deleteAllSearchableItems() }

Updating Index

func updateNoteInIndex(_ note: Note) async throws { // Simply re-index with same identifier // CoreSpotlight replaces existing item try await indexNote(note) }

NSUserActivity

Basic Setup

import UIKit

class NoteViewController: UIViewController { var note: Note!

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    setupUserActivity()
}

func setupUserActivity() {
    let activity = NSUserActivity(activityType: "com.yourapp.viewNote")

    // Basic properties
    activity.title = note.title
    activity.userInfo = ["noteId": note.id.uuidString]

    // Enable features
    activity.isEligibleForSearch = true        // Spotlight search
    activity.isEligibleForPrediction = true    // Siri suggestions
    activity.isEligibleForHandoff = true       // Handoff to other devices

    // Search attributes
    let attributes = CSSearchableItemAttributeSet(contentType: .text)
    attributes.title = note.title
    attributes.contentDescription = note.content
    activity.contentAttributeSet = attributes

    // Keywords
    activity.keywords = Set(note.tags)

    // Associate with view controller
    userActivity = activity
    activity.becomeCurrent()
}

}

SwiftUI Integration

struct NoteDetailView: View { let note: Note

var body: some View {
    ScrollView {
        VStack(alignment: .leading) {
            Text(note.title)
                .font(.largeTitle)
            Text(note.content)
        }
    }
    .userActivity("com.yourapp.viewNote") { activity in
        activity.title = note.title
        activity.userInfo = ["noteId": note.id.uuidString]
        activity.isEligibleForSearch = true
        activity.isEligibleForHandoff = true

        let attributes = CSSearchableItemAttributeSet(contentType: .text)
        attributes.title = note.title
        attributes.contentDescription = note.content
        activity.contentAttributeSet = attributes
    }
}

}

Web Page Integration

func setupWebEligibleActivity() { let activity = NSUserActivity(activityType: "com.yourapp.viewArticle") activity.title = article.title activity.webpageURL = URL(string: "https://yourapp.com/articles/\(article.id)")

activity.isEligibleForSearch = true
activity.isEligibleForPublicIndexing = true  // Can appear in public search
activity.isEligibleForHandoff = true

userActivity = activity
activity.becomeCurrent()

}

Correlating with CoreSpotlight

// Use same identifier in both let uniqueId = note.id.uuidString

// CoreSpotlight item let spotlightItem = CSSearchableItem( uniqueIdentifier: uniqueId, domainIdentifier: "com.yourapp.notes", attributeSet: attributes )

// NSUserActivity let activity = NSUserActivity(activityType: "com.yourapp.viewNote") activity.contentAttributeSet?.relatedUniqueIdentifier = uniqueId

Handling Spotlight Results

In App Delegate

class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void ) -> Bool { // Handle NSUserActivity if userActivity.activityType == "com.yourapp.viewNote" { if let noteId = userActivity.userInfo?["noteId"] as? String { navigateToNote(id: noteId) return true } }

    // Handle CoreSpotlight result
    if userActivity.activityType == CSSearchableItemActionType {
        if let uniqueId = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
            navigateToNote(id: uniqueId)
            return true
        }
    }

    return false
}

}

In Scene Delegate

class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene( _ scene: UIScene, continue userActivity: NSUserActivity ) { handleUserActivity(userActivity) }

func handleUserActivity(_ activity: NSUserActivity) {
    switch activity.activityType {
    case "com.yourapp.viewNote":
        if let noteId = activity.userInfo?["noteId"] as? String {
            navigateToNote(id: noteId)
        }
    case CSSearchableItemActionType:
        if let uniqueId = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
            navigateToNote(id: uniqueId)
        }
    default:
        break
    }
}

}

In SwiftUI

@main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() .onContinueUserActivity("com.yourapp.viewNote") { activity in handleNoteActivity(activity) } .onContinueUserActivity(CSSearchableItemActionType) { activity in handleSpotlightActivity(activity) } } }

func handleNoteActivity(_ activity: NSUserActivity) {
    guard let noteId = activity.userInfo?["noteId"] as? String else { return }
    // Navigate to note
    router.navigateTo(.note(id: noteId))
}

func handleSpotlightActivity(_ activity: NSUserActivity) {
    guard let uniqueId = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { return }
    router.navigateTo(.note(id: uniqueId))
}

}

Query Indexing Status

Check Index Status

func checkIndexStatus() async throws { let index = CSSearchableIndex.default()

// Check if indexing is enabled
let status = try await index.fetchLastClientState()
print("Last indexed: \(status)")

}

Client State for Incremental Updates

class IndexManager { func performIncrementalUpdate() async throws { let index = CSSearchableIndex.default()

    // Get last sync state
    let lastState = try await index.fetchLastClientState()

    // Fetch changes since last state
    let changes = try await fetchChangesSince(lastState)

    // Index new/modified items
    let newItems = changes.added + changes.modified
    if !newItems.isEmpty {
        let searchableItems = newItems.map { createSearchableItem(for: $0) }
        try await index.indexSearchableItems(searchableItems)
    }

    // Delete removed items
    if !changes.deleted.isEmpty {
        try await index.deleteSearchableItems(withIdentifiers: changes.deleted)
    }

    // Save new state
    let newState = createCurrentState()
    try await index.beginBatch()
    try await index.endBatch(withClientState: newState)
}

}

Spotlight Delegate

Index Maintenance

import CoreSpotlight

class SpotlightDelegate: NSObject, CSSearchableIndexDelegate { func searchableIndex( _ searchableIndex: CSSearchableIndex, reindexAllSearchableItemsWithAcknowledgementHandler acknowledgementHandler: @escaping () -> Void ) { // System requested full reindex Task { try? await reindexAllContent() acknowledgementHandler() } }

func searchableIndex(
    _ searchableIndex: CSSearchableIndex,
    reindexSearchableItemsWithIdentifiers identifiers: [String],
    acknowledgementHandler: @escaping () -> Void
) {
    // System requested reindex of specific items
    Task {
        try? await reindexItems(withIds: identifiers)
        acknowledgementHandler()
    }
}

func data(for searchableIndex: CSSearchableIndex, itemIdentifier: String, typeIdentifier: String) throws -> Data {
    // Provide data for a searchable item (e.g., for preview)
    guard let note = NoteManager.shared.find(id: itemIdentifier) else {
        throw IndexError.itemNotFound
    }
    return note.content.data(using: .utf8) ?? Data()
}

func fileURL(for searchableIndex: CSSearchableIndex, itemIdentifier: String, typeIdentifier: String, inPlace: Bool) throws -> URL {
    // Provide file URL for item
    throw IndexError.notSupported
}

}

Registering Delegate

@main struct MyApp: App { let spotlightDelegate = SpotlightDelegate()

init() {
    CSSearchableIndex.default().indexDelegate = spotlightDelegate
}

var body: some Scene {
    WindowGroup {
        ContentView()
    }
}

}

Best Practices

  1. Index on Data Changes

class NoteManager { func save(_ note: Note) async throws { try await database.save(note)

    // Index immediately after save
    try? await SpotlightIndexer.shared.index(note)
}

func delete(_ note: Note) async throws {
    // Remove from index first
    try? await SpotlightIndexer.shared.removeFromIndex(note.id)

    try await database.delete(note)
}

}

  1. Set Appropriate Expiration

// Short-lived content item.expirationDate = Date().addingTimeInterval(7 * 24 * 60 * 60) // 7 days

// Long-lived content item.expirationDate = Date().addingTimeInterval(365 * 24 * 60 * 60) // 1 year

// Never expire item.expirationDate = nil

  1. Use Domain Identifiers

// Group related content CSSearchableItem( uniqueIdentifier: note.id.uuidString, domainIdentifier: "com.yourapp.notes", // Easy bulk operations attributeSet: attributes )

// Delete all notes at once try await index.deleteSearchableItems(withDomainIdentifiers: ["com.yourapp.notes"])

  1. Provide Rich Metadata

// Include all relevant attributes attributes.title = note.title attributes.contentDescription = note.content attributes.keywords = note.tags attributes.lastUsedDate = note.lastViewedAt attributes.thumbnailData = note.thumbnail

// Better search relevance

  1. Handle Edge Cases

func safeIndex(_ note: Note) async { do { try await indexNote(note) } catch { // Log but don't crash logger.error("Failed to index note: (error)") } }

  1. Test with Spotlight

// In simulator/device: // 1. Index content // 2. Pull down on home screen // 3. Search for indexed content // 4. Tap result to verify deep linking

Official Resources

  • CoreSpotlight Documentation

  • NSUserActivity Documentation

  • Making content searchable

  • WWDC15: Introducing Search APIs

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

photographer-testino

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-lindbergh

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-lachapelle

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-vonunwerth

No summary provided by upstream source.

Repository SourceNeeds Review