From eb868ef5eb81bfbfcc35147440748748d1338623 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Fri, 28 Aug 2026 05:51:18 -0400 Subject: [PATCH] fix(lock): detect Spotlight hosted by Siri AI on macOS 27 On macOS 27 the Cmd-Space panel is drawn by the Siri AI process (`com.apple.campo`) and the `Spotlight` process never runs, so `FloatingAppMonitor` had no launcher process to observe and a Spotlight rule silently did nothing, whether Lock to or Switch to. Observe that host as well, and let `LauncherOverlayCatalog.ruleIdentity` map a focused host that is not the frontmost app to `com.apple.Spotlight`, so existing rules, backups and the picker entry keep working, while a frontmost host (Siri's regular chat window) keeps its own identity and can carry a rule of its own. The watched notifications gain `AXWindowResized` and `AXApplicationDeactivated`: the dismissed panel is only destroyed after its fade-out, about 0.7 s after focus has already returned to the app behind it, and the host's chat window deactivating must clear the stale attribution or the next panel over another app would be de-duplicated away. Such a re-read can also land after the frontmost change already cleared the attribution, so a same-value launcher report is now a no-op in `LockEngine` instead of resetting the address-bar focus. Finally, the app picker offers Spotlight even when its process is not running, since _/System/Library/CoreServices_ is never scanned and the running-apps pass was the only way it ever got listed. Closes #63 Signed-off-by: Kevin Cui --- .../AppMonitor/FloatingAppMonitor.swift | 21 ++++- .../AppMonitor/InstalledAppsScanner.swift | 22 +++++ .../AppMonitor/LauncherOverlayCatalog.swift | 56 +++++++++++-- .../LockIMEKit/LockEngine/LockEngine.swift | 22 ++++- .../InstalledAppsScannerTests.swift | 27 +++++++ .../LauncherOverlayCatalogTests.swift | 32 ++++++++ .../LockEngineAddressBarTests.swift | 27 +++++++ Tests/LockIMEKitTests/LockEngineTests.swift | 81 +++++++++++++++++++ docs/DESIGN.md | 7 ++ 9 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 Tests/LockIMEKitTests/InstalledAppsScannerTests.swift diff --git a/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift b/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift index 091ff77..339bb51 100644 --- a/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift +++ b/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift @@ -15,6 +15,18 @@ import Foundation /// which focus resolves back to the underlying app. So the whole thing is /// event-driven — no polling. /// +/// macOS 27 moved the Spotlight panel into the Siri AI process +/// (`LauncherOverlayCatalog.siriAI`) and no longer runs `Spotlight` at all, so +/// that host is observed instead; the same notifications fire from it. Two +/// differences shape the watched set (issue #63). The dismissed panel is only +/// destroyed once its fade-out ends (about 0.7 s), while keyboard focus has +/// already returned to the app behind it — but the panel *resizes* the instant +/// it starts closing, so `windowResized` re-reads focus without that lag. And +/// because the host is also a regular app (Siri's chat window), +/// `applicationDeactivated` re-reads focus when that window loses it, clearing +/// the attribution so the next panel over another app registers as a change +/// instead of being de-duplicated away as "still the same process". +/// /// **Accessibility-gated.** Without the grant `AXObserverAddNotification` fails /// and we observe nothing, leaving the permission-free core unchanged; the /// engine calls `refresh()` once the grant is detected to attach for real. @@ -107,12 +119,19 @@ public final class FloatingAppMonitor: FloatingAppMonitoring { } /// AX notifications worth a re-read: a launcher overlay appearing, taking - /// focus, or being torn down. + /// focus, or being torn down — plus the two earlier "focus has moved on" + /// signals macOS 27's Spotlight host needs (see the type comment): the + /// panel resizing as it starts to close, and the host process deactivating + /// when its regular window loses focus. Every re-read is de-duplicated + /// against `current`, so the extra notifications cost one focused-element + /// read each and never a spurious report. private static let watchedNotifications: [CFString] = [ kAXWindowCreatedNotification as CFString, kAXFocusedUIElementChangedNotification as CFString, kAXMainWindowChangedNotification as CFString, kAXUIElementDestroyedNotification as CFString, + kAXWindowResizedNotification as CFString, + kAXApplicationDeactivatedNotification as CFString, ] private func attach(_ pid: pid_t) { diff --git a/Sources/LockIMEKit/AppMonitor/InstalledAppsScanner.swift b/Sources/LockIMEKit/AppMonitor/InstalledAppsScanner.swift index 7c49b0b..4ba50a7 100644 --- a/Sources/LockIMEKit/AppMonitor/InstalledAppsScanner.swift +++ b/Sources/LockIMEKit/AppMonitor/InstalledAppsScanner.swift @@ -87,6 +87,28 @@ public enum InstalledAppsScanner { ) } + // Launcher overlays are rule targets even when their process is not + // running and they live outside the scanned directories. Spotlight is + // the case that matters: it was only ever discoverable as a *running* + // app (`/System/Library/CoreServices` is not scanned), and macOS 27 no + // longer runs it at all — the Siri AI process draws its panel — yet + // `com.apple.Spotlight` remains the identity a Spotlight rule keys on + // (`LauncherOverlayCatalog`). Resolve each catalog entry through Launch + // Services so an installed-but-idle launcher still gets its row. + for bundleID in LauncherOverlayCatalog.bundleIDs where !seen.contains(bundleID) { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID), + let bundle = Bundle(url: url) + else { continue } + seen.insert(bundleID) + apps.append( + InstalledApp( + bundleID: bundleID, + name: displayName(bundle, fallback: url.lastPathComponent), + path: url.path + ) + ) + } + return apps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } diff --git a/Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift b/Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift index 9fa4da6..e334fc1 100644 --- a/Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift +++ b/Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift @@ -17,19 +17,43 @@ import Foundation /// apps" set) rather than "any app whose focused element differs from the /// frontmost", which would misfire for helper processes and our own panels. public enum LauncherOverlayCatalog { - /// Bundle identifiers of known launcher overlays. Spotlight is the headline - /// case and is the only one that is *exclusively* an overlay; the others are - /// regular apps whose command bar happens to float over the frontmost app — - /// resolving their own bundle ID is correct in both modes, so listing them - /// is safe. + /// Spotlight's own bundle identifier: the identity a Spotlight rule keys on + /// (and the picker offers) on every macOS version, whichever process happens + /// to draw the panel — see `siriAI`. + public static let spotlight = "com.apple.Spotlight" + + /// The Siri AI process (`/System/Applications/Siri AI.app`, shown as "Siri"), + /// which on macOS 27 hosts the Cmd-Space Spotlight panel. The `Spotlight` + /// process no longer runs there at all, so while the panel is up the + /// system-wide focused element resolves to *this* process (issue #63). + /// The same process also owns Siri's regular chat window, which — unlike the + /// panel — activates it as the frontmost app; `ruleIdentity` tells the two + /// apart. + public static let siriAI = "com.apple.campo" + + /// Bundle identifiers of the launcher-overlay **processes** to observe. + /// Spotlight is the headline case and is the only one that is *exclusively* + /// an overlay; the others are regular apps whose command bar happens to + /// float over the frontmost app — resolving their own bundle ID is correct + /// in both modes, so listing them is safe. public static let bundleIDs: Set = [ - "com.apple.Spotlight", // Spotlight (Cmd-Space) + spotlight, // Spotlight (Cmd-Space), macOS 26 and earlier + siriAI, // Siri AI, hosting the Spotlight panel on macOS 27 "com.raycast.macos", // Raycast "com.runningwithcrayons.Alfred", // Alfred "at.obdev.LaunchBar", // LaunchBar ] - /// Whether `bundleID` is a known launcher overlay. + /// Processes that draw *another* launcher's overlay, keyed by host process + /// and valued by the identity whose rule the overlay should resolve. Kept + /// separate from `bundleIDs` so an existing Spotlight rule (and the picker's + /// Spotlight entry, backups, the activation log) keeps meaning "the Cmd-Space + /// panel" after the OS moved that panel into a different process. + private static let overlayHosts: [String: String] = [ + siriAI: spotlight, + ] + + /// Whether `bundleID` is a known launcher overlay (or a process hosting one). public static func isLauncher(_ bundleID: String?) -> Bool { guard let bundleID else { return false } return bundleIDs.contains(bundleID) @@ -39,7 +63,25 @@ public enum LauncherOverlayCatalog { /// ID resolved from the system-wide focused UI element. Returns the id when /// it names a known launcher, or `nil` to mean "focus is on a normal app — /// fall back to `NSWorkspace.frontmostApplication`". + /// + /// This is the *process* identity; map it through `ruleIdentity` before + /// resolving rules against it. public static func launcher(forFocusedBundleID focusedBundleID: String?) -> String? { isLauncher(focusedBundleID) ? focusedBundleID : nil } + + /// The identity app rules resolve against while `launcher` (as reported by + /// `launcher(forFocusedBundleID:)`) holds keyboard focus. + /// + /// A process hosting another launcher's overlay maps to the hosted identity + /// — Siri AI drawing the Spotlight panel resolves the *Spotlight* rule — + /// **unless the host is itself the frontmost app**: an overlay never + /// activates its process (that is what makes it an overlay), so a frontmost + /// host is being used as a regular app (Siri's chat window) and keeps its + /// own identity, so a rule for it can coexist with the Spotlight rule. + /// Every other launcher resolves as itself, in either mode. + public static func ruleIdentity(forLauncher launcher: String, frontmostBundleID: String?) -> String { + guard let hosted = overlayHosts[launcher], launcher != frontmostBundleID else { return launcher } + return hosted + } } diff --git a/Sources/LockIMEKit/LockEngine/LockEngine.swift b/Sources/LockIMEKit/LockEngine/LockEngine.swift index d0c4e3b..b66b1a0 100644 --- a/Sources/LockIMEKit/LockEngine/LockEngine.swift +++ b/Sources/LockIMEKit/LockEngine/LockEngine.swift @@ -66,8 +66,18 @@ public final class LockEngine { private var lastLauncherSwitchKey: SwitchKey? /// The app rules should resolve against right now: the focused launcher - /// overlay when one is up, otherwise the `NSWorkspace` frontmost app. - private var effectiveBundleID: String? { launcherBundleID ?? frontmostBundleID } + /// overlay when one is up, otherwise the `NSWorkspace` frontmost app. The + /// launcher is the focused *process*; the identity its rule keys on can + /// differ (macOS 27's Siri AI hosting the Spotlight panel resolves the + /// Spotlight rule — unless Siri AI is itself frontmost, i.e. its regular + /// chat window is up), which `LauncherOverlayCatalog.ruleIdentity` decides. + private var effectiveBundleID: String? { + guard let launcherBundleID else { return frontmostBundleID } + return LauncherOverlayCatalog.ruleIdentity( + forLauncher: launcherBundleID, + frontmostBundleID: frontmostBundleID + ) + } public var activationCount: Int { controller.activationCount } @@ -206,6 +216,14 @@ public final class LockEngine { /// focus. While it holds focus, rules resolve against *it* rather than the /// unchanged frontmost app; `nil` reverts to the frontmost app. private func handleLauncherChange(_ bundleID: String?) { + // A report that changes nothing is not a transition. The monitor re-reads + // focus on more than the open/close events (a launcher's regular window + // deactivating, the panel resizing as it closes), and such a re-read can + // land *after* the frontmost change already cleared the attribution. + // Treating it as a dismissal would reset `addressBarFocused` and drop an + // address-bar focus the browser monitor has just reported (and, being + // de-duplicated, will not repeat). + guard bundleID != launcherBundleID else { return } launcherBundleID = bundleID // A launcher overlay shadows the browser, so the address bar isn't the // keyboard focus any more; clear it and re-arm monitoring against the diff --git a/Tests/LockIMEKitTests/InstalledAppsScannerTests.swift b/Tests/LockIMEKitTests/InstalledAppsScannerTests.swift new file mode 100644 index 0000000..faf363c --- /dev/null +++ b/Tests/LockIMEKitTests/InstalledAppsScannerTests.swift @@ -0,0 +1,27 @@ +import Testing + +@testable import LockIMEKit + +@MainActor +@Suite("InstalledAppsScanner") +struct InstalledAppsScannerTests { + // Spotlight lives in `/System/Library/CoreServices` (never scanned) and, + // on macOS 27, is not a running process either — the Siri AI process draws + // its panel. It must still be offered, since `com.apple.Spotlight` is the + // identity a Spotlight rule keys on (issue #63). + @Test("offers Spotlight even when its process is not running") + func offersSpotlight() { + let apps = InstalledAppsScanner.scan() + let spotlight = apps.filter { $0.bundleID == LauncherOverlayCatalog.spotlight } + #expect(spotlight.count == 1) + #expect(spotlight.first?.name.isEmpty == false) + } + + @Test("rows are unique per identity and sorted by name") + func uniqueAndSorted() { + let apps = InstalledAppsScanner.scan() + #expect(Set(apps.map(\.bundleID)).count == apps.count) + let names = apps.map(\.name) + #expect(names == names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }) + } +} diff --git a/Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift b/Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift index 6314a3e..e234799 100644 --- a/Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift +++ b/Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift @@ -12,6 +12,15 @@ struct LauncherOverlayCatalogTests { #expect(LauncherOverlayCatalog.isLauncher("at.obdev.LaunchBar")) } + // macOS 27 draws the Cmd-Space panel from the Siri AI process and never + // runs `Spotlight`; the host must be observed or Spotlight is undetectable. + @Test("the Siri AI process hosting Spotlight's panel is observed as a launcher") + func recognisesSpotlightHost() { + #expect(LauncherOverlayCatalog.siriAI == "com.apple.campo") + #expect(LauncherOverlayCatalog.isLauncher(LauncherOverlayCatalog.siriAI)) + #expect(LauncherOverlayCatalog.bundleIDs.contains(LauncherOverlayCatalog.spotlight)) + } + @Test("ordinary apps and nil are not launchers") func rejectsNonLaunchers() { #expect(!LauncherOverlayCatalog.isLauncher("com.apple.Safari")) @@ -22,7 +31,30 @@ struct LauncherOverlayCatalogTests { @Test("launcher(forFocusedBundleID:) passes through launchers and nils out the rest") func resolvesFocusedBundle() { #expect(LauncherOverlayCatalog.launcher(forFocusedBundleID: "com.apple.Spotlight") == "com.apple.Spotlight") + // The host is reported as the *process* it is; `ruleIdentity` maps it. + #expect(LauncherOverlayCatalog.launcher(forFocusedBundleID: "com.apple.campo") == "com.apple.campo") #expect(LauncherOverlayCatalog.launcher(forFocusedBundleID: "com.apple.Safari") == nil) #expect(LauncherOverlayCatalog.launcher(forFocusedBundleID: nil) == nil) } + + @Test("the Spotlight panel hosted by Siri AI resolves the Spotlight rule") + func hostOverlayResolvesSpotlight() { + // The panel floats over another app: the host is focused but not + // frontmost, which is exactly what makes it an overlay. + #expect(LauncherOverlayCatalog.ruleIdentity(forLauncher: "com.apple.campo", frontmostBundleID: "com.apple.finder") == "com.apple.Spotlight") + // No frontmost app at all (nothing active yet) still reads as the panel. + #expect(LauncherOverlayCatalog.ruleIdentity(forLauncher: "com.apple.campo", frontmostBundleID: nil) == "com.apple.Spotlight") + } + + @Test("a frontmost Siri AI is its regular chat window, not the Spotlight panel") + func frontmostHostKeepsOwnIdentity() { + #expect(LauncherOverlayCatalog.ruleIdentity(forLauncher: "com.apple.campo", frontmostBundleID: "com.apple.campo") == "com.apple.campo") + } + + @Test("launchers that are not hosts resolve as themselves in either mode") + func plainLaunchersResolveAsThemselves() { + #expect(LauncherOverlayCatalog.ruleIdentity(forLauncher: "com.apple.Spotlight", frontmostBundleID: "com.apple.finder") == "com.apple.Spotlight") + #expect(LauncherOverlayCatalog.ruleIdentity(forLauncher: "com.raycast.macos", frontmostBundleID: "com.apple.finder") == "com.raycast.macos") + #expect(LauncherOverlayCatalog.ruleIdentity(forLauncher: "com.raycast.macos", frontmostBundleID: "com.raycast.macos") == "com.raycast.macos") + } } diff --git a/Tests/LockIMEKitTests/LockEngineAddressBarTests.swift b/Tests/LockIMEKitTests/LockEngineAddressBarTests.swift index 2ae11c8..76c14bf 100644 --- a/Tests/LockIMEKitTests/LockEngineAddressBarTests.swift +++ b/Tests/LockIMEKitTests/LockEngineAddressBarTests.swift @@ -51,6 +51,33 @@ struct LockEngineAddressBarTests { #expect(provider.current == us) // blur → back to the default lock } + // The launcher monitor re-reads focus on more than open/close (a launcher's + // regular window deactivating, the panel resizing as it closes), and such a + // re-read can arrive after the frontmost change already cleared the + // attribution. A "no launcher" report when the engine already has none must + // be a no-op — otherwise it would reset the address-bar focus the browser + // monitor just reported, which is de-duplicated and will not be repeated. + @Test("a stale no-launcher report does not drop a focused address bar") + func staleLauncherReportKeepsAddressBarFocus() { + let provider = MockInputSourceProvider(current: us, sources: [.stub(us.rawValue), .stub(abc.rawValue)]) + let floating = MockFloatingMonitor() + let ab = MockAddressBarMonitor() + let engine = LockEngine( + provider: provider, + appMonitor: MockFrontmostMonitor(bundleID: safari), + floatingAppMonitor: floating, + addressBarMonitor: ab + ) + engine.start() + engine.apply(config(action: .lock, source: abc, default: us)) + ab.setFocused(true) + #expect(provider.current == abc) // address-bar lock applied + + floating.setLauncher(nil) // late re-read: still no launcher + #expect(provider.current == abc) // focus attribution survives + #expect(provider.selectCalls == [abc]) // and nothing was re-forced + } + @Test("switch mode fires once on focus and re-arms after blur") func switchModeFiresOnceAndReArms() { let (engine, provider, _, ab) = makeEngine(current: us, frontmost: safari) diff --git a/Tests/LockIMEKitTests/LockEngineTests.swift b/Tests/LockIMEKitTests/LockEngineTests.swift index 2308fa6..6f7be20 100644 --- a/Tests/LockIMEKitTests/LockEngineTests.swift +++ b/Tests/LockIMEKitTests/LockEngineTests.swift @@ -304,6 +304,87 @@ struct LockEngineLauncherTests { engine.accessibilityDidChange() #expect(provider.current == us) // reverted to foo's default } + + // macOS 27 (issue #63): the Cmd-Space panel is drawn by the Siri AI process + // (`com.apple.campo`), which is what the focused-element read reports; the + // `Spotlight` process never runs. The user's rule is still keyed on + // `com.apple.Spotlight`, so the host must resolve *that* rule. + @Test("the Siri AI process hosting Spotlight's panel resolves the Spotlight rule") + func spotlightHostResolvesSpotlightRule() { + let (engine, provider, floating) = makeEngine(current: us, frontmost: "com.foo.App") + var reported: [String?] = [] + engine.onFrontmostChange = { reported.append($0) } + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: spotlight, mode: .locked, lockedSourceID: abc)] + )) + #expect(provider.current == us) // foo app → default + + floating.setLauncher(LauncherOverlayCatalog.siriAI) + #expect(provider.current == abc) // the Spotlight rule, not a (missing) Siri rule + #expect(reported.last == spotlight) // the UI and the log see it as Spotlight + + floating.setLauncher(nil) + #expect(provider.current == us) // dismissed → back to foo's default + #expect(reported.last == "com.foo.App") + } + + @Test("the hosted Spotlight panel does not inherit the underlying app's lock") + func spotlightHostDoesNotInheritUnderlyingLock() { + let (engine, provider, floating) = makeEngine(current: pinyin, frontmost: "com.cjkv.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.cjkv.App", mode: .locked, lockedSourceID: pinyin)] + )) + #expect(provider.current == pinyin) // the CJKV app is pinned to pinyin + + floating.setLauncher(LauncherOverlayCatalog.siriAI) + #expect(provider.current == us) // no Spotlight rule → global default, NOT pinyin + + floating.setLauncher(nil) + #expect(provider.current == pinyin) // dismissed → underlying lock returns + } + + // The same process owns Siri's regular chat window, which — unlike the + // panel — *does* become frontmost. There it is Siri, not Spotlight: its own + // rule applies and the Spotlight rule stays out of it. + @Test("a frontmost Siri AI resolves its own rule rather than Spotlight's") + func frontmostSiriKeepsOwnRule() { + let siri = LauncherOverlayCatalog.siriAI + let (engine, provider, floating) = makeEngine(current: us, frontmost: siri) + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [ + AppRule(bundleID: spotlight, mode: .locked, lockedSourceID: abc), + AppRule(bundleID: siri, mode: .locked, lockedSourceID: pinyin), + ] + )) + #expect(provider.current == pinyin) // frontmost Siri → its own rule + + // Focus inside the chat window is reported by the launcher monitor too + // (same process); a frontmost Siri must keep resolving as Siri. + floating.setLauncher(siri) + #expect(provider.current == pinyin) + #expect(!provider.selectCalls.contains(abc)) // the Spotlight rule never fired + } + + @Test("a frontmost Siri AI without its own rule takes the default, not the Spotlight rule") + func frontmostSiriWithoutRuleTakesDefault() { + let siri = LauncherOverlayCatalog.siriAI + let (engine, provider, floating) = makeEngine(current: abc, frontmost: siri) + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: spotlight, mode: .locked, lockedSourceID: abc)] + )) + #expect(provider.current == us) // no Siri rule → default + + floating.setLauncher(siri) + #expect(provider.current == us) // still the default; Spotlight's abc lock stays out + } } /// A URL provider that, like the real Accessibility reader, only yields a URL diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 40deeeb..8f9f7d1 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -282,6 +282,12 @@ hatch for anything the scan can't discover (say, a process that isn't running right now), added by typing its bundle identifier — or a `process:` identity. +Launcher overlays from `LauncherOverlayCatalog` are offered whenever they are +installed, even when their process is not running and they live outside the +scanned directories: Spotlight was only ever discoverable as a *running* app +(`/System/Library/CoreServices` is not scanned), and macOS 27 no longer runs +it at all, yet `com.apple.Spotlight` stays the identity a Spotlight rule keys on. + ### 4.6 Toast replacement — DELETE `ToastPresenter`/`ToastView` The black capsule is the single most off-brand element (ignores light/dark, accent, Reduce Transparency). Replace with: @@ -306,6 +312,7 @@ Reduce Transparency). Replace with: | About icon | 128pt | | Glass scope | Update buttons + transient confirmation only | | About/Update window host | **Keep `HostedWindowController`** — SwiftUI `Window` scenes opened from an `LSUIElement` menu fall behind other windows (project-verified P11 bug). Give the hosted window Tahoe styling itself. | +| Spotlight on macOS 27 (issue #63) | The Cmd-Space panel is drawn by the Siri AI process (`com.apple.campo`); `Spotlight` never runs. `FloatingAppMonitor` observes that host, and the engine resolves a focused host that is **not** frontmost as `com.apple.Spotlight` (existing rules, backups and the picker entry keep working), while a *frontmost* host — Siri's regular chat window — keeps its own identity so a Siri rule can coexist. No new rule identity, no polling. | ## 6. Risks (Xcode 26 / macOS 26)