Project Type: macOS Native Menu Bar Application Language: Swift 5.0 Target: macOS 13.0+ (Ventura) Architecture: Event-driven singleton pattern with AppKit UI
KeyStats is a privacy-focused macOS menu bar app that tracks keyboard/mouse statistics (counts only, no content logging). Core components:
InputMonitor: Global event tap for keyboard/mouse monitoringStatsManager: Data aggregation and persistence (UserDefaults)MenuBarController: Status bar UI with compact dual-line displayStatsPopoverViewController: Detailed statistics panel
Privacy First: NEVER log actual keystrokes, mouse positions, or user input content - only aggregate counts and distances.
1. Read the relevant file(s) first
2. Check existing patterns and naming conventions
3. Verify Swift version compatibility (5.0+)
4. Consider thread safety (main vs background threads)
5. Check if changes affect permissions or privacy
New feature request?
├─ UI-related?
│ ├─ Menu bar display? → Update MenuBarController
│ └─ Detail panel? → Update StatsPopoverViewController
├─ Statistics tracking?
│ ├─ New metric? → Update StatsManager.Stats struct + persistence
│ └─ New event type? → Update InputMonitor event callbacks
├─ Data persistence? → Update StatsManager Codable conformance
└─ Permissions needed? → Update Info.plist + AppDelegate
Issue type?
├─ No statistics updating?
│ ├─ Check: AXIsProcessTrusted() returns true
│ ├─ Check: InputMonitor.isMonitoring is true
│ └─ Check: Event tap is active (not nil)
├─ UI not updating?
│ ├─ Verify: Updates on DispatchQueue.main
│ └─ Check: menuBarUpdateHandler is set
├─ Data not persisting?
│ └─ Check: StatsManager.saveStats() called on changes
└─ Performance issues?
└─ Review: Event sampling rates and debounce timers
- ✅ Only track counts and distances, NEVER content
- ✅ Always check accessibility permissions before monitoring
- ✅ Use
weakreferences for delegates/closures to prevent leaks - ✅ Dispatch UI updates on
DispatchQueue.main - ✅ Clean up event taps in
stopMonitoring()anddeinit
- ✅ One class per file, filename matches class name
- ✅ Use
// MARK: -for code organization - ✅ Use
guardfor early returns and validation - ✅ Use descriptive names, avoid magic numbers
- ✅ Localize user-facing strings with
NSLocalizedString() - ✅ Ensure UI colors adapt to dark mode (use dynamic colors +
resolvedCGColor/resolvedColor) - ✅ When adding a new page/window/popover, add matching analytics at the same time: a
pageviewfor the page itself andclickevents for key entry/actions, reusing the shared helper and stable event/property names
- ✅
CALayer.backgroundColor/borderColoruseCGColor(a static snapshot) and do not automatically follow appearance changes - ✅ Do not cache
NSColor.controlBackgroundColor.withAlphaComponent(...)and reuse it across updates (especially when launched in dark mode), as it may lock in the old appearance - ✅ For "dynamic system color + alpha", always resolve under the current
effectiveAppearanceusing a helper, e.g.resolvedCGColor(color, alpha:for:) - ✅ Re-assign layer colors on every theme change; do not rely on existing
CGColorvalues to auto-update - ✅ Prefer multi-source appearance refresh triggers:
AppearanceTrackingView,NSApp.effectiveAppearance,AppleInterfaceThemeChangedNotification, andNSApplication.didBecomeActiveNotification - ✅ When debugging theme issues, log
app/view/windowappearance plus final layer RGBA first to distinguish "trigger path issues" from "color resolution issues"
- ✅ Document public APIs with
///comments - ✅ Use
privatefor internal implementation details - ✅ Implement Codable for data structures needing persistence
- ✅ Batch UI updates to reduce main thread blocking
# Development (Xcode - recommended)
open KeyStats.xcodeproj
# Press ⌘R to build and run
# Command line (Debug)
xcodebuild -project KeyStats.xcodeproj -scheme KeyStats -configuration Debug build
# Command line (Release)
xcodebuild -project KeyStats.xcodeproj -scheme KeyStats -configuration Release build# Create DMG for distribution
./scripts/build_dmg.sh# Currently no automated tests
# When adding: Use XCTest framework in separate Tests target
xcodebuild test -project KeyStats.xcodeproj -scheme KeyStatsclass StatsManager {
static let shared = StatsManager()
private init() {
// Load from persistence
}
}// Check permission status
let trusted = AXIsProcessTrusted()
// Request permissions with prompt
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
AXIsProcessTrustedWithOptions(options as CFDictionary)DispatchQueue.main.async {
self.updateMenuBarDisplay()
self.menuBarUpdateHandler?()
}let eventMask = (1 << CGEventType.keyDown.rawValue) |
(1 << CGEventType.leftMouseDown.rawValue)
eventTap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(eventMask),
callback: eventCallback,
userInfo: nil
)private var updateTimer: Timer?
func scheduleDebouncedStatsUpdate() {
updateTimer?.invalidate()
updateTimer = Timer.scheduledTimer(
withTimeInterval: 0.5,
repeats: false
) { [weak self] _ in
self?.updateMenuBar()
}
}struct Stats: Codable {
var keyPresses: Int = 0
var leftClicks: Int = 0
// ... other properties
}
func saveStats() {
if let encoded = try? JSONEncoder().encode(currentStats) {
UserDefaults.standard.set(encoded, forKey: "currentStats")
}
}
func loadStats() -> Stats? {
guard let data = UserDefaults.standard.data(forKey: "currentStats") else { return nil }
return try? JSONDecoder().decode(Stats.self, from: data)
}KeyStats/
├── AppDelegate.swift
│ ├─ App lifecycle & menu bar setup
│ ├─ Permission checking & request handling
│ └─ Window/status bar initialization
│
├── InputMonitor.swift
│ ├─ Global event tap creation (CGEvent.tapCreate)
│ ├─ Keyboard event handling (keyDown)
│ ├─ Mouse event handling (left/right clicks, movement)
│ └─ 30Hz mouse sampling for performance
│
├── StatsManager.swift
│ ├─ Statistics data model (Codable struct)
│ ├─ Data aggregation & calculation
│ ├─ UserDefaults persistence
│ ├─ Daily auto-reset at midnight
│ └─ Debounced UI update callbacks
│
├── MenuBarController.swift
│ ├─ NSStatusItem management
│ ├─ Dual-line compact display (keyPresses/clicks)
│ ├─ Number formatting (K/M suffixes)
│ └─ Popover presentation trigger
│
└── StatsPopoverViewController.swift
├─ Detailed statistics display (all metrics)
├─ Reset button handling
└─ Quit button handling
- Update Stats struct in
StatsManager.swift:
struct Stats: Codable {
var newMetric: Int = 0 // Add new property
// ... existing properties
}- Add tracking logic in
InputMonitor.swift:
private let eventCallback: CGEventTapCallBack = { proxy, type, event, refcon in
// ... existing logic
StatsManager.shared.incrementNewMetric() // Add call
}- Add increment method in
StatsManager.swift:
func incrementNewMetric() {
currentStats.newMetric += 1
scheduleDebouncedStatsUpdate()
}- Update UI in
StatsPopoverViewController.swift:
// Add label and update in refreshStats()
newMetricLabel.stringValue = "\(stats.newMetric)"Edit MenuBarController.updateMenuBarText():
func updateMenuBarText(keyPresses: Int, mouseClicks: Int) {
let line1 = formatNumber(keyPresses) // Top line
let line2 = formatNumber(mouseClicks) // Bottom line
// Update attributed string
}Edit StatsManager.resetStats():
func resetStats() {
currentStats = Stats() // Reset to defaults
saveStats() // Persist immediately
updateMenuBar() // Update UI
}- Prefer soft surfaces: use
controlBackgroundColorwith alpha ~0.6–0.85 for panels/cards - Avoid heavy borders: use thin 0.5pt separators with low alpha instead of 1pt strokes
- Use subtle shadows: small radius, low opacity, slight upward offset
- Keep corners consistent: 10–12pt for cards, smaller (6–8pt) for compact elements
- Always resolve dynamic colors with
resolvedCGColor(...)for dark mode consistency
private func applyGlassCardStyle(_ layer: CALayer?, for view: NSView) {
guard let layer = layer else { return }
layer.masksToBounds = false
layer.shadowColor = resolvedCGColor(NSColor.black.withAlphaComponent(0.07), for: view)
layer.shadowOpacity = 1
layer.shadowRadius = 8
layer.shadowOffset = NSSize(width: 0, height: -1)
layer.borderWidth = 0.5
layer.borderColor = resolvedCGColor(NSColor.separatorColor.withAlphaComponent(0.16), for: view)
}- Event callbacks: Run on background threads → dispatch UI updates to main
- UI updates: ALWAYS use
DispatchQueue.main.async - Timers: Run on RunLoop → ensure main thread for UI-affecting timers
- Mouse sampling: 30Hz (1/30 second) instead of every event
- Debounced saves: 500ms delay to batch rapid changes
- Lazy UI updates: Only refresh when popover is visible
// In code
let title = NSLocalizedString("stats.title", comment: "")
// In Localizable.strings (English)
"stats.title" = "Statistics";
// In zh-Hans.strings (Chinese)
"stats.title" = "统计数据";- English (default)
- 简体中文 (zh-Hans)
- Build succeeds (⌘B in Xcode)
- App runs without crashes
- Accessibility permission prompt works
- Statistics update in real-time
- Menu bar display formats correctly
- Data persists across app restarts
- Daily reset works at midnight
- No force unwraps added (use
if letorguard) - No retain cycles (use
[weak self]in closures) - UI updates on main thread
- Grant accessibility permission
- Type and click to verify counter increments
- Check menu bar display updates
- Open popover to verify detailed stats
- Test reset button
- Quit and relaunch to verify persistence
- Wait past midnight to verify auto-reset
// Mouse sampling rate
private let mouseSampleInterval: TimeInterval = 1.0 / 30.0 // 30Hz
// Debounce delay for stats updates
private let updateDebounceDelay: TimeInterval = 0.5 // 500ms
// Number formatting thresholds
let thousandThreshold = 1_000
let millionThreshold = 1_000_000
// UserDefaults keys
let statsKey = "currentStats"
let lastResetDateKey = "lastResetDate"- README.md - Chinese documentation
- README_EN.md - English documentation
- QUICKSTART.md - Quick start guide
- Read files before making suggestions
- Follow existing patterns (singleton, weak delegates, main thread UI)
- Check for thread safety implications
- Verify privacy compliance (no content logging)
- Match existing code style and naming
- Use
// MARK:sections for organization - Add
weakto delegate/closure references - Localize user-facing strings
- Document public methods with
///
- Check permission status first
- Verify event tap is active
- Confirm main thread for UI updates
- Review debounce timers and sampling rates
- Maintain backward compatibility with UserDefaults keys
- Keep singleton patterns intact
- Preserve thread safety
- Update all UI references if changing data models