Skip to content

Spotlight: index body correctly (textContent + keywords) and lock exposure to the levels iOS actually delivers#141

Merged
Considus merged 6 commits into
mainfrom
fix/spotlight-textcontent
Jul 24, 2026
Merged

Spotlight: index body correctly (textContent + keywords) and lock exposure to the levels iOS actually delivers#141
Considus merged 6 commits into
mainfrom
fix/spotlight-textcontent

Conversation

@Considus

@Considus Considus commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Summary

The on-device Spotlight investigation, end to end. Squash-merge — the intermediate DEBUG-probe commits add then remove diagnostic scaffolding.

Root cause (measured, not guessed): iOS 17+ global Spotlight surfaces only title/displayName matches for third-party items. Proven on-device: a body word matched contentDescription, textContent AND keywords via in-app CSSearchQuery (desc=1 text=1 kw=1), yet home-screen search showed nothing — while title matches ("reminder") surfaced. Known open Apple issue (FB17330079/FB17408320, no workaround).

What ships:

  • Body text now lands in textContent and tokenised keywords alongside contentDescription — all from ONE exposure-gated source (nil at None/Type-only). Correct per the API contract and future-proof for Apple's fix; verified present and matchable in the live index.
  • Exposure locked to None / Type only (owner decision): the two body levels render greyed + non-selectable with an explanatory caption — they'd put decrypted text in the OS index for search results iOS can't deliver. Re-enabling is a one-line isSelectable flip.
  • One-time migration: a previously chosen body level clamps to Type only, the OS index is scrubbed at launch, and type-label items rebuild on first unlock.

Tests

  • SpotlightExposureLockTests (new): offered set, clamp, fallback.
  • SpotlightIndexerTests: textContent/keywords population, tokenisation, exposure gating; the Type-only privacy guard (all body fields nil) held throughout.
  • Simulator build + full core suite green locally.

🤖 Generated with Claude Code

Considus and others added 6 commits July 24, 2026 17:12
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 <noreply@anthropic.com>
…dex diagnostics

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…faces

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 <noreply@anthropic.com>
…ody 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 <noreply@anthropic.com>
@Considus Considus changed the title Spotlight: index body into textContent so interior words are searchable Spotlight: index body correctly (textContent + keywords) and lock exposure to the levels iOS actually delivers Jul 24, 2026
@Considus
Considus merged commit 9c3e6c5 into main Jul 24, 2026
4 checks passed
@Considus
Considus deleted the fix/spotlight-textcontent branch July 24, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant