Skip to content

Releases: aaronsb/playtimed

playtimed 0.7.1

Choose a tag to compare

@aaronsb aaronsb released this 12 Oct 17:56
v0.7.1

Fixed

  • The packaged unit grants CAP_DAC_READ_SEARCH. Without it the daemon's root cannot enter a 0700 home or /run/user/<uid>, so Chrome and Firefox session files and the KWin window list went unread: no tab was ever seen, browser runtime stayed at zero, and a discord.com tab never drew on its allowance while the Discord app did. The source-install unit also gains CAP_SETUID and CAP_SETGID, which notifications already needed
  • The daemon warns at startup when it cannot read a monitored user's home, in place of failing every browser scan silently

0.7.0 — allowances

Choose a tag to compare

@aaronsb aaronsb released this 08 Sep 23:35
v0.7.0

Added

  • Allowances (ADR-005) — a restricted window may grant named rations of minutes per clock hour, and a pattern draws on one by carrying its name. mon-fri 0-16 restricted discord=5 with the Discord process and discord.com both attached to discord gives five shared minutes of Discord in every school hour, renewing with the clock
  • playtimed patterns allowance <id> <name> attaches a pattern; --clear detaches it; with no name it shows the current one. A gaming pattern is refused, since a restricted window closes games before any ration is read. windows show names the ration on each window that grants it, and status reports what is left of it this hour
  • Spend is presence, charged once per poll however many processes and tabs are open: what the ration admits is the same thing the close removes, and Discord in use idles under any CPU threshold
  • A spent ration warns, waits the strict-mode grace period, and closes every process carrying the allowance. A relaunch in the same hour is closed on sight with a notification naming when it comes back. A discord.com tab is withheld from the browser allowlist until the hour turns; a tab already open stays open, since managed policy applies at navigation (ADR-003)
  • A pattern carrying an allowance is rationed in every restricted window: where the window does not grant the name, the pattern is shut out rather than admitted as productive. Forgetting the token on one clause tightens that window rather than reopening it
  • allowance_expired and allowance_blocked notification intentions, with templates and router fallbacks
  • blocked_allowance audit events record each close made under a ration, and whether it was spent or ungranted

Changed

  • Template migration now seeds defaults for any intention that has none, in place of seeding only when the table is empty, so a host seeded under an earlier release gets new intentions without losing its edits
  • playtimed browser-policy lists withheld domains alongside permitted and blocked

0.6.1 — browser policy follows the window

Choose a tag to compare

@aaronsb aaronsb released this 06 Sep 18:36
v0.6.1

Fixed

  • The browser policy is rendered for the mode the schedule puts the daemon in, in place of the stored daemon_config.mode. Under 0.6.0 that setting became the manual override, but the policy sync still read it, so on a host whose stored mode was strict every transition to an open window rewrote the allowlist and kept it there. The daemon now hands the sync the mode it is running in, and the CLI (browser-policy, windows set, pattern edits, mode) resolves the mode from this hour's windows the same way
  • playtimed browser-policy shows the mode it renders and names the stored override when the two differ

0.6.0 — window schedules

Choose a tag to compare

@aaronsb aaronsb released this 05 Sep 03:14
v0.6.0

Added

  • Window schedules (ADR-004) — a user's allowance is a set of windows, each covering a contiguous range of hours on a set of days and carrying a mode (restricted or open) and an optional budget. Windows tile the week: for every day they partition hours 0-24 with no gaps and no overlaps, so no hour falls under two rules or none. An hour left unruled is filled as restricted, because the two readings of an unruled hour are the lockdown and its absence
  • Budgets are scoped to their window. "One hour of games between three and six, then an uncapped evening" is now expressible; under per-day limits the cap followed the user into the evening that was specified as uncapped
  • Each window chooses what its budget meters: gaming counts game processes and gaming-category browser domains, all counts every tracked second. The default is gaming, so productive and educational time stays free
  • The daemon derives its enforcement mode from the window covering the current hour and re-evaluates every poll, since the thing that changes it is the clock. A transition regenerates the browser policy through the path SIGHUP already used, so ADR-003 enforcement follows the schedule with no new mechanism
  • playtimed windows show|set|preset. windows set takes a spec — mon-fri 0-15 restricted; mon-fri 15-18 open:60 — and refuses a set that does not tile, leaving the previous schedule in force
  • Window spend is summed from hourly_activity over the hours a window covers rather than kept in a counter, so it cannot drift from the activity it summarizes and has no reset to get wrong

