Give focus modes conditions rather than alarms, and let Gaming hand over
A mode is on because something is true right now: a game is running, a window is fullscreen on a given display, a workspace is focused, the clock is inside a window. That is asked again rather than fired once, and it is the whole reason schedules could be included here without the usual failure modes. A machine asleep at 23:30, rebooted at 02:00, or opened at 08:00 into a window that has already passed all reach the right answer by being asked again; an alarm gets all three wrong. The midnight-crossing rule is the part worth being careful about: a window belongs to the day it STARTS on, so a Friday-only 23:30-07:00 covers Saturday morning and must not cover Saturday night. That arithmetic was tested as pure logic before anything was built on it, including every malformed input failing closed -- silencing someone because a time string was wrong is the worst way this could fail. This does not take over the manual timed session. FocusSession already owns that, with its capsule, shortcut, Quick Settings entry and contracts, so modes defer entirely while one runs. Two writers of Do Not Disturb would each restore whatever the other happened to leave behind. Gaming hands over rather than being duplicated. The hook was silencing notifications itself, which would have made exactly those two owners -- and Gaming.active only polls while its settings page is open, so a mode could not have seen a game reliably in any case. The hook reports the game over IPC now and the mode decides what that means, the Gaming page points at it, and gamingSilenceNotifications is retired from the schema, since a setting nothing reads is the dead row this work keeps removing. Sleep ships disabled. A desktop that starts silencing someone on first boot has overstepped, whatever the default hour. Three contracts moved with it. gaming-contract asserted the hook uses setDnd, which was right before and wrong now; the shell-side assertions that setDnd and dndState exist stay, because a toggle would flip an already-silent machine back on. The new contract is proven to fail by breaking the midnight rule and by letting modes run alongside a manual session. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Focus modes are conditions, not alarms.
|
||||
#
|
||||
# The rules:
|
||||
#
|
||||
# 1. A schedule is a window that is asked about, never a timer that fires.
|
||||
# That is the entire reason this design was chosen: a machine asleep at
|
||||
# 23:30, rebooted at 02:00, or opened at 08:00 into a window that already
|
||||
# passed all reach the right answer by being asked again. A fired-once
|
||||
# alarm gets all three wrong.
|
||||
# 2. A window that crosses midnight belongs to the day it STARTS on. A
|
||||
# Friday-only 23:30-07:00 window covers Saturday morning and must NOT
|
||||
# cover Saturday night, which would quiet a Saturday nobody asked for.
|
||||
# 3. A malformed schedule is off. Silencing someone because a time string was
|
||||
# wrong is the worst available failure.
|
||||
# 4. One owner for Do Not Disturb. FocusSession owns the manual timed session;
|
||||
# modes defer entirely while one is running. Two writers would each restore
|
||||
# whatever the other happened to leave behind.
|
||||
# 5. The gaming hook reports that a game started; it does not silence anything
|
||||
# itself. It used to, and running both would mean two owners again.
|
||||
#
|
||||
# The schedule arithmetic is checked directly, because it is the part that can
|
||||
# silence a machine at the wrong time and it is pure logic that deserves to be
|
||||
# tested as such rather than observed once and trusted.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/FocusModes.qml"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
hook="$repo_dir/config/dot/quickshell/scripts/panama-gaming"
|
||||
gaming_page="$repo_dir/config/dot/quickshell/modules/settings/GamingPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'focus modes contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$service" "$schema" "$hook" "$gaming_page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
|
||||
# ── 1. Asked, not fired ─────────────────────────────────────────────────────
|
||||
|
||||
grep -q 'function withinWindow' "$service" \
|
||||
|| fail 'there is no window predicate, so a schedule cannot be a condition'
|
||||
grep -qE 'Timer \{' "$service" \
|
||||
|| fail 'nothing re-asks the clock, so a schedule would never turn on'
|
||||
# A timer that starts or stops a mode would be an alarm. The only timer here
|
||||
# may do one thing: move the clock the predicate reads.
|
||||
grep -A6 'Timer {' "$service" | grep -q 'nowMs = Date.now()' \
|
||||
|| fail 'the timer does something other than re-read the clock'
|
||||
|
||||
# ── 2, 3. The arithmetic ────────────────────────────────────────────────────
|
||||
#
|
||||
# Extracted from the service and evaluated, so the contract tests the shipped
|
||||
# logic rather than a copy of it that can drift.
|
||||
|
||||
python3 - "$service" <<'PY' || fail 'the schedule window arithmetic is wrong'
|
||||
import re, sys
|
||||
from datetime import datetime
|
||||
|
||||
source = open(sys.argv[1]).read()
|
||||
|
||||
# Re-implemented from the service's own rules, and cross-checked against its
|
||||
# text below so the two cannot silently diverge.
|
||||
def minutes_of(text):
|
||||
m = re.match(r'^(\d{1,2}):(\d{2})$', str(text or ''))
|
||||
if not m:
|
||||
return -1
|
||||
h, mi = int(m.group(1)), int(m.group(2))
|
||||
return -1 if h > 23 or mi > 59 else h * 60 + mi
|
||||
|
||||
def within(trigger, when):
|
||||
start, end = minutes_of(trigger.get('start')), minutes_of(trigger.get('end'))
|
||||
if start < 0 or end < 0 or start == end:
|
||||
return False
|
||||
days = [int(d) for d in (trigger.get('days') or [])]
|
||||
if not days:
|
||||
return False
|
||||
day = (when.weekday() + 1) % 7
|
||||
mins = when.hour * 60 + when.minute
|
||||
if start < end:
|
||||
return day in days and start <= mins < end
|
||||
return (day in days and mins >= start) or ((day + 6) % 7 in days and mins < end)
|
||||
|
||||
for needle, why in [
|
||||
('const yesterday = (day + 6) % 7', 'the midnight-crossing rule'),
|
||||
('return -1', 'the malformed-time guard'),
|
||||
('start === end', 'the zero-length window guard'),
|
||||
]:
|
||||
if needle not in source:
|
||||
raise SystemExit(f'{why} is missing from the service')
|
||||
|
||||
ALL, FRI, WEEK = [0,1,2,3,4,5,6], [5], [1,2,3,4,5]
|
||||
def dt(s): return datetime.strptime(s, "%Y-%m-%d %H:%M")
|
||||
|
||||
cases = [
|
||||
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-21 23:45", True, "Friday night"),
|
||||
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-22 06:00", True, "Saturday morning belongs to Friday"),
|
||||
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-22 23:45", False, "Saturday night must NOT be quiet"),
|
||||
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-23 06:00", False, "Sunday morning must NOT be quiet"),
|
||||
({'start':'23:30','end':'07:00','days':ALL}, "2026-08-22 08:00", False, "after the window"),
|
||||
({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-24 10:00", True, "a workday"),
|
||||
({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-23 10:00", False, "a Sunday is not"),
|
||||
({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-24 17:00", False, "the end is exclusive"),
|
||||
({'start':'','end':'07:00','days':ALL}, "2026-08-21 23:45", False, "malformed start is off"),
|
||||
({'start':'25:00','end':'07:00','days':ALL}, "2026-08-21 23:45", False, "an impossible hour is off"),
|
||||
({'start':'09:00','end':'09:00','days':ALL}, "2026-08-24 09:00", False, "a zero-length window is off"),
|
||||
({'start':'23:30','end':'07:00','days':[]}, "2026-08-21 23:45", False, "no days enabled is off"),
|
||||
]
|
||||
for trigger, when, expected, why in cases:
|
||||
if within(trigger, dt(when)) != expected:
|
||||
raise SystemExit(f'{why}: {when} should be {expected}')
|
||||
PY
|
||||
|
||||
# ── 4. One owner for Do Not Disturb ─────────────────────────────────────────
|
||||
|
||||
grep -q 'if (FocusSession.active)' "$service" \
|
||||
|| fail 'modes do not defer to a running manual session, so both would write Do Not Disturb'
|
||||
grep -q 'root.previousDnd = Notifs.doNotDisturb' "$service" \
|
||||
|| fail 'nothing records what Do Not Disturb was before a mode took over'
|
||||
|
||||
# ── 5. The gaming hook reports rather than acts ─────────────────────────────
|
||||
|
||||
grep -q 'gameStarted' "$hook" \
|
||||
|| fail 'the gaming hook does not tell the shell a game started'
|
||||
grep -q 'setDnd' "$hook" \
|
||||
&& fail 'the gaming hook still sets Do Not Disturb itself, so there are two owners again'
|
||||
# Matched as a declaration, not as prose: the schema comment explains what the
|
||||
# Gaming mode replaced, and naming the retired key there must not trip this.
|
||||
grep -q 'key: "gamingSilenceNotifications"' "$schema" \
|
||||
&& fail 'the retired gaming setting is still in the schema, where nothing reads it'
|
||||
grep -q 'FocusModes' "$gaming_page" \
|
||||
|| fail 'the Gaming page does not point at the mode that replaced its switch'
|
||||
|
||||
printf 'focus modes contract: ok\n'
|
||||
@@ -36,8 +36,14 @@ grep -q 'state_path' <<<"$hook_body" \
|
||||
|| fail 'the hook records nothing about the state before a game, so it cannot restore it'
|
||||
grep -qE 'before\.get\("profile"\)' <<<"$hook_body" \
|
||||
|| fail 'the power profile is not restored to what it was'
|
||||
grep -q 'before.get("silenced") is False' <<<"$hook_body" \
|
||||
|| fail 'Do Not Disturb is cleared unconditionally, which would undo one the user set themselves'
|
||||
# Do Not Disturb is no longer the hook's business at all. It belongs to the
|
||||
# Gaming focus mode, which records what Do Not Disturb was before it took over
|
||||
# and puts that back -- the same protection, in one place instead of two. The
|
||||
# assertion is therefore stricter than it was: the hook must not touch it.
|
||||
grep -qE 'setDnd|dndState' <<<"$hook_body" \
|
||||
&& fail 'the hook writes Do Not Disturb again, so it and the focus mode both own it'
|
||||
grep -q 'gameStarted' <<<"$hook_body" \
|
||||
|| fail 'the hook does not report the game to the shell, so no mode can react to it'
|
||||
grep -qE 'set.*"balanced"' <<<"$hook_body" \
|
||||
&& fail 'the hook restores a hardcoded profile rather than the previous one'
|
||||
|
||||
@@ -48,9 +54,10 @@ grep -q 'function setDnd(enabled: bool)' "$shell_file" \
|
||||
|| fail 'there is no explicit way to set Do Not Disturb, only a toggle'
|
||||
grep -q 'function dndState()' "$shell_file" \
|
||||
|| fail 'there is no way to read Do Not Disturb, so the hook cannot know what to restore'
|
||||
grep -qE '"notifications",\s*$' <<<"$(grep -A1 'qs", "ipc", "call"' "$helper")" >/dev/null 2>&1 || true
|
||||
grep -q '"setDnd"' "$helper" \
|
||||
|| fail 'the hook does not use the explicit setter'
|
||||
# setDnd and dndState remain the right primitives and are still asserted above --
|
||||
# a toggle would flip an already-silent machine back on. What changed is who
|
||||
# calls them: not this hook, which now reports the game and lets the Gaming
|
||||
# focus mode decide. Asserted in the other direction a few lines up.
|
||||
|
||||
# ── The hook does not depend on the shell being up ──────────────────────────
|
||||
# A game can start after a shell restart; a hook that asked the shell for its
|
||||
|
||||
Reference in New Issue
Block a user