Skip to content

Architecture

Aditya Krishnan edited this page Oct 25, 2026 · 2 revisions

Architecture

ScreenGuard is three cooperating processes, three native C helpers, a Python X11 fallback and a database. This page explains how they fit together and why.

graph TD
    A[ScreenGuard Flutter GUI] -->|SQLite WAL| DB[(usage.db)]
    B[screenguard-daemon] -->|SQLite WAL| DB
    B -->|Poll active window every 3s| C{Display server}
    C -->|Wayland GNOME| D[GNOME Shell extension]
    D -->|D-Bus| B
    C -->|X11 desktop| E[X11 EWMH via xprop]
    B -->|Enforce limits and focus| F[screenguard-killer]
    B -->|Desktop notifications| G[screenguard-notify]
    H[screenguard-watchdog] -->|Restarts the daemon with backoff| B
Loading

The three pieces

1. The GUI — lib/

A Flutter application using Material 3. It owns the dashboard, the app-limit dialogs, the focus timer and the settings sidebar. It can start, stop and configure the daemon, including autostart.

The GUI is a client. Closing the window does not stop tracking.

2. The daemon — bin/daemon.dart

A single-file Dart program compiled to a native binary (screenguard-daemon). The current daemon is written in Dart; the v1.5 roadmap targets a C port that preserves its behavior and database contract. Until then, this section describes the Dart implementation.

It is the only component that:

  • samples the foreground window every few seconds,
  • writes usage sessions,
  • enforces daily app limits and the focus-mode distraction blocker,
  • checks for updates and for feedback requests,
  • writes a heartbeat file the GUI reads to tell whether the daemon is alive.

It runs as a systemd user service where systemd is available, with an XDG autostart fallback on systems without it.

Single instance. On start the daemon takes an exclusive lock on ~/.local/share/screenguard/daemon.lock. If the lock is already held, the new process exits immediately — two daemons would double-count every session.

Crash safety. A zone error handler catches unhandled exceptions, writes a local crash log and exits non-zero. The watchdog then restarts the daemon. Unclean shutdowns (logout, reboot) are recorded locally but deliberately not reported, because they are normal and would otherwise flood the error stream. Any sessions left orphaned by such a stop are closed on the next start, so they do not inflate the 30-day totals.

3. The platform bridge

GNOME Wayland — Wayland does not let an application ask which window has focus. The ScreenGuard GNOME Shell extension (screenguard@screenguard.app, GJS) runs inside the compositor and exports the active window's title, class and PID over D-Bus:

org.gnome.Shell.Extensions.ScreenGuard
  /org/gnome/Shell/Extensions/ScreenGuard

The extension also exposes CloseActiveWindow() and CloseWindowByPid(), and reports the real minimised state so the app can tell whether a window actually went away.

X11 — no extension needed. The daemon reads the active window from the standard EWMH property _NET_ACTIVE_WINDOW using xprop, and minimises or closes it with xdotool. Idle time comes from xprintidle.


Native helpers

Three small C programs in src/, plus a Python fallback in bin/, handle the parts that must not depend on the Dart runtime, on notify-send, or on optional system tools:

Binary Purpose
screenguard-notify Sends desktop notifications over D-Bus, with notify-send as a fallback. Doing this natively means alerts behave the same on systemd and on non-systemd systems (OpenRC, runit)
screenguard-killer Closes a window through an escalating, data-safe chain: polite window-close request → SIGTERM → process-tree SIGKILL → Proton-native wineserver -k → guarded pattern-kill
screenguard-watchdog A ~19 KB supervisor that detects a crashed daemon and restarts it with exponential backoff, across all Linux init systems
bin/active_window_helper.py Fallback window probe for unusual X11 setups

The killer's escalation is what makes Close actually work on Proton games, which ignore ordinary signals.


The database

SQLite at ~/.local/share/screenguard/usage.db, in WAL mode so the GUI and the daemon can write at the same time without blocking each other.

Table Holds
sessions One row per tracked window session: app id, app name, window title, PID, start and end timestamps, duration, idle flag
settings Key/value store for every preference and internal flag
app_limits Per-application daily limit, temporary extension and enabled flag
distracting_apps The applications blocked during focus sessions
focus_sessions Focus-session history with completion status
active_focus_state The single running session: end time, duration, paused flag, remaining seconds, cycle counters, break state

The settings table is also where the daemon publishes state the GUI needs to read — update information, crash counts, daemon ownership, the pending feedback request. It is a tiny message bus over SQLite.


How enforcement works

  1. The daemon samples the foreground window every three seconds.
  2. It resolves the window to an application id, then to a friendly name. Steam games resolve through your library manifests, so they appear under their real names with their own icons.
  3. It writes the session row into sessions.
  4. If that application has a limit and the accumulated time today crosses 100%, the daemon minimises the window and tells the GUI to show the warning screen — it launches the GUI with --times-up=<app>.
  5. The focus-mode blocker works the same way, with --focus-block=<app>.

Nothing is closed by default. The default path minimises the window and shows a warning screen with a one-minute save grace, +15 minutes, edit-limit and close options. Only with Strict auto-close enabled does the escalating kill chain run automatically. The per-app grace window is tracked separately, so the warning stops while a grace period is active.


Privacy by construction

Data Where it goes
Screen-time sessions The local SQLite file, and nowhere else
Window titles Stored locally; never transmitted
Update check GET https://api.github.com/repos/…/releases — no identifiers
Crash reports Opt-out; a random local UUID, the error text, the app version, the desktop environment, the display server and the distribution name. No usernames, no machine identity, no screen-time history
Feedback requests GET of one public JSON file; the app version in the User-Agent

There is no account system, no sync and no analytics endpoint.


Related: Features · Development · Troubleshooting

Clone this wiki locally