Changed

  • playtimed status reports the window in force, what has been spent inside it, and what remains, in place of a per-day gaming and total figure
  • playtimed schedule and schedule view render windows — the command shows what is actually enforced
  • schedule export emits a spec string that windows set accepts, making an export a restorable backup rather than a description
  • daemon_config.mode keeps one job as a manual override. passthrough outranks the schedule, which is what suspending enforcement has to mean; normal and strict are superseded by the window each poll
  • When monitored users hold different modes at the same moment, the most restrictive wins. Chrome's managed policy is machine-wide (ADR-003) and can only express one answer for the machine

Removed

  • daily_total is no longer an enforcement input. Under a window schedule it bound in the middle of a window specified as uncapped, which made "no time cap" false as written. Total time is still recorded in daily_summary; nothing caps it. A total cap is now expressed as a window budget with meters = all
  • schedule set, schedule edit, and schedule import refuse and name their replacement. They wrote a structure enforcement no longer reads, which would have produced a schedule that displays one thing and enforces another
  • The curses grid editor is removed rather than ported. It edits one cell per hour over {0,1}, and a window carries a mode, a budget, and a meter that no single cell can hold
  • _get_remaining_time and _send_warning_if_needed, both dead before this release and both computing the per-day model it retires

Migration

  • Existing schedules convert automatically. Each day's grid collapses into maximal runs, a run of 1 becoming an open window, and that day's daily_limits value lands on its first open window — where a per-day budget was first spendable
  • The legacy schedule and daily_limits columns are left in place and stop being read, so a downgrade to 0.5.x finds its configuration intact
  • Migration skips a user who already has windows, so operator edits survive every later pass

v0.5.4

Choose a tag to compare

@aaronsb aaronsb released this 02 Sep 23:34
v0.5.4
eaf62dd

Fixed

  • Strict mode admitted unreviewed discoveries — the mode advertises "whitelist only", but the poll loop only reached the warn-then-kill path when a process matched no pattern at all. A process matching a pattern in discovered state — a row discovery created automatically the first time it crossed the CPU threshold — ran unchallenged. On a host that had been in normal mode for a while, that covered most of what a user actually launches, so switching to strict changed nothing for them. Admission is now an explicit allowlist of active and ignored
  • discover disallow on a browser domain did nothing — the CLI wrote disallowed to the row and printed "will be terminated on detection", but the kill path operates on PIDs and browser domains are handled in a separate block that only records runtime. A domain could sit disallowed indefinitely and remain reachable. ADR-001 listed this as a stretch goal; it was never built

Added

  • Browser managed-policy generation (ADR-003) — playtimed writes Chrome-family and Firefox policy files from its own browser_domain patterns, so the browser enforces at navigation time across every tab and window rather than playtimed reacting to whichever tab has focus. normal mode generates a blocklist from disallowed domains; strict generates an allowlist of permitted domains with everything else blocked; passthrough withdraws playtimed's rules
  • playtimed browser-policy shows what would be written, --sync writes it. Generation also runs at daemon startup, on SIGHUP, and after any CLI command that changes a domain's state or the daemon mode
  • For Chrome-family browsers playtimed writes exactly one file, playtimed.json, and never reads or overwrites others in the merged policy directory. Firefox has no such directory, so there playtimed owns the policies.WebsiteFilter key inside the shared policies.json and leaves the rest of the document intact
  • An active gaming-category domain is written into the blocklist rather than the allowlist. active means "counted against the limit", and playtimed cannot close a tab when a domain's budget expires — so on a host that had been tracking YouTube time, reading active as "permitted" would have generated an allowlist of exactly the sites a lockdown exists to block

Changed

  • ActivityDB.get_patterns() now returns only pattern_type = 'process' rows by default. A browser_domain pattern holds a hostname, but process matching is an unanchored regex search over process name and command line, so a domain row in that scan meant any process whose cmdline contained the string was treated as that pattern — and a disallowed one was killed. Pass pattern_type=None for every type
  • get_browser_patterns() now honours the enabled column, which it previously ignored
  • The systemd unit grants write access to each browser's configuration root under /etc. ProtectSystem=strict made all of /etc read-only, so daemon-side policy writes would have failed EROFS and been logged as a warning while the feature appeared to work

