Fix Spotlight indexing: route the entity URL to contentURL
CI / build-and-deploy (push) Successful in 26s
CI / build-and-deploy (pull_request) Successful in 25s

Every bookmark failed to reach the Spotlight index. Each item died in
translation with "Provided object for field url is of class NSURL,
expected class: NSString", so on-device search and Ask Your Bookmarks
were retrieving from an index that was effectively empty.

Left to itself, App Intents indexes BookmarkEntity's `url` property under
the attribute set's own `url` key, which Spotlight's Cascade translator
types as NSString. Giving the property an explicit
`indexingKey: \.contentURL` sends it to a URL-typed field instead — and
contentURL is the right field for "where this content lives" regardless.
Keeping the property a URL rather than retyping it to String means
existing Shortcuts that read it are unaffected.

The attribute set now sets contentURL too, and the retrieval side reads
it back, so the round trip stays on one field.

Why it went unnoticed: translation happens after `indexAppEntities`
returns, so indexing logged success the whole time. Measured on the
simulator against the live library — 202 translation failures per launch
before, 0 after.

Adds SpotlightRetrievalTests, which indexes a bookmark and retrieves it
through the assistant's own path. Nothing weaker would have caught this,
since the failure was silent at every layer above the index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
This commit is contained in:
Krishna Kumar
2026-07-27 19:12:25 -05:00
co-authored by Claude Opus 5
parent 76df237473
commit 69556afc08
4 changed files with 59 additions and 5 deletions
+4
View File
@@ -25,6 +25,7 @@
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.swift */; }; 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.swift */; };
55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */; }; 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */; };
59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */; };
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; }; 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
@@ -136,6 +137,7 @@
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
240DBB87940F8D255A812EB2 /* BookmarkActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActions.swift; sourceTree = "<group>"; }; 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActions.swift; sourceTree = "<group>"; };
2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightRetrievalTests.swift; sourceTree = "<group>"; };
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; }; 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; };
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; }; 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; };
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
@@ -244,6 +246,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7623601C25E481DF58371F2A /* AppIntentsTests.swift */, 7623601C25E481DF58371F2A /* AppIntentsTests.swift */,
2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */,
); );
path = MarksTests; path = MarksTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -528,6 +531,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */, FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */,
59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+12 -2
View File
@@ -17,7 +17,17 @@ struct BookmarkEntity: AppEntity, IndexedEntity {
@Property(title: "Title") @Property(title: "Title")
var title: String var title: String
@Property(title: "URL") /// The explicit `indexingKey` is load-bearing. Left to itself App Intents
/// indexes this property under the attribute set's own `url` key, and
/// Spotlight's Cascade translator types that field as NSString so every
/// item failed with "Provided object for field url is of class NSURL,
/// expected class: NSString" and nothing reached the index. `contentURL`
/// is URL-typed there, and is the right field for "where this lives"
/// anyway. Routing it there keeps the property a URL for Shortcuts.
///
/// The failure is silent: translation happens after `indexAppEntities`
/// returns, so indexing logs success either way.
@Property(title: "URL", indexingKey: \.contentURL)
var url: URL var url: URL
@Property(title: "Website") @Property(title: "Website")
@@ -53,7 +63,7 @@ struct BookmarkEntity: AppEntity, IndexedEntity {
attrs.title = title attrs.title = title
attrs.contentDescription = details.isEmpty ? summary : details attrs.contentDescription = details.isEmpty ? summary : details
attrs.keywords = tags attrs.keywords = tags
attrs.url = url attrs.contentURL = url
return attrs return attrs
} }
} }
+3 -3
View File
@@ -24,7 +24,7 @@ enum SpotlightBookmarkSearch {
Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)") Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)")
let context = CSSearchQueryContext() let context = CSSearchQueryContext()
context.fetchAttributes = ["title", "contentDescription", "keywords", "url"] context.fetchAttributes = ["title", "contentDescription", "keywords", "contentURL"]
let query = CSSearchQuery(queryString: queryString, queryContext: context) let query = CSSearchQuery(queryString: queryString, queryContext: context)
var out: [RetrievedBookmark] = [] var out: [RetrievedBookmark] = []
@@ -33,9 +33,9 @@ enum SpotlightBookmarkSearch {
let a = result.item.attributeSet let a = result.item.attributeSet
out.append(RetrievedBookmark( out.append(RetrievedBookmark(
title: a.title ?? "Untitled", title: a.title ?? "Untitled",
host: a.url?.host() ?? "", host: a.contentURL?.host() ?? "",
description: a.contentDescription ?? "", description: a.contentDescription ?? "",
url: a.url?.absoluteString ?? "" url: a.contentURL?.absoluteString ?? ""
)) ))
if out.count >= limit { break } if out.count >= limit { break }
} }
+40
View File
@@ -0,0 +1,40 @@
import Testing
import CoreSpotlight
@testable import Marks
/// End-to-end guard for the whole Spotlight path: index a bookmark, then
/// retrieve it the way the on-device assistant does.
///
/// Worth keeping. The bug this was written for made every item fail to
/// translate into the index while `indexAppEntities` still reported success,
/// so nothing short of a round trip would have caught it.
struct SpotlightRetrievalTests {
@Test func indexedBookmarkIsRetrievable() async throws {
let b = Bookmark(
id: 999_001,
url: "https://github.com/example/zqxjkltest",
title: "Zqxjkltest concurrency notes",
description: "A distinctive probe document about zqxjkltest.",
tagNames: ["zqxjkltest"],
dateAdded: Date(), dateModified: Date(),
isArchived: false, unread: false, shared: false,
websiteTitle: nil, websiteDescription: nil,
faviconUrl: nil, previewImageUrl: nil,
aiSummary: nil, aiTags: nil
)
await SpotlightIndexer.index([b])
var hits: [RetrievedBookmark] = []
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(700))
hits = await SpotlightBookmarkSearch.run(query: "zqxjkltest", limit: 5)
if !hits.isEmpty { break }
}
await SpotlightIndexer.remove(ids: [999_001])
#expect(!hits.isEmpty, "Spotlight returned no hits for an indexed bookmark")
let hit = try #require(hits.first)
#expect(hit.title.contains("Zqxjkltest"))
#expect(hit.url == "https://github.com/example/zqxjkltest",
"url did not survive the round trip: \(hit.url)")
#expect(hit.host == "github.com", "host was \(hit.host)")
}
}