From 736ed94db5f5daf5ee6f16afe92ae407724bf577 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Wed, 22 Apr 2026 12:09:56 +0800 Subject: [PATCH 1/4] fix(input-monitor): harden event tap to avoid IME conflict Re-enable the CGEventTap on tapDisabledByTimeout / tapDisabledByUserInput instead of leaving it dormant, and move the synchronous NSRunningApplication lookup off the event tap callback thread via a new AppIdentityCache that resolves unknown PIDs asynchronously and coalesces concurrent lookups. Pre-cache the frontmost app's PID so the common path avoids the async hop entirely. These two changes keep the callback fast and the tap healthy, reducing interference with input methods such as WeChat IME on macOS 15. Refs #103 --- KeyStats/AppActivityTracker.swift | 80 ++++++----------------- KeyStats/AppStats.swift | 101 ++++++++++++++++++++++++++++++ KeyStats/InputMonitor.swift | 6 ++ KeyStats/StatsModels.swift | 7 +++ 4 files changed, 133 insertions(+), 61 deletions(-) diff --git a/KeyStats/AppActivityTracker.swift b/KeyStats/AppActivityTracker.swift index 4fee179..36962fb 100644 --- a/KeyStats/AppActivityTracker.swift +++ b/KeyStats/AppActivityTracker.swift @@ -1,22 +1,23 @@ import Cocoa import CoreGraphics -struct AppIdentity: Equatable { - let bundleId: String - let displayName: String - - static let unknown = AppIdentity(bundleId: "unknown", displayName: "") -} - final class AppActivityTracker { static let shared = AppActivityTracker() - private let lock = NSLock() - private var frontmostIdentity = AppIdentity.unknown - private var pidToBundleId: [pid_t: String] = [:] - private var bundleIdToName: [String: String] = [:] + private let cache: AppIdentityCache private init() { + let queue = DispatchQueue(label: "com.keystats.app-activity-resolver", qos: .utility) + self.cache = AppIdentityCache( + resolver: { pid in + guard let app = NSRunningApplication(processIdentifier: pid), + let bundleId = app.bundleIdentifier else { + return nil + } + return (bundleId: bundleId, name: app.localizedName ?? "") + }, + dispatcher: QueueAppIdentityDispatcher(queue: queue) + ) let workspace = NSWorkspace.shared updateFrontmostApp(workspace.frontmostApplication) workspace.notificationCenter.addObserver( @@ -32,49 +33,8 @@ final class AppActivityTracker { } func appIdentity(for event: CGEvent?) -> AppIdentity { - if let event = event { - let pidValue = event.getIntegerValueField(.eventSourceUnixProcessID) - if pidValue > 0 { - let pid = pid_t(pidValue) - if let identity = identityForPID(pid) { - return identity - } - } - } - return currentFrontmostIdentity() - } - - private func identityForPID(_ pid: pid_t) -> AppIdentity? { - lock.lock() - if let bundleId = pidToBundleId[pid] { - let name = bundleIdToName[bundleId] ?? "" - let identity = AppIdentity(bundleId: bundleId, displayName: name) - lock.unlock() - return identity - } - lock.unlock() - - guard let app = NSRunningApplication(processIdentifier: pid), - let bundleId = app.bundleIdentifier else { - return nil - } - let name = app.localizedName ?? "" - - lock.lock() - pidToBundleId[pid] = bundleId - if !name.isEmpty { - bundleIdToName[bundleId] = name - } - let identity = AppIdentity(bundleId: bundleId, displayName: name) - lock.unlock() - return identity - } - - private func currentFrontmostIdentity() -> AppIdentity { - lock.lock() - let identity = frontmostIdentity - lock.unlock() - return identity + let pid: pid_t? = event.map { pid_t($0.getIntegerValueField(.eventSourceUnixProcessID)) } + return cache.identity(forPID: pid) } @objc private func activeApplicationChanged(_ notification: Notification) { @@ -86,12 +46,10 @@ final class AppActivityTracker { private func updateFrontmostApp(_ app: NSRunningApplication?) { guard let app = app, let bundleId = app.bundleIdentifier else { return } - let name = app.localizedName ?? "" - lock.lock() - frontmostIdentity = AppIdentity(bundleId: bundleId, displayName: name) - if !name.isEmpty { - bundleIdToName[bundleId] = name - } - lock.unlock() + cache.updateFrontmost( + bundleId: bundleId, + name: app.localizedName ?? "", + pid: app.processIdentifier + ) } } diff --git a/KeyStats/AppStats.swift b/KeyStats/AppStats.swift index a0f8415..9ee69de 100644 --- a/KeyStats/AppStats.swift +++ b/KeyStats/AppStats.swift @@ -1,5 +1,106 @@ import Foundation +struct AppIdentity: Equatable { + let bundleId: String + let displayName: String + + static let unknown = AppIdentity(bundleId: "unknown", displayName: "") +} + +/// 允许测试注入同步调度器来驱动 AppIdentityCache 的异步路径。 +protocol AppIdentityDispatcher { + func dispatch(_ work: @escaping () -> Void) +} + +struct QueueAppIdentityDispatcher: AppIdentityDispatcher { + let queue: DispatchQueue + func dispatch(_ work: @escaping () -> Void) { + queue.async(execute: work) + } +} + +/// 线程安全的 PID → AppIdentity 缓存。未命中时派发后台解析, +/// 同一 PID 的并发查询会被折叠成单次解析,避免在事件 tap 回调 +/// 线程上同步调用 NSRunningApplication 进而阻塞 IME 事件。 +final class AppIdentityCache { + typealias Resolver = (pid_t) -> (bundleId: String, name: String)? + + private let lock = NSLock() + private var frontmost = AppIdentity.unknown + private var pidToBundleId: [pid_t: String] = [:] + private var bundleIdToName: [String: String] = [:] + private var pendingResolutions: Set = [] + private let resolver: Resolver + private let dispatcher: AppIdentityDispatcher + + init(resolver: @escaping Resolver, dispatcher: AppIdentityDispatcher) { + self.resolver = resolver + self.dispatcher = dispatcher + } + + func identity(forPID pid: pid_t?) -> AppIdentity { + if let pid = pid, pid > 0 { + if let cached = cachedIdentity(forPID: pid) { + return cached + } + scheduleResolution(forPID: pid) + } + lock.lock() + defer { lock.unlock() } + return frontmost + } + + func updateFrontmost(bundleId: String, name: String, pid: pid_t?) { + lock.lock() + frontmost = AppIdentity(bundleId: bundleId, displayName: name) + if !name.isEmpty { + bundleIdToName[bundleId] = name + } + if let pid = pid, pid > 0 { + pidToBundleId[pid] = bundleId + } + lock.unlock() + } + + var currentFrontmost: AppIdentity { + lock.lock() + defer { lock.unlock() } + return frontmost + } + + private func cachedIdentity(forPID pid: pid_t) -> AppIdentity? { + lock.lock() + defer { lock.unlock() } + guard let bundleId = pidToBundleId[pid] else { return nil } + let name = bundleIdToName[bundleId] ?? "" + return AppIdentity(bundleId: bundleId, displayName: name) + } + + private func scheduleResolution(forPID pid: pid_t) { + lock.lock() + if pendingResolutions.contains(pid) || pidToBundleId[pid] != nil { + lock.unlock() + return + } + pendingResolutions.insert(pid) + lock.unlock() + + dispatcher.dispatch { [weak self] in + guard let self = self else { return } + let result = self.resolver(pid) + self.lock.lock() + self.pendingResolutions.remove(pid) + if let result = result { + self.pidToBundleId[pid] = result.bundleId + if !result.name.isEmpty { + self.bundleIdToName[result.bundleId] = result.name + } + } + self.lock.unlock() + } + } +} + struct AppStats: Codable { var bundleId: String var displayName: String diff --git a/KeyStats/InputMonitor.swift b/KeyStats/InputMonitor.swift index d8f2388..910d952 100644 --- a/KeyStats/InputMonitor.swift +++ b/KeyStats/InputMonitor.swift @@ -67,6 +67,12 @@ class InputMonitor { // 创建事件回调 let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged? in + if isTapDisabledSignal(type) { + if let tap = InputMonitor.shared.eventTap { + CGEvent.tapEnable(tap: tap, enable: true) + } + return Unmanaged.passUnretained(event) + } InputMonitor.shared.handleEvent(type: type, event: event) return Unmanaged.passUnretained(event) } diff --git a/KeyStats/StatsModels.swift b/KeyStats/StatsModels.swift index 8044f04..a18863c 100644 --- a/KeyStats/StatsModels.swift +++ b/KeyStats/StatsModels.swift @@ -13,6 +13,13 @@ private let rightCommandRawMask = UInt64(NX_DEVICERCMDKEYMASK) private let leftOptionRawMask = UInt64(NX_DEVICELALTKEYMASK) private let rightOptionRawMask = UInt64(NX_DEVICERALTKEYMASK) +/// 判断 CGEventTap 回调收到的事件类型是否表示 tap 被系统禁用 +/// (回调超时或被用户输入抢占)。收到此类事件时应立即重新启用 tap, +/// 否则 tap 会长期处于半死状态,可能干扰输入法等事件处理链路。 +func isTapDisabledSignal(_ type: CGEventType) -> Bool { + return type == .tapDisabledByTimeout || type == .tapDisabledByUserInput +} + func baseKeyComponent(_ keyName: String) -> String { let trimmed = keyName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "" } From 676bb74f6980880485111cab7c005dc5199d2358 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Wed, 22 Apr 2026 12:10:01 +0800 Subject: [PATCH 2/4] test: cover AppIdentityCache and tap-disable signal Lock in the core invariants behind the IME-conflict fix: - resolver is never called synchronously from the event-tap callback path - concurrent lookups for the same PID coalesce into a single resolution - isTapDisabledSignal flags only tapDisabledByTimeout / tapDisabledByUserInput Refs #103 --- KeyStatsTests/AppIdentityCacheTests.swift | 194 ++++++++++++++++++++++ Package.swift | 2 +- 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 KeyStatsTests/AppIdentityCacheTests.swift diff --git a/KeyStatsTests/AppIdentityCacheTests.swift b/KeyStatsTests/AppIdentityCacheTests.swift new file mode 100644 index 0000000..ed328f8 --- /dev/null +++ b/KeyStatsTests/AppIdentityCacheTests.swift @@ -0,0 +1,194 @@ +import XCTest +import CoreGraphics +@testable import KeyStatsCore + +private final class ManualDispatcher: AppIdentityDispatcher { + var pending: [() -> Void] = [] + + func dispatch(_ work: @escaping () -> Void) { + pending.append(work) + } + + func drain() { + let work = pending + pending.removeAll() + work.forEach { $0() } + } +} + +private final class ResolverSpy { + private(set) var calls: [pid_t] = [] + var response: (pid_t) -> (bundleId: String, name: String)? = { _ in nil } + + func resolve(pid: pid_t) -> (bundleId: String, name: String)? { + calls.append(pid) + return response(pid) + } +} + +final class AppIdentityCacheTests: XCTestCase { + private func makeCache( + dispatcher: ManualDispatcher = ManualDispatcher(), + spy: ResolverSpy = ResolverSpy() + ) -> (AppIdentityCache, ManualDispatcher, ResolverSpy) { + let cache = AppIdentityCache(resolver: spy.resolve, dispatcher: dispatcher) + return (cache, dispatcher, spy) + } + + // MARK: - isTapDisabledSignal + + func testIsTapDisabledSignalReturnsTrueForTimeoutAndUserInput() { + XCTAssertTrue(isTapDisabledSignal(.tapDisabledByTimeout)) + XCTAssertTrue(isTapDisabledSignal(.tapDisabledByUserInput)) + } + + func testIsTapDisabledSignalReturnsFalseForNormalEventTypes() { + XCTAssertFalse(isTapDisabledSignal(.keyDown)) + XCTAssertFalse(isTapDisabledSignal(.flagsChanged)) + XCTAssertFalse(isTapDisabledSignal(.mouseMoved)) + XCTAssertFalse(isTapDisabledSignal(.leftMouseDown)) + XCTAssertFalse(isTapDisabledSignal(.rightMouseDown)) + XCTAssertFalse(isTapDisabledSignal(.scrollWheel)) + XCTAssertFalse(isTapDisabledSignal(.null)) + } + + // MARK: - identity(forPID:) + + func testIdentityForNilPIDReturnsFrontmost() { + let (cache, _, spy) = makeCache() + cache.updateFrontmost(bundleId: "com.front", name: "Front", pid: 100) + + let result = cache.identity(forPID: nil) + + XCTAssertEqual(result, AppIdentity(bundleId: "com.front", displayName: "Front")) + XCTAssertTrue(spy.calls.isEmpty, "nil PID 不应触发解析") + } + + func testIdentityForZeroPIDReturnsFrontmostWithoutResolution() { + let (cache, _, spy) = makeCache() + cache.updateFrontmost(bundleId: "com.front", name: "Front", pid: 100) + + let result = cache.identity(forPID: 0) + + XCTAssertEqual(result.bundleId, "com.front") + XCTAssertTrue(spy.calls.isEmpty, "PID=0 属于内核事件,不应触发解析") + } + + func testIdentityForNegativePIDReturnsFrontmostWithoutResolution() { + let (cache, _, spy) = makeCache() + cache.updateFrontmost(bundleId: "com.front", name: "Front", pid: 100) + + let result = cache.identity(forPID: -1) + + XCTAssertEqual(result.bundleId, "com.front") + XCTAssertTrue(spy.calls.isEmpty) + } + + func testIdentityForUnknownPIDReturnsFrontmostImmediately() { + let (cache, dispatcher, spy) = makeCache() + cache.updateFrontmost(bundleId: "com.front", name: "Front", pid: 100) + + let result = cache.identity(forPID: 7777) + + // 返回值必须立刻给出,不能等解析完成,否则会卡住事件 tap 回调线程。 + XCTAssertEqual(result, AppIdentity(bundleId: "com.front", displayName: "Front")) + XCTAssertTrue(spy.calls.isEmpty, "resolver 应在 dispatcher 驱动后才执行,而非同步") + XCTAssertEqual(dispatcher.pending.count, 1, "应当排入一次后台解析任务") + } + + func testIdentityForPreviouslyResolvedPIDReturnsCachedValue() { + let spy = ResolverSpy() + spy.response = { _ in (bundleId: "com.app", name: "Resolved") } + let dispatcher = ManualDispatcher() + let (cache, _, _) = makeCache(dispatcher: dispatcher, spy: spy) + + _ = cache.identity(forPID: 42) + dispatcher.drain() + + let result = cache.identity(forPID: 42) + XCTAssertEqual(result, AppIdentity(bundleId: "com.app", displayName: "Resolved")) + XCTAssertEqual(spy.calls, [42], "已解析的 PID 再次查询不应重新调用 resolver") + XCTAssertTrue(dispatcher.pending.isEmpty) + } + + func testConcurrentQueriesForSamePIDCoalesceIntoSingleResolverCall() { + let spy = ResolverSpy() + spy.response = { _ in (bundleId: "com.app", name: "App") } + let dispatcher = ManualDispatcher() + let (cache, _, _) = makeCache(dispatcher: dispatcher, spy: spy) + + _ = cache.identity(forPID: 42) + _ = cache.identity(forPID: 42) + _ = cache.identity(forPID: 42) + + XCTAssertEqual(dispatcher.pending.count, 1, "同一 PID 的重复查询应被折叠成单次解析") + dispatcher.drain() + XCTAssertEqual(spy.calls, [42]) + } + + func testResolverFailureDoesNotCachePIDAndAllowsRetry() { + let spy = ResolverSpy() + spy.response = { _ in nil } + let dispatcher = ManualDispatcher() + let (cache, _, _) = makeCache(dispatcher: dispatcher, spy: spy) + + _ = cache.identity(forPID: 42) + dispatcher.drain() + + _ = cache.identity(forPID: 42) + XCTAssertEqual(dispatcher.pending.count, 1, "resolver 返回 nil 后允许后续查询重试") + dispatcher.drain() + XCTAssertEqual(spy.calls, [42, 42]) + } + + // MARK: - updateFrontmost + + func testUpdateFrontmostPreCachesPID() { + let (cache, _, spy) = makeCache() + + cache.updateFrontmost(bundleId: "com.front", name: "Front", pid: 100) + + let result = cache.identity(forPID: 100) + XCTAssertEqual(result, AppIdentity(bundleId: "com.front", displayName: "Front")) + XCTAssertTrue(spy.calls.isEmpty, "前台 App 的 PID 已预填缓存,不应触发 resolver") + } + + func testUpdateFrontmostWithZeroPIDSkipsPIDCaching() { + let (cache, dispatcher, spy) = makeCache() + + cache.updateFrontmost(bundleId: "com.front", name: "Front", pid: 0) + + let result = cache.identity(forPID: nil) + XCTAssertEqual(result.bundleId, "com.front") + + _ = cache.identity(forPID: 500) + XCTAssertEqual(dispatcher.pending.count, 1) + XCTAssertTrue(spy.calls.isEmpty, "派发前 resolver 不应被调用") + } + + func testCurrentFrontmostReflectsLatestUpdate() { + let (cache, _, _) = makeCache() + + XCTAssertEqual(cache.currentFrontmost, AppIdentity.unknown) + + cache.updateFrontmost(bundleId: "com.a", name: "A", pid: 1) + XCTAssertEqual(cache.currentFrontmost, AppIdentity(bundleId: "com.a", displayName: "A")) + + cache.updateFrontmost(bundleId: "com.b", name: "B", pid: 2) + XCTAssertEqual(cache.currentFrontmost, AppIdentity(bundleId: "com.b", displayName: "B")) + } + + func testResolverResultWithEmptyNameStillCachesBundleId() { + let spy = ResolverSpy() + spy.response = { _ in (bundleId: "com.anon", name: "") } + let dispatcher = ManualDispatcher() + let (cache, _, _) = makeCache(dispatcher: dispatcher, spy: spy) + + _ = cache.identity(forPID: 9) + dispatcher.drain() + + let result = cache.identity(forPID: 9) + XCTAssertEqual(result, AppIdentity(bundleId: "com.anon", displayName: "")) + XCTAssertEqual(spy.calls, [9]) + } +} diff --git a/Package.swift b/Package.swift index b4d05d6..b5a465c 100644 --- a/Package.swift +++ b/Package.swift @@ -47,7 +47,7 @@ let package = Package( name: "KeyStatsCoreTests", dependencies: ["KeyStatsCore"], path: "KeyStatsTests", - sources: ["AppStatsTests.swift", "StatsModelsTests.swift"] + sources: ["AppStatsTests.swift", "StatsModelsTests.swift", "AppIdentityCacheTests.swift"] ) ] ) From 54f924c5f70a9df151b8859173d93cd913cd7654 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Wed, 22 Apr 2026 12:16:16 +0800 Subject: [PATCH 3/4] refactor(app-identity-cache): consolidate lock and add negative cache - Merge the cache-hit / pending-check / frontmost-read into a single lock block via lookupLocked(pid:), removing two lock roundtrips from the uncached fast path on the event tap callback thread. - Cache resolver failures in unresolvablePIDs so events from unresolvable system processes no longer spawn a new background resolution on every hit. - Clear the negative-cache entry for a PID when it shows up as frontmost, so PID reuse after process termination is handled. Addresses: https://github.com/debugtheworldbot/keyStats/pull/106#discussion_r3121512820 Addresses: https://github.com/debugtheworldbot/keyStats/pull/106#discussion_r3121512824 --- KeyStats/AppStats.swift | 45 +++++++++++++---------- KeyStatsTests/AppIdentityCacheTests.swift | 24 ++++++++++-- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/KeyStats/AppStats.swift b/KeyStats/AppStats.swift index 9ee69de..20e660a 100644 --- a/KeyStats/AppStats.swift +++ b/KeyStats/AppStats.swift @@ -22,6 +22,7 @@ struct QueueAppIdentityDispatcher: AppIdentityDispatcher { /// 线程安全的 PID → AppIdentity 缓存。未命中时派发后台解析, /// 同一 PID 的并发查询会被折叠成单次解析,避免在事件 tap 回调 /// 线程上同步调用 NSRunningApplication 进而阻塞 IME 事件。 +/// 解析失败会被负缓存,避免对不可解析的系统进程反复堆积后台任务。 final class AppIdentityCache { typealias Resolver = (pid_t) -> (bundleId: String, name: String)? @@ -30,6 +31,7 @@ final class AppIdentityCache { private var pidToBundleId: [pid_t: String] = [:] private var bundleIdToName: [String: String] = [:] private var pendingResolutions: Set = [] + private var unresolvablePIDs: Set = [] private let resolver: Resolver private let dispatcher: AppIdentityDispatcher @@ -39,15 +41,11 @@ final class AppIdentityCache { } func identity(forPID pid: pid_t?) -> AppIdentity { - if let pid = pid, pid > 0 { - if let cached = cachedIdentity(forPID: pid) { - return cached - } - scheduleResolution(forPID: pid) + let (identity, pidNeedingResolution) = lookupLocked(pid: pid) + if let pid = pidNeedingResolution { + dispatchResolution(forPID: pid) } - lock.lock() - defer { lock.unlock() } - return frontmost + return identity } func updateFrontmost(bundleId: String, name: String, pid: pid_t?) { @@ -58,6 +56,7 @@ final class AppIdentityCache { } if let pid = pid, pid > 0 { pidToBundleId[pid] = bundleId + unresolvablePIDs.remove(pid) } lock.unlock() } @@ -68,23 +67,27 @@ final class AppIdentityCache { return frontmost } - private func cachedIdentity(forPID pid: pid_t) -> AppIdentity? { + /// 在单次锁块内完成缓存命中判定、待解析 PID 登记和 frontmost 读取, + /// 减少事件 tap 回调路径上的锁抖动。返回的第二项非 nil 时,调用方 + /// 需在锁外派发后台解析(派发本身不能持锁,避免占锁过久)。 + private func lookupLocked(pid: pid_t?) -> (AppIdentity, pid_t?) { lock.lock() defer { lock.unlock() } - guard let bundleId = pidToBundleId[pid] else { return nil } - let name = bundleIdToName[bundleId] ?? "" - return AppIdentity(bundleId: bundleId, displayName: name) - } - - private func scheduleResolution(forPID pid: pid_t) { - lock.lock() - if pendingResolutions.contains(pid) || pidToBundleId[pid] != nil { - lock.unlock() - return + guard let pid = pid, pid > 0 else { + return (frontmost, nil) + } + if let bundleId = pidToBundleId[pid] { + let name = bundleIdToName[bundleId] ?? "" + return (AppIdentity(bundleId: bundleId, displayName: name), nil) + } + if unresolvablePIDs.contains(pid) || pendingResolutions.contains(pid) { + return (frontmost, nil) } pendingResolutions.insert(pid) - lock.unlock() + return (frontmost, pid) + } + private func dispatchResolution(forPID pid: pid_t) { dispatcher.dispatch { [weak self] in guard let self = self else { return } let result = self.resolver(pid) @@ -95,6 +98,8 @@ final class AppIdentityCache { if !result.name.isEmpty { self.bundleIdToName[result.bundleId] = result.name } + } else { + self.unresolvablePIDs.insert(pid) } self.lock.unlock() } diff --git a/KeyStatsTests/AppIdentityCacheTests.swift b/KeyStatsTests/AppIdentityCacheTests.swift index ed328f8..69fc0ef 100644 --- a/KeyStatsTests/AppIdentityCacheTests.swift +++ b/KeyStatsTests/AppIdentityCacheTests.swift @@ -126,7 +126,7 @@ final class AppIdentityCacheTests: XCTestCase { XCTAssertEqual(spy.calls, [42]) } - func testResolverFailureDoesNotCachePIDAndAllowsRetry() { + func testResolverFailureIsNegativelyCachedToAvoidRepeatedWork() { let spy = ResolverSpy() spy.response = { _ in nil } let dispatcher = ManualDispatcher() @@ -135,10 +135,28 @@ final class AppIdentityCacheTests: XCTestCase { _ = cache.identity(forPID: 42) dispatcher.drain() + // 对无法解析的 PID,后续查询不应反复堆积后台任务; + // 避免某些系统进程或 XPC 服务持续触发无效解析。 + _ = cache.identity(forPID: 42) + _ = cache.identity(forPID: 42) + XCTAssertTrue(dispatcher.pending.isEmpty, "解析失败后不应再派发解析任务") + XCTAssertEqual(spy.calls, [42], "resolver 不应被重复调用") + } + + func testUpdateFrontmostClearsNegativeCacheForPID() { + let spy = ResolverSpy() + spy.response = { _ in nil } + let dispatcher = ManualDispatcher() + let (cache, _, _) = makeCache(dispatcher: dispatcher, spy: spy) + _ = cache.identity(forPID: 42) - XCTAssertEqual(dispatcher.pending.count, 1, "resolver 返回 nil 后允许后续查询重试") dispatcher.drain() - XCTAssertEqual(spy.calls, [42, 42]) + + // PID 可能被系统回收给新进程,前台切换带来的新信息应覆盖负缓存, + // 让该 PID 再次参与正常解析路径。 + cache.updateFrontmost(bundleId: "com.recycled", name: "Recycled", pid: 42) + let result = cache.identity(forPID: 42) + XCTAssertEqual(result, AppIdentity(bundleId: "com.recycled", displayName: "Recycled")) } // MARK: - updateFrontmost From c098a5ff2fd637b36418fd966f6bff0d9aee03b0 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Wed, 22 Apr 2026 12:18:42 +0800 Subject: [PATCH 4/4] fix(input-monitor): return nil on tap-disable to avoid NULL-event crash Apple's docs note that the event parameter is NULL when the tap callback is invoked with a tap-disabled notification. Swift maps CGEventTapCallBack to a non-optional CGEvent, so calling Unmanaged.passUnretained on a NULL event is a latent crash. The disable path is purely a state signal, so returning nil matches the notification semantics and is safe. Addresses: https://github.com/debugtheworldbot/keyStats/pull/106#discussion_r3121512816 --- KeyStats/InputMonitor.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/KeyStats/InputMonitor.swift b/KeyStats/InputMonitor.swift index 910d952..64bcadb 100644 --- a/KeyStats/InputMonitor.swift +++ b/KeyStats/InputMonitor.swift @@ -71,7 +71,10 @@ class InputMonitor { if let tap = InputMonitor.shared.eventTap { CGEvent.tapEnable(tap: tap, enable: true) } - return Unmanaged.passUnretained(event) + // tap-disable 通知中 event 参数按 Apple 文档可能为 NULL, + // Swift 把它映射成非可选 CGEvent 会误导 Unmanaged.passUnretained, + // 直接返回 nil 既安全也符合通知语义。 + return nil } InputMonitor.shared.handleEvent(type: type, event: event) return Unmanaged.passUnretained(event)