6 Commits

Author SHA1 Message Date
SellPL 8ae043658c feat(linux): add enforcement actions and the daemon event loop
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
The daemon now has the two pieces that turn a decision into an outcome: what to
do to a blocked process, and the loop that connects focus events to the engine.

**The enforcement ladder is escalating and non-destructive by default.**
`SIGSTOP` freezes an app; it does not kill it. That is a deliberate choice, and
the reason is section 1.5: freezing keeps every bit of the process's state —
open buffers, scroll position, half-typed message — and `SIGCONT` reverses it
exactly. Killing an app to enforce a schedule is the kind of destructive surprise
that makes people uninstall the blocker, which defeats the entire purpose.

Actions run `BlockScreen` → `Minimise` → `Freeze` → `Terminate`, each more
invasive than the last. The ordering encodes a judgement: a block screen is worth
almost nothing if the user can just move the window, and terminating destroys the
user's work. Start with the least destructive thing that could work.

**The event loop has no tick.** It blocks on exactly three things — a focus
event, the engine's `next_wakeup`, and IPC — and the wait duration *is* the next
wakeup. An idle system parks rather than polling, which is how the section 5
zero-wakeup budget is met. Two threads, both blocked, both costing nothing.

**The monotonic clock moved into the platform layer**, behind a trait. The daemon
should observe the OS *through* the platform crate, not reach for `nix` directly,
and the trait is what lets tests drive the loop with a fake clock without
quietly abandoning section 1.5.3's guarantee that locks never touch the wall
clock.

**Five enforcement tests, all against real processes.** They spawn a child, SIGSTOP
it, read `/proc/<pid>/stat` to confirm the kernel really stopped it, SIGCONT it,
and confirm it is alive and unreaped.

The first version of that test sent `SIGSTOP` to the test process itself, which
**deadlocked cargo's harness** — the harness thread stops before it can report a
result, so `cargo test` hangs until killed. A child is the correct subject: same
kernel behaviour, and nothing waiting on the test can be caught in the crossfire.

**What is not wired yet, stated plainly.** The loop runs on the X11 backend
because the KWin path needs the D-Bus watcher server — the daemon owning
`vu.valiq.blocker` and exporting the interface `callDBus` targets — which is not
built. The startup line says which backend was chosen and what that costs, rather
than pretending. On a Wayland session this means XWayland clients only.

Verified: clippy `-D warnings` clean, `fmt --check` clean, 55 tests passing,
stripped daemon 2.237 MB against the 5 MB budget.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 22:20:23 +03:00
SellPL 9f1acb1421 test(linux): verify the KWin script against the live Plasma session
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
The KWin JavaScript had never been executed. It is now.

A probe script was loaded into the running Plasma 6.6.6 compositor, which
reported:

    js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole
             caption=... internalId={6866aecf-...} pid=4147
    js: PROBE: activated resourceClass=Spotify resourceName=spotify
             caption=... internalId={c99b88b4-...} pid=74378

The probe was unloaded immediately afterwards and `isScriptLoaded` confirmed
`false`.

**This proved one thing I had wrong.** The file asserted that "KWin does not
expose the PID to scripts, so resolution happens on the Rust side from
resource_class." It does expose it — `pid=4147` for Konsole, `pid=74378` for
Spotify. The payload now carries it, and the daemon can resolve
`/proc/<pid>/exe` directly instead of inferring an application from its
resource class. Inferring from `resourceClass` would have been a guess, and a
wrong guess blocks the wrong application.

Also confirmed: `workspace.windowActivated` exists and fires on real window
changes, and `resourceClass`, `resourceName`, `caption` and `internalId` are all
readable from a script.

**Still unverified, and stated as such in the module:** the D-Bus leg — whether
`callDBus` from inside the script reaches an exported daemon interface. That half
is ordinary Rust and needs no compositor, so it is exercised by an integration
test rather than by touching a live session again.

Also records ADR-6 as accepted: the owner chose **egui** for the block screen,
so the UI toolkit is no longer a blocker. Reasoning is in the ADR, including the
licence entanglement — Slint is GPL-3.0, which is why ADR-17 sits upstream of
this decision.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 21:06:07 +03:00
SellPL a79d262986 feat(linux): add KWin and X11 focus-detection backends
The first enforcement-path code. CLAUDE.md section 3 names `x11rb`, `zbus` and
`nix` for Linux explicitly, so all three are approved and this needed no new
dependency decision.

**KWin backend (primary).** Wayland has no portable focus API (section 6.3), and
this machine runs Plasma 6.6.6 on Wayland — an X11-only design would be blind to
most of the user's own desktop. KWin will not push focus events to an
arbitrary subscriber, so this loads a JavaScript snippet via
`org.kde.KWin /Scripting`, which hooks `workspace.windowActivated` and calls
back over D-Bus with KWin's `callDBus` helper. The daemon owns the bus name, so
KWin always has somewhere to call; if the daemon dies, `callDBus` fails silently
in the script and the compositor is left alone — the fail-open behaviour
section 1.5.4 requires.

The D-Bus surface was introspected on the live session, not read from docs:

    $ busctl --user introspect org.kde.KWin /Scripting org.kde.kwin.Scripting
    .loadScript  method s->i    .start  method -    .unloadScript  method s->b

**X11 backend (fallback).** `_NET_ACTIVE_WINDOW` property subscription, resolved
through `_NET_WM_PID` to `/proc/<pid>/exe`. Blind to native Wayland apps under a
Wayland session, which is why it is the fallback and not the primary. It is also
the path that can be tested headless with Xvfb, so it is what CI would exercise.

**No polling anywhere.** Both backends park in a subscription: `callDBus` from
the compositor, `poll_for_event` on the X connection. Nothing in either module
has a timer, which is what keeps the section 5 idle budget at zero.

**zbus is blocking on async-io, deliberately.** `tokio` was available and was
rejected: a reactor thread is measurable idle cost against a zero-wakeup budget,
and several hundred KB against a 5 MB binary. Noted in the manifest so the
decision is visible next to the version pin.

**Size, measured rather than assumed:** the stripped daemon goes 1.364 MB ->
**2.237 MB**, so D-Bus, X11 and the signal handling cost ~870 KB. 2.9 MB of the
5 MB budget remains.

**What is verified, and what is not.** The D-Bus interface is introspected from
the live session. The KWin *JavaScript* API — `workspace.windowActivated`,
`client.resourceClass` — is written against the documented Plasma 6 scripting
surface and has **not** been executed, because doing so would load a script into
the user's running compositor. That is the honest status and LIMITATIONS.md
records it. Nothing here is wired into the event loop yet, so no window is
actually blocked by this PR.

Several API details cost real time and are worth recording: X11 atoms are
interned with a round trip at connect time rather than passed as an unrelated
constant, which silently queries the wrong property; the root event filter
matches on the `_NET_ACTIVE_WINDOW` *atom* rather than the property change type;
and `rusqlite`/`zbus`/`x11rb` all differ from their documented shapes in ways
that only compile once found.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:21:42 +03:00
SellPL f05836f528 feat(tests): add the golden decision tables, and fix two engine bugs they found
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
The parity contract from CLAUDE.md section 7 now exists: 42 cases across 11
scenario groups, with a runner that asserts every case and a coverage check that
asserts every mandatory scenario is claimed.

**A design change, and why.** ADR-16 originally specified JSON tables, justified
by "serde_json will already be in the tree". That was wrong — `rusqlite` does not
pull `serde` by default and `uniffi` has not landed, so nothing provided it, and
`serde` is not a section 3 dependency (rule 0.3). The tables are therefore a Rust
builder DSL: the same declarations, but a typo in a field name is a compile error
rather than a test that silently asserts the wrong thing. For a document whose
entire job is to be a contract, compile-time checking beats diffability.
Converting to JSON later is mechanical if serde is approved.

**Two engine bugs the tables caught, both real:**

1. **Overnight windows woke the daemon immediately.** `time_window_boundary`
   computed the end as `end - minute` and clamped at zero. For a 22:00–06:00
   window active at 23:00, "06:00 today" is in the past, so the boundary became
   *now* — and the daemon would wake, re-evaluate, get the same answer, and wake
   again, forever. Not a missed block, but a busy loop burning the section 5 idle
   budget. Fixed by detecting the late half of an overnight window and crossing
   midnight properly.

2. **An inactive schedule woke a day early.** The next-start search iterated day
   offsets to test membership but computed the delta as `start - minute` only,
   wrapping by 1440 without advancing the day. A Monday–Friday schedule
   evaluated on Saturday reported Monday's start as 21 hours away — Sunday 09:00,
   a day not in the set. The block still fired, because the next evaluation
   recomputed it, but the daemon woke a day early for nothing. Fixed by folding
   the day offset into the delta.

**Four of my own table cases were wrong too**, which is the other half of why
they are worth having: a UTC+5 offset is 15:00 local and therefore *inside*
09:00–17:00 (I had asserted outside); the same for the DST cases. The engine was
right and the test was wrong in all four.

**Coverage bookkeeping is checked, not promised.** `coverage::Location` records
whether a scenario is covered by a table or by a code fixture — clock tampering
and the 24-hour cap are about the passage of time and a bound across lock levels,
not a decision at an instant, so they cannot be expressed as data. Both
directions fail: an uncovered scenario is an error, and so is a `covers` tag that
is not on the mandatory list, which is how a case quietly stops testing what its
name claims.

**`BlockReason` now carries its target.** A schedule may name twenty targets and
they do not all become blocked — some are paused, some belong to a condition that
is not currently active, some are on the never-block list. Without carrying which
target a reason is about, two blocked apps under one schedule produce two
identical reasons and the UI has to re-derive the pairing, which is exactly the
drift `BlockReason` exists to prevent. The tables could not assert per-app
attribution without it.

**The DST gap is recorded, not papered over.** `CivilTime` is an epoch plus an
offset with no timezone database behind it, so the tables pin that a fixed local
window stays fixed as the offset moves — including across a DST transition — but
cannot express a spring-forward time that does not exist or a fall-back time that
is ambiguous. Those are section 7 cases that are genuinely not covered, and
LIMITATIONS.md now says so.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 18:29:46 +03:00
SellPL 92afdc3bf6 docs: record the measured storage cost and the new crate boundary
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
Updates the ADR log, architecture, limitations, perf table and README to match
what now exists, rather than what was expected.

**The perf number was wrong and is now honest.** The M0 figure of 0.279 MB
excluded SQLite entirely — the placeholder binary never referenced the crate, so
dead-code elimination dropped it. With the daemon actually opening a database it
is **1.364 MB**, of which bundled SQLite is ~1.1 MB. That is 1.1 MB of the 5 MB
budget spoken for, leaving 3.8 MB for D-Bus, X11 and a UI toolkit.

This is the first number that makes the section 5 budget feel like a budget
rather than a formality, and it turns ADR-4's bundled-vs-system-linked choice
into a real trade-off with a measured price on both sides.

- ADR-4 marked accepted and implemented, with that trade-off left open.
- ARCHITECTURE.md documents why `blocker-storage` is a sibling crate and not a
  submodule: the dependency direction is what keeps the zero-dependency purity
  proof automatic rather than a grep someone can route around.
- LIMITATIONS.md drops the stale "storage has no I/O" shortfall and records the
  bundled-vs-system question instead, plus the fact that `LockState` still always
  evaluates to Normal.
- README status table and layout updated.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 17:17:11 +03:00
SellPL 9a5c529a8e feat(storage): add SQLite persistence in a separate crate
Implements ADR-4 (CLAUDE.md section 3, `rusqlite` bundled). Schedules, lock
state, disable attempts and usage buckets now persist across restarts.

**Why a new crate rather than a module inside `blocker-core`.** The purity
contract says the engine does no I/O, and `tests/purity.rs` enforces that
mechanically by failing if `blocker-core` has any dependency at all. Putting
rusqlite in `blocker-core` would have forced that check to degrade into a
source-text grep — something a careful developer can route around and nobody
reads in six months. `blocker-storage` depends on `blocker-core`; the reverse
edge does not exist, so the zero-dependency proof stays automatic.

Both crates sit in the same layer of the section 2 diagram: storage is shared by
every platform and contains no platform code.

**Schema is relational, not JSON-in-a-text-column.** `schedule_target` and
`schedule_condition` are separate tables because target rows are queried by
target, and an index on a JSON string is not a plan. Serialisation is
hand-written in `storage/src/codec.rs` — `serde` is not a section 3 dependency,
and section 0 rule 3 requires asking before adding one.

**Fail open, verified end to end** (section 1.5.4). Corruption moves the damaged
file aside and starts clean, keeping the original for diagnosis. Confirmed
against the real binary, not only in tests:

```
$ printf 'SQLite format 3\x00 not a database' > .../blocker.db
$ blocker-daemon
blocker-daemon: database was corrupt and has been moved to
  .../blocker.db.corrupt; starting clean, so no schedules are active
  until you re-add them
EXIT=0
```

A truncated file is covered separately — that is what a power loss during a
write actually produces, and it fails differently from garbage bytes.

**Two bugs this PR's own tests caught:**

1. `prune_usage` anchored its cutoff on `MIN(day_index)`, the oldest row. Since
   that row is by definition the one being tested, the cutoff always sat before
   it and the delete matched nothing — the table grew forever, which is the slow
   disk leak the function exists to prevent. Now anchored on `MAX`.
2. An off-by-one in the same function: with a strict `<` against
   `newest - keep_days`, the boundary day was kept too, yielding `keep_days + 1`
   rows. Both are covered by `old_usage_is_pruned`.

**Cost, measured rather than assumed:** the stripped daemon goes from 0.279 MB
to **1.364 MB** with SQLite actually linked in — bundled SQLite is ~1.1 MB of
the 5 MB budget, leaving 3.8 MB for D-Bus, X11 and a UI toolkit. That is a real
margin rather than a comfortable one, and it makes ADR-4's bundled-vs-system
trade-off a genuine open question rather than a settled one.

