Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions Catchlight/UI/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -514,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",
Expand All @@ -529,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)
Expand All @@ -550,7 +560,13 @@ struct SettingsView: View {

private var spotlightExposureBinding: Binding<SpotlightExposure> {
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)
Expand Down
23 changes: 21 additions & 2 deletions Catchlight/UI/Settings/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
33 changes: 32 additions & 1 deletion Catchlight/UI/ViewModels/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -192,7 +210,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) }
}

Expand All @@ -207,6 +231,7 @@ final class AppModel {
reindexAllTakes()
}


/// 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.
Expand Down Expand Up @@ -394,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")
Expand Down
43 changes: 40 additions & 3 deletions Sources/CatchlightCore/Spotlight/SpotlightIndexer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,35 @@ 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<String>()
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:
/// • `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
Expand All @@ -142,7 +165,20 @@ 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
// 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,
Expand Down Expand Up @@ -191,6 +227,7 @@ public final class CoreSpotlightIndexer: SpotlightIndexing, @unchecked Sendable
public func deindexAll() {
index.deleteSearchableItems(withDomainIdentifiers: [SpotlightConstants.domainIdentifier]) { _ in }
}

}
#endif

Expand Down
65 changes: 65 additions & 0 deletions Tests/CatchlightAppTests/SpotlightExposureLockTests.swift
Original file line number Diff line number Diff line change
@@ -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
44 changes: 42 additions & 2 deletions Tests/CatchlightCoreTests/SpotlightIndexerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,52 @@ 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")
}

// 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

Expand Down
Loading