Omahub
← All plugins
R

Screen Time

by ricky

Daily screen time tracking with a GitHub-style contribution heatmap. Digital Wellbeing for your Omarchy bar, fully local.

Security review

No obvious issues detected

Deterministic scan — not a security guarantee

None
Risk level
None
Analyzed commit
1f6526d
Scanned
1 month ago

No potentially dangerous behavior detected in the analyzed commit.

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

Language-model assessment · ~deepseek/deepseek-v4-flash-latest — advisory only

Low
AI risk level
Low
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
1f6526d
Reviewed
1 month ago

No malicious, obfuscated, or destructive behavior was found; the deterministic scan's 'none' result matches this review. The plugin is a transparent, local-only screen-time tracker that reads focused app/window info, window titles, and per-app TCP byte counts, all disclosed in the README and manifest. The only notable consideration is privacy-sensitive local data collection, especially the default 'full' detail level that records browser site labels and terminal program names.

  • Default detailLevel 'full' records browser site labels (e.g. YouTube, Google Search) and terminal program/directory names; this is disclosed but is sensitive local data if the history file is ever exposed.
  • The service runs for the whole session (keepLoaded) and periodically spawns Python helpers, so it has a persistent but modest resource footprint.
  • No network exfiltration, install-time code, obfuscation, or destructive commands were found.
How this check works

This review combines the deterministic scan (the rule-based results above) with an independent look at the plugin's code by a language model. The model reads a trimmed sample of the repository's files, the manifest, and the README, then gives a plain-language risk level and a recommendation: install (no notable danger), review (look closer first), or avoid (clearly dangerous).

It runs on the same analyzed commit as the deterministic scan and is strictly advisory — it is not a security guarantee and never blocks a plugin by itself. A human moderator still reviews plugins before they are listed.

AI advisory only — automated analysis, not a security guarantee.

Install
$ omarchy plugin add https://github.com/srineshr1/omarchy-screentime --enable
Productivity #system

Screen Time

Daily screen time for the Omarchy bar, drawn as a GitHub-style grid of daily boxes. Digital Wellbeing / Apple Screen Time, in your top bar, entirely local.

The bar shows today's total next to a strip of the last seven days. Clicking it opens a panel with the per-app breakdown, six months of daily boxes, and the last seven days as bars.

Preview

Screen Time in the Omarchy bar, with the popup panel open showing the per-app
breakdown and the six-month contribution grid

 Screen Time                        1 day under limit   ⟳
 16m
 5H 43M LEFT TODAY
 ────────────────────────────────────────────────────────
 TODAY                            D 1.2G  U 45M · 16m
 Cyberpunk 2077                   D 890M  U 12M    13m
 Ghostty                          D 4.2M  U 1.1M   13m
 Helium                           D 310M  U 32M     2m
 Spotify                          D 8.4M  U 210K   21s

 Mar – Aug 2026                     16m · 16m/day   ‹  ›
       Mar        Apr      May      Jun      Jul     Aug
 Mon  ▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▪
 Wed  ▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫
 Fri  ▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫
 less ▫▪▪▪▪▪ more · over 6h

Why another screen time plugin

Two others exist (agx.screen-time, omasot) and both are good. This one is built around a different picture: a contribution grid, one box per day, the thing you already read at a glance on a GitHub profile. Boxes are shaded against a daily limit you set, and a day that went over it turns your theme's urgent colour instead of getting "greener". More is not better here.

Features

