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
21 changes: 20 additions & 1 deletion Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions Sources/LockIMEKit/AppMonitor/InstalledAppsScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down
56 changes: 49 additions & 7 deletions Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
"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)
Expand All @@ -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
}
}
22 changes: 20 additions & 2 deletions Sources/LockIMEKit/LockEngine/LockEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions Tests/LockIMEKitTests/InstalledAppsScannerTests.swift
Original file line number Diff line number Diff line change
@@ -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 })
}
}
32 changes: 32 additions & 0 deletions Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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")
}
}
27 changes: 27 additions & 0 deletions Tests/LockIMEKitTests/LockEngineAddressBarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading