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..20e660a 100644 --- a/KeyStats/AppStats.swift +++ b/KeyStats/AppStats.swift @@ -1,5 +1,111 @@ 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 var unresolvablePIDs: 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 { + let (identity, pidNeedingResolution) = lookupLocked(pid: pid) + if let pid = pidNeedingResolution { + dispatchResolution(forPID: pid) + } + return identity + } + + 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 + unresolvablePIDs.remove(pid) + } + lock.unlock() + } + + var currentFrontmost: AppIdentity { + lock.lock() + defer { lock.unlock() } + return frontmost + } + + /// 在单次锁块内完成缓存命中判定、待解析 PID 登记和 frontmost 读取, + /// 减少事件 tap 回调路径上的锁抖动。返回的第二项非 nil 时,调用方 + /// 需在锁外派发后台解析(派发本身不能持锁,避免占锁过久)。 + private func lookupLocked(pid: pid_t?) -> (AppIdentity, pid_t?) { + lock.lock() + defer { lock.unlock() } + 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) + 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) + 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 + } + } else { + self.unresolvablePIDs.insert(pid) + } + self.lock.unlock() + } + } +} + struct AppStats: Codable { var bundleId: String var displayName: String diff --git a/KeyStats/InputMonitor.swift b/KeyStats/InputMonitor.swift index d8f2388..64bcadb 100644 --- a/KeyStats/InputMonitor.swift +++ b/KeyStats/InputMonitor.swift @@ -67,6 +67,15 @@ 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) + } + // tap-disable 通知中 event 参数按 Apple 文档可能为 NULL, + // Swift 把它映射成非可选 CGEvent 会误导 Unmanaged.passUnretained, + // 直接返回 nil 既安全也符合通知语义。 + return nil + } 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 "" } diff --git a/KeyStatsTests/AppIdentityCacheTests.swift b/KeyStatsTests/AppIdentityCacheTests.swift new file mode 100644 index 0000000..69fc0ef --- /dev/null +++ b/KeyStatsTests/AppIdentityCacheTests.swift @@ -0,0 +1,212 @@ +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 testResolverFailureIsNegativelyCachedToAvoidRepeatedWork() { + let spy = ResolverSpy() + spy.response = { _ in nil } + let dispatcher = ManualDispatcher() + let (cache, _, _) = makeCache(dispatcher: dispatcher, spy: spy) + + _ = 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) + dispatcher.drain() + + // 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 + + 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"] ) ] )