Daily boxes Six months of days as a GitHub-style grid. Cells resize to fit the panel, so the newest day is never clipped.
Shaded against a limit Intensity is a share of your daily limit, and over-limit days go urgent-red. Optional traffic palette ramps green → amber → red.
Click any day Opens that day's per-app breakdown. Click again, or press t, to go back to today.
Per-app breakdown Today's apps with share bars, resolved to real names from .desktop entries (com.mitchellh.ghostty → Ghostty).
Games by name Steam reports a window as steam_app_1091500; the game names itself in its own manifest, so the row reads Cyberpunk 2077.
Data per app Every row carries what that app downloaded and uploaded — D 890M U 12M — with day, week and all-time totals. No root, no kernel module.
What you were actually doing A terminal reports the program running in it, not the terminal: opencode 20m, claude 14m, nvim 8m, or the working directory when a shell is idle. Browsers report the site: YouTube 1h 30m.
Openable folders The breakdown is a tree. Each app is a folder you click open to see what ran inside it — Ghostty → grok, kiro-cli, workflows. Unresolved time inside a folder shows as other, so the children always add up to the parent.
Limit tracking A progress bar toward the limit, time left, and a streak of consecutive days under it.
Last 7 days Clickable bars, today emphasised, zero days shown as a faint rule rather than a stub.
Idle-aware The clock stops after idleTimeoutSec with no input, and when no window has focus. A bare desktop is not usage.
Video still counts Idle inhibitors are respected, so a two-hour film with no keypresses is still two hours of screen time.
Screensaver never counts The screensaver holds an idle inhibitor while it runs, so it is excluded by app id, along with lock screens.
Suspend-safe A sample that arrives far later than its interval is treated as a gap and dropped, not billed as hours. A backwards clock step is dropped too.
Midnight-safe A session running through midnight splits across both days by wall clock, not by whenever the next commit happens.
Bar modes Right-click cycles boxes + time → time only → icon only. Remembered on the widget entry.
Terminal CLI screentime today, week, year render in the terminal, including an ASCII heatmap.
Local only One JSON file. No network, no telemetry, no accounts.

Install

omarchy plugin add https://github.com/srineshr1/omarchy-screentime.git
omarchy plugin enable io.github.ricky.screentime

Needs Omarchy Quattro (the Quickshell shell) and python3, which Omarchy already ships. A Nerd Font provides the glyph.

Using it

Action What happens
Left click Open / close the panel
Middle click Commit buffered time and refresh now
Right click Cycle the bar display mode
Click a box Show that day's per-app breakdown
t Back to today
a Expand / collapse the full app list
g Open / close every app folder
[ ] Move the window one month back / forward
r Refresh
Esc Close

Summon it from a keybind:

omarchy-shell io.github.ricky.screentime toggle

Settings

Configurable from Setup > Plugins, or inline on the widget's entry in ~/.config/omarchy/shell.json.

Key Default What it does
dailyGoalHours 6 Your daily limit. Box shading and the over-limit colour scale against it.
historyMonths 6 Months of daily boxes in the panel. Fewer months means bigger boxes.
idleTimeoutSec 120 No input for this long stops the clock.
barMode strip strip (boxes + time), total (time only), icon (glyph only).
gridPalette accent accent for GitHub's single hue, traffic for green → amber → red.
detailLevel full off for app names only, terminal to also record what runs in terminals, full to also record which site a browser is on.
weekStartsMonday true Set false for Sunday-first rows, like GitHub.
ignoredApps "" Extra app ids never to count, comma-separated. A trailing * matches a prefix (steam_app_*). Screensaver and lock screens are always ignored.
detailRetentionDays 120 How long per-app detail is kept. Daily totals are kept forever.
trackNetwork true Record how much each app downloaded and uploaded. Set false to stop sampling the socket table entirely.

How time is counted

The service watches the compositor's focused toplevel and accrues seconds against that app. Elapsed time is credited to the app that held focus during the interval, not whatever is focused when the timer fires, so switching windows attributes cleanly.

Nothing accrues while the seat is idle, while no window has focus, or while the focused surface is a screensaver or lock screen.

Seconds accumulate in memory and are handed to bin/screentime commit every 60 seconds, on wake from idle, at midnight, and on shutdown. That helper is the only writer of history.json and it writes atomically via a temp file and rename, so a crash costs at most one 60-second batch and can never leave a torn store. The network sampler writes only its own small state file, and both take the same lock, so two overlapping runs cannot lose each other's numbers.

Known limit: idle is detected by timeout, so up to idleTimeoutSec of the period after you walk away is still counted. Lower it if that bothers you.

What you were actually doing

"Ghostty 4h" is not a useful sentence. bin/resolve-focus turns the focused window into the thing you care about, using the window title first:

Title Row
opencode opencode
Refactor the tracker - claude claude
~/Projects/Screentime Screentime
…/temp/ComfyUI/workflows workflows
Never Gonna Give You Up - YouTube - Helium YouTube
*Unsaved Workflow - ComfyUI - Helium ComfyUI