Security

  • Browser database copies used tempfile.mktemp() — the workers copy Chrome's History and Firefox's places.sqlite somewhere unlocked before reading them, and mktemp returns a path without creating it. The daemon runs as root, the source is a file the monitored user owns and controls the contents of, and /tmp is world-writable, so that user could plant a symlink at the predicted path and have root write bytes of their choosing to a path of their choosing. PrivateTmp=true in the unit blocks it for the packaged daemon; playtimed run started by hand is not covered. Both workers now call one helper in browser/base.py, built on mkstemp, which creates the file itself with mode 0600. The helper removes the file if the copy raises: the caller has no name for it until the helper returns, so nothing else can — and a directory left at the profile path satisfies the caller's .exists() guard, making the copy fail on every poll
  • The content goes through the descriptor mkstemp returned rather than being written back by path. A write by name follows a symlink at the destination and carries no O_EXCL, so reopening would have reopened the same question, leaving only /tmp's sticky bit between root and a user-directed write
  • The copy is a descriptor write rather than shutil.copy2. Browsers leave those profile files world-readable, and copy2 would preserve the source mode — widening the 0600 back out and exposing the user's browsing history to every local account for as long as the copy exists

Changed

  • make check passes. It never had: ruff's default rule selection grows between minor releases, so the gate went red on code no commit touched, reaching 244 findings. ruff is now pinned in the dev extra, three rule groups are switched off with the reason recorded beside each (naive datetimes, which are deliberate for a daemon whose budgets are per local calendar day; blind excepts, which are all log-and-continue probes; and the iteration idiom for entries that vanish mid-scan), and the rest are fixed
  • playtimed message list prints the urgency of each template. The colour was computed and discarded

Note

Chrome reloads managed policy on launch and periodically thereafter, not immediately. A domain disallowed while the browser is open stays reachable until it reloads — the process layer is instantaneous and the browser layer is not.

v0.5.3 — Pattern specificity priority

Choose a tag to compare

@aaronsb aaronsb released this 28 Jul 03:15
v0.5.3
58f7c38

[0.5.3] - 2026-07-27

Fixed

  • A global catch-all outranked user-scoped patterns — process matching tested every gaming pattern before any launcher pattern, so the global \.exe$ catch-all claimed Wine helper processes that were already classified as launchers. On a Wine-heavy host this tracked explorer.exe, winedevice.exe, xalia.exe, svchost.exe and friends as "Proton Game", one session apiece — a single afternoon of one game produced 37 helper sessions, burying the real game in the session log

Changed

  • Gaming and launcher patterns are now matched as one ordered set via rank_patterns(), ranked by specificity first and category second: a user-scoped pattern outranks a global one whatever its category, and within the same scope gaming still outranks launcher so a game misfiled as a launcher keeps counting

Note

Reclassifying Wine helpers as launchers only takes effect on this release. On 0.5.2 and earlier the same reclassification made things worse, because the helpers then fell through to the \.exe$ catch-all and were counted under a misleading name.

v0.5.2 — Legacy column reads and session leak

Choose a tag to compare

@aaronsb aaronsb released this 28 Jul 02:27
v0.5.2
98edc0d

[0.5.2] - 2026-07-27

Fixed

  • Status total percentage read a dead column — playtimed status computed the Total progress bar from the legacy daily_total column while Gaming used the current per-day limits, so a stale value could render nonsense (789% against a real 100%). Both now measure against daily_limits, completing the migration started in 0.5.1
  • History coloured every row against a flat limit — playtimed history compared each day's gaming time to the legacy gaming_limit column instead of that day's own budget, marking weekday rows red against a weekend allowance. Each row is now coloured against its own day's limit
  • Warns column always showed 0 — playtimed history read the warnings_sent counter, which the daemon never increments; warnings are recorded as the warned_30/warned_15/warned_5 flags. The column now derives its count from those flags
  • Sessions leaked on unclean daemon exit — active sessions are tracked in memory only, so a crash, reboot, or SIGKILL left rows with no end_time while the next poll opened fresh ones for the same processes. Open sessions are now closed on shutdown, and any left behind by a previous run are reconciled at startup

Added

  • ActivityDB.get_open_sessions(), close_session(), and get_last_poll_at() supporting session reconciliation
  • Sessions closed at an unverified upper bound record a NULL duration and display as unknown rather than being credited with playtime that may never have happened — they drop out of per-app aggregates instead of inflating them
  • shutdown and orphaned end reasons distinguish a clean daemon stop from a session recovered after an unclean exit