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)