Terminals put the running program or the working directory in their title, and browsers put the page title there. The title is the primary signal because it is the only one that survives a terminal that keeps every window in one process: Ghostty runs all of its windows under a single pid, so walking that pid's children cannot tell one window from another.

When a terminal's title is uninformative — a bare foot, or Omarchy's floating terminal calling itself Omarchy — the resolver falls back to the process tree, picking the foreground process group under the window's pid. If several windows share that pid and disagree, it reports nothing rather than guessing.

Two things it deliberately does not do:

  • The page title never reaches disk. Only the trailing site segment is kept, so how to treat a rash - Google Search is stored as Google Search and a video is stored as YouTube. What you searched for or watched is dropped.
  • Unrecognised pages get no label. A page with no site segment is counted as plain browser time instead of creating one row per page, which would both bloat the store and record what you were reading.

Set detailLevel to terminal to keep terminal detail but stop looking at browser titles, or off to record nothing but app names.

Detail is stored as appId/detail, so the daily totals the heatmap draws are unchanged by it, and g in the panel rolls detail back up into per-app totals.

Games by name

Hyprland reports a Steam game's window as steam_app_1091500, and that is the id the store keeps, since it is stable and Steam's own. For display, the id is looked up in the game's appmanifest_1091500.acf — the file Steam already keeps next to the install — so the row reads Cyberpunk 2077. Every library listed in libraryfolders.vdf is searched, including a second drive and Flatpak's Steam, and an uninstalled game falls back to Steam app 1091500 rather than losing its hours. Nothing is fetched from the network.

Data per app

Each row also shows what that app moved: D 890M U 12M. Day, week and all-time totals sit in the panel's header lines, and screentime net prints the same breakdown in a terminal.

It works without root and without a kernel module. ss -tinep reports every TCP socket you own along with the pid holding it and that socket's lifetime byte counters. The plugin samples that every 15 seconds, diffs each socket against its previous reading, and walks the owning pid up the process tree until it hits a process that owns a window — so a browser's network process, a game's helper and Steam's web helper all land on the app you would name them by, keyed exactly like screen time. A socket with no window above it is filed under its own process name, which is what you want to read for a daemon or a CLI download.

Sockets belonging to another user — the system resolver, a VPN daemon — do not expose a pid to you, so their bytes go in one System row rather than being dropped.

The kernel keeps byte counters on TCP sockets and not on UDP ones, so a browser talking QUIC / HTTP3 is invisible to the socket table — and a browser is exactly what moves the most data. Rather than let that traffic vanish, each sample is reconciled against the interface counters in /proc/net/dev, and whatever the wire moved that no socket accounted for lands in one Other traffic row. So the day total is what your ISP would agree with, Steam 2.4 GB is exact, and an hour of YouTube shows up as Other traffic instead of as nothing at all.

Only real devices are summed: a NIC has a device link under /sys/class/net and a tunnel or a container's veth pair does not. That is not tidiness — a VPN's bytes are also counted on the interface carrying them, so adding both would double every one of them.

Three things to know about the numbers:

  • Other traffic is not only QUIC. Packet headers are in there too, which is why it is never exactly zero even on a machine with no HTTP/3 at all.
  • QUIC cannot be attributed to an app. Nothing short of root can say which process moved a UDP byte, and guessing would put invented figures in a report people use to check a data cap.
  • The tail of a connection is lost from its app. Bytes moved after the last sample that saw a TCP socket go with it when it closes; the interface counters still see them, so they resurface under Other traffic rather than going missing.

Set trackNetwork to false to stop sampling the socket table at all.

Sampling runs whether or not the seat is idle: a download finishing while the screen is locked is still data you used, even though it is not screen time. Bytes buffer in a small side file and are folded into the store by the same 60-second commit that banks screen time, so the store is not rewritten on the sampling cadence.

Reading from helpers

The shell runs for the whole session, so anything it reads from a helper has to be bounded as it arrives, not after. Quickshell's StdioCollector cannot do that: it appends every chunk into one buffer and only hands the result to QML once the stream ends, so a ceiling applied to the finished text bounds what is kept rather than what was taken. A helper that ran away would already have been allocated inside the shell by the time any QML code could object.

