KhueApps
Home/Python/Track Per‑App Usage Time on macOS with Python (PyObjC)

Track Per‑App Usage Time on macOS with Python (PyObjC)

Last updated: October 07, 2025

Overview

Track how long each app is active (frontmost) on macOS using Python. This script polls the current frontmost application via NSWorkspace and uses Quartz to detect user idle time. It aggregates per‑app durations, prints a summary, and can be extended to log to CSV.

  • Platform: macOS
  • Language: Python
  • Libraries: PyObjC (AppKit, Quartz)
  • Output: Console summary (hh:mm:ss), optional CSV

Quickstart

  1. Ensure Python 3.9+ is installed (python3 --version).
  2. Create and activate a virtual environment.
  3. Install required packages.
  4. Save the minimal script below.
  5. Run it, switch between apps, then press Ctrl+C to stop and view the report.

Commands

  • Create venv: python3 -m venv .venv && source .venv/bin/activate
  • Install deps: pip install pyobjc-framework-AppKit pyobjc-framework-Quartz
  • Run: python app_usage.py

Minimal working example

Save as app_usage.py and run with python app_usage.py.

#!/usr/bin/env python3
import time
import signal
import atexit
from collections import defaultdict

from AppKit import NSWorkspace
from Quartz import (
    CGEventSourceSecondsSinceLastEventType,
    kCGEventSourceStateCombinedSessionState,
    kCGAnyInputEventType,
)

IDLE_THRESHOLD = 300  # seconds considered "idle" (adjust as needed)
POLL_INTERVAL = 1.0   # how often to sample the active app (seconds)

_totals = defaultdict(float)
_current_label = None
_last_ts = time.time()


def active_app_name() -> str:
    ws = NSWorkspace.sharedWorkspace()
    app = ws.frontmostApplication()
    if app is None:
        return "Unknown"
    name = app.localizedName()
    if name:
        return str(name)
    bid = app.bundleIdentifier()
    return str(bid) if bid else "Unknown"


def idle_seconds() -> float:
    return CGEventSourceSecondsSinceLastEventType(
        kCGEventSourceStateCombinedSessionState,
        kCGAnyInputEventType,
    )


def _switch_to(label: str | None) -> None:
    global _current_label, _last_ts
    now = time.time()
    if _current_label is not None:
        _totals[_current_label] += (now - _last_ts)
    _current_label = label
    _last_ts = now


def _summarize() -> None:
    # close the last segment
    _switch_to(None)
    items = sorted(
        ((k, v) for k, v in _totals.items() if k),
        key=lambda kv: kv[1],
        reverse=True,
    )
    print("\nUsage summary (hh:mm:ss):")
    for name, seconds in items:
        h = int(seconds // 3600)
        m = int((seconds % 3600) // 60)
        s = int(seconds % 60)
        print(f"{name:30s} {h:02d}:{m:02d}:{s:02d}")


atexit.register(_summarize)
signal.signal(signal.SIGINT, lambda s, f: exit(0))


def main() -> None:
    global _current_label, _last_ts
    _current_label = None
    _last_ts = time.time()
    while True:
        label = active_app_name()
        if idle_seconds() >= IDLE_THRESHOLD:
            label = "Idle"
        if label != _current_label:
            _switch_to(label)
        time.sleep(POLL_INTERVAL)


if __name__ == "__main__":
    main()

What you get:

  • Live tracking of the active (frontmost) app name.
  • Idle time is grouped under "Idle".
  • A final per‑app summary when you press Ctrl+C.

How it works

  • NSWorkspace.frontmostApplication(): returns the currently active app (NSRunningApplication). We read localizedName (fallback to bundle ID).
  • Quartz CGEventSourceSecondsSinceLastEventType: exposes time since last input event. If above a threshold, we count time under "Idle".
  • The loop samples once per second and closes segments when the active label changes.

Extend: write a CSV log

Add this function and call it periodically (e.g., every N seconds) or at exit.

import csv, os

CSV_PATH = os.path.expanduser("~/app-usage.csv")


def write_csv(totals: dict[str, float], path: str = CSV_PATH) -> None:
    rows = [(name, round(seconds, 1)) for name, seconds in totals.items() if name]
    rows.sort(key=lambda r: r[1], reverse=True)
    header = ["app", "seconds"]
    write_header = not os.path.exists(path)
    with open(path, "a", newline="") as f:
        w = csv.writer(f)
        if write_header:
            w.writerow(header)
        for name, secs in rows:
            w.writerow([name, secs])

# Example: call at exit
# atexit.register(lambda: write_csv(_totals))

CSV columns:

ColumnMeaning
appLocalized app name (or bundle ID fallback)
secondsTotal active seconds in this run

Setup steps (condensed)

  1. Create venv: python3 -m venv .venv
  2. Activate: source .venv/bin/activate
  3. Install: pip install pyobjc-framework-AppKit pyobjc-framework-Quartz
  4. Save script: app_usage.py
  5. Run: python app_usage.py
  6. Switch between apps; press Ctrl+C to see the summary.

Pitfalls and permissions

  • Sampling resolution: With POLL_INTERVAL=1s, very fast app switches (<1s) may be missed. Lower to 0.2–0.5s if you need finer granularity (slightly higher CPU).
  • Idle definition: Idle is based on lack of input events, not screen lock or sleep explicitly. Long sleep/lock periods accrue under "Idle".
  • App naming: localizedName can differ by locale and app variant. For stable grouping across runs, consider using bundleIdentifier.
  • Multiple desktops/spaces: NSWorkspace reports the active app for the currently active Space. Background Space activity is not tracked.
  • Permissions: This approach typically needs no extra permissions.
    • We do not read window titles or pixels, so Screen Recording is not required.
    • AppleScript/System Events is not used, so Automation prompts are avoided.
FeaturePermissionWhen needed
Frontmost app via NSWorkspaceNoneNot needed for this script
Idle time via QuartzNoneNot needed for this script
Window titles via CGWindowListScreen RecordingOnly if you later add window‑level tracking

Performance notes

  • Polling cost: A 1 Hz poll with PyObjC calls is typically negligible (<1% CPU on modern Macs). Avoid heavy work inside the loop.
  • Reduce wakeups: Increase POLL_INTERVAL if second‑level resolution isn’t required. For minute‑level reports, 2–5s is often sufficient.
  • Event‑driven alternative: NSWorkspace posts an activation notification when the active app changes. Listening to that (plus idle detection) can reduce CPU further. Polling remains simplest and is robust.
  • I/O: If logging frequently, buffer writes and flush periodically (e.g., every 60s) to avoid excessive disk I/O.

Small tweaks

  • Track bundle IDs:
    • Replace name = app.localizedName() with bid = app.bundleIdentifier() and use that as the label.
  • Separate idle bucket: Keep "Idle" if you want to see inactive time; omit it from totals if you only want active apps.
  • Export JSON: Instead of CSV, dump a dict of {app: seconds} for integration with dashboards.

FAQ

  • Can I track per‑window usage?
    • Yes, but you’ll need CGWindow APIs and likely Screen Recording permission. This script focuses on per‑app time.
  • Will this track background audio apps?
    • No. It tracks the frontmost app only, not background activity.
  • Does it work on Intel and Apple Silicon?
    • Yes. PyObjC supports both; ensure you use a matching Python build for your architecture.
  • Can I run it in the background?
    • Yes. Run it in a terminal multiplexer (tmux) or as a launchd agent; ensure the process stays alive.
  • How accurate is it?
    • Accuracy is bounded by POLL_INTERVAL and idle threshold. For most workflows, 0.5–1s polling is sufficient.

Series: Automate boring tasks with Python

Python