From 670a70154f65ac62eb95fbfef7ece700bf3f9c4a Mon Sep 17 00:00:00 2001 From: Considus Date: Fri, 24 Jul 2026 17:12:39 +0100 Subject: [PATCH 1/6] Spotlight: index body into textContent so interior words are searchable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Body text was only in contentDescription (the result SUBTITLE field). The type label (in title/displayName) matched, but interior body words did not — owner-reported: "reminder" found Takes, "Considus" (mid-note) did not, at full-text exposure. Set attributes.textContent — Spotlight's dedicated full-text field — from the SAME exposure-gated source as contentDescription. This adds NO new exposure: nil at None/Type-only, first line at first-line, full body at full-text — identical to today, just placed where search reads it word-by-word. Both fields share one `body` value so they can't diverge. Tests: the Type-only privacy guard (textContent nil) already existed and still holds; added positive coverage that textContent carries the body at first-line and full-text and equals contentDescription. Co-Authored-By: Claude Fable 5 --- .../Spotlight/SpotlightIndexer.swift | 17 ++++++++++++++--- .../SpotlightIndexerTests.swift | 17 +++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift index afe3eae..e780dca 100644 --- a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift +++ b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift @@ -127,8 +127,10 @@ public enum SpotlightAttributes { /// Build the CSSearchableItem for a Take at the given exposure. Returns `nil` /// for `.none` (nothing to index). Privacy contract enforced here: /// • `title`/`displayName` are ALWAYS the activity-type label, never the body. - /// • `contentDescription` carries body text ONLY at `.firstLine`/`.all`, per - /// the explicit user setting; it stays nil at `.none`/`.type`. + /// • `contentDescription` AND `textContent` carry body text ONLY at + /// `.firstLine`/`.all`, per the explicit user setting; both stay nil at + /// `.none`/`.type`. They share ONE gated source (`contentDescription(for: + /// exposure:)`) so they cannot diverge — the same text, in two fields. public static func makeItem(for take: Take, exposure: SpotlightExposure) -> CSSearchableItem? { guard exposure != .none else { return nil } let attributes: CSSearchableItemAttributeSet @@ -142,7 +144,16 @@ public enum SpotlightAttributes { attributes.displayName = label // Body content is indexed ONLY when the user opts past `.type`; it stays // nil at the private levels (`contentDescription(for:exposure:)`). - attributes.contentDescription = contentDescription(for: take, exposure: exposure) + let body = contentDescription(for: take, exposure: exposure) + // `contentDescription` is the subtitle shown UNDER a Spotlight result; + // `textContent` is the dedicated full-text field Spotlight actually + // word-searches. Body words (e.g. mid-note terms) were unreliable / + // unmatched with only `contentDescription` set — the title label matched + // but interior text did not (owner-reported 2026-07-24). Set BOTH from + // the SAME exposure-gated `body`, so this adds no new exposure at any + // level — it just places the already-permitted text where search reads it. + attributes.contentDescription = body + attributes.textContent = body let item = CSSearchableItem( uniqueIdentifier: take.id.uuidString, diff --git a/Tests/CatchlightCoreTests/SpotlightIndexerTests.swift b/Tests/CatchlightCoreTests/SpotlightIndexerTests.swift index d22b527..07bb314 100644 --- a/Tests/CatchlightCoreTests/SpotlightIndexerTests.swift +++ b/Tests/CatchlightCoreTests/SpotlightIndexerTests.swift @@ -140,12 +140,25 @@ final class SpotlightIndexerTests: XCTestCase { XCTAssertEqual(SpotlightAttributes.contentDescription(for: t, exposure: .all), "line one\nline two") } - func testMakeItem_firstLine_titleStaysTypeLabel_bodyOnlyInDescription() { + func testMakeItem_firstLine_titleStaysTypeLabel_bodyInDescriptionAndTextContent() { let t = Take(blocks: [.textLine("call the framer")], isNote: true) let item = SpotlightAttributes.makeItem(for: t, exposure: .firstLine)! XCTAssertEqual(item.attributeSet.title, "Note") // type label, never the body XCTAssertEqual(item.attributeSet.displayName, "Note") - XCTAssertEqual(item.attributeSet.contentDescription, "call the framer") + XCTAssertEqual(item.attributeSet.contentDescription, "call the framer") // subtitle shown in results + XCTAssertEqual(item.attributeSet.textContent, "call the framer") // the full-text search field + } + + func testMakeItem_all_bodyLandsInTextContentForFullTextSearch() { + // Regression for the 2026-07-24 report: at full-text the type label matched + // but interior body words did not, because the body was only in + // `contentDescription`. It must ALSO be in `textContent` — the field + // Spotlight word-searches — and the two must agree. + let t = Take(blocks: [.textLine("meeting with Considus about the roadmap")], isNote: true) + let item = SpotlightAttributes.makeItem(for: t, exposure: .all)! + XCTAssertEqual(item.attributeSet.textContent, "meeting with Considus about the roadmap") + XCTAssertEqual(item.attributeSet.textContent, item.attributeSet.contentDescription, + "both body fields share one exposure-gated source and must not diverge") } #endif From c6f48ff2e5a49670301f44cda183021a05e1fe60 Mon Sep 17 00:00:00 2001 From: Considus Date: Fri, 24 Jul 2026 17:37:26 +0100 Subject: [PATCH 2/6] Add DEBUG-only 'force subscribed + rebuild Spotlight' test aid + reindex diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sideloaded dev build has no App Store receipt, so refreshEntitlements() derives .lapsed — which wipes the Spotlight index and blocks re-indexing, making on-device Spotlight validation impossible (this is why the textContent fix couldn't be tested). This DEBUG-only Settings button pins status to .subscribed via the existing test hook and rebuilds the index at the current exposure. Compiled out of Release/TestFlight. Also adds a content-free reindex breadcrumb (.lifecycle, export-only): count + exposure level + status, never any Take text or id. Co-Authored-By: Claude Fable 5 --- Catchlight/UI/Settings/SettingsView.swift | 31 +++++++++++++++++++++++ Catchlight/UI/ViewModels/AppModel.swift | 23 ++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Catchlight/UI/Settings/SettingsView.swift b/Catchlight/UI/Settings/SettingsView.swift index a2e938e..47b7cec 100644 --- a/Catchlight/UI/Settings/SettingsView.swift +++ b/Catchlight/UI/Settings/SettingsView.swift @@ -61,6 +61,9 @@ struct SettingsView: View { #if DEBUG /// Gate for the destructive DEBUG reset's confirmation alert (section 2). @State private var showResetConfirm = false + /// Latches once the DEBUG "force subscribed + rebuild Spotlight" aid has run, + /// so the row confirms it fired (2026-07-24 on-device Spotlight testing). + @State private var debugForcedEntitled = false #endif var body: some View { @@ -202,6 +205,34 @@ struct SettingsView: View { .accessibilityIdentifier("debug-reset") .accessibilityHint("Wipes everything and returns to onboarding. Debug builds only.") + // 2026-07-24 — a sideloaded dev build has no App Store receipt, so the + // app resolves to `.lapsed`, which wipes the Spotlight index and blocks + // re-indexing — making Spotlight impossible to test on-device. This + // pins the status to subscribed and rebuilds the index at the current + // exposure so Spotlight search can be exercised. DEBUG only. + Button { + app.debugForceEntitledAndReindex() + debugForcedEntitled = true + } label: { + HStack(spacing: 14) { + Image(systemName: debugForcedEntitled ? "checkmark.circle" : "magnifyingglass.circle") + .font(.system(size: 20, weight: .regular)) + .frame(width: 26) + .accessibilityHidden(true) + Text(debugForcedEntitled ? "Entitled — Spotlight re-indexed" : "Force subscribed + rebuild Spotlight") + .font(CatchlightFont.ui(.regular, size: 17, relativeTo: .body)) + .multilineTextAlignment(.leading) + Spacer(minLength: 8) + } + .frame(minHeight: 40) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(Color.ckTextPrimary) + .listRowBackground(Color.ckSurface) + .accessibilityIdentifier("debug-force-entitled") + .accessibilityHint("Forces subscribed status and rebuilds the Spotlight index. Debug builds only.") + } header: { sectionHeader("Debug") } diff --git a/Catchlight/UI/ViewModels/AppModel.swift b/Catchlight/UI/ViewModels/AppModel.swift index 57d9fe8..35b4f7e 100644 --- a/Catchlight/UI/ViewModels/AppModel.swift +++ b/Catchlight/UI/ViewModels/AppModel.swift @@ -192,7 +192,13 @@ final class AppModel { /// locked (the placeholder store is empty) or at `.none` (`index(_:)` skips). /// Shared by lapse-recovery and the Settings exposure change (D-110). private func reindexAllTakes() { - guard lockState == .unlocked, let takes = try? dailiesVM.store.allTakes() else { return } + guard lockState == .unlocked, let takes = try? dailiesVM.store.allTakes() else { + DiagnosticsLog.shared.record(.lifecycle, "Spotlight reindex skipped (locked or store unavailable)") + return + } + // Content-free: count + exposure level only, never any Take text or id. + DiagnosticsLog.shared.record(.lifecycle, + "Spotlight reindex: \(takes.count) takes at exposure=\(spotlight.exposure.rawValue), status=\(subscription.status)") takes.forEach { spotlight.index($0) } } @@ -207,6 +213,21 @@ final class AppModel { reindexAllTakes() } + #if DEBUG + /// DEBUG-ONLY on-device test aid (2026-07-24). A sideloaded dev build has no + /// App Store receipt, so `refreshEntitlements()` derives `.lapsed` — which + /// WIPES the Spotlight index and blocks re-indexing. That makes Spotlight + /// impossible to validate on a dev build. This pins the status to + /// `.subscribed` (via the existing test hook, which also stops + /// `refreshEntitlements` overwriting it) and rebuilds the index at the current + /// exposure, so on-device Spotlight search can actually be exercised. Compiled + /// out of Release/TestFlight entirely. + func debugForceEntitledAndReindex() { + subscription.forceStatusForTesting(.subscribed) + applySpotlightExposure(SpotlightExposure.current) + } + #endif + /// Called by OnboardingViewModel after the master key is stored. Rebinds the /// feature view model to the now-openable production store and (for a fresh /// generate) seeds the first Takes, then flips to the main app. From 57b3259a30d3a0106ddabd491af85986bd304cad Mon Sep 17 00:00:00 2001 From: Considus Date: Fri, 24 Jul 2026 18:11:09 +0100 Subject: [PATCH 3/6] Make the DEBUG Spotlight aid REPORT availability + item count + OS error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Force-subscribed alone did not surface body results, so the swallowed indexSearchableItems error is now surfaced: the debug button shows, in an alert, CSSearchableIndex.isIndexingAvailable(), how many items were submitted, the exposure level, and the exact OS error (content-free — counts + error text only). This measures the actual failure instead of guessing. Co-Authored-By: Claude Fable 5 --- Catchlight/UI/Settings/SettingsView.swift | 22 +++++++++++------ Catchlight/UI/ViewModels/AppModel.swift | 16 +++++++++++-- .../Spotlight/SpotlightIndexer.swift | 24 +++++++++++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/Catchlight/UI/Settings/SettingsView.swift b/Catchlight/UI/Settings/SettingsView.swift index 47b7cec..0fea2ed 100644 --- a/Catchlight/UI/Settings/SettingsView.swift +++ b/Catchlight/UI/Settings/SettingsView.swift @@ -61,9 +61,10 @@ struct SettingsView: View { #if DEBUG /// Gate for the destructive DEBUG reset's confirmation alert (section 2). @State private var showResetConfirm = false - /// Latches once the DEBUG "force subscribed + rebuild Spotlight" aid has run, - /// so the row confirms it fired (2026-07-24 on-device Spotlight testing). - @State private var debugForcedEntitled = false + /// The DEBUG Spotlight diagnostic's on-screen report (2026-07-24), shown in an + /// alert so the actual availability / item count / OS error is visible without + /// exporting diagnostics. + @State private var debugSpotlightReport: String? #endif var body: some View { @@ -211,15 +212,14 @@ struct SettingsView: View { // pins the status to subscribed and rebuilds the index at the current // exposure so Spotlight search can be exercised. DEBUG only. Button { - app.debugForceEntitledAndReindex() - debugForcedEntitled = true + app.debugForceEntitledAndReindex { report in debugSpotlightReport = report } } label: { HStack(spacing: 14) { - Image(systemName: debugForcedEntitled ? "checkmark.circle" : "magnifyingglass.circle") + Image(systemName: "magnifyingglass.circle") .font(.system(size: 20, weight: .regular)) .frame(width: 26) .accessibilityHidden(true) - Text(debugForcedEntitled ? "Entitled — Spotlight re-indexed" : "Force subscribed + rebuild Spotlight") + Text("Force subscribed + rebuild Spotlight") .font(CatchlightFont.ui(.regular, size: 17, relativeTo: .body)) .multilineTextAlignment(.leading) Spacer(minLength: 8) @@ -232,6 +232,14 @@ struct SettingsView: View { .listRowBackground(Color.ckSurface) .accessibilityIdentifier("debug-force-entitled") .accessibilityHint("Forces subscribed status and rebuilds the Spotlight index. Debug builds only.") + .alert("Spotlight rebuild", isPresented: Binding( + get: { debugSpotlightReport != nil }, + set: { if !$0 { debugSpotlightReport = nil } } + )) { + Button("OK", role: .cancel) { debugSpotlightReport = nil } + } message: { + Text(debugSpotlightReport ?? "") + } } header: { sectionHeader("Debug") diff --git a/Catchlight/UI/ViewModels/AppModel.swift b/Catchlight/UI/ViewModels/AppModel.swift index 35b4f7e..7bb5e9d 100644 --- a/Catchlight/UI/ViewModels/AppModel.swift +++ b/Catchlight/UI/ViewModels/AppModel.swift @@ -222,9 +222,21 @@ final class AppModel { /// `refreshEntitlements` overwriting it) and rebuilds the index at the current /// exposure, so on-device Spotlight search can actually be exercised. Compiled /// out of Release/TestFlight entirely. - func debugForceEntitledAndReindex() { + func debugForceEntitledAndReindex(_ report: @escaping (String) -> Void) { subscription.forceStatusForTesting(.subscribed) - applySpotlightExposure(SpotlightExposure.current) + spotlight.deindexAll() + spotlight.exposure = SpotlightExposure.current + guard lockState == .unlocked, let takes = try? dailiesVM.store.allTakes() else { + report("locked or store unavailable"); return + } + if let core = spotlight as? CoreSpotlightIndexer { + core.debugReport(reindexing: takes) { msg in + DispatchQueue.main.async { report(msg) } + } + } else { + takes.forEach { spotlight.index($0) } + report("indexed \(takes.count) (non-CoreSpotlight indexer)") + } } #endif diff --git a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift index e780dca..476ba58 100644 --- a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift +++ b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift @@ -202,6 +202,30 @@ public final class CoreSpotlightIndexer: SpotlightIndexing, @unchecked Sendable public func deindexAll() { index.deleteSearchableItems(withDomainIdentifiers: [SpotlightConstants.domainIdentifier]) { _ in } } + + #if DEBUG + /// DEBUG diagnostic (2026-07-24): re-index every Take and report what iOS + /// actually did — whether indexing is even available to the app, how many + /// items were submitted, and the exact error the OS returns (normally + /// swallowed by the fire-and-forget callbacks). Content-free: counts + + /// exposure + OS error text only, never any Take body or id. Not shipped. + public func debugReport(reindexing takes: [Take], _ completion: @escaping (String) -> Void) { + let available = CSSearchableIndex.isIndexingAvailable() + let items = takes.compactMap { SpotlightAttributes.makeItem(for: $0, exposure: exposure) } + guard !items.isEmpty else { + completion("available=\(available)\ntakes=\(takes.count) items=0\nexposure=\(exposure.rawValue)") + return + } + index.indexSearchableItems(items) { error in + completion(""" + available=\(available) + takes=\(takes.count) items=\(items.count) + exposure=\(self.exposure.rawValue) + error=\(error?.localizedDescription ?? "none") + """) + } + } + #endif } #endif From a8d35cb1d4322b9948ee04f492efaa44b09bd387 Mon Sep 17 00:00:00 2001 From: Considus Date: Fri, 24 Jul 2026 18:21:23 +0100 Subject: [PATCH 4/6] Add DEBUG per-field Spotlight query probe After indexing measured healthy (available=true, 11/11 items, no error) but body words still don't surface in global Spotlight, add a DEBUG probe: type a word, run a live CSSearchQuery against title / contentDescription / textContent separately, and report the per-field match COUNTS. Isolates 'the index can't match this word' from 'global Spotlight won't surface body matches'. Counts only; no item content leaves the device. Co-Authored-By: Claude Fable 5 --- Catchlight/UI/Settings/SettingsView.swift | 20 +++++++++++- Catchlight/UI/ViewModels/AppModel.swift | 12 +++++++ .../Spotlight/SpotlightIndexer.swift | 31 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/Catchlight/UI/Settings/SettingsView.swift b/Catchlight/UI/Settings/SettingsView.swift index 0fea2ed..9d7ed78 100644 --- a/Catchlight/UI/Settings/SettingsView.swift +++ b/Catchlight/UI/Settings/SettingsView.swift @@ -65,6 +65,8 @@ struct SettingsView: View { /// alert so the actual availability / item count / OS error is visible without /// exporting diagnostics. @State private var debugSpotlightReport: String? + /// The probe word for the DEBUG per-field Spotlight query. + @State private var debugQueryTerm = "" #endif var body: some View { @@ -232,7 +234,7 @@ struct SettingsView: View { .listRowBackground(Color.ckSurface) .accessibilityIdentifier("debug-force-entitled") .accessibilityHint("Forces subscribed status and rebuilds the Spotlight index. Debug builds only.") - .alert("Spotlight rebuild", isPresented: Binding( + .alert("Spotlight", isPresented: Binding( get: { debugSpotlightReport != nil }, set: { if !$0 { debugSpotlightReport = nil } } )) { @@ -241,6 +243,22 @@ struct SettingsView: View { Text(debugSpotlightReport ?? "") } + // Per-field query probe: type a word, see which index field matches it. + HStack(spacing: 10) { + TextField("word to query", text: $debugQueryTerm) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(CatchlightFont.ui(.regular, size: 17, relativeTo: .body)) + Button("Query") { + app.debugSpotlightQuery(debugQueryTerm) { debugSpotlightReport = $0 } + } + .font(CatchlightFont.ui(.medium, size: 15, relativeTo: .subheadline)) + .foregroundStyle(Color.ckAccent) + } + .frame(minHeight: 40) + .listRowBackground(Color.ckSurface) + .accessibilityIdentifier("debug-spotlight-query") + } header: { sectionHeader("Debug") } diff --git a/Catchlight/UI/ViewModels/AppModel.swift b/Catchlight/UI/ViewModels/AppModel.swift index 7bb5e9d..5fc85c1 100644 --- a/Catchlight/UI/ViewModels/AppModel.swift +++ b/Catchlight/UI/ViewModels/AppModel.swift @@ -238,6 +238,18 @@ final class AppModel { report("indexed \(takes.count) (non-CoreSpotlight indexer)") } } + + /// DEBUG-ONLY (2026-07-24): query the live Spotlight index for `term` against + /// each field, so we can see which field matches — isolating an index-match + /// problem from a global-Spotlight surfacing limitation. + func debugSpotlightQuery(_ term: String, _ report: @escaping (String) -> Void) { + guard let core = spotlight as? CoreSpotlightIndexer else { + report("non-CoreSpotlight indexer"); return + } + core.debugQuery(term: term) { msg in + DispatchQueue.main.async { report(msg) } + } + } #endif /// Called by OnboardingViewModel after the master key is stored. Rebinds the diff --git a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift index 476ba58..6ea7797 100644 --- a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift +++ b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift @@ -225,6 +225,37 @@ public final class CoreSpotlightIndexer: SpotlightIndexing, @unchecked Sendable """) } } + + /// DEBUG diagnostic (2026-07-24): query the LIVE Core Spotlight index for + /// `term` against each searchable field separately, so we can see which + /// field(s) actually match — isolating "the index can't match this word" + /// from "global home-screen Spotlight won't surface body matches". The term + /// is the user's own probe word, evaluated on-device; only per-field COUNTS + /// are reported, never any item content. + public func debugQuery(term: String, _ completion: @escaping (String) -> Void) { + let safe = term.replacingOccurrences(of: "\"", with: "") + guard !safe.isEmpty else { completion("enter a word to query"); return } + + func count(_ attribute: String, _ done: @escaping (Int, String?) -> Void) { + let query = CSSearchQuery(queryString: "\(attribute) == \"*\(safe)*\"cd", attributes: ["title"]) + var n = 0 + query.foundItemsHandler = { items in n += items.count } + query.completionHandler = { error in + DispatchQueue.main.async { done(n, error?.localizedDescription) } + } + query.start() + } + // Chained so the three queries don't contend; report all counts together. + count("title") { t, e1 in + count("contentDescription") { d, e2 in + count("textContent") { x, e3 in + let err = [e1, e2, e3].compactMap { $0 }.first + completion("query \"\(safe)\"\ntitle=\(t) desc=\(d) text=\(x)" + + (err.map { "\nerr=\($0)" } ?? "")) + } + } + } + } #endif } #endif From a2633b8916d5abf6d8b50e42d7a1c0b617be3b62 Mon Sep 17 00:00:00 2001 From: Considus Date: Fri, 24 Jul 2026 18:27:01 +0100 Subject: [PATCH 5/6] =?UTF-8?q?Spotlight:=20mirror=20body=20into=20keyword?= =?UTF-8?q?s=20=E2=80=94=20the=20field=20global=20Spotlight=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decisive on-device measurement (2026-07-24): an in-app CSSearchQuery matched a body word in contentDescription AND textContent (desc=1, text=1), yet the global home-screen Spotlight surfaced nothing — while title matches ("reminder") did surface. iOS's global search ranks/surfaces title + keywords, NOT contentDescription/textContent, for third-party items. So neither the contentDescription nor the textContent change could ever fix GLOBAL search. Fix: also populate keywords (deduped, lowercased, >=2-char body word tokens) from the SAME exposure-gated source — nil at None/Type-only, first-line tokens at first-line, full-body tokens at full-text. No new exposure; capped at 100 tokens so a long Take can't produce a runaway array. Tests: the Type-only privacy guard already asserts keywords nil (now protects this field too); added tokenisation + exposure-gating + interior-word coverage. Co-Authored-By: Claude Fable 5 --- .../Spotlight/SpotlightIndexer.swift | 33 +++++++++++++++++-- .../SpotlightIndexerTests.swift | 27 +++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift index 6ea7797..71dc7be 100644 --- a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift +++ b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift @@ -123,6 +123,27 @@ public enum SpotlightAttributes { } } + /// Body words as `keywords` — the ONLY body-carrying field iOS's global + /// home-screen Spotlight actually surfaces matches on (proven on-device + /// 2026-07-24: an in-app query matched a body word in `contentDescription` + /// and `textContent`, yet global Spotlight showed nothing; `title`/`keywords` + /// are what it ranks and surfaces). Derived from the SAME exposure-gated body + /// as `contentDescription`, so it exposes nothing new — nil at `.none`/`.type`, + /// first-line tokens at `.firstLine`, full-body tokens at `.all`. Deduped, + /// lowercased, ≥2 chars, capped so a very long Take can't produce a runaway + /// keyword array. + public static func keywords(for take: Take, exposure: SpotlightExposure) -> [String]? { + guard let body = contentDescription(for: take, exposure: exposure) else { return nil } + var seen = Set() + var out: [String] = [] + for token in body.lowercased().components(separatedBy: CharacterSet.alphanumerics.inverted) + where token.count >= 2 && seen.insert(token).inserted { + out.append(token) + if out.count >= 100 { break } + } + return out.isEmpty ? nil : out + } + #if canImport(CoreSpotlight) /// Build the CSSearchableItem for a Take at the given exposure. Returns `nil` /// for `.none` (nothing to index). Privacy contract enforced here: @@ -154,6 +175,10 @@ public enum SpotlightAttributes { // level — it just places the already-permitted text where search reads it. attributes.contentDescription = body attributes.textContent = body + // Body words ALSO go in `keywords` — the field global home-screen Spotlight + // surfaces matches on (contentDescription/textContent are matched by in-app + // CSSearchQuery but NOT surfaced by the global search). Same gated source. + attributes.keywords = keywords(for: take, exposure: exposure) let item = CSSearchableItem( uniqueIdentifier: take.id.uuidString, @@ -249,9 +274,11 @@ public final class CoreSpotlightIndexer: SpotlightIndexing, @unchecked Sendable count("title") { t, e1 in count("contentDescription") { d, e2 in count("textContent") { x, e3 in - let err = [e1, e2, e3].compactMap { $0 }.first - completion("query \"\(safe)\"\ntitle=\(t) desc=\(d) text=\(x)" - + (err.map { "\nerr=\($0)" } ?? "")) + count("keywords") { k, e4 in + let err = [e1, e2, e3, e4].compactMap { $0 }.first + completion("query \"\(safe)\"\ntitle=\(t) desc=\(d) text=\(x) kw=\(k)" + + (err.map { "\nerr=\($0)" } ?? "")) + } } } } diff --git a/Tests/CatchlightCoreTests/SpotlightIndexerTests.swift b/Tests/CatchlightCoreTests/SpotlightIndexerTests.swift index 07bb314..9c7cc87 100644 --- a/Tests/CatchlightCoreTests/SpotlightIndexerTests.swift +++ b/Tests/CatchlightCoreTests/SpotlightIndexerTests.swift @@ -160,6 +160,33 @@ final class SpotlightIndexerTests: XCTestCase { XCTAssertEqual(item.attributeSet.textContent, item.attributeSet.contentDescription, "both body fields share one exposure-gated source and must not diverge") } + + // MARK: - keywords (the field GLOBAL Spotlight surfaces — proven on-device 2026-07-24) + + func testKeywords_typeExposure_isNil() { + let t = Take(blocks: [.textLine("secret body")], isNote: true) + XCTAssertNil(SpotlightAttributes.keywords(for: t, exposure: .type)) + } + + func testKeywords_all_tokenisesBodyWords_dedupedAndLowercased() { + let t = Take(blocks: [.textLine("Considus Considus roadmap A")], isNote: true) + let kw = SpotlightAttributes.keywords(for: t, exposure: .all) + // "considus" deduped, "roadmap" kept, single-char "A" dropped (<2 chars). + XCTAssertEqual(kw, ["considus", "roadmap"]) + } + + func testKeywords_firstLine_onlyFirstLineTokens() { + let t = Take(blocks: [.textLine("alpha bravo"), .textLine("charlie")], isNote: true) + XCTAssertEqual(SpotlightAttributes.keywords(for: t, exposure: .firstLine), ["alpha", "bravo"]) + } + + func testMakeItem_all_bodyWordAppearsInKeywords() { + // The actual on-device fix: an interior body word must be a keyword, since + // that is the body field global home-screen Spotlight surfaces. + let t = Take(blocks: [.textLine("meeting with zorbleflux tomorrow")], isNote: true) + let item = SpotlightAttributes.makeItem(for: t, exposure: .all)! + XCTAssertEqual(item.attributeSet.keywords?.contains("zorbleflux"), true) + } #endif // MARK: - Recording-mock contract (the wiring tests) From 17773410934a706336a18261a67810fa3174dbf7 Mon Sep 17 00:00:00 2001 From: Considus Date: Fri, 24 Jul 2026 21:31:41 +0100 Subject: [PATCH 6/6] Lock Spotlight exposure to None / Type only while iOS won't surface body text Owner decision 2026-07-24. Global Spotlight on iOS 17+ surfaces only title/displayName matches for third-party items (Apple FB17330079/FB17408320, unresolved; proven on-device: a body word matched contentDescription, textContent AND keywords via in-app CSSearchQuery yet never appeared in home-screen search). The two body-indexing levels therefore put decrypted text in the OS index in exchange for search results iOS cannot deliver. - Settings menu now renders the two body levels greyed + non-selectable (explicit Buttons; a Picker cannot disable individual options), with the caption explaining why. - SpotlightExposure.isSelectable gates the offer; SpotlightExposure.current and the view binding clamp a previously persisted body level to .type. - One-time migration in AppModel.init: rewrite the stored choice, wipe the OS index immediately (works while locked), and rebuild type-label items on the first unlock so un-edited Takes do not vanish from title search. - The indexing pipeline keeps full body support (textContent + keywords land in the index and match via CSSearchQuery) so re-enabling when Apple fixes surfacing is a one-line isSelectable flip. - DEBUG diagnostics from the hunt (force-entitled button, per-field query probe, debugReport) are removed; the content-free reindex breadcrumb stays. Tests: SpotlightExposureLockTests pins the offered set, the clamp, and the fallback; existing core SpotlightIndexerTests still green. Co-Authored-By: Claude Fable 5 --- Catchlight/UI/Settings/SettingsView.swift | 85 +++++-------------- .../UI/Settings/SettingsViewModel.swift | 23 ++++- Catchlight/UI/ViewModels/AppModel.swift | 62 ++++++-------- .../Spotlight/SpotlightIndexer.swift | 56 ------------ .../SpotlightExposureLockTests.swift | 65 ++++++++++++++ 5 files changed, 132 insertions(+), 159 deletions(-) create mode 100644 Tests/CatchlightAppTests/SpotlightExposureLockTests.swift diff --git a/Catchlight/UI/Settings/SettingsView.swift b/Catchlight/UI/Settings/SettingsView.swift index 9d7ed78..c63a082 100644 --- a/Catchlight/UI/Settings/SettingsView.swift +++ b/Catchlight/UI/Settings/SettingsView.swift @@ -61,12 +61,6 @@ struct SettingsView: View { #if DEBUG /// Gate for the destructive DEBUG reset's confirmation alert (section 2). @State private var showResetConfirm = false - /// The DEBUG Spotlight diagnostic's on-screen report (2026-07-24), shown in an - /// alert so the actual availability / item count / OS error is visible without - /// exporting diagnostics. - @State private var debugSpotlightReport: String? - /// The probe word for the DEBUG per-field Spotlight query. - @State private var debugQueryTerm = "" #endif var body: some View { @@ -208,57 +202,6 @@ struct SettingsView: View { .accessibilityIdentifier("debug-reset") .accessibilityHint("Wipes everything and returns to onboarding. Debug builds only.") - // 2026-07-24 — a sideloaded dev build has no App Store receipt, so the - // app resolves to `.lapsed`, which wipes the Spotlight index and blocks - // re-indexing — making Spotlight impossible to test on-device. This - // pins the status to subscribed and rebuilds the index at the current - // exposure so Spotlight search can be exercised. DEBUG only. - Button { - app.debugForceEntitledAndReindex { report in debugSpotlightReport = report } - } label: { - HStack(spacing: 14) { - Image(systemName: "magnifyingglass.circle") - .font(.system(size: 20, weight: .regular)) - .frame(width: 26) - .accessibilityHidden(true) - Text("Force subscribed + rebuild Spotlight") - .font(CatchlightFont.ui(.regular, size: 17, relativeTo: .body)) - .multilineTextAlignment(.leading) - Spacer(minLength: 8) - } - .frame(minHeight: 40) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(Color.ckTextPrimary) - .listRowBackground(Color.ckSurface) - .accessibilityIdentifier("debug-force-entitled") - .accessibilityHint("Forces subscribed status and rebuilds the Spotlight index. Debug builds only.") - .alert("Spotlight", isPresented: Binding( - get: { debugSpotlightReport != nil }, - set: { if !$0 { debugSpotlightReport = nil } } - )) { - Button("OK", role: .cancel) { debugSpotlightReport = nil } - } message: { - Text(debugSpotlightReport ?? "") - } - - // Per-field query probe: type a word, see which index field matches it. - HStack(spacing: 10) { - TextField("word to query", text: $debugQueryTerm) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(CatchlightFont.ui(.regular, size: 17, relativeTo: .body)) - Button("Query") { - app.debugSpotlightQuery(debugQueryTerm) { debugSpotlightReport = $0 } - } - .font(CatchlightFont.ui(.medium, size: 15, relativeTo: .subheadline)) - .foregroundStyle(Color.ckAccent) - } - .frame(minHeight: 40) - .listRowBackground(Color.ckSurface) - .accessibilityIdentifier("debug-spotlight-query") - } header: { sectionHeader("Debug") } @@ -571,12 +514,22 @@ struct SettingsView: View { // description. Changing it re-indexes via AppModel.applySpotlightExposure. VStack(alignment: .leading, spacing: 6) { Menu { - Picker("Spotlight & Siri", selection: spotlightExposureBinding) { - ForEach(SpotlightExposure.allCases) { option in - Text(option.label).tag(option) + // Explicit Buttons rather than a Picker so the LOCKED body + // levels render greyed + non-selectable (owner 2026-07-24; + // see `SpotlightExposure.isSelectable` for why). A Picker + // can't disable individual options. + ForEach(SpotlightExposure.allCases) { option in + Button { + spotlightExposureBinding.wrappedValue = option + } label: { + if option == spotlightExposureBinding.wrappedValue { + Label(option.label, systemImage: "checkmark") + } else { + Text(option.label) + } } + .disabled(!option.isSelectable) } - .labelsHidden() } label: { SelectorRow(icon: "magnifyingglass", label: "Spotlight & Siri", @@ -586,7 +539,7 @@ struct SettingsView: View { .accessibilityElement(children: .combine) .accessibilityLabel("Spotlight and Siri indexing \(spotlightExposureBinding.wrappedValue.label)") - Text("Considus can never read your Takes. This only affects on-device search. Anything beyond the Take type (Note / Task / Reminder) becomes readable by iOS search and Siri, outside Catchlight's encryption.") + Text("Considus can never read your Takes. This only affects on-device search. The text options are unavailable for now. iOS does not currently show app text in search results, so Catchlight only offers the levels that work. They will return when Apple resolves this.") .font(CatchlightFont.ui(.regular, size: 13, relativeTo: .caption)) .foregroundStyle(Color.ckTextSecondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -607,7 +560,13 @@ struct SettingsView: View { private var spotlightExposureBinding: Binding { Binding( - get: { SpotlightExposure(rawValue: spotlightExposureRaw) ?? .default }, + get: { + // Clamp exactly like `SpotlightExposure.current` — a pre-lock + // body level reads as `.type` (the menu's locked rows can't be + // selected, so only the clamped value can round-trip). + let stored = SpotlightExposure(rawValue: spotlightExposureRaw) ?? .default + return stored.isSelectable ? stored : .type + }, set: { spotlightExposureRaw = $0.rawValue app.applySpotlightExposure($0) diff --git a/Catchlight/UI/Settings/SettingsViewModel.swift b/Catchlight/UI/Settings/SettingsViewModel.swift index a2e6e69..1c34009 100644 --- a/Catchlight/UI/Settings/SettingsViewModel.swift +++ b/Catchlight/UI/Settings/SettingsViewModel.swift @@ -463,11 +463,30 @@ extension SpotlightExposure { } } + /// Whether the app currently OFFERS this level (owner 2026-07-24). The two + /// body-indexing levels are LOCKED: since iOS 17, global Spotlight no longer + /// surfaces third-party body fields — title/displayName matches only (Apple + /// FB17330079/FB17408320, unresolved). Verified on-device 2026-07-24: a body + /// word matched in `contentDescription`, `textContent` AND `keywords` via an + /// in-app CSSearchQuery, yet never appeared in home-screen search. Offering + /// these levels would put decrypted text in the OS index in exchange for + /// search results iOS cannot deliver. The indexing pipeline still supports + /// them end-to-end — re-enable here once Apple fixes surfacing. + var isSelectable: Bool { + switch self { + case .none, .type: return true + case .firstLine, .all: return false + } + } + /// The user's current choice (falls back to the default), read from the same - /// UserDefaults key the Settings picker writes. + /// UserDefaults key the Settings picker writes. A persisted body level (chosen + /// before the 2026-07-24 lock) clamps to `.type` — the nearest level still + /// offered; that user had indexing ON, so `.none` would under-shoot their + /// intent. `AppModel.init` performs the matching one-time index scrub. static var current: SpotlightExposure { guard let raw = UserDefaults.standard.string(forKey: defaultsKey), let value = SpotlightExposure(rawValue: raw) else { return .default } - return value + return value.isSelectable ? value : .type } } diff --git a/Catchlight/UI/ViewModels/AppModel.swift b/Catchlight/UI/ViewModels/AppModel.swift index 5fc85c1..9de0146 100644 --- a/Catchlight/UI/ViewModels/AppModel.swift +++ b/Catchlight/UI/ViewModels/AppModel.swift @@ -119,6 +119,10 @@ final class AppModel { /// seeds the starter Takes, so seeding survives a cancel-then-retry without the /// eager open path having to run. private var seedOnNextUnlock = false + /// One-shot: the 2026-07-24 body-level lock migration wiped the OS index at + /// init (locked, placeholder store); rebuild the type-label items on the + /// first real unlock. + private var reindexAfterUnlock = false // Feature view model. Available once a store is bound (after onboarding). // (Dock redesign 2026-06-10: the timeline is the ONE surface; the former @@ -170,6 +174,20 @@ final class AppModel { // Apply the persisted Spotlight/Siri exposure (D-110) so per-save indexing // honours it from launch. Default `.none` — index nothing until opted in. spotlight.exposure = SpotlightExposure.current + // One-time migration for the 2026-07-24 body-level lock: a user who had + // opted into a body-indexing level still has decrypted Take text sitting + // in the OS index. Rewrite their stored choice to the clamped `.type`, + // wipe the index NOW (a CoreSpotlight delete needs no store, so it runs + // even while locked), and rebuild the type-label items after the first + // unlock — per-save alone would leave un-edited Takes missing from + // search indefinitely. + if let raw = UserDefaults.standard.string(forKey: SpotlightExposure.defaultsKey), + let stored = SpotlightExposure(rawValue: raw), !stored.isSelectable { + UserDefaults.standard.set(SpotlightExposure.type.rawValue, + forKey: SpotlightExposure.defaultsKey) + spotlight.deindexAll() + reindexAfterUnlock = true + } self.dailiesVM = DailiesViewModel(store: initialStore, spotlight: spotlight) // Hand the indexer to the subscription manager so the lapse transition // triggers a deindex-all without AppModel needing to observe status. @@ -213,44 +231,6 @@ final class AppModel { reindexAllTakes() } - #if DEBUG - /// DEBUG-ONLY on-device test aid (2026-07-24). A sideloaded dev build has no - /// App Store receipt, so `refreshEntitlements()` derives `.lapsed` — which - /// WIPES the Spotlight index and blocks re-indexing. That makes Spotlight - /// impossible to validate on a dev build. This pins the status to - /// `.subscribed` (via the existing test hook, which also stops - /// `refreshEntitlements` overwriting it) and rebuilds the index at the current - /// exposure, so on-device Spotlight search can actually be exercised. Compiled - /// out of Release/TestFlight entirely. - func debugForceEntitledAndReindex(_ report: @escaping (String) -> Void) { - subscription.forceStatusForTesting(.subscribed) - spotlight.deindexAll() - spotlight.exposure = SpotlightExposure.current - guard lockState == .unlocked, let takes = try? dailiesVM.store.allTakes() else { - report("locked or store unavailable"); return - } - if let core = spotlight as? CoreSpotlightIndexer { - core.debugReport(reindexing: takes) { msg in - DispatchQueue.main.async { report(msg) } - } - } else { - takes.forEach { spotlight.index($0) } - report("indexed \(takes.count) (non-CoreSpotlight indexer)") - } - } - - /// DEBUG-ONLY (2026-07-24): query the live Spotlight index for `term` against - /// each field, so we can see which field matches — isolating an index-match - /// problem from a global-Spotlight surfacing limitation. - func debugSpotlightQuery(_ term: String, _ report: @escaping (String) -> Void) { - guard let core = spotlight as? CoreSpotlightIndexer else { - report("non-CoreSpotlight indexer"); return - } - core.debugQuery(term: term) { msg in - DispatchQueue.main.async { report(msg) } - } - } - #endif /// Called by OnboardingViewModel after the master key is stored. Rebinds the /// feature view model to the now-openable production store and (for a fresh @@ -439,6 +419,12 @@ final class AppModel { rebind(to: store) // bind the REAL store before the UI un-gates session.clearObscured() // drop the privacy curtain so it can't flash post-Face ID lockState = .unlocked + if reindexAfterUnlock { + // The body-level lock's index scrub ran at init (locked, empty store); + // now the real store is bound, rebuild the type-label items once. + reindexAfterUnlock = false + reindexAllTakes() + } } // MARK: - Zero-Face-ID capture (owner 2026-06-23, "one glance → type") diff --git a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift index 71dc7be..a1db254 100644 --- a/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift +++ b/Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift @@ -228,62 +228,6 @@ public final class CoreSpotlightIndexer: SpotlightIndexing, @unchecked Sendable index.deleteSearchableItems(withDomainIdentifiers: [SpotlightConstants.domainIdentifier]) { _ in } } - #if DEBUG - /// DEBUG diagnostic (2026-07-24): re-index every Take and report what iOS - /// actually did — whether indexing is even available to the app, how many - /// items were submitted, and the exact error the OS returns (normally - /// swallowed by the fire-and-forget callbacks). Content-free: counts + - /// exposure + OS error text only, never any Take body or id. Not shipped. - public func debugReport(reindexing takes: [Take], _ completion: @escaping (String) -> Void) { - let available = CSSearchableIndex.isIndexingAvailable() - let items = takes.compactMap { SpotlightAttributes.makeItem(for: $0, exposure: exposure) } - guard !items.isEmpty else { - completion("available=\(available)\ntakes=\(takes.count) items=0\nexposure=\(exposure.rawValue)") - return - } - index.indexSearchableItems(items) { error in - completion(""" - available=\(available) - takes=\(takes.count) items=\(items.count) - exposure=\(self.exposure.rawValue) - error=\(error?.localizedDescription ?? "none") - """) - } - } - - /// DEBUG diagnostic (2026-07-24): query the LIVE Core Spotlight index for - /// `term` against each searchable field separately, so we can see which - /// field(s) actually match — isolating "the index can't match this word" - /// from "global home-screen Spotlight won't surface body matches". The term - /// is the user's own probe word, evaluated on-device; only per-field COUNTS - /// are reported, never any item content. - public func debugQuery(term: String, _ completion: @escaping (String) -> Void) { - let safe = term.replacingOccurrences(of: "\"", with: "") - guard !safe.isEmpty else { completion("enter a word to query"); return } - - func count(_ attribute: String, _ done: @escaping (Int, String?) -> Void) { - let query = CSSearchQuery(queryString: "\(attribute) == \"*\(safe)*\"cd", attributes: ["title"]) - var n = 0 - query.foundItemsHandler = { items in n += items.count } - query.completionHandler = { error in - DispatchQueue.main.async { done(n, error?.localizedDescription) } - } - query.start() - } - // Chained so the three queries don't contend; report all counts together. - count("title") { t, e1 in - count("contentDescription") { d, e2 in - count("textContent") { x, e3 in - count("keywords") { k, e4 in - let err = [e1, e2, e3, e4].compactMap { $0 }.first - completion("query \"\(safe)\"\ntitle=\(t) desc=\(d) text=\(x) kw=\(k)" - + (err.map { "\nerr=\($0)" } ?? "")) - } - } - } - } - } - #endif } #endif diff --git a/Tests/CatchlightAppTests/SpotlightExposureLockTests.swift b/Tests/CatchlightAppTests/SpotlightExposureLockTests.swift new file mode 100644 index 0000000..7f04932 --- /dev/null +++ b/Tests/CatchlightAppTests/SpotlightExposureLockTests.swift @@ -0,0 +1,65 @@ +// +// SpotlightExposureLockTests.swift +// CatchlightAppTests — the 2026-07-24 body-level lock +// +// Since iOS 17, global Spotlight surfaces only title/displayName matches for +// third-party items (Apple FB17330079, verified on-device 2026-07-24), so the +// two body-indexing exposure levels are LOCKED in Settings and any previously +// persisted body level clamps to `.type`. These tests pin the clamp and the +// offered set, so re-enabling is a deliberate act (flip `isSelectable`), not +// an accident. +// + +#if canImport(Catchlight) +import XCTest +@testable import Catchlight +@testable import CatchlightCore + +final class SpotlightExposureLockTests: XCTestCase { + + private var savedRaw: String? + + override func setUp() { + super.setUp() + savedRaw = UserDefaults.standard.string(forKey: SpotlightExposure.defaultsKey) + } + + override func tearDown() { + if let savedRaw { + UserDefaults.standard.set(savedRaw, forKey: SpotlightExposure.defaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: SpotlightExposure.defaultsKey) + } + super.tearDown() + } + + func testOfferedLevels_areNoneAndTypeOnly() { + XCTAssertTrue(SpotlightExposure.none.isSelectable) + XCTAssertTrue(SpotlightExposure.type.isSelectable) + XCTAssertFalse(SpotlightExposure.firstLine.isSelectable) + XCTAssertFalse(SpotlightExposure.all.isSelectable) + } + + func testCurrent_persistedBodyLevel_clampsToType() { + for locked in [SpotlightExposure.firstLine, .all] { + UserDefaults.standard.set(locked.rawValue, forKey: SpotlightExposure.defaultsKey) + XCTAssertEqual(SpotlightExposure.current, .type, + "a pre-lock body level must clamp to Type only, not \(locked)") + } + } + + func testCurrent_selectableLevels_roundTripUnchanged() { + for level in [SpotlightExposure.none, .type] { + UserDefaults.standard.set(level.rawValue, forKey: SpotlightExposure.defaultsKey) + XCTAssertEqual(SpotlightExposure.current, level) + } + } + + func testCurrent_missingOrGarbageValue_fallsBackToDefault() { + UserDefaults.standard.removeObject(forKey: SpotlightExposure.defaultsKey) + XCTAssertEqual(SpotlightExposure.current, .default) + UserDefaults.standard.set("not-a-level", forKey: SpotlightExposure.defaultsKey) + XCTAssertEqual(SpotlightExposure.current, .default) + } +} +#endif