From 69556afc08da150f62ea7231b89467c28de23622 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Mon, 27 Jul 2026 19:12:25 -0500 Subject: [PATCH] Fix Spotlight indexing: route the entity URL to contentURL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks.xcodeproj/project.pbxproj | 4 ++ Marks/Intents/BookmarkEntity.swift | 14 ++++++- Marks/Services/SpotlightBookmarkSearch.swift | 6 +-- MarksTests/SpotlightRetrievalTests.swift | 40 ++++++++++++++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 MarksTests/SpotlightRetrievalTests.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index c90b335..9974c22 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.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 */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.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 = ""; }; 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 = ""; }; + 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightRetrievalTests.swift; sourceTree = ""; }; 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = ""; }; 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = ""; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; @@ -244,6 +246,7 @@ isa = PBXGroup; children = ( 7623601C25E481DF58371F2A /* AppIntentsTests.swift */, + 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */, ); path = MarksTests; sourceTree = ""; @@ -528,6 +531,7 @@ buildActionMask = 2147483647; files = ( FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */, + 59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Marks/Intents/BookmarkEntity.swift b/Marks/Intents/BookmarkEntity.swift index 94f9164..f619805 100644 --- a/Marks/Intents/BookmarkEntity.swift +++ b/Marks/Intents/BookmarkEntity.swift @@ -17,7 +17,17 @@ struct BookmarkEntity: AppEntity, IndexedEntity { @Property(title: "Title") 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 @Property(title: "Website") @@ -53,7 +63,7 @@ struct BookmarkEntity: AppEntity, IndexedEntity { attrs.title = title attrs.contentDescription = details.isEmpty ? summary : details attrs.keywords = tags - attrs.url = url + attrs.contentURL = url return attrs } } diff --git a/Marks/Services/SpotlightBookmarkSearch.swift b/Marks/Services/SpotlightBookmarkSearch.swift index bb55a6d..2e50766 100644 --- a/Marks/Services/SpotlightBookmarkSearch.swift +++ b/Marks/Services/SpotlightBookmarkSearch.swift @@ -24,7 +24,7 @@ enum SpotlightBookmarkSearch { Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)") let context = CSSearchQueryContext() - context.fetchAttributes = ["title", "contentDescription", "keywords", "url"] + context.fetchAttributes = ["title", "contentDescription", "keywords", "contentURL"] let query = CSSearchQuery(queryString: queryString, queryContext: context) var out: [RetrievedBookmark] = [] @@ -33,9 +33,9 @@ enum SpotlightBookmarkSearch { let a = result.item.attributeSet out.append(RetrievedBookmark( title: a.title ?? "Untitled", - host: a.url?.host() ?? "", + host: a.contentURL?.host() ?? "", description: a.contentDescription ?? "", - url: a.url?.absoluteString ?? "" + url: a.contentURL?.absoluteString ?? "" )) if out.count >= limit { break } } diff --git a/MarksTests/SpotlightRetrievalTests.swift b/MarksTests/SpotlightRetrievalTests.swift new file mode 100644 index 0000000..9413564 --- /dev/null +++ b/MarksTests/SpotlightRetrievalTests.swift @@ -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)") + } +}