Skip to content
Open
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
80 changes: 19 additions & 61 deletions KeyStats/AppActivityTracker.swift
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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) {
Expand All @@ -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
)
}
}
106 changes: 106 additions & 0 deletions KeyStats/AppStats.swift
Original file line number Diff line number Diff line change
@@ -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<pid_t> = []
private var unresolvablePIDs: Set<pid_t> = []
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
}
Comment thread
debugtheworldbot marked this conversation as resolved.

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)
Comment thread
debugtheworldbot marked this conversation as resolved.
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
Expand Down
9 changes: 9 additions & 0 deletions KeyStats/InputMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ class InputMonitor {

// 创建事件回调
let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? 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)
}
Expand Down
7 changes: 7 additions & 0 deletions KeyStats/StatsModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 "" }
Expand Down
Loading