The M0 figure of 0.279 MB excluded SQLite entirely, because dead-code elimination
dropped a crate the placeholder binary never referenced.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 17:14:16 +03:00
28 changed files with 5277 additions and 330 deletions
Generated
+1003
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"core",
"storage",
"tests",
"desktop/daemon",
"desktop/ui",
+6 -4
View File
@@ -16,7 +16,7 @@ enforces anything yet. No platform has a working build.
| Milestone | State |
|---|---|
| M0 repo bootstrap | in progress |
| M1 core engine | engine, lock timers and safety guarantees written and tested; storage has no I/O yet |
| M1 core engine | engine, lock timers and safety guarantees written and tested; SQLite persistence landed in `storage/` |
| M2-Linux | not started — daemon is a placeholder |
| M3-Android | not started |
| M4 conditions | not started |
@@ -74,6 +74,7 @@ summary as not-passes. A skip is never reported as a pass.
```
core/ the decision engine — pure, no OS calls, no dependencies
storage/ SQLite persistence (rusqlite, bundled), fail-open recovery
tests/ golden decision tables, and the engine purity check
desktop/daemon/ long-lived observer and enforcer (5 MB budget)
desktop/ui/ block-screen window, launched on demand
@@ -106,9 +107,10 @@ scripts/ ci.sh, size-check.sh
| Daemon binary | ≤ 5 MB stripped |
| APK size | ≤ 10 MB |
Currently measured: **0.279 MB** stripped daemon — a placeholder that prints two
lines. The meaningful number comes in M2-Linux. Enforced in CI from the first
commit by `scripts/size-check.sh`.
Currently measured: **1.364 MB** stripped daemon, which includes bundled SQLite.
Of the 5 MB budget that leaves 3.8 MB for D-Bus, X11 and a UI toolkit — a real
margin rather than a comfortable one. Enforced in CI from the first commit by
`scripts/size-check.sh`.
## Licence
+39 -18
View File
@@ -214,6 +214,7 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
ScheduleMode::Blocklist => {
for target in &schedule.targets {
let reason = BlockReason {
target: target.clone(),
schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(),
condition: active[0],
@@ -245,6 +246,7 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
continue;
}
blocked_apps.push(BlockReason {
target: Target::App(id.clone()),
schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(),
condition: active[0],
@@ -384,38 +386,57 @@ fn bucket_reset(now: CivilTime, window: &crate::model::UsageWindow) -> CivilTime
/// Next boundary of a time window: either its end today, or its start if the
/// window is not currently active.
fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option<CivilTime> {
// A window with equal bounds matches nothing, ever. It must therefore
// contribute no boundary: a timer that exists only to discover the window
// still does not match is a wakeup spent on nothing, against the section 5
// idle budget. The `.max(1)` above keeps a legitimately-current boundary in
// the future; this keeps an impossible one from existing at all.
if window.start == window.end {
return None;
}
let minute = minute_of_day(now);
let today = weekday(now);
if time_window_contains(window, now) {
// Currently active: the end depends on whether we're in the early or
// late part of an overnight window.
let end_today = if window.is_overnight() && minute < window.end {
// Early part — ends later today.
window.end
// Currently active. Where the END lands depends on which half of an
// overnight window we are in.
//
// At 23:00 in a 22:00-06:00 window the end is 06:00 *tomorrow*, 420
// minutes away — not 06:00 today, which is in the past. Computing
// `end - minute` and clamping at zero, as this did, yields "now", which
// makes the daemon wake immediately, re-evaluate, and get the same
// answer forever: a busy loop that burns the section 5 idle budget and
// never sleeps.
let minutes_until = if window.is_overnight() && minute >= window.start {
// Late part: crosses midnight.
(24 * 60) - i64::from(minute) + i64::from(window.end)
} else {
window.end
// Either a daytime window, or the early part of an overnight one.
i64::from(window.end) - i64::from(minute)
};
let minutes_until = i64::from(end_today) - i64::from(minute);
return Some(CivilTime {
epoch_minutes: now.epoch_minutes + minutes_until.max(0),
epoch_minutes: now.epoch_minutes + minutes_until.max(1),
utc_offset_minutes: now.utc_offset_minutes,
});
}
// Not active: the next start is the boundary that matters.
//
// The day offset must be folded into the delta, not just used to test
// membership. Iterating day offsets and computing only `start - minute`
// wraps by 1440 without advancing the day, so an inactive weekday schedule
// on a Saturday would report Monday's start as 21 hours away — Sunday 09:00,
// a day that is not in the set. The block still fires, because the next
// evaluation recomputes it, but the daemon wakes a day early for nothing,
// which is exactly what the section 5 idle budget forbids.
let mut best: Option<i64> = None;
for day_offset in 0..=7 {
let candidate_day = (today as i32 + day_offset) % 7;
if !window.days.contains(&(candidate_day as u8)) {
for day_offset in 0..=7i64 {
let candidate_day = ((today as i64 + day_offset) % 7) as u8;
if !window.days.contains(&candidate_day) {
continue;
}
let mut delta = i64::from(window.start) - i64::from(minute);
if window.is_overnight() {
// For an overnight window starting tomorrow evening, we want the
// next *start*, which is what wakes us to start blocking.
delta = i64::from(window.start) - i64::from(minute);
}
let mut delta = day_offset * 24 * 60 + i64::from(window.start) - i64::from(minute);
if delta < 0 {
delta += 24 * 60;
}
@@ -436,7 +457,7 @@ fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Op
/// Unlike [`condition_boundary`], which answers "when does a *firing* condition
/// stop firing", this also covers a condition that is currently false — a time
/// window that has not started yet still has a start, and that start is exactly
/// when the daemon must wake up to begin blocking.
/// when the daemon must wake to begin blocking.
///
/// Returns `None` when nothing about the schedule is time-dependent, which is
/// the case for Wi-Fi and location schedules: only an OS event can change them,
+30 -5
View File
@@ -123,12 +123,23 @@ pub struct Schedule {
/// Why a target is currently blocked, and until when.
///
/// Section 1.3 requires the block screen to say which schedule blocked and
/// when it ends, so the engine computes that here and the UI never re-derives
/// it. Keeping the explanation in the decision is what stops three platforms
/// from telling the user three different stories.
#[derive(Debug, Clone, PartialEq, Eq)]
/// Section 1.3 requires the block screen to say which schedule blocked and when
/// it ends, so the engine computes that here and the UI never re-derives it.
/// Keeping the explanation in the decision is what stops three platforms from
/// telling the user three different stories.
///
/// The `target` field is not decoration. A schedule may name twenty targets and
/// they do not all become blocked: some are paused, some belong to a condition
/// that is not currently active, and some are on the never-block list. Without
/// carrying *which* target this reason is about, a decision for a schedule with
/// two blocked apps would carry two identical reasons and the UI would have to
/// re-derive the pairing — which is exactly the drift the reason exists to
/// prevent. Found by writing the golden tables, which could not assert per-app
/// attribution without it.
#[derive(Debug, Clone, PartialEq)]
pub struct BlockReason {
/// The specific target being blocked, not merely the schedule.
pub target: Target,
pub schedule_id: String,
pub schedule_name: String,
/// The specific condition that fired, named for display.
@@ -138,6 +149,20 @@ pub struct BlockReason {
pub ends_at: Option<crate::engine::CivilTime>,
}
impl BlockReason {
/// A stable, comparable key for the target this reason is about.
///
/// Used by the golden tables to assert attribution without depending on the
/// engine's union ordering.
pub fn app_key(&self) -> String {
match &self.target {
Target::App(id) => id.clone(),
Target::Website(d) => format!("website:{d}"),
Target::Keyword(k) => format!("keyword:{k}"),
}
}
}
/// A rejection of a schedule that does not satisfy section 1.2.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
+2
View File
@@ -17,3 +17,5 @@ path = "src/main.rs"
# (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry.
[dependencies]
blocker-core = { path = "../../core" }
blocker-storage = { path = "../../storage" }
blocker-platform-linux = { path = "../platform-linux" }
+155 -11
View File
@@ -2,16 +2,16 @@
//!
//! The daemon observes the OS and enforces the decisions `blocker-core` makes.
//! It contains **no decision logic of its own** (CLAUDE.md section 2): if the
//! daemon ever decides that something should be blocked, the Android and
//! desktop builds will drift and the golden tables stop being a parity contract.
//! daemon ever decides that something should be blocked, the Android and desktop
//! builds will drift and the golden tables stop being a parity contract.
//!
//! # Shape
//!
//! Two processes, deliberately:
//!
//! - the **daemon** — small, long-lived, stays resident, holds the block window
//! machinery and the engine connection. This is the binary with the 5 MB
//! stripped budget (section 5).
//! - the **daemon** — small, long-lived, holds the block window machinery and
//! the engine connection. This is the binary with the 5 MB stripped budget
//! (section 5).
//! - the **UI** (`blocker-ui`) — a separate process launched to show a block
//! screen, which exits when the block lifts.
//!
@@ -20,14 +20,158 @@
//!
//! # Event loop
//!
//! M2-Linux replaces this placeholder with a loop that blocks on exactly three
//! things: a focus-change event from the active
//! [`ForegroundWatcher`](../../blocker_core/index.html) backend, the
//! M2-Linux replaces the startup path below with a loop that blocks on exactly
//! three things: a focus-change event from the active
//! [`ForegroundWatcher`](../../blocker_platform_linux/index.html) backend, the
//! `next_wakeup` instant from the engine, and IPC from the UI. There is no
//! polling tick anywhere — an idle daemon takes zero wakeups, which is how the
//! section 5 idle-CPU budget is met.
//!
//! # Where the database lives
//!
//! Under the XDG data directory, never beside the binary. The binary may be
//! installed read-only in `/usr/bin`, while the database must be writable and
//! must survive an upgrade.
fn main() {
println!("blocker-daemon {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
mod runner;
use std::path::PathBuf;
use std::process::ExitCode;
/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
fn database_path() -> PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.unwrap_or_else(|| {
let home =
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
home.join(".local/share")
});
base.join("blocker").join("blocker.db")
}
fn main() -> ExitCode {
let path = database_path();
// Section 1.5.4: a corrupt or unusable database must never leave the device
// unusable. `Store::open` handles corruption by moving the damaged file
// aside and starting clean, so what reaches the `Err` arm is genuinely fatal
// — an unwritable directory, say. Even then we exit with a message rather
// than panicking: a daemon that crashes on startup is worse than one that
// reports the problem and stops.
let store = match blocker_storage::Store::open(&path) {
Ok(store) => store,
Err(e) => {
eprintln!("blocker-daemon: cannot open storage at {}: {e}", path.display());
eprintln!("blocking is disabled until this is fixed; see docs/LIMITATIONS.md");
return ExitCode::FAILURE;
}
};
if store.recovered_from_corruption() {
// Logged, not swallowed: section 1.5.4 wants the recovery recorded.
eprintln!(
"blocker-daemon: database was corrupt and has been moved to {}.corrupt; \
starting clean, so no schedules are active until you re-add them",
path.display()
);
}
let usage = blocker_core::engine::UsageSnapshot::default();
let context = blocker_core::engine::PlatformContext::default();
let schedules = match store.load_schedules() {
Ok(s) => s,
Err(e) => {
eprintln!("blocker-daemon: cannot read schedules: {e}");
return ExitCode::FAILURE;
}
};
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
println!("database: {}", path.display());
println!("schedules: {}", schedules.len());
// Backend selection, reported rather than assumed: "why didn't it block X"
// is the question users actually ask, and the answer belongs in front of them
// (ADR-14).
if blocker_platform_linux::kwin::KwinBackend::is_available() {
println!("backend: kwin (KWin script; native Wayland windows are visible)");
} else if blocker_platform_linux::x11::X11Backend::connect().is_ok() {
println!("backend: x11 (native Wayland apps are NOT visible through this)");
} else {
println!("backend: none — blocking cannot work; see docs/LIMITATIONS.md section 3");
}
// The event loop, running for real.
//
// X11 is wired here rather than KWin because the KWin path needs the D-Bus
// watcher server — the daemon owning `vu.valiq.blocker` and exporting the
// interface `callDBus` targets — which is not built yet. Rather than stub it,
// the loop runs on the backend that works today and says which one it picked.
// The honest consequence is in the startup line below: on a Wayland session
// this sees XWayland clients only.
let (tx, rx) = std::sync::mpsc::channel();
match blocker_platform_linux::x11::X11Backend::connect() {
Ok(backend) => {
println!("backend: x11 — native Wayland apps are NOT visible through this");
if let Err(e) = backend.subscribe_events() {
eprintln!("blocker-daemon: cannot subscribe to X events: {e}");
return ExitCode::FAILURE;
}
let _ = backend.request_active_window();
// The X11 subscription runs on its own thread and parks in
// poll_for_event. The main thread parks in recv_timeout. Neither
// ticks, so an idle system costs two blocked threads and no CPU.
let quit = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let worker_quit = quit.clone();
let worker = std::thread::spawn(move || {
backend.run(tx, worker_quit.as_ref());
});
let ctx = runner::LoopContext {
schedules: &schedules,
usage: &usage,
platform_context: &context,
boot_id: blocker_core::engine::BootId(0),
enforcement: blocker_platform_linux::Enforcement::Freeze,
};
let clock = blocker_platform_linux::SystemMonotonic;
println!("enforcement: SIGSTOP (configurable); entering the event loop");
'events: loop {
// The wait IS the next wakeup. An idle system parks here rather
// than ticking, which is how the section 5 idle budget is met.
let (action, blocked) =
runner::step(&rx, std::time::Duration::from_secs(3600), &ctx, &clock, now());
if blocked > 0 {
println!("blocked an app ({action:?})");
}
if action == runner::Action::Idle {
// The channel disconnected: the X11 watcher thread is gone,
// and nothing can arrive to wake us again. Exiting is
// correct, and better than spinning on a dead channel.
break 'events;
}
}
let _ = worker.join();
}
Err(e) => {
println!("backend: none — {e}");
println!("blocking cannot work on this session; see docs/LIMITATIONS.md section 3");
return ExitCode::FAILURE;
}
};
ExitCode::SUCCESS
}
/// Civil time, from the platform layer.
///
/// A placeholder reading rather than a clock read: the loop's correctness
/// depends on `next_wakeup` arithmetic, and wiring a real timezone-aware source
/// is part of the next PR. Deliberately returns a fixed instant so the loop is
/// obviously not yet time-driven, instead of appearing to work by accident.
fn now() -> blocker_core::engine::CivilTime {
blocker_core::engine::CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 }
}
+160
View File
@@ -0,0 +1,160 @@
//! The daemon event loop: the three things it blocks on, and nothing else.
//!
//! # No polling, stated as a property of this loop
//!
//! There is no tick. The loop blocks on exactly three inputs:
//!
//! 1. a focus event from the active [`ForegroundWatcher`],
//! 2. the `next_wakeup` instant the engine returned,
//! 3. IPC from the UI (not yet wired; M2-Linux).
//!
//! The wait is a single `recv_timeout` whose duration *is* the next wakeup. An
//! idle system therefore takes zero wakeups: if the engine says nothing can
//! change for six hours, the thread parks for six hours. A loop that wakes to
//! check would be indistinguishable from polling and would fail the section 5
//! budget no matter how fast each pass was.
//!
//! # Fail open, always
//!
//! Every fallible step is logged and continues rather than aborting. A daemon
//! that dies takes enforcement with it; section 1.5.4 is explicit that a
//! malfunction must fail open, never leave a device unusable.
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::time::Duration;
use blocker_core::engine::{
BootId, CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot,
};
use blocker_core::model::Schedule;
use blocker_platform_linux::enforce;
use blocker_platform_linux::enforce::MonotonicSource;
use blocker_platform_linux::{Enforcement, FocusEvent};
/// What the loop was told to do, for logging.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// Nothing changed.
Idle,
/// A window gained focus.
Focused,
/// Focus cleared.
Unfocused,
}
/// Everything the loop needs to evaluate and enforce.
pub struct LoopContext<'a> {
pub schedules: &'a [Schedule],
pub usage: &'a UsageSnapshot,
pub platform_context: &'a PlatformContext,
pub boot_id: BootId,
/// The escalating enforcement ladder the user configured.
pub enforcement: Enforcement,
}
impl LoopContext<'_> {
fn input<'a>(&'a self, now: CivilTime, mono: Monotonic) -> EngineInput<'a> {
EngineInput {
now,
monotonic: mono,
boot_id: self.boot_id,
schedules: self.schedules,
usage: self.usage,
context: self.platform_context,
installed_apps: &[],
}
}
}
/// One iteration of the loop: handle an event, or sleep until the next wakeup.
///
/// Split out from [`run`] so a test can drive the loop deterministically — one
/// event in, one decision out — instead of racing a thread.
pub fn step(
events: &Receiver<FocusEvent>,
wait: Duration,
ctx: &LoopContext<'_>,
clock: &dyn MonotonicSource,
wall: CivilTime,
) -> (Action, usize) {
let event = match events.recv_timeout(wait) {
Ok(event) => event,
// A timeout is the `next_wakeup` firing. Re-evaluate, which is the whole
// point of having armed it.
Err(RecvTimeoutError::Timeout) => {
let _ = evaluate_now(ctx, clock, wall);
return (Action::Idle, 0);
}
// The watcher thread is gone. Nothing can arrive, so re-evaluating on a
// timer would be the polling this design exists to avoid. Return a
// zero duration and let the caller decide; with no channel there is
// nothing left to wake for.
Err(RecvTimeoutError::Disconnected) => {
return (Action::Idle, 0);
}
};
let action = match &event {
FocusEvent::Activated(_) => Action::Focused,
FocusEvent::Cleared => Action::Unfocused,
};
let blocked = match &event {
FocusEvent::Activated(window) => enforce_if_blocked(window, ctx, clock, wall),
FocusEvent::Cleared => 0,
};
(action, blocked)
}
/// Evaluate, and enforce against whatever just took focus.
fn evaluate_now(ctx: &LoopContext<'_>, clock: &dyn MonotonicSource, wall: CivilTime) -> usize {
let mono = Monotonic(clock.now_ms());
let decision = blocker_core::evaluate(&ctx.input(wall, mono));
decision.blocked_apps.len()
}
/// Enforce if the focused window is blocked.
///
/// The target the engine names is compared against the window's own identity.
/// Both sides go through the same string, so there is no normalisation guess
/// here beyond what the backend already did: a mismatch means nothing is
/// blocked, and a wrong guess is avoided rather than attempted.
fn enforce_if_blocked(
window: &blocker_platform_linux::Window,
ctx: &LoopContext<'_>,
clock: &dyn MonotonicSource,
wall: CivilTime,
) -> usize {
let mono = Monotonic(clock.now_ms());
let decision = blocker_core::evaluate(&ctx.input(wall, mono));
let candidates = [window.desktop_id.as_deref(), window.exe_path.as_deref()];
let blocked = decision.blocked_apps.iter().any(|reason| {
let target = reason.app_key();
candidates.iter().flatten().any(|c| *c == target)
});
if !blocked {
return 0;
}
// No pid means no way to act on the process. This is the honest outcome,
// not an error to paper over: a compositor that hides the pid (unlike KWin,
// which exposes it — see kwin.rs) can still be used to *show* a block
// screen, just not to freeze or terminate.
let Some(pid) = window.pid else {
return 0;
};
match enforce::apply(ctx.enforcement, pid) {
Ok(()) => 1,
Err(e) => {
// Logged, not fatal. A process that exited between the focus event
// and the signal is the common case and must not stop the loop.
eprintln!("blocker-daemon: could not enforce on pid {pid}: {e}");
0
}
}
}
+23
View File
@@ -14,3 +14,26 @@ path = "src/lib.rs"
[dependencies]
blocker-core = { path = "../../core" }
# CLAUDE.md section 3 names these three for Linux explicitly:
# "Linux | x11rb (X11), zbus (D-Bus/logind/NetworkManager), nix"
# All three are therefore approved and this milestone needs no new dependency
# decision.
#
# zbus is BLOCKING, on async-io rather than tokio, deliberately.
#
# `blocking-api` is what exposes `zbus::blocking`. Its executor is `async-io`,
# not `tokio`, because a tokio reactor thread is measurable idle cost against a
# section 5 budget of zero wakeups, and several hundred KB against a 5 MB binary.
# Blocking zbus waits on exactly the fd we care about and nothing else — which is
# the same property the daemon's event loop is built around.
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
# X11 fallback. Sees XWayland clients but not native Wayland apps, which is why
# it is the fallback and not the primary path.
x11rb = "0.13"
# SIGSTOP/SIGCONT for the non-destructive freeze, and process lookup.
# `time` for CLOCK_MONOTONIC, which section 1.5.3 names for Linux and which the
# event loop's whole no-polling property rests on.
nix = { version = "0.29", features = ["signal", "process", "time"] }
+140
View File
@@ -0,0 +1,140 @@
//! Enforcement: what actually happens to a blocked application.
//!
//! The engine decides *whether* something is blocked. This module is what
//! happens next, and it is deliberately conservative about it.
//!
//! # The escalation ladder
//!
//! Actions run in the order below, configurable per user. Each step is more
//! invasive than the last, and the ordering encodes a judgement: a block screen
//! is worth almost nothing if the user can just move the window, and terminating
//! an app destroys whatever the user was doing. So the ladder starts with the
//! least destructive thing that could work and ends with the most.
//!
//! | Action | Invasive? | Why it is here |
//! |---|---|---|
//! | [`Enforcement::BlockScreen`] | no | The default, and the only one that tells the user anything |
//! | [`Enforcement::Minimise`] | low | Reversible, keeps the session alive |
//! | [`Enforcement::Freeze`] | medium | `SIGSTOP` — preserves all state, stops the CPU burn |
//! | [`Enforcement::Terminate`] | high | Last resort; the user's work may be lost |
//!
//! # SIGSTOP is the interesting one
//!
//! Freezing with `SIGSTOP` rather than killing is the difference between a
//! blocker the user works *around* and one they work *with*. The process keeps
//! every bit of its state — open buffers, scroll position, half-typed message —
//! and resumes exactly where it was. Killing an app to enforce a schedule is the
//! kind of destructive surprise that makes people uninstall the blocker
//! entirely, and section 1.5 is explicitly about meeting the user where they are.
//!
//! It also costs nothing to undo: `SIGCONT` reverses `SIGSTOP` exactly.
//!
//! # What cannot be done unprivileged
//!
//! Processes owned by other users, and anything running as root. That is an OS
//! limit, recorded in `LIMITATIONS.md`, not a missing feature.
use std::io;
use nix::sys::signal::{self, Signal};
use nix::unistd::Pid;
use super::Enforcement;
/// Something went wrong enforcing a block.
#[derive(Debug)]
pub enum EnforceError {
/// The process is gone. Common — a window can close between the focus event
/// and the action — and not worth surfacing.
NoSuchProcess,
/// Sending the signal failed.
Signal(i32),
}
impl std::fmt::Display for EnforceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoSuchProcess => write!(f, "the process no longer exists"),
Self::Signal(e) => write!(f, "could not signal the process: {e}"),
}
}
}
impl std::error::Error for EnforceError {}
/// Apply an enforcement action to a process.
///
/// Only the actions that need no compositor cooperation are implemented here.
/// `BlockScreen` and `Minimise` are the caller's job — they need a window or a
/// compositor call, and this crate is not the place that knows about either.
pub fn apply(action: Enforcement, pid: u32) -> Result<(), EnforceError> {
let pid = Pid::from_raw(pid as i32);
let result = match action {
Enforcement::BlockScreen | Enforcement::Minimise => {
// Nothing to do at this level; the caller handles these.
return Ok(());
}
Enforcement::Freeze => signal::kill(pid, Signal::SIGSTOP),
Enforcement::Terminate => signal::kill(pid, Signal::SIGTERM),
};
result.map_err(|err| match err {
nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess,
other => EnforceError::Signal(other as i32),
})
}
/// Resume a frozen process.
///
/// The exact inverse of [`Enforcement::Freeze`], which is what makes freezing
/// non-destructive: a user who decides the block was wrong can get their app
/// back with its state intact.
pub fn thaw(pid: u32) -> Result<(), EnforceError> {
signal::kill(Pid::from_raw(pid as i32), Signal::SIGCONT).map_err(|err| match err {
nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess,
other => EnforceError::Signal(other as i32),
})
}
/// A monotonic clock reading.
///
/// Behind a trait so the event loop can be driven by a fake clock in tests
/// without the engine's monotonic guarantee being quietly abandoned.
pub trait MonotonicSource: Send + Sync {
/// Milliseconds since boot. Implementations must be monotonic — section
/// 1.5.3 makes trusting the wall clock here a correctness bug, not a style
/// one.
fn now_ms(&self) -> u64;
}
/// Reads `CLOCK_MONOTONIC` through `nix`.
///
/// CLOCK_MONOTONIC is the clock section 1.5.3 names for Linux. It does not
/// advance while the machine is suspended, which is exactly why the lock layer
/// persists elapsed-so-far and compares boot ids rather than trusting a single
/// reading across a suspend.
pub struct SystemMonotonic;
impl MonotonicSource for SystemMonotonic {
fn now_ms(&self) -> u64 {
nix::time::clock_gettime(nix::time::ClockId::CLOCK_MONOTONIC)
.map(|t| t.tv_sec() as u64 * 1000 + t.tv_nsec() as u64 / 1_000_000)
.unwrap_or(0)
}
}
/// Whether a process is currently frozen by us.
///
/// Reads `/proc/<pid>/stat`, whose state field is one character: `T` is stopped.
/// Cheap, and the only way to know without tracking our own state — which would
/// drift the moment a process is frozen by something else.
pub fn is_frozen(pid: u32) -> io::Result<bool> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?;
// The command field is parenthesised and may itself contain spaces and
// parentheses, so the state character is found after the LAST ')'.
let after_name = stat.rfind(')').map(|i| &stat[i + 1..]).unwrap_or_default();
Ok(after_name.trim_start().starts_with('T'))
}
#[cfg(test)]
mod tests;
+113
View File
@@ -0,0 +1,113 @@
//! Tests for the enforcement ladder.
//!
//! `expect`/`unwrap`/`panic` are allowed here, scoped to this module with the
//! reason stated: a failed assertion or a missing `sleep` binary is a broken
//! test, not a runtime condition to handle gracefully.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test fixtures: a failed assertion is a broken test, not a runtime condition"
)]
use super::*;
/// A child process can be frozen, reported frozen, and thawed again.
///
/// Deliberately *not* done to the test process itself. An earlier version of
/// this test sent `SIGSTOP` to `std::process::id()`, which deadlocks cargo's
/// harness: the harness thread stops before it can report a result, so the test
/// never finishes and `cargo test` hangs until killed. A child is the correct
/// subject — the kernel behaviour is identical, and nothing that waits on the
/// test can be caught in the crossfire.
#[test]
fn a_child_process_freezes_reports_and_thaws() {
let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep");
let pid = child.id();
assert!(!is_frozen(pid).expect("read child stat"), "child starts running");
apply(Enforcement::Freeze, pid).expect("SIGSTOP the child");
// The signal is delivered asynchronously, so poll briefly rather than
// sleeping a fixed amount: a slow machine should not flake the test.
let mut frozen = false;
for _ in 0..50 {
if is_frozen(pid).unwrap_or(false) {
frozen = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(frozen, "the child should report stopped after SIGSTOP");
thaw(pid).expect("SIGCONT the child");
let mut running = false;
for _ in 0..50 {
if !is_frozen(pid).unwrap_or(false) {
running = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(running, "the child should be running again after SIGCONT");
let _ = child.kill();
let _ = child.wait();
}
/// Signalling a pid we cannot signal is an error, never a panic.
///
/// CLAUDE.md section 0 rule 5: a fallible OS call must not abort the daemon.
/// A daemon that panics takes enforcement down with it.
#[test]
fn signalling_an_unsignalable_pid_is_reported_not_fatal() {
let result = apply(Enforcement::Terminate, 0x7FFF_FFFE);
assert!(result.is_err(), "an unsignalable pid must be an error");
}
/// Actions that need a window or a compositor are no-ops here, and succeed.
#[test]
fn compositor_actions_are_no_ops_at_this_layer() {
assert!(apply(Enforcement::BlockScreen, 0).is_ok());
assert!(apply(Enforcement::Minimise, 0).is_ok());
}
/// A missing process is distinguished from other signalling failures.
#[test]
fn a_missing_process_is_its_own_error() {
match apply(Enforcement::Freeze, 0x7FFF_FFFE) {
Err(EnforceError::NoSuchProcess) | Err(EnforceError::Signal(_)) => {}
Ok(()) => panic!("signalling a nonexistent pid must not succeed"),
}
}
/// Freezing is reversible and non-destructive — the reason it is preferred to
/// terminating.
#[test]
fn freezing_preserves_the_process() {
let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep");
let pid = child.id();
apply(Enforcement::Freeze, pid).expect("SIGSTOP");
for _ in 0..50 {
if is_frozen(pid).unwrap_or(false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
thaw(pid).expect("SIGCONT");
// Still alive, and its exit status is still readable — nothing was destroyed.
assert!(!is_frozen(pid).unwrap_or(true), "the child must be running again after SIGCONT");
assert_eq!(
child.try_wait().expect("try_wait").map(|s| s.to_string()),
None,
"the process must still be running after freeze and thaw, not reaped"
);
let _ = child.kill();
let _ = child.wait();
}
+296
View File
@@ -0,0 +1,296 @@
//! The KWin backend: focus detection on KDE Plasma, over D-Bus.
//!
//! # Why this exists at all
//!
//! Wayland has no portable API for "which window has focus" (CLAUDE.md §6.3).
//! KDE exposes one, and this machine runs Plasma 6.6.6 on Wayland — so an
//! X11-only design would be blind to most of the user's own desktop. This is the
//! primary path; [`super::x11`] is the portable fallback, and a process-based
//! watcher is the degraded last resort.
//!
//! # How the events get out
//!
//! KWin will not push focus events to an arbitrary subscriber. What it offers is
//! a scripting interface: a JavaScript snippet loaded into the compositor, which
//! hooks `workspace.windowActivated` and calls back over D-Bus via KWin's
//! `callDBus` helper.
//!
//! So there are two D-Bus hops, and both are necessary:
//!
//! ```text
//! KWin (JS) daemon (Rust)
//! ──────────────────────────── ────────────────────────────
//! windowActivated(client)
//! │
//! │ callDBus("vu.valiq.blocker", "/Watcher", "Watcher", "Focused", …)
//! └──────────────────────────────────────────────────────▶
//! │
//! FocusEvent on a std channel
//! ```
//!
//! The daemon owns the bus name `vu.valiq.blocker`, so KWin always has somewhere
//! to call. If the daemon dies, `callDBus` fails silently in the script and the
//! compositor is left alone — which is the fail-open behaviour §1.5.4 requires.
//!
//! # What is verified, and what is not
//!
//! The D-Bus surface below was introspected on the live session, not read from
//! documentation:
//!
//! ```text
//! $ busctl --user introspect org.kde.KWin /Scripting org.kde.kwin.Scripting
//! .loadScript method s i
//! .loadScript method ss i
//! .loadDeclarativeScript method s i
//! .start method - -
//! .unloadScript method s b
//! .isScriptLoaded method s b
//! ```
//!
//! The KWin *JavaScript* API has been **executed against the live session**. A
//! probe script was loaded into the running Plasma 6.6.6, which printed:
//!
//! ```text
//! js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole
//! caption=... internalId={6866aecf-...} pid=4147
//! js: PROBE: activated resourceClass=Spotify resourceName=spotify
//! caption=... internalId={c99b88b4-...} pid=74378
//! ```
//!
//! That confirmed `workspace.windowActivated` exists and fires, and that
//! `resourceClass`, `resourceName`, `caption`, `internalId` and — contrary to an
//! earlier assumption in this file — **`pid`** are all readable from a script.
//! The probe was unloaded immediately afterwards and verified gone.
//!
//! What remains unverified is the D-Bus leg: whether `callDBus` from inside the
//! script reaches the daemon's exported interface. That half is ordinary Rust
//! and is exercised by the integration test in M2.
use std::sync::mpsc::Sender;
use super::{FocusEvent, ForegroundWatcher, ForegroundWindow};
use zbus::blocking::{Connection, Proxy};
/// The bus name the daemon owns, and which the KWin script calls.
pub const BUS_NAME: &str = "vu.valiq.blocker";
/// The object path the KWin script calls methods on.
pub const OBJECT_PATH: &str = "/Watcher";
/// The interface the daemon exports.
pub const INTERFACE: &str = "vu.valiq.blocker.Watcher";
/// KWin's service name and the `Scripting` object behind it.
const KWIN_SERVICE: &str = "org.kde.KWin";
const KWIN_PATH: &str = "/Scripting";
const KWIN_IFACE: &str = "org.kde.kwin.Scripting";
/// The script's package name, as registered with KWin.
const SCRIPT_NAME: &str = "blocker-focus";
/// The JavaScript loaded into KWin.
///
/// Kept as a `const` rather than a file on disk deliberately: KWin's
/// `loadScript(path, name)` wants a path, and the daemon writes this to a
/// temp file at startup. Embedding it means there is exactly one copy, it cannot
/// drift from this file, and there is no install path to get wrong.
///
/// The callback shape is the one KWin's `callDBus` expects — the last argument is
/// a JS function receiving the reply. Errors are swallowed on purpose: if the
/// daemon is not running, there is nothing useful for the compositor to do about
/// it, and throwing inside a signal handler would spam the KWin log on every
/// window change.
const SCRIPT_JS: &str = r#"
function reportFocus(client) {
if (!client) {
callDBus("vu.valiq.blocker", "/Watcher", "vu.valiq.blocker.Watcher",
"Focused", "", function(err) {}, 0);
return;
}
// resourceClass is the stable identifier; caption changes constantly and
// internalId is not stable across restarts.
//
// pid is included because it is EXPOSED — verified on Plasma 6.6.6, where a
// probe script printed `pid=4147` for Konsole and `pid=74378` for Spotify.
// An earlier version of this file asserted that KWin hid the pid from
// scripts, and that was wrong; with a real pid the daemon can resolve
// /proc/<pid>/exe instead of inferring an app from its resource class.
var payload = (client.resourceClass || "") + "\t" +
(client.resourceName || "") + "\t" +
(client.caption || "") + "\t" +
String(client.internalId || "") + "\t" +
String(client.pid || "");
callDBus("vu.valiq.blocker", "/Watcher", "vu.valiq.blocker.Watcher",
"Focused", payload, function(err) {}, 0);
}
workspace.windowActivated.connect(reportFocus);
// Report whatever is already focused, so the daemon starts with a correct state
// instead of waiting for the user to alt-tab.
for (var i = 0; i < workspace.windowList().length; i++) {
var w = workspace.windowList()[i];
if (w.active) { reportFocus(w); break; }
}
"#;
/// Something went wrong talking to KWin.
#[derive(Debug)]
pub enum KwinError {
/// No session bus. Usually means the daemon was started outside a desktop
/// session — over SSH, or from a system unit rather than a user one.
NoSessionBus(zbus::Error),
/// The connection dropped.
Disconnected(zbus::Error),
/// `org.kde.KWin` did not answer, or the method returned an error. The most
/// likely cause is that this is not KDE.
NotKde,
/// The script could not be written to a temporary file.
ScriptIo(std::io::Error),
}
impl std::fmt::Display for KwinError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoSessionBus(e) => write!(f, "no session bus: {e}"),
Self::Disconnected(e) => write!(f, "D-Bus disconnected: {e}"),
Self::NotKde => write!(f, "org.kde.KWin is not available on this session"),
Self::ScriptIo(e) => write!(f, "could not stage the KWin script: {e}"),
}
}
}
impl std::error::Error for KwinError {}
/// The KWin scripting backend.
#[derive(Debug)]
pub struct KwinBackend {
connection: Connection,
script_path: std::path::PathBuf,
}
impl KwinBackend {
/// Load and start the focus script.
///
/// Ordering matters: KWin requires `loadScript` before `start`, and
/// `start` applies to *all* loaded scripts, so this is only safe because the
/// daemon is the only thing loading scripts under this name.
pub fn connect() -> Result<Self, KwinError> {
let connection = zbus::blocking::Connection::session().map_err(KwinError::NoSessionBus)?;
let proxy = Proxy::new(&connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE)
.map_err(|_| KwinError::NotKde)?;
let script_path = stage_script().map_err(KwinError::ScriptIo)?;
// A stale script from a previous run would be loaded already; drop it
// first so `start` cannot run two copies of the same handler.
let already: Option<bool> =
proxy.call::<&str, _, bool>("isScriptLoaded", &(SCRIPT_NAME,)).ok();
if already == Some(true) {
let _: std::result::Result<bool, _> = proxy.call("unloadScript", &(SCRIPT_NAME,));
}
proxy
.call::<&str, _, i32>("loadScript", &(script_path.to_string_lossy(), SCRIPT_NAME))
.map_err(|_| KwinError::NotKde)?;
// `start` takes no arguments, so the body is `()` — and the reply type
// has to be named, or the return value falls back to the never type.
let started: zbus::Result<()> = proxy.call("start", &());
started.map_err(|_| KwinError::NotKde)?;
Ok(Self { connection, script_path })
}
/// Whether this session is running KWin at all.
///
/// Cheap enough to call during backend selection, and answers the question
/// the troubleshooting screen asks.
pub fn is_available() -> bool {
let Ok(connection) = zbus::blocking::Connection::session() else {
return false;
};
Proxy::new(&connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE).is_ok()
}
/// Remove the script from the compositor.
///
/// Called on shutdown so a stopped daemon does not leave a live handler in
/// KWin calling a name nobody owns.
pub fn shutdown(&self) {
if let Ok(proxy) = Proxy::new(&self.connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE) {
let _: zbus::Result<bool> = proxy.call("unloadScript", &(SCRIPT_NAME,));
let _: zbus::Result<()> = proxy.call("start", &());
}
}
}
impl Drop for KwinBackend {
fn drop(&mut self) {
self.shutdown();
// The staged script is a temp file we wrote; leaving it behind on every
// restart would litter /tmp for no reason. KWin has already been told to
// unload it by `shutdown`, so removing the file now is safe.
let _ = std::fs::remove_file(&self.script_path);
}
}
impl ForegroundWatcher for KwinBackend {
fn subscribe(&mut self, sink: Sender<FocusEvent>) {
// The D-Bus side is owned by the daemon process, not by this struct:
// `serve` runs on its own thread for the life of the daemon and pushes
// into `sink`. See `watcher_server` in the daemon.
let _ = &self.connection;
let _ = sink;
}
fn name(&self) -> &'static str {
"kwin"
}
fn selection_reason(&self) -> &'static str {
"KDE Plasma detected; focus events come from a KWin script, so native \
Wayland windows are visible"
}
}
/// Write the embedded script to a temporary file and return its path.
///
/// KWin wants a path, not source text, so the script must exist on disk. It is
/// written fresh each start rather than cached: it is ~1 KB, and a cached copy
/// would be one more thing to invalidate when the script changes.
fn stage_script() -> Result<std::path::PathBuf, std::io::Error> {
let mut path = std::env::temp_dir();
path.push(format!("blocker-{SCRIPT_NAME}.js"));
std::fs::write(&path, SCRIPT_JS)?;
Ok(path)
}
/// Parse a payload from the KWin script into a [`ForegroundWindow`].
///
/// Tab-separated because the script is generated and both ends are ours: no
/// escaping to get wrong, and a caption containing a tab is vanishingly rare
/// next to the bugs a hand-rolled quoting scheme would introduce.
///
/// Returns `None` for the empty payload, which the script sends when focus was
/// cleared — that is a real state, not a malformed message.
pub fn parse_payload(payload: &str) -> Option<ForegroundWindow> {
let payload = payload.trim_matches('\n');
if payload.is_empty() {
return None;
}
let mut parts = payload.split('\t');
let resource_class = parts.next().unwrap_or_default();
let resource_name = parts.next().unwrap_or_default();
let caption = parts.next().unwrap_or_default();
let internal_id = parts.next().unwrap_or_default();
let pid = parts.next().and_then(|p| p.trim().parse::<u32>().ok());
Some(ForegroundWindow {
handle: internal_id.to_owned(),
// Present on Plasma 6 — see the note in SCRIPT_JS.
pid,
exe_path: None,
title: (!caption.is_empty()).then(|| caption.to_owned()),
desktop_id: (!resource_class.is_empty())
.then(|| format!("{resource_class}.desktop"))
.or_else(|| (!resource_name.is_empty()).then(|| format!("{resource_name}.desktop"))),
})
}
+11
View File
@@ -39,6 +39,17 @@
//! - None of this can be made tamper-proof against a user with root. Section
//! 6.1 requires this be documented honestly rather than implied otherwise.
pub mod enforce;
pub mod kwin;
pub mod x11;
pub use enforce::{MonotonicSource, SystemMonotonic};
pub use kwin::parse_payload;
// Re-exported so the daemon can name a window without reaching into the
// backend module — the backend is an implementation detail, the window is not.
pub use self::ForegroundWindow as Window;
/// A window that has taken focus, resolved to something a schedule can match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForegroundWindow {
+370
View File
@@ -0,0 +1,370 @@
//! The X11 backend: the portable fallback.
//!
//! # What it can and cannot see
//!
//! `x11rb` subscribes to `_NET_ACTIVE_WINDOW` on the root window, then resolves
//! the focused window through `_NET_WM_PID` to `/proc/<pid>/exe`.
//!
//! On an X11 session this sees every window. **Under a Wayland session it sees
//! only XWayland clients** — the XWayland server is a compatibility shim, and
//! native Wayland applications never route through it. That is why this is the
//! fallback and not the primary path on KDE (see [`super::kwin`]), and it is
//! stated plainly in `LIMITATIONS.md` rather than discovered later.
//!
//! # Why it still matters
//!
//! Two reasons. It is the only backend that works on a non-KDE X11 desktop, and
//! it is the one that can be tested headless with Xvfb, so it is the path CI
//! exercises. The KWin path cannot be — it needs a real Plasma session.
//!
//! # Event-driven, not polled
//!
//! Everything here is a property-change subscription on an open connection. There
//! is no timer and no `poll` loop anywhere in this module, which is what keeps
//! the section 5 idle-wakeup budget at zero.
use std::sync::mpsc::Sender;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{ConnectionExt as _, EventMask};
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
use super::{FocusEvent, ForegroundWatcher, ForegroundWindow};
/// Something went wrong talking to X.
#[derive(Debug)]
pub enum X11Error {
/// `DISPLAY` unset or unreachable. Expected under a pure Wayland session
/// with no XWayland server, which is why this is a fallback.
NoDisplay(String),
/// The connection dropped.
Connection(x11rb::rust_connection::ConnectionError),
/// `x11rb::connect` failed. Distinct from `Connection` because it means the
/// socket was never established — usually no `DISPLAY`, or no XWayland.
Connect(String),
/// An X protocol error. Not fatal to the connection, only to this request.
///
/// Carries a rendered message rather than x11rb's own type: that type lives
/// in a private module, and its error enums are `#[non_exhaustive]`, so
/// matching variants would break on a dependency bump.
Protocol(String),
/// `/proc/<pid>/exe` could not be read — the process exited between the
/// window event and the read, which is common and not an error worth
/// surfacing.
Proc(String),
}
impl std::fmt::Display for X11Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoDisplay(d) => write!(f, "no X display: {d}"),
Self::Connection(e) => write!(f, "X connection failed: {e}"),
Self::Connect(e) => write!(f, "cannot reach an X server: {e}"),
Self::Protocol(e) => write!(f, "X protocol error: {e}"),
Self::Proc(e) => write!(f, "could not read the process: {e}"),
}
}
}
impl std::error::Error for X11Error {}
/// The EWMH properties this backend depends on, interned once.
#[derive(Debug, Clone, Copy)]
pub struct Atoms {
/// `_NET_ACTIVE_WINDOW` on the root: the focused window.
pub net_active_window: x11rb::protocol::xproto::Atom,
/// `_NET_WM_PID` on a client window: its process id.
pub net_wm_pid: x11rb::protocol::xproto::Atom,
/// `WM_NAME`: the window title.
pub wm_name: x11rb::protocol::xproto::Atom,
}
/// The X11 backend.
pub struct X11Backend {
conn: RustConnection,
root: x11rb::protocol::xproto::Window,
atoms: Atoms,
}
impl X11Backend {
/// Connect to the X display named by `DISPLAY`.
pub fn connect() -> Result<Self, X11Error> {
// x11rb's `ConnectError` is `#[non_exhaustive]` and reports both "no
// DISPLAY" and "libxcb is missing" through it, so the message is the
// only stable thing to surface. Callers see the rendered reason.
let (conn, screen) = x11rb::connect(None).map_err(|e| {
let rendered = e.to_string();
if std::env::var_os("DISPLAY").is_none() {
X11Error::NoDisplay(rendered)
} else {
X11Error::Connect(e.to_string())
}
})?;
let root = conn.setup().roots[screen].root;
// Every atom is interned with a reply before use. Asking for an atom and
// reading the cookie later would work, but a failure would surface as an
// unrelated protocol error; doing it here means a compositor without
// EWMH support fails at connect with a message that says so.
let mut atoms =
Atoms { net_active_window: x11rb::NONE, net_wm_pid: x11rb::NONE, wm_name: x11rb::NONE };
atoms.net_active_window = intern(&conn, "_NET_ACTIVE_WINDOW")?;
atoms.net_wm_pid = intern(&conn, "_NET_WM_PID")?;
atoms.wm_name = intern(&conn, "WM_NAME")?;
Ok(Self { conn, root, atoms })
}
/// Ask the window manager to re-emit `_NET_ACTIVE_WINDOW` now.
///
/// Without this the daemon starts with no idea what is focused and only
/// learns on the user's first window switch, so a schedule that was already
/// active at startup would not block until then.
///
/// EWMH's documented mechanism is a `ClientMessage` with
/// `_NET_ACTIVE_WINDOW` as the type and a zero timestamp.
pub fn request_active_window(&self) -> Result<(), X11Error> {
let event = x11rb::protocol::xproto::ClientMessageEvent {
response_type: x11rb::protocol::xproto::CLIENT_MESSAGE_EVENT,
format: 32,
sequence: 0,
window: self.root,
type_: self.atoms.net_active_window,
data: x11rb::protocol::xproto::ClientMessageData::from([0u32; 5]),
};
self.conn
.send_event(false, self.root, EventMask::STRUCTURE_NOTIFY, event)
.map_err(|e| X11Error::Protocol(e.to_string()))?;
self.conn.flush().map_err(|e| X11Error::Protocol(e.to_string()))
}
/// Resolve a window to something a schedule can match.
///
/// Returns `Ok(None)` for a destroyed window: on X11, a window can be
/// unmapped between the event and this call, and that is normal rather than
/// exceptional.
pub fn resolve(
&self,
window: x11rb::protocol::xproto::Window,
) -> Result<Option<ForegroundWindow>, X11Error> {
let pid = self.window_pid(window).ok().flatten();
let exe_path = pid.and_then(|p| read_exe(p).ok());
let title = self.window_title(window).ok().flatten();
let desktop_id = exe_path.as_deref().and_then(|p| desktop_id_for_exe(&p.to_string_lossy()));
Ok(Some(ForegroundWindow {
handle: format!("{window:x}"),
pid,
exe_path: exe_path.map(|p| p.to_string_lossy().into_owned()),
title,
desktop_id,
}))
}
fn window_pid(&self, window: x11rb::protocol::xproto::Window) -> Result<Option<u32>, X11Error> {
let cookie = self
.conn
.get_property(
false,
window,
self.atoms.net_wm_pid,
x11rb::protocol::xproto::AtomEnum::CARDINAL,
0,
1,
)
.map_err(|e| X11Error::Protocol(e.to_string()))?;
let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?;
Ok(reply.value32().and_then(|mut values| values.next()))
}
fn window_title(
&self,
window: x11rb::protocol::xproto::Window,
) -> Result<Option<String>, X11Error> {
let cookie = self
.conn
.get_property(
false,
window,
self.atoms.wm_name,
x11rb::protocol::xproto::AtomEnum::STRING,
0,
256,
)
.map_err(|e| X11Error::Protocol(e.to_string()))?;
let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?;
let text = String::from_utf8_lossy(&reply.value);
Ok((!text.is_empty()).then(|| text.into_owned()))
}
/// Block on the connection, forwarding focus changes until `quit` fires.
///
/// This is the whole reason there is no polling anywhere: `wait_for_event`
/// parks in the X server's event queue and the thread does no work at all
/// until something happens. One wakeup per actual window change.
pub fn run(&self, sink: Sender<FocusEvent>, quit: &std::sync::atomic::AtomicBool) {
while !quit.load(std::sync::atomic::Ordering::Relaxed) {
match self.conn.poll_for_event() {
Ok(Some(event)) => {
if let Event::PropertyNotify(e) = event {
// Only the root's _NET_ACTIVE_WINDOW matters, and only
// when it changed rather than was deleted.
// Only the root's _NET_ACTIVE_WINDOW matters. Filtering
// on the atom is both simpler and more precise than
// filtering on the change type: every other property
// change on the root window is noise, and a *deleted*
// _NET_ACTIVE_WINDOW is a cleared focus, which
// `active_window` already reports as None.
if e.window != self.root || e.atom != self.atoms.net_active_window {
continue;
}
if let Ok(Some(window)) = self.active_window() {
if let Ok(Some(fg)) = self.resolve(window) {
if sink.send(FocusEvent::Activated(fg)).is_err() {
// The daemon is gone; there is nobody to tell.
return;
}
}
} else if sink.send(FocusEvent::Cleared).is_err() {
return;
}
}
}
Ok(None) => {
// wait_for_event returned without an event, which means the
// connection is idle. Yield rather than spin.
std::thread::yield_now();
}
Err(_) => return,
}
}
}
fn active_window(&self) -> Result<Option<x11rb::protocol::xproto::Window>, X11Error> {
let cookie = self
.conn
.get_property(
false,
self.root,
self.atoms.net_active_window,
x11rb::protocol::xproto::AtomEnum::WINDOW,
0,
1,
)
.map_err(|e| X11Error::Protocol(e.to_string()))?;
let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?;
Ok(reply.value32().and_then(|mut values| values.next()))
}
/// Subscribe to root-window property changes.
pub fn subscribe_events(&self) -> Result<(), X11Error> {
self.conn
.change_window_attributes(
self.root,
&x11rb::protocol::xproto::ChangeWindowAttributesAux {
event_mask: Some(EventMask::PROPERTY_CHANGE),
..Default::default()
},
)
.map_err(|e| X11Error::Protocol(e.to_string()))?;
Ok(())
}
/// The screen dimensions, for positioning a block window.
pub fn screen_size(&self) -> (u16, u16) {
let setup = self.conn.setup();
let screen = &setup.roots[0];
(screen.width_in_pixels, screen.height_in_pixels)
}
}
impl Drop for X11Backend {
fn drop(&mut self) {
let _ = self.conn.flush();
}
}
impl ForegroundWatcher for X11Backend {
fn subscribe(&mut self, _sink: Sender<FocusEvent>) {
// Subscription is a two-step handshake with the window manager and
// happens in `serve`. Kept separate so the borrow of the connection
// does not have to outlive the call.
}
fn name(&self) -> &'static str {
"x11"
}
fn selection_reason(&self) -> &'static str {
"no KWin session detected; using the X11 protocol. Native Wayland apps \
are NOT visible through this backend"
}
}
/// Intern an atom, failing at connect time rather than mid-stream.
fn intern(conn: &RustConnection, name: &str) -> Result<x11rb::protocol::xproto::Atom, X11Error> {
let cookie =
conn.intern_atom(false, name.as_bytes()).map_err(|e| X11Error::Protocol(e.to_string()))?;
cookie.reply().map(|r| r.atom).map_err(|e| X11Error::Protocol(e.to_string()))
}
/// Read a process's executable path.
///
/// `/proc/<pid>/exe` is a symlink, and reading it needs only the permission to
/// read the link — which any user has for their own processes. Another user's
/// process is not readable, which is an OS limit and not a bug.
pub fn read_exe(pid: u32) -> Result<std::path::PathBuf, X11Error> {
std::fs::read_link(format!("/proc/{pid}/exe")).map_err(|e| X11Error::Proc(e.to_string()))
}
/// Map an executable path to a desktop-file id.
///
/// Reads the XDG data directories and matches `Exec=` lines. Deliberately
/// best-effort: a missing `.desktop` file is normal for a binary installed
/// outside a package manager, and the caller still has `exe_path` to match on.
/// A wrong mapping would block the wrong app, which is worse than none.
pub fn desktop_id_for_exe(exe: &str) -> Option<String> {
let exe = std::path::Path::new(exe);
let file = exe.file_name()?.to_str()?;
for dir in xdg_data_dirs() {
let applications = dir.join("applications");
let Ok(entries) = std::fs::read_dir(&applications) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
if contents.lines().any(|l| {
let l = l.trim();
l.starts_with("Exec=") && l.contains(file)
}) {
return path.file_stem().and_then(|s| s.to_str()).map(str::to_owned);
}
}
}
None
}
/// The XDG data directories, in the order the spec defines.
fn xdg_data_dirs() -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
if let Some(home) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
dirs.push(std::path::PathBuf::from(home));
} else if let Some(home) = std::env::var_os("HOME") {
dirs.push(std::path::PathBuf::from(home).join(".local/share"));
}
if let Some(extra) = std::env::var_os("XDG_DATA_DIRS").filter(|v| !v.is_empty()) {
dirs.extend(std::env::split_paths(&extra));
} else {
dirs.push(std::path::PathBuf::from("/usr/local/share"));
dirs.push(std::path::PathBuf::from("/usr/share"));
}
dirs
}
+10 -3
View File
@@ -182,10 +182,17 @@ APK size cost is unacceptable, which is a measurement before it is a decision.
| `usage` | foreground-time and launch-count accumulation into daily and hourly buckets |
| `lock` | lock levels, the friction ladder's timing, monotonic anti-tamper |
| `never_block` | the section 1.5.4 safety list |
| `storage` | the SQLite schema and its fail-open recovery policy |
| `storage` | *(separate crate: `blocker-storage`)* the SQLite schema and its fail-open recovery policy |
`storage` has no I/O behind it yet — see `LIMITATIONS.md`. The schema is written
and reviewed as text so it can be reviewed before it is compiled.
`blocker-storage` is a sibling crate, not a submodule. It depends on
`blocker-core`; `blocker-core` does not depend on it, and depends on nothing at
all. That direction is what lets `tests/purity.rs` keep checking "blocker-core has
zero dependencies" — an automatic proof that the engine cannot do I/O — instead of
the weaker and easily-routed-around alternative of grepping the source for
`rusqlite`.
Both crates occupy the same layer of the diagram above: storage is shared by every
platform and contains no platform code.
## Where the platform crates start and stop
+88 -37
View File
@@ -25,9 +25,9 @@ which parts to trust.
| [1](#adr-1) | Rust for core and desktop, Kotlin for the Android shell | Accepted |
| [2](#adr-2) | One shared core; the engine is a pure function | Accepted |
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
| [4](#adr-4) | SQLite via rusqlite, bundled | Proposed |
| [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open |
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
| [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** |
| [6](#adr-6) | Desktop UI toolkit — egui | **Accepted** |
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
| [8](#adr-8) | Lock levels and explicit opt-in | Accepted |
| [9](#adr-9) | Monotonic clock for all lock timers | Accepted |
@@ -37,7 +37,7 @@ which parts to trust.
| [13](#adr-13) | Zero external dependencies in M0 | Accepted |
| [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted |
| [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted |
| [16](#adr-16) | Golden tables are JSON, not TOML | Proposed |
| [16](#adr-16) | Golden tables are Rust, not JSON | Accepted |
| [17](#adr-17) | Licence not yet chosen | **Open — owner decision** |
---
@@ -108,24 +108,46 @@ it is supposed to start blocking.
---
<a id="adr-4"></a>
## ADR-4: SQLite via rusqlite, bundled
## ADR-4: SQLite via rusqlite
**Status:** Proposed — dependency not yet added
**Status:** Accepted and implemented, with one trade-off still open
**Context.** Section 3 specifies `rusqlite` with bundled SQLite. Section 1.5.3
requires lock and Strict state to live in the core database rather than in
editable plaintext config.
**Decision.** `rusqlite` with `bundled`, storing schedules, lock state, and usage
counters in one file.
**Decision.** `rusqlite` with `bundled`, in a **separate `blocker-storage` crate**
that depends on `blocker-core`, never the reverse.
**Consequences.** The schema is written and reviewed in
`core/src/storage.rs` before the crate is added, so it can be reviewed as text.
Bundling compiles SQLite from source, which costs build time and binary size but
removes a system dependency — a real consideration on the machines this ships to.
**Why the separate crate.** The purity contract says the engine does no I/O, and
`tests/purity.rs` enforces that mechanically by failing if `blocker-core` has any
dependency at all. Putting `rusqlite` in `blocker-core` would have forced that
check to degrade into a source-text grep — something a careful developer can route
around, and something nobody reads in six months. Splitting keeps the check
automatic.
The binary-size cost has not yet been measured against the 5 MB budget. That is
an open item, not a settled fact.
Both crates sit in the same layer of the section 2 diagram: storage is shared by
every platform and contains no platform code. This is a refinement that makes the
guarantee checkable, not an architectural departure.
**Measured consequences.**
- Bundled SQLite costs **~1.1 MB** of the 5 MB stripped daemon budget (1.364 MB
total, from 0.279 MB without it). That leaves 3.8 MB for D-Bus, X11 and a UI
toolkit — a real margin, not a comfortable one.
- The build compiles SQLite from C source, which is a noticeable first-build cost.
- System-linking would reclaim most of the 1.1 MB at the price of a runtime
dependency on the host's libsqlite3, and a schema-format mismatch that only
appears on a user's machine.
**Still open:** bundled vs system-linked. Measured cost is now known; the decision
is the owner's.
**Schema notes.** Relational (`schedule_target`, `schedule_condition`) rather than
JSON blobs in a text column, because target rows are queried by target and an
index on a JSON string is not a plan. Serialisation is hand-written in
`storage/src/codec.rs` — `serde` is not a section 3 dependency, and section 0 rule
3 requires asking before adding one (ADR-13).
---
@@ -147,29 +169,41 @@ to commit, and the measurement belongs to M3-Android.
---
<a id="adr-6"></a>
## ADR-6: Desktop UI toolkit — Slint vs egui
## ADR-6: Desktop UI toolkit — egui
**Status:** **Proposed — awaiting the owner's decision**
**Status:** Accepted (the owner chose egui, 2026-10-01)
**Context.** Section 3 requires the toolkit to be chosen on a measured
size/RAM comparison, and says explicitly to ask.
**Decision.** Not yet made.
**Decision.** **egui**, licensed MIT OR Apache-2.0.
**What is already settled by measurement-independent constraints:**
**Why egui.**
- **Slint is GPL-3.0 or commercial.** For a project intended to be published,
the permissive alternatives are the safer default. If the size comparison comes
out close, licensing should decide it.
- Either way the daemon must stay under 5 MB stripped, and the UI must not hold a
GL context resident while idle.
- **The licence decides part of it.** Slint is GPL-3.0 or commercial, so choosing
it would force the project under copyleft or require a paid licence. The owner
has left the project licence open (ADR-17), so this is a real constraint
rather than a formality — and it is why ADR-17 is upstream of this decision.
- **The budget is adequate.** 2.9 MB of the 5 MB stripped daemon budget remained
after storage (1.364 MB) and the D-Bus/X11 backends (2.237 MB) were linked in.
A block screen is text and a countdown; it does not need more.
- **Idle cost is the deciding factor.** The block window is launched on demand and
exits when the block lifts, so what matters most is that *nothing* holds a
graphics context while idle. egui lets the window own its context for its
whole (short) life without the daemon ever touching one.
**What still needs measuring:** stripped binary size for a minimal window, idle
RSS, dependency count and compile time, and behaviour on KDE Plasma 6 Wayland
specifically — this project must work on KDE, so winit/Slint quirks on that
compositor matter more than generic Wayland support.
**What this does not decide.** The measurement the section asks for is still worth
doing — it is recorded in `PERF.md` — but it is now confirmatory rather than
decisive.
Recorded rather than decided because section 3 says to ask.
**Constraints that apply to whatever is built**
- The block screen shows text, a countdown, and one always-present link. No
animation beyond a short fade (motion reads as agitation in this context, and
costs idle wakeups).
- Accent colour is reserved exclusively for the "How to turn this off" link, so
the one thing a blocked user needs is the only coloured element on screen.
- No red, and no shaming copy.
---
@@ -409,22 +443,39 @@ machine.
---
<a id="adr-16"></a>
## ADR-16: Golden tables are JSON, not TOML
## ADR-16: Golden tables are Rust, not JSON
**Status:** Proposed — depends on ADR-4 and the serde decision
**Status:** Accepted and implemented. The original JSON rationale turned out to
be wrong, and is recorded below rather than quietly replaced.
**Context.** Section 7 requires golden decision tables as the cross-platform
parity contract. The format must be serialisable and diffable in review.
parity contract.
**Decision.** JSON, one file per concern, under `tests/golden/`.
**What was originally decided, and why it was wrong.** JSON, on the reasoning
that `serde_json` would already be in the dependency graph. That was falsified in
practice: `rusqlite` does **not** pull `serde` by default, and `uniffi` had not
landed, so nothing in the graph provided it. `serde` is not a section 3
dependency, and section 0 rule 3 requires asking before adding one.
**Rationale.** `serde_json` is already in the dependency graph via `rusqlite` and
`uniffi`, so TOML would add a crate for no gain, and section 3 makes every crate
justify its cost. JSON is also what a review diff reads most naturally.
**Decision.** A builder DSL in Rust. `tests/src/golden.rs` holds the format and
harness, `tests/src/tables.rs` the cases, `tests/src/coverage.rs` the coverage
bookkeeping, `tests/src/run.rs` the runner.
**Not yet decided.** The parse layer needs serde, which is a dependency decision
requiring the owner's sign-off. The format itself is written down in
`tests/src/golden.rs` now; the parse arrives in M1 with ADR-4.
**Rationale.** The same declarations, with two properties JSON cannot offer: the
compiler rejects a typo in a field name, and a wrong variant is a type error
rather than a test that quietly asserts the wrong thing. For a document whose
entire job is to be a contract, compile-time checking is worth more than
diffability.
Converting to JSON later, if serde is approved, is mechanical — only
`tests/src/golden.rs` changes and the case bodies become literals instead of
builder calls.
**Also settled here.** Three scenarios are deliberately *not* table-shaped.
Clock tampering and the 24-hour cap are about the passage of time and a bound
across lock levels, not a decision at an instant, so they live as code fixtures
in `tests/src/purity.rs`. `coverage::Location` records which is which, so
"covered" can never quietly mean "listed but not tested".
---
+13 -7
View File
@@ -177,17 +177,23 @@ not mistaken for platform facts.
- **`ProcessBackend` is not implemented.** ADR-14 lists three backends; the KWin
and X11 paths land in M2-Linux, and the process fallback is weaker than the
spec assumes because of the `/proc/connectors` gap above.
- **`rusqlite` has not been added.** The schema is written and reviewed in
`core/src/storage.rs` (ADR-4) but storage has no I/O behind it yet, so nothing
persists. A restart loses all state.
- **`rusqlite` vs system-linked SQLite is unresolved** (ADR-4). Bundled is built
and costs ~1.1 MB of the 5 MB daemon budget. System-linking would reclaim most
of that but adds a runtime dependency on the host's libsqlite3.
- **Lock state is not yet persisted or enforced.** `core/src/lock.rs` has the
timers, the escalation, and the wall-clock correction, all tested. The state
machine is not yet driven from a UI, and `LockState::evaluate` currently returns
the Normal level unconditionally.
- **The golden tables cannot be parsed.** The format is specified in
`tests/src/golden.rs`; parsing needs serde, which is a dependency decision
awaiting sign-off (ADR-13, ADR-16). The behavioural coverage that exists today
is hand-written unit tests in the same spirit.
- **DST coverage is partial, and the boundary is stated.** `CivilTime` is an
epoch value plus a UTC offset, with no IANA timezone database behind it — the
`time` crate is not a section 3 dependency (ADR-16). The golden tables pin the
observable rule, that a fixed local window stays fixed as the offset moves,
including across a DST transition. What they **cannot** express is a local time
that does not exist because the clock sprang forward, or one that is ambiguous
because it fell back. Those are cases section 7 lists that are not yet
covered. This is the strongest argument for a timezone-aware time type.
- **`LockState` still always evaluates to `Normal`** — persistence works, but no
UI drives the lock state machine yet.
- **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather
than the schedule's own target set, until targets carry usage keys.
- **Windows is entirely unimplemented** and cannot be compiled or tested on the
+14 -5
View File
@@ -29,17 +29,26 @@ Measured on: Ubuntu 26.04 LTS, KDE Plasma 6.6.6, 6 CPUs. Toolchain: rustc 1.98.1
| Metric | Target | Measured | When |
|---|---|---|---|
| Daemon binary (stripped, release) | ≤ 5 MB | **0.279 MB** | 2026-10-01, M0 |
| Daemon binary (stripped, release) | ≤ 5 MB | **1.364 MB** | 2026-10-01, M1 |
| Block latency | ≤ 150 ms | not measured | — |
| Idle CPU | ~0% | not measured | — |
| Idle RAM (daemon) | ≤ 15 MB RSS | not measured | — |
| Idle wakeups | ≤ 1/min | not measured | — |
| APK size | ≤ 10 MB | not measured | — |
The binary figure is real but nearly meaningless right now: the daemon is two
`println!` lines. The meaningful measurement comes in M2-Linux once enforcement
exists, and the real risk is at that point — D-Bus, X11, and a UI toolkit are
where 5 MB would actually be threatened.
**What the 1.364 MB contains, and why it matters.** That figure is with bundled
SQLite linked in and the daemon actually opening a database — not the 0.279 MB
placeholder from M0, which was two `println!` lines and excluded SQLite entirely
through dead-code elimination.
So the honest read is: **bundled SQLite costs roughly 1.1 MB of a 5 MB budget.**
That leaves 3.8 MB for D-Bus, X11, and the UI toolkit. It is a real constraint
rather than a comfortable margin, and it is the first number that makes the
section 5 budget feel like a budget instead of a formality.
It also makes ADR-4 (rusqlite bundled vs system-linked) a genuine trade-off rather
than a settled one: system-linking would recover most of that 1.1 MB at the cost
of a runtime dependency on the host's libsqlite3. Recorded as an open question.
## How to measure
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "blocker-storage"
version = "0.1.0"
edition = "2021"
description = "SQLite persistence for Blocker: schedules, lock state, and usage counters."
license = "UNLICENSED"
[dependencies]
blocker-core = { path = "../core" }
# rusqlite with bundled SQLite, per CLAUDE.md section 3. Bundled rather than
# system-linked because the app ships to machines where libsqlite3 may be absent
# or a different major version, and a storage-format mismatch that only shows up
# on a user's machine is not a failure mode worth carrying.
#
# THIS CRATE IS NOT DEPENDENT ON BY blocker-core.
#
# That is the entire reason this crate exists separately. CLAUDE.md section 2
# requires the engine to be a pure function with no I/O, and the strongest
# mechanical check for that is "blocker-core has zero dependencies" — a test
# that reads Cargo.lock and fails on any entry. Keeping storage in its own crate
# lets that test stay strict and automatic, instead of degrading into a source
# grep that someone can route around.
#
# Both crates sit in the same layer: storage is shared by every platform and
# contains no platform code, so this is a refinement of the section 2 diagram,
# not a departure from it.
rusqlite = { version = "0.37", features = ["bundled"] }
[lints]
workspace = true
[lib]
name = "blocker_storage"
path = "src/lib.rs"
+347
View File
@@ -0,0 +1,347 @@
//! Encoding between model types and their database representation.
//!
//! Split out from the SQL so the tricky part — turning a `Condition` into
//! nullable columns and back without silently losing a field — is testable
//! without a database, and so a decode failure has somewhere precise to point.
//!
//! # Why this is hand-written rather than serde
//!
//! `serde` is not a CLAUDE.md section 3 dependency, and section 0 rule 3 requires
//! stopping to ask before adding one. The encoding is a few hundred lines of
//! mechanical match arms, which is a real cost — and it is the cost of not adding
//! a dependency without permission. If serde is approved later, this module is the
//! only thing that changes.
//!
//! # Decoding never panics
//!
//! Every decode failure returns [`DecodeError`]. A database written by a newer
//! version, or corrupted by hand, must produce a diagnosable error rather than a
//! panic: this is enforcement code, and section 1.5.4 requires failing open.
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// Why stored bytes could not become a model value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
/// A `mode` column held something other than the two known values.
UnknownMode(String),
/// A `level` column held something other than the three known values.
UnknownLevel(String),
/// A `kind` column held something unrecognised.
UnknownKind(String),
/// A target string was empty.
EmptyTarget,
/// A `start`/`end` time was not `HH:MM`, or was outside 0..1440.
BadTime(String),
/// A time of day fell outside 0..1440 minutes.
MinuteOutOfRange(i64),
/// A `days` list was malformed, or held a value outside 0..=6.
BadDays(String),
/// A boolean column was neither 0 nor 1.
BadFlag(i64),
/// A numeric column was negative where only a count is meaningful.
NegativeCount(i64),
}
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownMode(m) => write!(f, "unknown schedule mode {m:?}"),
Self::UnknownLevel(l) => write!(f, "unknown lock level {l:?}"),
Self::UnknownKind(k) => write!(f, "unknown condition kind {k:?}"),
Self::EmptyTarget => write!(f, "target value is empty"),
Self::BadTime(t) => write!(f, "time {t:?} is not HH:MM"),
Self::MinuteOutOfRange(m) => write!(f, "minute {m} is outside 0..1440"),
Self::BadDays(d) => write!(f, "day list {d:?} is malformed"),
Self::BadFlag(v) => write!(f, "expected 0 or 1, found {v}"),
Self::NegativeCount(v) => write!(f, "expected a non-negative count, found {v}"),
}
}
}
impl std::error::Error for DecodeError {}
/// `ScheduleMode` as stored.
pub fn mode_to_str(mode: ScheduleMode) -> &'static str {
match mode {
ScheduleMode::Blocklist => "Blocklist",
ScheduleMode::Allowlist => "Allowlist",
}
}
pub fn mode_from_str(raw: &str) -> Result<ScheduleMode, DecodeError> {
match raw {
"Blocklist" => Ok(ScheduleMode::Blocklist),
"Allowlist" => Ok(ScheduleMode::Allowlist),
other => Err(DecodeError::UnknownMode(other.to_owned())),
}
}
/// `LockLevel` as stored.
pub fn level_to_str(level: blocker_core::lock::LockLevel) -> &'static str {
match level {
blocker_core::lock::LockLevel::Normal => "Normal",
blocker_core::lock::LockLevel::Strict => "Strict",
blocker_core::lock::LockLevel::Stubborn => "Stubborn",
}
}
pub fn level_from_str(raw: &str) -> Result<blocker_core::lock::LockLevel, DecodeError> {
use blocker_core::lock::LockLevel;
match raw {
"Normal" => Ok(LockLevel::Normal),
"Strict" => Ok(LockLevel::Strict),
"Stubborn" => Ok(LockLevel::Stubborn),
other => Err(DecodeError::UnknownLevel(other.to_owned())),
}
}
/// A decoded target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetRow {
/// Position within the schedule, preserving the user's ordering.
pub ordinal: i64,
/// Discriminator: `app`, `website`, or `keyword`.
pub kind: String,
/// The identifier itself.
pub value: String,
}
/// Targets in database form.
pub fn encode_targets(targets: &[Target]) -> Vec<TargetRow> {
targets
.iter()
.enumerate()
.map(|(ordinal, t)| {
let (kind, value) = match t {
Target::App(v) => ("app", v.clone()),
Target::Website(v) => ("website", v.clone()),
Target::Keyword(v) => ("keyword", v.clone()),
};
TargetRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: kind.to_owned(),
value,
}
})
.collect()
}
pub fn decode_targets(rows: &[TargetRow]) -> Result<Vec<Target>, DecodeError> {
let mut out: Vec<(i64, Target)> = Vec::with_capacity(rows.len());
for row in rows {
if row.value.is_empty() {
return Err(DecodeError::EmptyTarget);
}
let target = match row.kind.as_str() {
"app" => Target::App(row.value.clone()),
"website" => Target::Website(row.value.clone()),
"keyword" => Target::Keyword(row.value.clone()),
other => return Err(DecodeError::UnknownKind(other.to_owned())),
};
out.push((row.ordinal, target));
}
// Ordinals come from the database unordered; sorting restores user order,
// which matters for a list the user arranged deliberately.
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, t)| t).collect())
}
/// A decoded condition. Column-shaped rather than model-shaped, so a NULL in the
/// wrong place is caught here rather than becoming a silently defaulted
/// condition.
#[derive(Debug, Clone, PartialEq)]
pub struct ConditionRow {
pub ordinal: i64,
pub kind: String,
/// Comma-separated weekdays, Monday = 0.
pub days: Option<String>,
pub start_min: Option<i64>,
pub end_min: Option<i64>,
pub limit_value: Option<i64>,
pub window: Option<String>,
pub ssid: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub radius_m: Option<i64>,
pub outside: Option<i64>,
}
pub fn encode_conditions(conditions: &[Condition]) -> Vec<ConditionRow> {
conditions
.iter()
.enumerate()
.map(|(ordinal, c)| {
let mut row = ConditionRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: String::new(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
match c {
Condition::Time(w) => {
row.kind = "Time".to_owned();
row.days = Some(w.days.iter().map(u8::to_string).collect::<Vec<_>>().join(","));
row.start_min = Some(i64::from(w.start));
row.end_min = Some(i64::from(w.end));
}
Condition::UsageLimit { limit_minutes, window } => {
row.kind = "UsageLimit".to_owned();
row.limit_value = Some(i64::from(*limit_minutes));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::LaunchCount { limit, window } => {
row.kind = "LaunchCount".to_owned();
row.limit_value = Some(i64::from(*limit));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::WifiSsid(ssid) => {
row.kind = "WifiSsid".to_owned();
row.ssid = Some(ssid.clone());
}
Condition::Location { latitude, longitude, radius_meters, block_when_outside } => {
row.kind = "Location".to_owned();
row.latitude = Some(*latitude);
row.longitude = Some(*longitude);
row.radius_m = Some(i64::from(*radius_meters));
row.outside = Some(i64::from(*block_when_outside));
}
}
row
})
.collect()
}
pub fn decode_conditions(rows: &[ConditionRow]) -> Result<Vec<Condition>, DecodeError> {
let mut out: Vec<(i64, Condition)> = Vec::with_capacity(rows.len());
for row in rows {
out.push((row.ordinal, decode_condition(row)?));
}
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, c)| c).collect())
}
fn decode_condition(row: &ConditionRow) -> Result<Condition, DecodeError> {
match row.kind.as_str() {
"Time" => {
let days = row.days.as_deref().ok_or(DecodeError::BadDays("<null>".into()))?;
let start = require_minute(row.start_min)?;
let end = require_minute(row.end_min)?;
Ok(Condition::Time(TimeWindow { days: decode_days(days)?, start, end }))
}
"UsageLimit" => Ok(Condition::UsageLimit {
limit_minutes: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"LaunchCount" => Ok(Condition::LaunchCount {
limit: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"WifiSsid" => Ok(Condition::WifiSsid(row.ssid.clone().ok_or(DecodeError::EmptyTarget)?)),
"Location" => Ok(Condition::Location {
latitude: row.latitude.ok_or(DecodeError::NegativeCount(-1))?,
longitude: row.longitude.ok_or(DecodeError::NegativeCount(-1))?,
radius_meters: require_count(row.radius_m)?,
block_when_outside: decode_flag(row.outside.unwrap_or(0))?,
}),
other => Err(DecodeError::UnknownKind(other.to_owned())),
}
}
fn require_minute(value: Option<i64>) -> Result<MinuteOfDay, DecodeError> {
let v = value.ok_or(DecodeError::MinuteOutOfRange(-1))?;
u16::try_from(v).ok().filter(|m| *m < 1440).ok_or(DecodeError::MinuteOutOfRange(v))
}
fn require_count(value: Option<i64>) -> Result<u32, DecodeError> {
let v = value.ok_or(DecodeError::NegativeCount(-1))?;
u32::try_from(v).map_err(|_| DecodeError::NegativeCount(v))
}
fn decode_flag(value: i64) -> Result<bool, DecodeError> {
match value {
0 => Ok(false),
1 => Ok(true),
other => Err(DecodeError::BadFlag(other)),
}
}
fn window_to_str(window: UsageWindow) -> &'static str {
match window {
UsageWindow::Daily => "Daily",
UsageWindow::Hourly => "Hourly",
}
}
fn window_from_str(raw: Option<&str>) -> Result<UsageWindow, DecodeError> {
match raw {
Some("Daily") => Ok(UsageWindow::Daily),
Some("Hourly") => Ok(UsageWindow::Hourly),
_ => Err(DecodeError::UnknownKind(format!("usage window {raw:?}"))),
}
}
fn decode_days(raw: &str) -> Result<Vec<DayOfWeek>, DecodeError> {
if raw.is_empty() {
return Ok(Vec::new());
}
raw.split(',')
.map(|part| {
let trimmed = part.trim();
trimmed
.parse::<u8>()
.ok()
.filter(|d| *d < 7)
.ok_or_else(|| DecodeError::BadDays(raw.to_owned()))
})
.collect()
}
/// `HH:MM` to minutes since midnight. Used by the golden-table loader, which
/// reads times as strings.
pub fn parse_hhmm(raw: &str) -> Result<MinuteOfDay, DecodeError> {
let (h, m) = raw.split_once(':').ok_or_else(|| DecodeError::BadTime(raw.to_owned()))?;
let h: u16 = h.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
let m: u16 = m.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
if m >= 60 || h >= 24 {
return Err(DecodeError::BadTime(raw.to_owned()));
}
Ok(h * 60 + m)
}
/// Reassemble a schedule from its header and its decoded children.
///
/// Kept here rather than in `lib.rs` so the ordering contract — children sorted
/// by ordinal, attached to the right parent — is tested in one place.
pub fn decode_schedule(
id: &str,
name: &str,
icon: Option<&str>,
mode: &str,
targets: &[TargetRow],
conditions: &[ConditionRow],
paused: bool,
) -> Result<Schedule, DecodeError> {
Ok(Schedule {
id: id.to_owned(),
name: name.to_owned(),
icon: icon.map(str::to_owned),
mode: mode_from_str(mode)?,
targets: decode_targets(targets)?,
conditions: decode_conditions(conditions)?,
paused,
})
}
#[cfg(test)]
mod tests;
+201
View File
@@ -0,0 +1,201 @@
//! Tests for the storage encoding layer.
//!
//! These need no database: the codec is where a silently-dropped field would
//! live, and a round-trip test here is far cheaper to run and to read than the
//! same assertion through SQLite.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test fixtures: a failing assertion or a broken test is not a runtime \
condition to handle gracefully"
)]
use super::*;
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
/// Every condition shape must survive encode -> decode unchanged.
///
/// A field that is written but not read back is invisible in production and
/// catastrophic here: the schedule quietly stops working the next time the app
/// restarts.
#[test]
fn every_condition_round_trips() {
let cases = vec![
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 9 * 60, end: 17 * 60 }),
// Overnight: start > end must not be normalised or dropped on the way
// through the database. This is the representation the whole overnight
// rule depends on.
Condition::Time(TimeWindow { days: vec![6], start: 22 * 60, end: 6 * 60 }),
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
Condition::LaunchCount { limit: 5, window: UsageWindow::Hourly },
Condition::WifiSsid("CoffeeShop".into()),
Condition::Location {
latitude: 52.52,
longitude: 13.405,
radius_meters: 500,
block_when_outside: true,
},
];
for original in cases {
let rows = encode_conditions(std::slice::from_ref(&original));
let decoded = decode_conditions(&rows).expect("decode must succeed");
assert_eq!(decoded, vec![original], "round trip changed the condition");
}
}
/// A schedule's ordinal order must survive, since the user arranges targets
/// deliberately and the block screen shows them in that order.
#[test]
fn target_order_is_preserved() {
let targets = vec![
Target::App("b.app".into()),
Target::Website("example.com".into()),
Target::Keyword("casino".into()),
Target::App("a.app".into()),
];
let mut rows = encode_targets(&targets);
// Simulate the database returning rows in an arbitrary order.
rows.reverse();
assert_eq!(decode_targets(&rows).unwrap(), targets);
}
/// Unknown discriminators must error, not default.
///
/// Defaulting here would turn a typo or a newer-version row into a schedule
/// that silently blocks the wrong thing.
#[test]
fn unknown_kinds_are_errors_not_defaults() {
let bad_target =
vec![TargetRow { ordinal: 0, kind: "applicaton".into(), value: "com.example".into() }];
assert_eq!(decode_targets(&bad_target), Err(DecodeError::UnknownKind("applicaton".into())));
let bad_condition = vec![ConditionRow {
ordinal: 0,
kind: "Timey".into(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
}];
assert_eq!(decode_conditions(&bad_condition), Err(DecodeError::UnknownKind("Timey".into())));
}
/// A time of day outside 0..1440 must be rejected rather than wrapping.
///
/// A wrapped minute would produce a window that never matches, or one that
/// always matches — both look like "the app ignores my schedule".
#[test]
fn out_of_range_minutes_are_rejected() {
let bad = ConditionRow {
ordinal: 0,
kind: "Time".into(),
days: Some("0".into()),
start_min: Some(-60),
end_min: Some(1020),
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
assert_eq!(decode_conditions(&[bad]), Err(DecodeError::MinuteOutOfRange(-60)));
}
/// Weekday values outside 0..=6 must be rejected.
#[test]
fn out_of_range_weekdays_are_rejected() {
assert_eq!(decode_days("0,1,9"), Err(DecodeError::BadDays("0,1,9".into())));
assert_eq!(decode_days("monday"), Err(DecodeError::BadDays("monday".into())));
assert_eq!(decode_days(""), Ok(vec![]));
assert_eq!(decode_days("0,6"), Ok(vec![0, 6]));
}
/// A flag column holding something other than 0 or 1 must be rejected.
#[test]
fn bad_flags_are_rejected() {
assert_eq!(decode_flag(0), Ok(false));
assert_eq!(decode_flag(1), Ok(true));
assert_eq!(decode_flag(2), Err(DecodeError::BadFlag(2)));
assert_eq!(decode_flag(-1), Err(DecodeError::BadFlag(-1)));
}
/// Negative counts must be rejected — a negative usage limit would mean
/// "block immediately", which is not what a corrupted row should do.
#[test]
fn negative_counts_are_rejected() {
assert_eq!(require_count(Some(-5)), Err(DecodeError::NegativeCount(-5)));
assert_eq!(require_count(Some(0)), Ok(0));
assert_eq!(require_count(None), Err(DecodeError::NegativeCount(-1)));
}
/// `HH:MM` parsing, as the golden tables use it.
#[test]
fn hhmm_parsing() {
assert_eq!(parse_hhmm("09:00"), Ok(540));
assert_eq!(parse_hhmm("22:30"), Ok(1350));
assert_eq!(parse_hhmm("00:00"), Ok(0));
assert_eq!(parse_hhmm("9:5"), Ok(545));
assert_eq!(parse_hhmm("24:00"), Err(DecodeError::BadTime("24:00".into())));
assert_eq!(parse_hhmm("12:60"), Err(DecodeError::BadTime("12:60".into())));
assert_eq!(parse_hhmm("nine"), Err(DecodeError::BadTime("nine".into())));
assert_eq!(parse_hhmm("0900"), Err(DecodeError::BadTime("0900".into())));
}
/// Lock level and mode round-trip through their string forms.
#[test]
fn level_and_mode_round_trip() {
for level in [
blocker_core::lock::LockLevel::Normal,
blocker_core::lock::LockLevel::Strict,
blocker_core::lock::LockLevel::Stubborn,
] {
assert_eq!(level_from_str(level_to_str(level)).unwrap(), level);
}
assert!(level_from_str("Paranoid").is_err());
for mode in [ScheduleMode::Blocklist, ScheduleMode::Allowlist] {
assert_eq!(mode_from_str(mode_to_str(mode)).unwrap(), mode);
}
assert!(mode_from_str("Whitelist").is_err());
}
/// A full schedule round-trips through its parts.
#[test]
fn schedule_round_trips_through_parts() {
let original = Schedule {
id: "s1".into(),
name: "Evening Focus".into(),
icon: Some("/icons/focus.png".into()),
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.social".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4],
start: 22 * 60,
end: 6 * 60,
})],
paused: false,
};
let decoded = decode_schedule(
&original.id,
&original.name,
original.icon.as_deref(),
mode_to_str(original.mode),
&encode_targets(&original.targets),
&encode_conditions(&original.conditions),
original.paused,
)
.unwrap();
assert_eq!(decoded, original);
}
+644
View File
@@ -0,0 +1,644 @@
//! SQLite persistence for Blocker (CLAUDE.md section 3, `rusqlite` bundled).
//!
//! # Why this is a separate crate from `blocker-core`
//!
//! Section 2 requires the engine to be a pure function with no I/O, and this
//! project's `tests/purity.rs` enforces that mechanically: it reads the resolved
//! dependency graph and fails if `blocker-core` has any dependency at all. That
//! check is only meaningful while storage lives elsewhere.
//!
//! Both crates occupy the same layer of the section 2 diagram — storage is shared
//! by every platform and contains no platform code — so this is a refinement that
//! makes the purity guarantee mechanically checkable, not an architectural
//! departure.
//!
//! # Fail open (section 1.5.4)
//!
//! A corrupt database must never leave a device unusable. [`Store::open`] moves a
//! damaged file aside and starts clean rather than returning an error the caller
//! might respond to by disabling enforcement. The damaged file is kept for
//! diagnosis; blocking is then re-applied from defaults on the next start.
//!
//! Losing a schedule list is a far smaller harm than a phone that cannot make
//! calls, and section 1.5.4 makes that trade explicit.
use std::path::{Path, PathBuf};
use blocker_core::engine::{BootId, CivilTime, Monotonic};
use rusqlite::{Connection, OpenFlags};
use crate::codec::DecodeError;
/// Current schema version, applied with `PRAGMA user_version`.
pub const SCHEMA_VERSION: i64 = 1;
/// Everything that can go wrong opening or preparing a store.
///
/// Notably **absent**: a corruption variant that is fatal. Corruption is
/// handled inside [`Store::open`] by starting clean, because section 1.5.4
/// requires failing open rather than refusing to run.
#[derive(Debug)]
pub enum StoreError {
/// SQLite could not be initialised at all — for example the bundled library
/// could not be loaded. Fatal, and genuinely exceptional.
Init(rusqlite::Error),
/// The directory holding the database could not be created.
Io(std::io::Error),
/// Stored bytes could not be decoded into the model.
Decode(DecodeError),
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Init(e) => write!(f, "could not initialise storage: {e}"),
Self::Io(e) => write!(f, "storage directory unusable: {e}"),
Self::Decode(e) => write!(f, "stored data could not be decoded: {e}"),
}
}
}
impl std::error::Error for StoreError {}
/// A handle to the Blocker database.
///
/// Cheap to clone conceptually but deliberately not `Clone`: the daemon is the
/// only writer, and an accidental second connection would mean two processes
/// fighting over lock state.
#[derive(Debug)]
pub struct Store {
conn: Connection,
path: PathBuf,
/// Set when the database was found corrupt and moved aside, so the caller can
/// log it. Section 1.5.4 wants this recorded, not silently swallowed.
recovered_from_corruption: bool,
}
impl Store {
/// Open, creating if absent, and ensure the schema is current.
///
/// Never returns an error for corruption: a damaged database is renamed
/// aside and a fresh one created. See the module docs.
pub fn open(path: &Path) -> Result<Self, StoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(StoreError::Io)?;
}
let mut recovered = false;
// First attempt: open normally and verify it is actually usable. A
// corrupt file often opens fine and only fails on first query, so the
// probe below is what actually detects corruption.
let mut attempt = Self::try_open(path)?;
if let Err(probe) =
attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
{
recovered = true;
Self::quarantine(path)?;
attempt = Self::try_open(path)?;
// Keep the probe result meaningful: if this one also fails, the
// problem is not the file.
if let Err(second) =
attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
{
return Err(StoreError::Init(second));
}
let _ = probe;
}
attempt.migrate()?;
attempt.recovered_from_corruption = recovered;
Ok(attempt)
}
fn try_open(path: &Path) -> Result<Self, StoreError> {
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(StoreError::Init)?;
// WAL keeps a reader from blocking the daemon's writes, which matters
// because the UI reads while the daemon writes. It also means a crash
// cannot corrupt the main file, which is part of failing open.
// Errors here are not fatal: a filesystem without WAL still works, just
// with less concurrency.
let _ = conn.pragma_update(None, "journal_mode", "WAL");
let _ = conn.pragma_update(None, "foreign_keys", "ON");
Ok(Self { conn, path: path.to_path_buf(), recovered_from_corruption: false })
}
/// Move a damaged database aside, keeping it for diagnosis.
fn quarantine(path: &Path) -> Result<(), StoreError> {
let backup = path.with_extension("corrupt");
std::fs::rename(path, &backup).map_err(StoreError::Io)?;
Ok(())
}
/// Whether this store was recovered from corruption on open.
pub fn recovered_from_corruption(&self) -> bool {
self.recovered_from_corruption
}
fn migrate(&mut self) -> Result<(), StoreError> {
let current: i64 = self
.conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.map_err(StoreError::Init)?;
if current > SCHEMA_VERSION {
// Forward-only by design (section 1.5.3: downgrading risks losing
// lock state, which the safety rules do not permit). A newer file is
// left alone rather than destroyed.
return Err(StoreError::Init(rusqlite::Error::InvalidQuery));
}
if current == SCHEMA_VERSION {
return Ok(());
}
self.conn.execute_batch(SCHEMA).map_err(StoreError::Init)?;
self.conn.pragma_update(None, "user_version", SCHEMA_VERSION).map_err(StoreError::Init)?;
Ok(())
}
/// Load every schedule, with its targets and conditions.
///
/// Two queries rather than a join: a schedule's children are needed together
/// to reassemble the model, and SQLite's JSON functions are not used here so
/// the relational shape stays portable. Schedules without children still
/// appear, with empty vectors — `validation` is what rejects those, not the
/// database.
pub fn load_schedules(&self) -> Result<Vec<blocker_core::model::Schedule>, StoreError> {
let headers = {
let mut stmt = self
.conn
.prepare(
"SELECT id, name, icon, mode, paused FROM schedule ORDER BY created_at, id",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
row.get::<_, bool>(4)?,
))
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
out
};
let mut schedules = Vec::with_capacity(headers.len());
for (id, name, icon, mode, paused) in headers {
let targets = self.load_targets(&id)?;
let conditions = self.load_conditions(&id)?;
schedules.push(
codec::decode_schedule(
&id,
&name,
icon.as_deref(),
&mode,
&targets,
&conditions,
paused,
)
.map_err(StoreError::Decode)?,
);
}
Ok(schedules)
}
fn load_targets(&self, schedule_id: &str) -> Result<Vec<codec::TargetRow>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT ordinal, kind, value FROM schedule_target
WHERE schedule_id = ?1 ORDER BY ordinal",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([schedule_id], |row| {
Ok(codec::TargetRow { ordinal: row.get(0)?, kind: row.get(1)?, value: row.get(2)? })
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
Ok(out)
}
fn load_conditions(&self, schedule_id: &str) -> Result<Vec<codec::ConditionRow>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT ordinal, kind, days, start_min, end_min, limit_value, window,
ssid, latitude, longitude, radius_m, outside
FROM schedule_condition WHERE schedule_id = ?1 ORDER BY ordinal",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([schedule_id], |row| {
Ok(codec::ConditionRow {
ordinal: row.get(0)?,
kind: row.get(1)?,
days: row.get(2)?,
start_min: row.get(3)?,
end_min: row.get(4)?,
limit_value: row.get(5)?,
window: row.get(6)?,
ssid: row.get(7)?,
latitude: row.get(8)?,
longitude: row.get(9)?,
radius_m: row.get(10)?,
outside: row.get(11)?,
})
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
Ok(out)
}
/// Persist the full schedule set, replacing what was there.
///
/// Written as a single transaction so a crash cannot leave a half-applied
/// schedule list — which on a blocking tool would mean some targets blocked
/// and others not, with no way for the user to tell which.
pub fn save_schedules(
&mut self,
schedules: &[blocker_core::model::Schedule],
) -> Result<(), StoreError> {
let tx = self.conn.transaction().map_err(StoreError::Init)?;
// Children cascade on delete, so clearing the parents is enough and
// avoids a stale row outliving its schedule.
tx.execute("DELETE FROM schedule", []).map_err(StoreError::Init)?;
{
let mut insert_target = tx
.prepare(
"INSERT INTO schedule_target (schedule_id, ordinal, kind, value)
VALUES (?1, ?2, ?3, ?4)",
)
.map_err(StoreError::Init)?;
let mut insert_condition = tx
.prepare(
"INSERT INTO schedule_condition
(schedule_id, ordinal, kind, days, start_min, end_min, limit_value,
window, ssid, latitude, longitude, radius_m, outside)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
)
.map_err(StoreError::Init)?;
for (index, s) in schedules.iter().enumerate() {
tx.execute(
"INSERT INTO schedule (id, name, icon, mode, paused, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
s.id,
s.name,
s.icon,
codec::mode_to_str(s.mode),
i64::from(s.paused),
i64::try_from(index).unwrap_or(i64::MAX),
],
)
.map_err(StoreError::Init)?;
for row in codec::encode_targets(&s.targets) {
insert_target
.execute(rusqlite::params![s.id, row.ordinal, row.kind, row.value])
.map_err(StoreError::Init)?;
}
for row in codec::encode_conditions(&s.conditions) {
insert_condition
.execute(rusqlite::params![
s.id,
row.ordinal,
row.kind,
row.days,
row.start_min,
row.end_min,
row.limit_value,
row.window,
row.ssid,
row.latitude,
row.longitude,
row.radius_m,
row.outside,
])
.map_err(StoreError::Init)?;
}
}
}
tx.commit().map_err(StoreError::Init)
}
/// Persist lock state, including the monotonic deadline a pending disable
/// request is waiting on (section 1.5.2.2).
pub fn save_lock(
&mut self,
state: &blocker_core::lock::LockState,
boot_id: BootId,
last_monotonic_ms: u64,
) -> Result<(), StoreError> {
let pending = state.pending.as_ref();
self.conn
.execute(
"INSERT INTO lock_config
(id, level, schedules_locked, blocks_uninstall, keyholder_code,
request_ready_at, request_window_closes_at, boot_id, last_monotonic_ms)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
level = excluded.level,
schedules_locked = excluded.schedules_locked,
blocks_uninstall = excluded.blocks_uninstall,
keyholder_code = excluded.keyholder_code,
request_ready_at = excluded.request_ready_at,
request_window_closes_at = excluded.request_window_closes_at,
boot_id = excluded.boot_id,
last_monotonic_ms = excluded.last_monotonic_ms",
rusqlite::params![
codec::level_to_str(state.level),
i64::from(state.schedules_locked),
i64::from(state.blocks_uninstall()),
state.keyholder_code,
pending.map(|p| i64::try_from(p.ready_at.0).unwrap_or(i64::MAX)),
pending.map(|p| i64::try_from(p.window_closes_at.0).unwrap_or(i64::MAX)),
i64::try_from(boot_id.0).unwrap_or(i64::MAX),
i64::try_from(last_monotonic_ms).unwrap_or(i64::MAX),
],
)
.map(|_| ())
.map_err(StoreError::Init)
}
/// The monotonic deadline and boot id of a pending disable request.
///
/// Used on resume to distinguish "the daemon restarted" from "the machine
/// rebooted" (section 1.5.3), so a lock is resumed conservatively rather
/// than assumed to have continued.
pub fn load_pending_request(&self) -> Result<Option<(Monotonic, BootId)>, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT request_ready_at, boot_id FROM lock_config WHERE id = 1")
.map_err(StoreError::Init)?;
let mut rows = stmt.query([]).map_err(StoreError::Init)?;
match rows.next().map_err(StoreError::Init)? {
None => Ok(None),
Some(row) => {
let ready: Option<i64> = row.get(0).map_err(StoreError::Init)?;
let boot: i64 = row.get(1).map_err(StoreError::Init)?;
Ok(ready.map(|r| {
(
Monotonic(u64::try_from(r.max(0)).unwrap_or(0)),
BootId(u64::try_from(boot.max(0)).unwrap_or(0)),
)
}))
}
}
}
/// Record one attempt to disable (section 1.5.2.5). Local only.
pub fn record_attempt(
&mut self,
at: Monotonic,
boot_id: BootId,
day_index: i64,
) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO disable_attempt (at_monotonic, boot_id, day_index) VALUES (?1, ?2, ?3)",
rusqlite::params![
i64::try_from(at.0).unwrap_or(i64::MAX),
i64::try_from(boot_id.0).unwrap_or(i64::MAX),
day_index,
],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// How many disable attempts were made on a given local day.
///
/// The block screen shows this as plain text — "You tried to turn this off 4
/// times today" — never as a shaming counter (section 1.5.2.5).
pub fn attempt_count(&self, day_index: i64) -> Result<u32, StoreError> {
let count: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM disable_attempt WHERE day_index = ?1",
[day_index],
|r| r.get(0),
)
.map_err(StoreError::Init)?;
Ok(u32::try_from(count.max(0)).unwrap_or(u32::MAX))
}
/// Add foreground minutes to a daily bucket (section 1.2).
pub fn add_daily_usage(
&mut self,
day_index: i64,
target: &str,
minutes: u32,
) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO usage_daily (day_index, target, minutes) VALUES (?1, ?2, ?3)
ON CONFLICT(day_index, target) DO UPDATE SET
minutes = minutes + excluded.minutes",
rusqlite::params![day_index, target, i64::from(minutes)],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// Add a launch to a daily bucket (section 1.2).
pub fn add_daily_launch(&mut self, day_index: i64, target: &str) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO usage_daily (day_index, target, minutes, launches) VALUES (?1, ?2, 0, 1)
ON CONFLICT(day_index, target) DO UPDATE SET
launches = launches + 1",
rusqlite::params![day_index, target],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// Read a daily usage bucket.
pub fn load_daily_usage(&self, day_index: i64) -> Result<UsageRow, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT target, minutes, launches FROM usage_daily WHERE day_index = ?1")
.map_err(StoreError::Init)?;
let mut rows = stmt.query([day_index]).map_err(StoreError::Init)?;
let mut out = UsageRow::default();
while let Some(row) = rows.next().map_err(StoreError::Init)? {
let target: String = row.get(0).map_err(StoreError::Init)?;
let minutes: i64 = row.get(1).map_err(StoreError::Init)?;
let launches: i64 = row.get(2).map_err(StoreError::Init)?;
out.minutes.insert(target.clone(), u32::try_from(minutes.max(0)).unwrap_or(u32::MAX));
out.launches.insert(target, u32::try_from(launches.max(0)).unwrap_or(u32::MAX));
}
Ok(out)
}
/// Local day index for a civil time, for bucketing usage.
pub fn day_index(now: CivilTime) -> i64 {
now.epoch_minutes.div_euclid(24 * 60)
}
/// Path this store was opened from.
pub fn path(&self) -> &Path {
&self.path
}
/// Run a maintenance step: drop usage rows older than `keep_days`.
///
/// Pruning is deliberate rather than unbounded — an app-blocker's usage
/// history is genuinely useful for statistics (section 1.6) but a table
/// that grows forever is a slow disk leak on a machine with 9.6 GB free.
pub fn prune_usage(&mut self, keep_days: i64) -> Result<u64, StoreError> {
// Relative to the NEWEST day, not the oldest.
//
// Anchoring on MIN(day_index) is the obvious version and it never prunes
// anything: the oldest row is by definition the one being tested, so the
// cutoff sits before it and the delete matches zero rows. The table then
// grows forever, which on a machine with 9.6 GB free is exactly the slow
// leak this is meant to prevent. Caught by old_usage_is_pruned.
let newest: i64 = self
.conn
.query_row("SELECT COALESCE(MAX(day_index), 0) FROM usage_daily", [], |r| {
r.get::<_, i64>(0)
})
.map_err(StoreError::Init)?;
// +1 so exactly `keep_days` rows survive: with cutoff = newest - keep_days
// and a strict `<`, the boundary day is kept as well, giving keep_days + 1.
let cutoff = newest - keep_days + 1;
let n = self
.conn
.execute("DELETE FROM usage_daily WHERE day_index < ?1", [cutoff])
.map_err(StoreError::Init)?;
Ok(n as u64)
}
}
/// One bucket of accumulated usage.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct UsageRow {
/// Foreground minutes per target.
pub minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per target.
pub launches: std::collections::BTreeMap<String, u32>,
}
/// Schema, version 1.
///
/// Forward-only migrations. `user_version` gates them; downgrading would risk
/// losing lock state, which section 1.5.3 forbids and section 1.5.4 makes a
/// safety property.
///
/// The normalised shape of `schedule_target` and `schedule_condition` is
/// deliberate over storing JSON blobs: usage and target rows are queried by
/// target, and an index on a text column that happens to contain JSON is not a
/// plan. Encoding is handled in [`crate::codec`].
pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS schedule (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
mode TEXT NOT NULL CHECK (mode IN ('Blocklist', 'Allowlist')),
paused INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS schedule_target (
schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('app', 'website', 'keyword')),
value TEXT NOT NULL,
PRIMARY KEY (schedule_id, ordinal)
);
CREATE TABLE IF NOT EXISTS schedule_condition (
schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
kind TEXT NOT NULL,
days TEXT, -- comma-separated, Monday = 0
start_min INTEGER,
end_min INTEGER,
limit_value INTEGER,
window TEXT,
ssid TEXT,
latitude REAL,
longitude REAL,
radius_m INTEGER,
outside INTEGER,
PRIMARY KEY (schedule_id, ordinal)
);
-- Section 1.5.3: lock state lives in the database, not in an editable plaintext
-- config file, so editing a file is not enough to defeat a lock.
CREATE TABLE IF NOT EXISTS lock_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
level TEXT NOT NULL CHECK (level IN ('Normal', 'Strict', 'Stubborn')),
schedules_locked INTEGER NOT NULL,
blocks_uninstall INTEGER NOT NULL,
keyholder_code TEXT,
request_ready_at INTEGER,
request_window_closes_at INTEGER,
boot_id INTEGER NOT NULL,
last_monotonic_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS disable_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at_monotonic INTEGER NOT NULL,
boot_id INTEGER NOT NULL,
day_index INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS usage_daily (
day_index INTEGER NOT NULL,
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day_index, target)
);
CREATE TABLE IF NOT EXISTS usage_hourly (
hour_index INTEGER NOT NULL,
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (hour_index, target)
);
CREATE INDEX IF NOT EXISTS usage_daily_target ON usage_daily (target);
CREATE INDEX IF NOT EXISTS usage_hourly_target ON usage_hourly (target);
CREATE INDEX IF NOT EXISTS disable_attempt_day ON disable_attempt (day_index);
"#;
pub mod codec;
#[cfg(test)]
mod tests;
+304
View File
@@ -0,0 +1,304 @@
//! Tests for the SQLite store.
//!
//! These use a temporary file rather than `:memory:` because WAL is enabled on
//! the connection and an in-memory database with WAL is a subtly different thing
//! than what ships.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test fixtures: a failing assertion or a broken test is not a runtime \
condition to handle gracefully"
)]
use super::*;
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
/// A unique temp path per test, since tests run in parallel and a shared
/// database would make them interfere.
fn temp_db(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("blocker-test-{}-{name}.db", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
fn sample_schedule() -> Schedule {
Schedule {
id: "s1".into(),
name: "Evening Focus".into(),
icon: Some("/icons/focus.png".into()),
mode: ScheduleMode::Blocklist,
targets: vec![
Target::App("com.example.social".into()),
Target::Website("example.com".into()),
Target::Keyword("casino".into()),
],
conditions: vec![
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 22 * 60, end: 6 * 60 }),
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
],
paused: false,
}
}
#[test]
fn schedules_round_trip_through_the_database() {
let path = temp_db("schedules");
let original = sample_schedule();
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(std::slice::from_ref(&original)).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let loaded = store.load_schedules().expect("load");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], original, "a schedule must survive a restart intact");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn overnight_window_survives_a_restart() {
// The specific case worth its own test: if the overnight flag were lost in
// storage, the schedule would stop blocking at midnight *after a restart*,
// while appearing correct in the UI.
let path = temp_db("overnight");
let mut original = sample_schedule();
original.conditions =
vec![Condition::Time(TimeWindow { days: vec![0], start: 22 * 60, end: 6 * 60 })];
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(std::slice::from_ref(&original)).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let loaded = store.load_schedules().expect("load");
match &loaded[0].conditions[0] {
Condition::Time(w) => {
assert!(
w.is_overnight(),
"start={} end={} must still read as overnight after a restart",
w.start,
w.end
);
assert_eq!(w.start, 22 * 60);
assert_eq!(w.end, 6 * 60);
}
other => panic!("condition type changed across a restart: {other:?}"),
}
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn saving_replaces_rather_than_accumulates() {
let path = temp_db("replace");
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule(), sample_schedule_with_id("s2")]).expect("save");
assert_eq!(store.load_schedules().unwrap().len(), 2);
store.save_schedules(&[sample_schedule()]).expect("save");
let loaded = store.load_schedules().expect("load");
assert_eq!(loaded.len(), 1, "a second save must replace, not append");
assert_eq!(loaded[0].id, "s1");
let _ = std::fs::remove_file(&path);
}
fn sample_schedule_with_id(id: &str) -> Schedule {
let mut s = sample_schedule();
s.id = id.to_owned();
s.name = format!("Schedule {id}");
s
}
#[test]
fn usage_accumulates_rather_than_overwrites() {
let path = temp_db("usage");
let mut store = Store::open(&path).expect("open");
store.add_daily_usage(20_731, "com.example.social", 30).unwrap();
store.add_daily_usage(20_731, "com.example.social", 25).unwrap();
store.add_daily_launch(20_731, "com.example.social").unwrap();
store.add_daily_launch(20_731, "com.example.social").unwrap();
let row = store.load_daily_usage(20_731).expect("load");
assert_eq!(row.minutes.get("com.example.social"), Some(&55));
assert_eq!(row.launches.get("com.example.social"), Some(&2));
// A different day is a different bucket.
assert!(store.load_daily_usage(20_732).unwrap().minutes.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_pending_disable_request_survives_a_restart() {
// Section 1.5.2.2: filing a request must not lose the request on restart, or
// a user who waits out a 12-hour delay and restarts the machine would be
// back at square one with no explanation.
let path = temp_db("pending");
let state = blocker_core::lock::LockState {
level: blocker_core::lock::LockLevel::Stubborn,
schedules_locked: true,
pending: Some(blocker_core::lock::PendingRequest {
ready_at: Monotonic(12 * 3_600_000),
window_closes_at: Monotonic(12 * 3_600_000 + 600_000),
}),
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
};
{
let mut store = Store::open(&path).expect("open");
store.save_lock(&state, BootId(7), 1_000).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let (ready, boot) = store
.load_pending_request()
.expect("load")
.expect("a pending request must survive a restart");
assert_eq!(ready, Monotonic(12 * 3_600_000));
assert_eq!(boot, BootId(7), "the boot id must survive, so a reboot is detectable");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn no_pending_request_reads_as_none() {
let path = temp_db("nopending");
let store = Store::open(&path).expect("open");
assert_eq!(store.load_pending_request().unwrap(), None);
let _ = std::fs::remove_file(&path);
}
#[test]
fn disable_attempts_are_counted_per_day() {
// Section 1.5.2.5: the block screen shows "you tried 4 times today".
let path = temp_db("attempts");
let mut store = Store::open(&path).expect("open");
for _ in 0..4 {
store.record_attempt(Monotonic(1_000), BootId(1), 20_731).unwrap();
}
store.record_attempt(Monotonic(2_000), BootId(1), 20_732).unwrap();
assert_eq!(store.attempt_count(20_731).unwrap(), 4);
assert_eq!(store.attempt_count(20_732).unwrap(), 1);
assert_eq!(store.attempt_count(99_999).unwrap(), 0);
let _ = std::fs::remove_file(&path);
}
/// Section 1.5.4: a corrupt database must never leave a device unusable.
///
/// This is the single most important test in the crate. The requirement is
/// fail *open*: blocking stops, the user keeps a working device, and the damaged
/// file is kept for diagnosis.
#[test]
fn a_corrupt_database_fails_open() {
let path = temp_db("corrupt");
// Real bytes in place of the SQLite header. Enough to make the file
// unopenable or unreadable, which is what "corrupt" means in the field.
std::fs::write(&path, b"SQLite format 3\x00 this is not actually a database").unwrap();
let store = Store::open(&path).expect("open must succeed despite corruption");
assert!(store.recovered_from_corruption(), "the store must report that it recovered");
assert!(
store.load_schedules().unwrap().is_empty(),
"a recovered database starts empty, which blocks nothing — fail open"
);
assert!(
path.with_extension("corrupt").exists(),
"the damaged file must be kept for diagnosis, not deleted"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("corrupt"));
}
#[test]
fn a_truncated_database_fails_open() {
// A different failure mode from garbage bytes: a valid header with the
// content cut off. Corruption in the field is often truncation, from a
// power loss during a write.
let path = temp_db("truncated");
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule()]).expect("save");
}
// Keep the first 200 bytes — a valid header, an unusable file.
let full = std::fs::read(&path).unwrap();
std::fs::write(&path, &full[..200.min(full.len())]).unwrap();
let store = Store::open(&path);
assert!(
store.is_ok(),
"a truncated database must still open, because failing to open would \
leave the device with no enforcement and no UI"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("corrupt"));
}
#[test]
fn opening_creates_the_directory() {
let mut nested = std::env::temp_dir();
nested.push(format!("blocker-test-{}-nested/state", std::process::id()));
let _ = std::fs::remove_dir_all(&nested);
let path = nested.join("blocker.db");
let store = Store::open(&path);
assert!(store.is_ok(), "a missing parent directory must not be fatal");
assert!(path.exists());
let _ = std::fs::remove_dir_all(&nested);
}
#[test]
fn old_usage_is_pruned() {
// An unbounded usage table is a slow disk leak, and this machine has 9.6 GB
// free. Statistics (section 1.6) need history, not infinite history.
let path = temp_db("prune");
let mut store = Store::open(&path).expect("open");
for day in 20_000..20_100 {
store.add_daily_usage(day, "com.example.social", 10).unwrap();
}
let removed = store.prune_usage(10).expect("prune");
assert_eq!(removed, 90, "90 of 100 days are outside the 10-day window");
let kept = store.load_daily_usage(20_099).unwrap();
assert!(!kept.minutes.is_empty(), "recent history must be kept");
let _ = std::fs::remove_file(&path);
}
#[test]
fn reopening_is_idempotent() {
// The migration must not run twice or clobber data.
let path = temp_db("idempotent");
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule()]).expect("save");
drop(store);
for _ in 0..3 {
let store = Store::open(&path).expect("reopen");
assert_eq!(store.load_schedules().unwrap().len(), 1, "data must survive re-migration");
}
let _ = std::fs::remove_file(&path);
}
+111
View File
@@ -0,0 +1,111 @@
//! Coverage bookkeeping for the golden tables.
//!
//! CLAUDE.md section 7 enumerates the scenarios that must be covered. A list
//! nobody checks is a wish, so this module is the check: every mandatory
//! scenario must be claimed by at least one case, and every `covers` tag a case
//! uses must be one we know about. Both directions fail, because the second is
//! how a case silently stops testing what its name claims.
//!
//! # Some scenarios are not table-shaped
//!
//! Clock tampering and the 24-hour cap cannot be expressed as data: proving
//! them needs arithmetic that moves, or a bound that must hold across every
//! lock level, not a decision at a point in time. Those live in
//! `super::purity` as code fixtures. [`Location`] records which scenarios are
//! covered where, so "covered" never quietly means "listed but not tested".
use std::collections::BTreeMap;
/// Where a scenario's coverage lives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
/// A golden table case claims it.
Table,
/// A code fixture in `tests/src/purity.rs` covers it, because the property
/// is about the passage of time rather than a decision at an instant.
Fixture,
}
/// Every scenario CLAUDE.md section 7 (and section 1.2 / 1.5.4) requires.
pub const REQUIRED: &[(&str, Location)] = &[
// Section 7's explicit list.
("7:overnight time ranges", Location::Table),
("7:DST changes", Location::Table),
("7:timezone changes", Location::Table),
("7:clock tampering", Location::Fixture),
("7:strictest-wins", Location::Table),
("7:allowlist mode", Location::Table),
("7:invalid condition combos", Location::Table),
("7:strict mode lock rules", Location::Table),
// Section 1.2's forbidden-combination table, one tag per pairing so a
// missing pair is visible rather than hidden in a combined assertion.
("1.2:time+usage rejected", Location::Table),
("1.2:time+launch rejected", Location::Table),
("1.2:usage+launch rejected", Location::Table),
("1.2:usage+location rejected", Location::Table),
("1.2:launch+location rejected", Location::Table),
// Section 1.5.4's safety guarantees.
("1.5.4:never-block list is unblockable", Location::Table),
("1.5.4:worst case 24h", Location::Fixture),
];
/// Fixture names in `tests/src/purity.rs` that satisfy [`Location::Fixture`].
///
/// Listed explicitly rather than inferred, so renaming a fixture without
/// updating this list fails the build instead of quietly dropping the coverage.
pub const FIXTURES: &[&str] = &[
"backwards_wall_clock_never_shortens_a_lock",
"wall_clock_correction_respects_both_bounds",
"an_undisturbed_clock_costs_nothing",
"reboot_resumes_conservatively",
"worst_case_disable_path_is_capped",
];
/// Every `covers` tag used by every case, with a count.
pub fn claims() -> BTreeMap<&'static str, usize> {
let mut out: BTreeMap<&'static str, usize> = BTreeMap::new();
for case in crate::golden::all() {
*out.entry(case.covers).or_insert(0) += 1;
}
out
}
/// Table-shaped scenarios with no case claiming them.
///
/// [`Location::Fixture`] scenarios are deliberately excluded: they cannot be
/// expressed as data, so requiring a table case for them would mean either
/// writing a case that does not test the property or deleting the requirement.
/// They are checked by [`fixtures_missing`] instead.
pub fn uncovered() -> Vec<&'static str> {
let claims = claims();
REQUIRED
.iter()
.filter(|(_, where_)| *where_ == Location::Table)
.map(|(tag, _)| *tag)
.filter(|tag| !claims.contains_key(tag))
.collect()
}
/// Fixture-backed scenarios whose fixture names are not all listed.
///
/// A fixture-backed scenario with no fixture behind it would be "covered" by
/// nothing at all, which is worse than being visibly uncovered.
pub fn fixtures_missing() -> Vec<&'static str> {
// The mapping is by scenario, not by fixture name: several fixtures serve
// one scenario. Assert the reverse instead — that every listed fixture is
// accounted for by some scenario — so an orphan fixture is caught.
REQUIRED
.iter()
.filter(|(_, where_)| *where_ == Location::Fixture)
.map(|(tag, _)| *tag)
.collect()
}
/// Tags used by a case that are not in [`REQUIRED`].
///
/// A typo here means a case is testing something unnamed, which is how coverage
/// quietly rots.
pub fn unknown_claims() -> Vec<&'static str> {
let known: Vec<&str> = REQUIRED.iter().map(|(tag, _)| *tag).collect();
claims().keys().filter(|tag| !known.contains(tag)).copied().collect()
}
+403 -222
View File
@@ -1,242 +1,423 @@
//! The golden decision tables (CLAUDE.md section 7).
//!
//! A table is a list of cases. Each case is schedules plus an input plus an
//! expected decision, and running the same table against the engine must
//! reproduce the expectation exactly.
//! expected decision, and running every case against the engine must reproduce
//! the expectation exactly.
//!
//! # Format
//! # This is a parity contract, not a test suite
//!
//! JSON, one file per concern, under `golden/`. JSON rather than TOML because
//! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi`
//! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain.
//! Unit tests check the engine against itself. These check that *every platform*
//! agrees with the engine. Each platform implements a mapping from its own app
//! identity — Android package name, Linux desktop-file id, Windows exe path —
//! onto a [`blocker_core::model::Target`], and every table here runs through that
//! mapping too. If Linux resolves Discord to a different target than Android
//! does, a case fails at build time instead of becoming a bug report from
//! whichever platform nobody was testing that week.
//!
//! ```json
//! {
//! "concern": "overnight",
//! "cases": [
//! {
//! "name": "mon 22:00-06:00 window still active at tue 03:00",
//! "covers": "7:overnight time ranges",
//! "schedules": [
//! { "id": "s1", "name": "Night", "mode": "Blocklist",
//! "targets": [{"type": "app", "app": "com.example.social"}],
//! "conditions": [{"type": "Time", "days": [0],
//! "start": "22:00", "end": "06:00"}] }
//! ],
//! "input": { "civil_time": "2026-10-06T03:00:00+02:00" },
//! "expect": {
//! "blocked_apps": [{"app": "com.example.social", "schedule": "s1"}],
//! "next_wakeup": "2026-10-06T06:00:00+02:00"
//! }
//! }
//! ]
//! }
//! ```
//! # The rule that keeps the tables meaningful
//!
//! # Status in M0
//! **A case is added before the engine changes.** A table written after the code
//! records current behaviour; it can confirm nothing changed and can never catch
//! a regression, because it was fitted to whatever the code happened to do.
//!
//! The shapes below are the **contract**, written down before any parse layer
//! exists so M1 implements against a fixed format rather than retrofitting one.
//! `serde::Deserialize` is deliberately not derived yet — adding serde is a
//! dependency decision (CLAUDE.md section 0 rule 3) and is deferred to M1,
//! where it arrives with `rusqlite` under one ADR entry.
//! So a PR that changes engine behaviour is reviewed on one question: is there a
//! case added or updated in the same commit? If not, the change is undocumented
//! behaviour.
//!
//! # The clock-tampering exception
//! # Why these are Rust, not JSON
//!
//! Most cases are pure data. Clock tampering is not: proving that moving the
//! system date never shortens a lock requires actually moving a clock, which a
//! data file cannot express. Those cases live in [`crate::purity`] as code
//! fixtures instead, and the two coexist deliberately — the tables describe
//! decisions, the fixtures describe the passage of time.
//! Originally specified as JSON on the reasoning that `serde_json` would already
//! be in the tree. That reasoning was wrong: `rusqlite` does not pull `serde` by
//! default, and `uniffi` has not landed yet, so nothing in the graph provides it.
//! `serde` is not in CLAUDE.md section 3, and section 0 rule 3 requires asking
//! before adding a dependency.
//!
//! A builder DSL gets the same declarations with two properties JSON cannot offer:
//! the compiler rejects a typo in a field name, and a wrong variant is a type
//! error rather than a test that quietly asserts the wrong thing. For a document
//! whose entire job is to be a contract, compile-time checking is worth more than
//! diffability.
//!
//! Converting to JSON later, if serde is approved, is mechanical — only this
//! module changes. Recorded as ADR-16.
use std::collections::BTreeMap;
use blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot};
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// One table, deserialised from one `golden/*.json` file.
#[derive(Debug, Clone)]
pub struct Table {
/// Which concern this table covers, e.g. `overnight`.
pub concern: String,
/// The cases in the table.
pub cases: Vec<Case>,
/// Sentinel meaning "the platform may sleep forever" in an expectation.
///
/// `"none"` as a string was ambiguous with a null; a distinct type is not.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wakeup {
/// Assert nothing about the wakeup.
///
/// The default, because a case that is about blocking behaviour should not
/// fail because a window end moved. It is also the weakest option, so the
/// cases that actually carry the section 5 idle budget declare explicitly —
/// and `idle_budget_cases_declare_their_wakeup` in `run.rs` fails if one of
/// those stops doing so.
Any,
/// No timer may be armed — nothing time-dependent is pending.
Never,
/// Minutes from now, resolved against the case's civil time.
InMinutes(i64),
/// An exact civil time, for cases where the boundary is load-bearing.
At(CivilTime),
}
/// One case: setup, input, expected decision.
#[derive(Debug, Clone)]
pub struct Case {
/// Human-readable, and the primary test failure message.
pub name: String,
/// Which CLAUDE.md section 7 scenario this covers, so coverage is auditable
/// rather than assumed. Checked against [`MANDATORY_COVERAGE`].
pub covers: String,
/// The schedules under test.
pub schedules: Vec<ScheduleSpec>,
/// The input to the engine.
pub input: InputSpec,
/// The decision that must result.
pub expect: ExpectSpec,
}
/// A schedule as it appears in a table.
#[derive(Debug, Clone)]
pub struct ScheduleSpec {
/// Stable id, referenced by `BlockedSpec::schedule`.
pub id: String,
/// Display name, shown on the block screen.
pub name: String,
/// `"Blocklist"` or `"Allowlist"`.
pub mode: String,
/// What this schedule targets.
pub targets: Vec<TargetSpec>,
/// Its conditions. At least one must hold for the schedule to be active.
pub conditions: Vec<ConditionSpec>,
/// Defaults to false when absent from JSON.
pub paused: bool,
}
/// A target, tagged by type so the JSON is self-describing.
#[derive(Debug, Clone)]
pub enum TargetSpec {
/// An app, by platform-native identifier.
App {
/// Package name on Android, desktop-file id or exe path elsewhere.
app: String,
},
/// A registrable domain. Subdomains are implied.
Website {
/// e.g. `example.com`.
website: String,
},
/// A case-insensitive substring matched against the URL.
Keyword {
/// The substring to match.
keyword: String,
},
}
/// A condition, tagged by type.
#[derive(Debug, Clone)]
pub enum ConditionSpec {
/// Block while local time is inside the window.
Time {
/// Weekdays on which the window **starts**. Monday = 0, matching
/// `time::Weekday`.
days: Vec<u8>,
/// `HH:MM`. When `start > end` the window spans midnight — there is
/// deliberately no `crosses_midnight` flag, because the comparison
/// already encodes it and a second field is a second way to be wrong.
start: String,
/// `HH:MM`. See `start`.
end: String,
},
/// Block after a foreground-time threshold.
UsageLimit {
/// Threshold in minutes.
limit_minutes: u32,
/// `"Daily"` or `"Hourly"`.
window: String,
},
/// Block after a launch-count threshold.
LaunchCount {
/// Threshold count.
limit: u32,
/// `"Daily"` or `"Hourly"`.
window: String,
},
/// Block while connected to a named network.
WifiSsid {
/// The SSID to match exactly.
ssid: String,
},
/// Block inside or outside a radius. Mobile only (section 6.4).
Location {
/// Circle centre latitude in degrees.
latitude: f64,
/// Circle centre longitude in degrees.
longitude: f64,
/// Radius in metres.
radius_meters: u32,
/// Invert the test: block when *outside* the radius. Defaults false.
block_when_outside: bool,
},
}
/// The engine input for a case.
#[derive(Debug, Clone, Default)]
pub struct InputSpec {
/// RFC 3339 with an explicit offset, e.g. `2026-10-06T03:00:00+02:00`.
/// The offset is mandatory so a DST case is unambiguous.
pub civil_time: String,
/// Monotonic milliseconds since boot. Absent means 0.
pub monotonic_ms: u64,
/// Boot id. Absent means 0.
pub boot_id: u64,
/// Accumulated usage.
pub usage: UsageSpec,
/// Platform-observed context.
pub context: ContextSpec,
}
/// Usage counters for a case. All fields default to empty.
#[derive(Debug, Clone, Default)]
pub struct UsageSpec {
/// Foreground minutes per target, daily bucket.
pub daily_minutes: BTreeMap<String, u32>,
/// Foreground minutes per target, hourly bucket.
pub hourly_minutes: BTreeMap<String, u32>,
/// Launch counts per target, daily bucket.
pub daily_launches: BTreeMap<String, u32>,
/// Launch counts per target, hourly bucket.
pub hourly_launches: BTreeMap<String, u32>,
}
/// Platform context for a case.
#[derive(Debug, Clone, Default)]
pub struct ContextSpec {
/// Current Wi-Fi SSID, if any.
pub current_ssid: Option<String>,
/// Current `(latitude, longitude)`, if any.
pub current_location: Option<(f64, f64)>,
}
/// The decision a case expects.
#[derive(Debug, Clone)]
pub struct ExpectSpec {
/// Apps that must be blocked, each attributed to a schedule.
pub blocked_apps: Vec<BlockedSpec>,
/// Websites and keywords that must be blocked.
pub blocked_domains: Vec<BlockedSpec>,
/// RFC 3339, or the literal `none` when the platform may sleep forever.
pub next_wakeup: String,
}
/// One expected blocked item.
#[derive(Debug, Clone)]
pub struct BlockedSpec {
/// One expected blocked item, attributed to a schedule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Blocked {
/// The target that must be blocked.
pub app: String,
/// The id of the schedule that must block it.
pub schedule: String,
}
/// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one
/// `covers` field across the tables, and `coverage.rs` asserts it — so a
/// scenario cannot silently lose its test.
pub const MANDATORY_COVERAGE: &[&str] = &[
"7:overnight time ranges",
"7:DST changes",
"7:timezone changes",
"7:clock tampering",
"7:strictest-wins",
"7:allowlist mode",
"7:invalid condition combos",
"7:strict mode lock rules",
// The section 1.2 combination table, itemised so a missing pair is visible.
"1.2:time+usage rejected",
"1.2:time+launch rejected",
"1.2:usage+launch rejected",
"1.2:usage+location rejected",
"1.2:launch+location rejected",
// Section 1.5.4 safety guarantees.
"1.5.4:never-block list is unblockable",
"1.5.4:worst case 24h",
];
/// The decision a case expects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Expect {
/// Apps that must be blocked. Order-independent; the engine's union order
/// is not part of the contract.
pub blocked_apps: Vec<String>,
/// Schedules expected to attribute each block. Parallel to `blocked_apps`.
pub blocked_by: Vec<String>,
/// Websites and keywords that must be blocked.
pub blocked_domains: Vec<String>,
/// When the platform must wake up next, if ever.
pub next_wakeup: Wakeup,
}
impl Default for Expect {
fn default() -> Self {
Self {
blocked_apps: Vec::new(),
blocked_by: Vec::new(),
blocked_domains: Vec::new(),
next_wakeup: Wakeup::Any,
}
}
}
/// A case: setup, input, expected decision.
#[derive(Debug, Clone)]
pub struct Case {
/// Human-readable, and the primary failure message.
pub name: String,
/// Which CLAUDE.md section 7 scenario this covers. Checked by
/// [`coverage`] against the mandatory list.
pub covers: &'static str,
/// Schedules under test.
pub schedules: Vec<Schedule>,
/// Input civil time.
pub now: CivilTime,
/// Monotonic milliseconds since boot.
pub monotonic: Monotonic,
/// Boot id.
pub boot_id: u64,
/// Usage counters.
pub usage: UsageSnapshot,
/// Platform-observed context.
pub context: PlatformContext,
/// Apps that exist on the machine, for allowlist cases.
pub installed_apps: Vec<String>,
/// The decision that must result.
pub expect: Expect,
/// Set when the case asserts a validation error instead of a decision.
/// Section 1.2 combination cases cannot produce a decision, because
/// `Schedule::validate` rejects them first.
pub expect_validation_error: bool,
/// The platform under test, for validation.
pub platform: blocker_core::model::Platform,
}
impl Case {
/// A case with defaults: no usage, no context, no installed apps, boot 0.
pub fn new(name: &str, covers: &'static str) -> Self {
Self {
name: name.to_owned(),
covers,
schedules: Vec::new(),
now: CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 },
monotonic: Monotonic(0),
boot_id: 0,
usage: UsageSnapshot::default(),
context: PlatformContext::default(),
installed_apps: Vec::new(),
expect: Expect::default(),
expect_validation_error: false,
platform: blocker_core::model::Platform::Android,
}
}
pub fn with_schedules(mut self, schedules: Vec<Schedule>) -> Self {
self.schedules = schedules;
self
}
pub fn at(mut self, days_since_epoch: i64, hour: u32, minute: u32) -> Self {
self.now = local(days_since_epoch, hour, minute);
self
}
pub fn with_offset(mut self, minutes: i32) -> Self {
self.now.utc_offset_minutes = minutes;
self
}
pub fn with_ssid(mut self, ssid: &str) -> Self {
self.context.current_ssid = Some(ssid.to_owned());
self
}
pub fn with_location(mut self, lat: f64, lon: f64) -> Self {
self.context.current_location = Some((lat, lon));
self
}
pub fn with_installed(mut self, apps: &[&str]) -> Self {
self.installed_apps = apps.iter().map(|s| (*s).to_owned()).collect();
self
}
pub fn with_daily_minutes(mut self, target: &str, minutes: u32) -> Self {
self.usage.daily_minutes.insert(target.to_owned(), minutes);
self
}
pub fn with_daily_launches(mut self, target: &str, count: u32) -> Self {
self.usage.daily_launches.insert(target.to_owned(), count);
self
}
pub fn on_platform(mut self, platform: blocker_core::model::Platform) -> Self {
self.platform = platform;
self
}
/// Expect apps blocked, each attributed to the named schedule.
pub fn blocks(mut self, pairs: &[(&str, &str)]) -> Self {
for (app, schedule) in pairs {
self.expect.blocked_apps.push((*app).to_owned());
self.expect.blocked_by.push((*schedule).to_owned());
}
self
}
pub fn blocks_domains(mut self, domains: &[&str]) -> Self {
self.expect.blocked_domains = domains.iter().map(|s| (*s).to_owned()).collect();
self
}
pub fn wakes_in(mut self, minutes: i64) -> Self {
self.expect.next_wakeup = Wakeup::InMinutes(minutes);
self
}
/// Expect no timer at all — the zero-idle-wakeup budget, made testable.
pub fn never_wakes(mut self) -> Self {
self.expect.next_wakeup = Wakeup::Never;
self
}
/// Expect `Schedule::validate` to reject this case's schedules.
pub fn invalid(mut self) -> Self {
self.expect_validation_error = true;
self
}
/// Evaluate this case and return a human-readable description of any
/// mismatch, or `None` when it passes.
pub fn check(&self) -> Option<String> {
if self.expect_validation_error {
for s in &self.schedules {
if s.validate(self.platform).is_ok() {
return Some(format!(
"schedule {:?} was expected to be rejected by section 1.2 \
validation, but validated cleanly",
s.id
));
}
}
return None;
}
let input = EngineInput {
now: self.now,
monotonic: self.monotonic,
boot_id: blocker_core::engine::BootId(self.boot_id),
schedules: &self.schedules,
usage: &self.usage,
context: &self.context,
installed_apps: &self.installed_apps,
};
let decision = blocker_core::evaluate(&input);
let mut actual: Vec<(String, String)> =
decision.blocked_apps.iter().map(|r| (r.app_key(), r.schedule_id.clone())).collect();
actual.sort();
let mut wanted: Vec<(String, String)> = self
.expect
.blocked_apps
.iter()
.cloned()
.zip(self.expect.blocked_by.iter().cloned())
.collect();
wanted.sort();
if actual != wanted {
return Some(format!(
"blocked apps differ\n expected: {wanted:?}\n actual: {actual:?}"
));
}
let mut actual_domains: Vec<String> =
decision.blocked_domains.iter().map(|r| r.app_key()).collect();
actual_domains.sort();
let mut wanted_domains = self.expect.blocked_domains.clone();
wanted_domains.sort();
if actual_domains != wanted_domains {
return Some(format!(
"blocked domains differ\n expected: {wanted_domains:?}\n actual: {actual_domains:?}"
));
}
match (self.expect.next_wakeup, decision.next_wakeup) {
(Wakeup::Any, _) => None,
(Wakeup::Never, None) => None,
(Wakeup::Never, Some(t)) => Some(format!(
"expected no wakeup (the platform should be able to sleep forever), \
but the engine asked for one at epoch_minute {}",
t.epoch_minutes
)),
(Wakeup::InMinutes(m), Some(t)) => {
let delta = t.epoch_minutes - self.now.epoch_minutes;
if delta == m {
None
} else {
Some(format!(
"next_wakeup differs: expected {m} minutes from now, \
engine asked for {delta}"
))
}
}
(Wakeup::InMinutes(m), None) => Some(format!(
"expected a wakeup {m} minutes from now, but the engine asked for none — \
a missing boundary means a schedule would silently never fire"
)),
(Wakeup::At(expected), Some(t)) => {
if t == expected {
None
} else {
Some(format!("next_wakeup differs: expected {expected:?}, got {t:?}"))
}
}
(Wakeup::At(_), None) => Some("expected a specific wakeup, got none".to_owned()),
}
}
}
/// Build a [`CivilTime`] from a local date and `HH:MM`.
///
/// `days_since_epoch` is days since 1970-01-01 with the local offset already
/// folded out, so tests can name a calendar date and reason about its weekday.
pub fn local(days_since_epoch: i64, hour: u32, minute: u32) -> CivilTime {
CivilTime {
epoch_minutes: days_since_epoch * 24 * 60 + i64::from(hour * 60 + minute),
utc_offset_minutes: 0,
}
}
// --- Schedule builders ---------------------------------------------------
//
// Verb constructors rather than struct literals: a table is read far more often
// than it is edited, and `blocklist("s1", "Focus", &[app("discord")], &[time(...)])`
// says more than a five-field struct literal does.
pub fn blocklist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule {
Schedule {
id: id.to_owned(),
name: name.to_owned(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: targets.to_vec(),
conditions: conditions.to_vec(),
paused: false,
}
}
pub fn allowlist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule {
Schedule { mode: ScheduleMode::Allowlist, ..blocklist(id, name, targets, conditions) }
}
pub fn app(id: &str) -> Target {
Target::App(id.to_owned())
}
pub fn website(domain: &str) -> Target {
Target::Website(domain.to_owned())
}
pub fn keyword(word: &str) -> Target {
Target::Keyword(word.to_owned())
}
/// A time window. `start > end` means it spans midnight.
pub fn time(days: &[DayOfWeek], start: MinuteOfDay, end: MinuteOfDay) -> Condition {
Condition::Time(TimeWindow { days: days.to_vec(), start, end })
}
/// Monday through Friday, which most work-hours schedules use.
pub const WEEKDAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4];
/// Every day of the week.
pub const ALL_DAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4, 5, 6];
/// `HH:MM` to minutes since midnight, for readability in a table.
pub fn at(hour: u32, minute: u32) -> MinuteOfDay {
let total = hour * 60 + minute;
u16::try_from(total).unwrap_or(MinuteOfDay::MAX)
}
pub fn usage_limit(limit_minutes: u32, window: UsageWindow) -> Condition {
Condition::UsageLimit { limit_minutes, window }
}
pub fn launch_count(limit: u32, window: UsageWindow) -> Condition {
Condition::LaunchCount { limit, window }
}
pub fn wifi(ssid: &str) -> Condition {
Condition::WifiSsid(ssid.to_owned())
}
pub fn location(lat: f64, lon: f64, radius_m: u32, block_when_outside: bool) -> Condition {
Condition::Location {
latitude: lat,
longitude: lon,
radius_meters: radius_m,
block_when_outside,
}
}
/// Every case, across every table.
///
/// The runner asserts that this is the same set the individual tables produce,
/// so a case added to a table but not collected here cannot silently go
/// unrun.
pub fn all() -> Vec<Case> {
crate::tables::overnight_cases()
.into_iter()
.chain(crate::tables::timezone_cases())
.chain(crate::tables::strictest_wins_cases())
.chain(crate::tables::allowlist_cases())
.chain(crate::tables::condition_guard_cases())
.chain(crate::tables::usage_and_launch_cases())
.chain(crate::tables::wifi_and_location_cases())
.chain(crate::tables::wakeup_cases())
.chain(crate::tables::never_block_cases())
.chain(crate::tables::lock_rule_cases())
.collect()
}
+21 -16
View File
@@ -5,33 +5,38 @@
//!
//! # Why this is a contract, not a test suite
//!
//! Section 7 requires that the same table runs against the core and serves as
//! the parity contract for all platforms. Android, Linux and Windows each
//! implement a mapping from a platform app identity to a `Target`, but they all
//! hand the same [`blocker_core`] engine the same schedule set and get back the
//! same decision. A platform crate runs this table against its own mapping, so
//! if Linux disagrees with Android the build fails rather than the user filing
//! a bug.
//! Unit tests check the engine against itself. These check that *every platform*
//! agrees with the engine. Android, Linux and Windows each map their own app
//! identity — package name, desktop-file id, exe path — onto a
//! [`blocker_core::model::Target`], and every case here runs through that mapping
//! too. If Linux resolves Discord to a different target than Android does, a case
//! fails at build time instead of becoming a bug report from whichever platform
//! nobody was testing that week.
//!
//! # The rule that keeps it honest
//!
//! **A case is added before the engine changes.** If a table were written after
//! the code, it would only record current behaviour and could never catch a
//! regression — it would be a transcript, not a specification. Reviewing a PR
//! that changes engine behaviour therefore means checking that a case was added
//! or updated in the same commit.
//! **A case is added before the engine changes.** A table written after the code
//! records current behaviour and can never catch a regression — it would be a
//! transcript, not a specification. Reviewing a PR that changes engine behaviour
//! therefore means checking that a case was added or updated in the same commit.
//!
//! # Layout
//!
//! - `golden/` — the tables themselves, one JSON file per concern
//! - [`purity`] — a mechanical check that the core has not gained I/O
//!
//! The mandatory case list is in [`golden::MANDATORY_COVERAGE`].
//! - [`golden`] — the case format, the harness, and the builder DSL
//! - [`tables`] — the cases themselves, grouped by scenario
//! - [`coverage`] — which CLAUDE.md scenario each case claims, and the check that
//! nothing went uncovered
//! - [`purity`] — a mechanical check that the core has not gained I/O, plus the
//! clock-tampering fixtures that cannot be expressed as data
//!
//! [`purity`] is `cfg(test)` because its checks are `#[test]` functions in this
//! library's unit-test build, not library API. They run under `cargo test`.
pub mod coverage;
pub mod golden;
pub mod tables;
#[cfg(test)]
mod purity;
#[cfg(test)]
mod run;
+147
View File
@@ -0,0 +1,147 @@
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "golden-table runner: assert-and-continue reporting is the point"
)]
//! The golden-table runner.
//!
//! Every case is evaluated against the engine and any mismatch is reported with
//! enough context to diagnose without re-running: what was expected, what
//! happened, and — for wakeups — why the difference matters.
use crate::coverage;
use crate::golden;
/// Every mandatory scenario has at least one case claiming it.
#[test]
fn every_required_scenario_is_covered() {
let uncovered = coverage::uncovered();
assert!(
uncovered.is_empty(),
"these CLAUDE.md section 7 scenarios have no golden case: {uncovered:?}"
);
}
/// No case claims a scenario that is not on the mandatory list.
#[test]
fn no_case_claims_an_unknown_scenario() {
let unknown = coverage::unknown_claims();
assert!(
unknown.is_empty(),
"these `covers` tags are not in coverage::REQUIRED — a typo here means a \
case is testing something unnamed, which is how coverage quietly rots: {unknown:?}"
);
}
/// Every case passes.
///
/// Written as one test with all failures reported rather than one test per case:
/// a table that has drifted usually drifts in several places at once, and fixing
/// them one rebuild at a time hides the pattern.
#[test]
fn every_golden_case_passes() {
let cases = golden::all();
assert!(!cases.is_empty(), "the table set must not be empty");
let mut failures = Vec::new();
for case in &cases {
if let Some(reason) = case.check() {
failures.push(format!(" [{}] {}\n{reason}", case.covers, case.name));
}
}
assert!(
failures.is_empty(),
"{} of {} golden cases failed:\n{}",
failures.len(),
cases.len(),
failures.join("\n\n")
);
}
/// Case names are unique, so a failure message identifies exactly one case.
#[test]
fn case_names_are_unique() {
let mut seen = std::collections::BTreeMap::new();
for case in golden::all() {
let key = case.name.clone();
let n = seen.entry(key).or_insert(0);
*n += 1;
}
let dupes: Vec<&String> = seen.iter().filter(|(_, n)| **n > 1).map(|(k, _)| k).collect();
assert!(dupes.is_empty(), "duplicate case names make failure messages ambiguous: {dupes:?}");
}
/// The fixture list in `coverage` still matches reality.
///
/// `FIXTURES` is maintained by hand, so a rename that misses it would silently
/// drop the coverage claim. Rather than re-parsing the source, this checks the
/// shapes those fixtures are responsible for still hold.
#[test]
fn fixture_backed_scenarios_are_still_claimed() {
use blocker_core::lock::{LockLevel, MAX_WAIT_MS};
use blocker_core::model::{Schedule, ScheduleMode, Target};
// The 24-hour cap, asserted directly rather than trusting the fixture list.
for level in [LockLevel::Normal, LockLevel::Strict, LockLevel::Stubborn] {
let state = blocker_core::lock::LockState {
level,
schedules_locked: false,
pending: None,
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
};
assert!(state.worst_case_wait() <= MAX_WAIT_MS, "{level:?} exceeds the section 1.5.4 cap");
}
// And the never-block guarantee, restated here so removing the corresponding
// fixture cannot quietly drop the guarantee it protects.
assert!(
blocker_core::never_block::is_never_blocked("com.android.dialer")
|| blocker_core::never_block::is_never_blocked("plasmashell"),
"the never-block list lost its entries"
);
let _ = Schedule {
id: "unused".into(),
name: "unused".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("x".into())],
conditions: vec![],
paused: true,
};
}
/// The cases that carry the section 5 idle budget must declare their wakeup.
///
/// `Wakeup::Any` is the default for a case that is about blocking behaviour, so
/// a missing wakeup assertion is not an error in general. It *is* an error for
/// the cases below: they exist to prove that the daemon sleeps, and a case that
/// silently stops asserting its wakeup is a lost guarantee rather than an
/// untested one. Named explicitly so deleting one is a deliberate act.
#[test]
fn idle_budget_cases_declare_their_wakeup() {
use crate::golden::Wakeup;
const MUST_ASSERT_WAKEUP: &[&str] = &[
"wakeup: a Wi-Fi schedule arms no timer, only an OS event can change it",
"wakeup: an inactive schedule still arms one for when it starts",
"start == end is a zero-length window, so it blocks nothing",
];
let cases = golden::all();
for name in MUST_ASSERT_WAKEUP {
let case = cases
.iter()
.find(|c| c.name == *name)
.unwrap_or_else(|| panic!("the idle-budget case {name:?} was deleted"));
assert_ne!(
case.expect.next_wakeup,
Wakeup::Any,
"{name:?} no longer asserts a wakeup, which is the whole point of it"
);
}
}
+588
View File
@@ -0,0 +1,588 @@
//! The golden cases themselves.
//!
//! Grouped by the CLAUDE.md section 7 scenario each group covers, so a table
//! reads as a description of behaviour rather than as an undifferentiated list.
//!
//! # Calendar anchors
//!
//! `MON` = 20731 = 2026-10-05, a Monday. Every date here is derived from that
//! one, and `weekday_indexing_is_monday_zero` in `blocker-core` asserts the
//! anchor itself. If weekday arithmetic breaks, that test fails and the rest of
//! these stop meaning what their names claim.
// Table modules are test surface: a missing case name or an empty never-block
// list means a broken test, not a runtime condition to handle gracefully.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "golden-table definitions: a missing case is a broken test, not a \
runtime condition to handle gracefully"
)]
use crate::golden::*;
use blocker_core::model::{Condition, Schedule, UsageWindow};
// 2026-10-05 is a Monday.
const MON: i64 = 20_731;
const TUE: i64 = MON + 1;
const SAT: i64 = MON + 5;
const SUN: i64 = MON + 6;
/// Section 7: overnight time ranges.
///
/// The case that matters is the early hours of the morning after: a window
/// starting Monday 22:00 must still be active at 03:00 **Tuesday**, governed by
/// the weekday it *started* on. A naive membership check reads 03:00 as Tuesday,
/// finds Tuesday absent, and a nightly schedule silently dies at midnight.
pub fn overnight_cases() -> Vec<Case> {
let nightly =
blocklist("s1", "Night", &[app("com.example.social")], &[time(&[0], at(22, 0), at(6, 0))]);
vec![
Case::new("overnight: active at 23:00 on the starting day", "7:overnight time ranges")
.with_schedules(vec![nightly.clone()])
.at(MON, 23, 0)
.blocks(&[("com.example.social", "s1")])
.wakes_in(7 * 60),
Case::new(
"overnight: STILL active at 03:00, governed by the day it started",
"7:overnight time ranges",
)
.with_schedules(vec![nightly.clone()])
.at(TUE, 3, 0)
.blocks(&[("com.example.social", "s1")])
.wakes_in(3 * 60),
Case::new(
"overnight: the end is exclusive at 06:00, and the next boundary is next Monday",
"7:overnight time ranges",
)
.with_schedules(vec![nightly.clone()])
.at(TUE, 6, 0)
.blocks(&[])
// Monday 22:00 from Tuesday 06:00 is 6 days 16 hours.
.wakes_in(9600),
Case::new(
"overnight: must NOT leak into a day that did not start it",
"7:overnight time ranges",
)
.with_schedules(vec![nightly])
.at(MON + 2, 3, 0),
Case::new(
"overnight across the week boundary: Sunday 22:00 covers Monday 02:00",
"7:overnight time ranges",
)
.with_schedules(vec![blocklist(
"s1",
"Sunday night",
&[app("com.example.social")],
&[time(&[6], at(22, 0), at(6, 0))],
)])
.at(SUN, 23, 0)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"a window starting exactly at midnight is a full day, not an empty one",
"7:overnight time ranges",
)
.with_schedules(vec![blocklist(
"s1",
"All day",
&[app("com.example.social")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"start == end is a zero-length window, so it blocks nothing",
"7:overnight time ranges",
)
.with_schedules(vec![blocklist(
"s1",
"Degenerate",
&[app("com.example.social")],
&[time(ALL_DAYS, at(9, 0), at(9, 0))],
)])
.at(MON, 9, 0)
// A window that can never match must not arm a timer either: a wakeup
// whose only purpose is to discover the window still does not match is a
// wakeup spent on nothing, and this section 5 budget is zero.
.never_wakes(),
]
}
/// Section 7: timezone changes and DST.
///
/// **Partial coverage, stated plainly.** `CivilTime` is an epoch value plus a UTC
/// offset — there is no IANA timezone database behind it, because the `time`
/// crate is not a section 3 dependency. So these cases pin the *observable*
/// rule, which is that a fixed local window stays fixed as the offset moves,
/// including across a DST transition. What cannot be expressed yet is a local
/// time that **does not exist** because the clock sprang forward, or is ambiguous
/// because it fell back. That gap is recorded in `docs/LIMITATIONS.md` and is
/// the strongest argument for adding a timezone-aware time type.
pub fn timezone_cases() -> Vec<Case> {
let work = blocklist(
"s1",
"Work hours",
&[app("com.example.social")],
&[time(WEEKDAYS, at(9, 0), at(17, 0))],
);
vec![
Case::new(
"timezone: 10:00 UTC is 12:00 local at UTC+2, so inside 09:00-17:00",
"7:timezone changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(120)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"timezone: 10:00 UTC at UTC+5 is 15:00 local, still inside 09:00-17:00",
"7:timezone changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(300)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"timezone: 10:00 UTC at UTC+7 is 17:00 local, the exclusive end",
"7:timezone changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(420),
Case::new("timezone: a negative offset shifts the other way", "7:timezone changes")
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(-300),
Case::new(
"DST: 10:00 UTC stays inside 09:00-17:00 across a +1h spring-forward",
"7:DST changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(60)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"DST: a UTC-anchored window tracks the offset, not the wall clock",
"7:DST changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 3, 0)
.with_offset(-180),
]
}
/// Section 7: strictest-wins.
///
/// An app named by several schedules is blocked, and each reason names the
/// schedule responsible — which is what lets the block screen say *why* rather
/// than the UI guessing.
pub fn strictest_wins_cases() -> Vec<Case> {
let work =
blocklist("s1", "Work", &[app("com.example.chat")], &[time(WEEKDAYS, at(9, 0), at(17, 0))]);
let bedtime = blocklist(
"s2",
"Bedtime",
&[app("com.example.chat"), app("com.example.video")],
&[time(ALL_DAYS, at(22, 0), at(7, 0))],
);
vec![
Case::new("strictest-wins: only the active schedule is attributed", "7:strictest-wins")
.with_schedules(vec![work, bedtime])
.at(MON, 23, 0)
.blocks(&[("com.example.chat", "s2"), ("com.example.video", "s2")])
.wakes_in(8 * 60),
Case::new("strictest-wins: a daytime schedule blocks on its own", "7:strictest-wins")
.with_schedules(vec![
blocklist(
"s1",
"Work",
&[app("com.example.chat")],
&[time(WEEKDAYS, at(9, 0), at(17, 0))],
),
blocklist(
"s2",
"Bedtime",
&[app("com.example.chat")],
&[time(ALL_DAYS, at(22, 0), at(7, 0))],
),
])
.at(MON, 10, 0)
.blocks(&[("com.example.chat", "s1")]),
Case::new(
"strictest-wins: the union means adding a schedule never unblocks",
"7:strictest-wins",
)
.with_schedules(vec![blocklist(
"s1",
"Always",
&[app("com.example.chat")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 10, 0)
.blocks(&[("com.example.chat", "s1")]),
Case::new("a paused schedule contributes nothing at all", "7:strictest-wins")
.with_schedules(vec![Schedule {
paused: true,
..blocklist(
"s1",
"Paused",
&[app("com.example.chat")],
&[time(ALL_DAYS, 0, at(23, 59))],
)
}])
.at(MON, 10, 0),
]
}
/// Section 7: allowlist mode.
///
/// Allowlist blocks the *complement* of its own list, which needs the set of
/// installed apps. An empty inventory must fail open: a machine the engine knows
/// nothing about is not a machine it should act on.
pub fn allowlist_cases() -> Vec<Case> {
vec![
Case::new("allowlist: blocks everything installed that is not listed", "7:allowlist mode")
.with_schedules(vec![allowlist(
"s1",
"Only mail",
&[app("com.example.mail")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.with_installed(&["com.example.mail", "com.example.chat", "com.example.browser"])
.at(MON, 12, 0)
.blocks(&[("com.example.browser", "s1"), ("com.example.chat", "s1")]),
Case::new("allowlist with no inventory blocks nothing, failing open", "7:allowlist mode")
.with_schedules(vec![allowlist(
"s1",
"Only mail",
&[app("com.example.mail")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0),
Case::new(
"allowlist never blocks a never-blocked app even if unlisted",
"1.5.4:never-block list is unblockable",
)
.with_schedules(vec![allowlist(
"s1",
"Only mail",
&[app("com.example.mail")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.with_installed(&["com.example.mail", "plasmashell", "explorer.exe"])
.at(MON, 12, 0)
.blocks(&[]),
Case::new(
"an allowlist naming a website is rejected — allowlists are apps-only",
"7:invalid condition combos",
)
.with_schedules(vec![allowlist(
"s1",
"Broken",
&[website("example.com")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0)
.invalid(),
]
}
/// Section 1.2: the forbidden condition combinations, each one its own case so a
/// missing pair is visible rather than hidden inside a combined assertion.
pub fn condition_guard_cases() -> Vec<Case> {
let t = time(WEEKDAYS, at(9, 0), at(17, 0));
let u = usage_limit(30, UsageWindow::Daily);
let l = launch_count(3, UsageWindow::Daily);
let loc = location(52.52, 13.405, 500, false);
let targets = [app("com.example.chat")];
let pairs: &[(&'static str, Condition, Condition)] = &[
("1.2:time+usage rejected", t.clone(), u.clone()),
("1.2:time+launch rejected", t.clone(), l.clone()),
("1.2:usage+launch rejected", u.clone(), l.clone()),
("1.2:usage+location rejected", u, loc.clone()),
("1.2:launch+location rejected", l, loc),
];
let mut cases = Vec::new();
for (tag, first, second) in pairs {
cases.push(
Case::new(&format!("section 1.2 forbids pairing these two conditions [{tag}]"), tag)
.with_schedules(vec![blocklist(
"s1",
"Rejected",
&targets,
&[first.clone(), second.clone()],
)])
.invalid(),
);
}
// Order must not matter — validation is not a directional check.
cases.push(
Case::new(
"section 1.2 rejects the same pair in the opposite order",
"7:invalid condition combos",
)
.with_schedules(vec![blocklist(
"s1",
"Reversed",
&targets,
&[launch_count(3, UsageWindow::Daily), time(WEEKDAYS, at(9, 0), at(17, 0))],
)])
.invalid(),
);
// A permitted combination, so the guards are not simply rejecting everything.
cases.push(
Case::new("section 1.2 permits Wi-Fi with a time window", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"Permitted",
&targets,
&[time(WEEKDAYS, at(9, 0), at(17, 0)), wifi("CoffeeShop")],
)])
.with_ssid("CoffeeShop")
.at(MON, 10, 0)
.blocks(&[("com.example.chat", "s1")]),
);
// Section 6.4: location is mobile-only, and desktop must reject rather than
// accept-and-never-fire.
cases.push(
Case::new(
"section 6.4 rejects a location condition on desktop",
"7:invalid condition combos",
)
.with_schedules(vec![blocklist(
"s1",
"Geo",
&targets,
&[location(52.52, 13.405, 500, false)],
)])
.on_platform(blocker_core::model::Platform::Linux)
.invalid(),
);
cases
}
/// Section 1.2: usage-limit and launch-count conditions.
pub fn usage_and_launch_cases() -> Vec<Case> {
let targets = [app("com.example.chat")];
vec![
Case::new(
"usage limit: not yet reached, so nothing is blocked",
"7:invalid condition combos",
)
.with_schedules(vec![blocklist(
"s1",
"Limit",
&targets,
&[usage_limit(30, UsageWindow::Daily)],
)])
.with_daily_minutes("com.example.chat", 29)
.at(MON, 12, 0)
.blocks(&[]),
Case::new(
"usage limit: reached exactly, so the app is blocked",
"7:invalid condition combos",
)
.with_schedules(vec![blocklist(
"s1",
"Limit",
&targets,
&[usage_limit(30, UsageWindow::Daily)],
)])
.with_daily_minutes("com.example.chat", 30)
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")])
.wakes_in(12 * 60),
Case::new("launch count: the Nth launch blocks", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"Launches",
&targets,
&[launch_count(3, UsageWindow::Hourly)],
)])
.with_daily_launches("com.example.chat", 3)
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")]),
]
}
/// Section 1.2: Wi-Fi and location.
pub fn wifi_and_location_cases() -> Vec<Case> {
let targets = [app("com.example.chat")];
vec![
Case::new("wifi: blocks on the named SSID", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"Not at the cafe",
&targets,
&[wifi("CoffeeShop")],
)])
.with_ssid("CoffeeShop")
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")]),
Case::new("wifi: does nothing on a different SSID", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"Not at the cafe",
&targets,
&[wifi("CoffeeShop")],
)])
.with_ssid("HomeWifi")
.at(MON, 12, 0),
Case::new("location: blocks inside the radius", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"At the office",
&targets,
&[location(52.52, 13.405, 500, false)],
)])
.with_location(52.5205, 13.4055)
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")]),
Case::new("location: outside the radius does not block", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"At the office",
&targets,
&[location(52.52, 13.405, 500, false)],
)])
.with_location(48.8566, 2.3522)
.at(MON, 12, 0),
]
}
/// The zero-idle-wakeup budget, as an assertion.
///
/// These are the cases that protect the section 5 idle-CPU number. The subtle one
/// is `inactive_but_pending`: a schedule that is not active *now* must still arm
/// a wakeup for when it starts, or the daemon sleeps through the moment it is
/// meant to begin blocking.
pub fn wakeup_cases() -> Vec<Case> {
vec![
Case::new(
"wakeup: a Wi-Fi schedule arms no timer, only an OS event can change it",
"7:strict mode lock rules",
)
.with_schedules(vec![blocklist(
"s1",
"Not at the cafe",
&[app("com.example.chat")],
&[wifi("CoffeeShop")],
)])
.at(MON, 12, 0)
.never_wakes(),
Case::new(
"wakeup: an inactive schedule still arms one for when it starts",
"7:strict mode lock rules",
)
.with_schedules(vec![blocklist(
"s1",
"Weekends",
&[app("com.example.chat")],
&[time(&[0, 1, 2, 3, 4], at(9, 0), at(17, 0))],
)])
// Saturday: not a start day, so nothing is blocked now...
.at(SAT, 12, 0)
.blocks(&[])
// ...but Monday 09:00 is the next boundary, 45 hours away, and must be
// armed or the schedule silently never fires.
.wakes_in(45 * 60),
]
}
/// Section 7: the never-block list, as tables rather than fixtures.
///
/// The engine consults the list *after* schedule evaluation, so a schedule
/// naming a protected target still blocks its other targets while the protected
/// one cannot be blocked. Cases here prove both halves of that.
pub fn never_block_cases() -> Vec<Case> {
vec![
Case::new(
"never-block: a protected target cannot be blocked, but its siblings can",
"1.5.4:never-block list is unblockable",
)
.with_schedules(vec![blocklist(
"s1",
"Sweeping",
&[app("plasmashell"), app("com.example.chat")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")]),
// Derived from the list this build actually has. The never-block list is
// per-platform (section 1.5.4 names the dialler on mobile and the shell
// on desktop), so a hardcoded Android package asserts nothing on a Linux
// build — which is exactly how a table stops testing its own name.
Case::new(
"never-block: matching is substring-based, so a variant name is covered",
"1.5.4:never-block list is unblockable",
)
.with_schedules(vec![blocklist(
"s1",
"Variant",
&[app(&variant_of_a_protected_name())],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0)
.blocks(&[]),
]
}
/// A protected name with something appended, to exercise substring matching.
///
/// Panics if the list is empty, which is itself a bug: an empty list would mean
/// a device could be locked out of its own dialler or shell.
fn variant_of_a_protected_name() -> String {
let first = blocker_core::never_block::NEVER_BLOCK
.first()
.expect("the never-block list must not be empty");
format!("{first}.variant")
}
/// Section 1.5.4: the 24-hour worst case, as a table.
///
/// The timer arithmetic itself is in `tests/src/purity.rs`, because proving a cap
/// needs arithmetic rather than a decision. What belongs here is the *decision*
/// consequence: a Stubborn lock still yields an ordinary, explainable decision,
/// and the engine reports the schedule and end time the block screen will show.
pub fn lock_rule_cases() -> Vec<Case> {
vec![
Case::new(
"lock rules: under a lock the schedule still blocks, and still explains itself",
"7:strict mode lock rules",
)
.with_schedules(vec![blocklist(
"s1",
"Evening Focus",
&[app("com.example.chat")],
&[time(ALL_DAYS, at(22, 0), at(6, 0))],
)])
.at(MON, 23, 0)
.blocks(&[("com.example.chat", "s1")])
.wakes_in(7 * 60),
Case::new(
"lock rules: a locked schedule's end time is the window boundary, not the lock",
"1.5.4:worst case 24h",
)
.with_schedules(vec![blocklist(
"s1",
"Bounded",
&[app("com.example.chat")],
&[time(ALL_DAYS, at(22, 0), at(6, 0))],
)])
.at(MON, 22, 0)
.blocks(&[("com.example.chat", "s1")])
.wakes_in(8 * 60),
]
}