BoundedReader.qml reads through SplitParser with an empty split marker instead, which emits each chunk as it arrives and retains nothing of its own. It counts as it goes, stops retaining at the ceiling, and terminates the producer — escalating to SIGKILL if the producer writes more after being asked to stop. What remains is one chunk in flight, a transient the size of a pipe read rather than an accumulation the size of the output.

Stream Ceiling On overflow
snapshot / commit stdout 4 MB Stop the helper, report the error
resolver / netsample stdout 64 KB Stop the helper, drop the reply
snapshot / commit stderr 4 KB Cap it, keep reading
resolver / netsample stderr nothing kept Drain it, keep reading

stderr is capped but never fatal. The ceiling is what bounds the allocation, and killing a helper over noise on stderr would cost a committed batch for nothing. Nor is it left unread: an unread pipe fills up and blocks the helper mid-write instead of letting it exit. Killing a helper cannot lose data in any case — commit writes the store atomically, under a lock, before it prints anything.

The helper side is bounded the same way, before any parse: a Hyprland control socket reply stops at 8 MB and is refused whole rather than parsed truncated, and one ss dump stops at 4 MB or five seconds, whichever comes first.

Data

One file:

$XDG_DATA_HOME/omarchy-screentime/history.json
{
  "version": 1,
  "days": {
    "2026-08-24": {
      "total": 16200,
      "apps": {
        "com.mitchellh.ghostty/opencode": 7200,
        "com.mitchellh.ghostty/claude": 1800,
        "helium/YouTube": 5400,
        "org.gnome.Nautilus": 1800
      },
      "net": {
        "helium": [312000000, 32100000],
        "steam_app_1091500": [890000000, 12400000]
      },
      "netTotal": [1202000000, 44500000]
    }
  }
}

Integers. A key in apps is appId or appId/detail, split on the first slash, and the value is seconds. A key in net is always a plain appId, and the value is [down, up] in bytes — which tab downloaded what is not something the socket table can answer. total and netTotal are authoritative and survive pruning, so a day whose per-app detail has aged out still reports what it added up to. screentime path prints the location; delete the file to reset everything.

A second file, netstate.json, holds the previous reading of each open socket plus the bytes not yet folded into the store. It is scratch space: deleting it loses at most the last minute of data.

Not under $XDG_DATA_HOME/omarchy/ on purpose: on a real install that is a symlink to the root-owned /usr/share/omarchy.

CLI

The same engine the widget uses, from a terminal:

screentime today          # today's total and per-app breakdown
screentime --group today   # the same, rolled up per app
screentime week           # last 7 days as bars
screentime year [YYYY]    # ASCII contribution heatmap
screentime net            # per-app data usage, down and up
screentime json           # raw store
screentime prune          # drop aged per-app detail
screentime path           # where the store lives

Live state over IPC:

omarchy-shell screentime status   # JSON: current app, counting, pending, window
omarchy-shell screentime today    # "4h 30m"
omarchy-shell screentime net      # "D 1.2 GB · U 45 MB"
omarchy-shell screentime flush    # commit buffered time now

Development

node --test "tests/*.test.mjs"          # pure JS logic (Model.js, Tracker.js, Stream.js)
python3 -m unittest discover -s tests   # the data engine, and the QML read boundary
omarchy plugin validate .               # same checks the shell runs at load

lib/Tracker.js holds the accrual rules as pure functions — idle handling, suspend gaps, midnight splits, ignore rules — so what counts as screen time is testable without a compositor. lib/Model.js is presentation-only, and lib/Stream.js is the per-chunk ceiling arithmetic behind BoundedReader.qml. All three are QML JS modules loaded under plain node by the test harness.

tests/test_qml_reader.py is the exception to that: whether the shell allocates a helper's whole output before QML can refuse it is not visible to any pure function, so those cases run a real headless Quickshell against a real producer and measure peak RSS from outside. They skip when Quickshell is not installed.

Note that saving a file only hot-reloads when the plugin directory is a real directory under ~/.config/omarchy/plugins/. If you symlink a working copy in, restart the shell to pick up changes:

omarchy-restart-shell

License

MIT