Repository navigation
Releases: aaronsb/playtimed
Releases · aaronsb/playtimed
Release list
playtimed 0.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 adiscord.comtab never drew on its allowance while the Discord app did. The source-install unit also gainsCAP_SETUIDandCAP_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
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=5with the Discord process anddiscord.comboth attached todiscordgives five shared minutes of Discord in every school hour, renewing with the clock playtimed patterns allowance <id> <name>attaches a pattern;--cleardetaches 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 shownames the ration on each window that grants it, andstatusreports 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.comtab 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_expiredandallowance_blockednotification intentions, with templates and router fallbacksblocked_allowanceaudit 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-policylists withheld domains alongside permitted and blocked
0.6.1 — browser policy follows the window
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 wasstrictevery 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-policyshows the mode it renders and names the stored override when the two differ
0.6.0 — window schedules
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 (
restrictedoropen) 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 asrestricted, 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:
gamingcounts game processes and gaming-category browser domains,allcounts every tracked second. The default isgaming, 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 settakes 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_activityover 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 statusreports the window in force, what has been spent inside it, and what remains, in place of a per-day gaming and total figureplaytimed scheduleandschedule viewrender windows — the command shows what is actually enforcedschedule exportemits a spec string thatwindows setaccepts, making an export a restorable backup rather than a descriptiondaemon_config.modekeeps one job as a manual override.passthroughoutranks the schedule, which is what suspending enforcement has to mean;normalandstrictare 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_totalis 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 indaily_summary; nothing caps it. A total cap is now expressed as a window budget withmeters = allschedule set,schedule edit, andschedule importrefuse 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_timeand_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
1becoming anopenwindow, and that day'sdaily_limitsvalue lands on its firstopenwindow — where a per-day budget was first spendable - The legacy
scheduleanddaily_limitscolumns 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
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
discoveredstate — a row discovery created automatically the first time it crossed the CPU threshold — ran unchallenged. On a host that had been innormalmode for a while, that covered most of what a user actually launches, so switching tostrictchanged nothing for them. Admission is now an explicit allowlist ofactiveandignored discover disallowon a browser domain did nothing — the CLI wrotedisallowedto 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_domainpatterns, so the browser enforces at navigation time across every tab and window rather than playtimed reacting to whichever tab has focus.normalmode generates a blocklist from disallowed domains;strictgenerates an allowlist of permitted domains with everything else blocked;passthroughwithdraws playtimed's rules playtimed browser-policyshows what would be written,--syncwrites 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 thepolicies.WebsiteFilterkey inside the sharedpolicies.jsonand leaves the rest of the document intact - An
activegaming-category domain is written into the blocklist rather than the allowlist.activemeans "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, readingactiveas "permitted" would have generated an allowlist of exactly the sites a lockdown exists to block
Changed
ActivityDB.get_patterns()now returns onlypattern_type = 'process'rows by default. Abrowser_domainpattern 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. Passpattern_type=Nonefor every typeget_browser_patterns()now honours theenabledcolumn, which it previously ignored- The systemd unit grants write access to each browser's configuration root under
/etc.ProtectSystem=strictmade all of/etcread-only, so daemon-side policy writes would have failedEROFSand been logged as a warning while the feature appeared to work
Security
- Browser database copies used
tempfile.mktemp()— the workers copy Chrome'sHistoryand Firefox'splaces.sqlitesomewhere unlocked before reading them, andmktempreturns 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/tmpis 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=truein the unit blocks it for the packaged daemon;playtimed runstarted by hand is not covered. Both workers now call one helper inbrowser/base.py, built onmkstemp, 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
mkstempreturned rather than being written back by path. A write by name follows a symlink at the destination and carries noO_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, andcopy2would 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 checkpasses. 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 fixedplaytimed message listprints 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
[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 trackedexplorer.exe,winedevice.exe,xalia.exe,svchost.exeand 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
[0.5.2] - 2026-07-27
Fixed
- Status total percentage read a dead column —
playtimed statuscomputed the Total progress bar from the legacydaily_totalcolumn while Gaming used the current per-day limits, so a stale value could render nonsense (789% against a real 100%). Both now measure againstdaily_limits, completing the migration started in 0.5.1 - History coloured every row against a flat limit —
playtimed historycompared each day's gaming time to the legacygaming_limitcolumn 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 historyread thewarnings_sentcounter, which the daemon never increments; warnings are recorded as thewarned_30/warned_15/warned_5flags. 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_timewhile 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(), andget_last_poll_at()supporting session reconciliation- Sessions closed at an unverified upper bound record a NULL duration and display as
unknownrather than being credited with playtime that may never have happened — they drop out of per-app aggregates instead of inflating them shutdownandorphanedend reasons distinguish a clean daemon stop from a session recovered after an unclean exit