8 Commits

Author SHA1 Message Date
SellPL a43a8e8231 docs: record the measured storage cost and the new crate boundary
CI / ci (push) Has been cancelled
CI / android (push) 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-02 13:10:25 +00:00
SellPL 610d6b4190 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-02 13:10:25 +00:00
SellPL da3ce160b4 docs: add Gitea workflows, issue/PR templates, README and licence notice
ci.yml calls `scripts/ci.sh` rather than restating the checks: a workflow that
reimplements them becomes a second definition of "done", and the two drift.

Android runs as a separate job. It fails when the SDK is absent while ci.sh
skips it — a developer should not be blocked by a missing toolchain, but a
silently skipped Android build in CI means regressions ship unnoticed. Every
action is pinned to an exact version.

LICENSE.md records that no licence is chosen yet, and why it is not a formality:
the choice is upstream of the UI toolkit decision, since Slint is GPL-3.0.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 13:10:25 +00:00
SellPL 8a521e760e docs: add ADR log, architecture, limitations, permissions, perf, testing
The documentation set CLAUDE.md sections 2, 6, 8 and 10.1 call for, written as
records of decisions actually made rather than as a description of intent.

**DECISIONS.md** — 17 ADRs. Two are deliberately left open and say so: ADR-6
(the desktop UI toolkit, which section 3 requires be decided on measurement and
which needs the owner's call, partly because Slint is GPL-3.0) and ADR-17 (the
licence, which is a legal commitment nobody should make on the owner's behalf).
Where CLAUDE.md gives a reason for a decision, that reason is recorded; where it
does not, the file says the reason is unrecorded rather than inventing one.

**LIMITATIONS.md** — all eight section 6 limits plus the section 1.5.4 safety
guarantees, stated as facts about the operating systems. The words
"unbypassable", "tamper-proof", "guaranteed" and "secure" appear nowhere, per
sections 6.1 and 6.7. Ends with a separate list of shortfalls in the *current
code* — storage has no I/O, the golden tables cannot yet be parsed, LockState
always returns Normal — so a known gap in this implementation is never mistaken
for a platform limit.

**ARCHITECTURE.md** — the layering, the purity contract and its mechanical
enforcement, why `next_wakeup` must be armed over inactive schedules too, the
overnight weekday-of-start rule, and the end-to-end flow for one block event.

**PERF.md** — targets and results as separate tables, so an aspiration is never
read as a measurement. The one real number recorded (0.279 MB stripped daemon) is
labelled as nearly meaningless, since the daemon is two print statements. Lists
the common causes of idle-wakeup regressions.

**PERMISSIONS.md** — per platform, what each permission is for and what degrades
without it. Flags the two invasive ones (Android accessibility, extension host
permissions) rather than burying them, and records that unprivileged eBPF is
*assumed* unavailable here because the sysctls were never read.

**TESTING.md** — the golden-table contract, the add-the-case-first rule, and why
clock tampering needs code fixtures instead of data.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 13:10:25 +00:00
SellPL 5c50742a61 ci: add ci.sh, size gate, and shared script helpers
`scripts/ci.sh` is the source of truth for what must pass before a PR is
mergeable. CLAUDE.md section 10.4 requires the Gitea workflow to run the same
checks; the workflow calls this script rather than reimplementing the steps, so
the two cannot drift apart.

Runs: cargo fmt --check, clippy -D warnings, cargo test, the section 5 size
gate, Android lint/assembleRelease, and a gitleaks scan.

Verified behaviour, rather than assumed:

- **Failures really fail.** Injected a syntax error into `core/src/lib.rs` and
  confirmed `ci.sh --keep-going` exits 1 and names all four dependent checks;
  then restored the file and confirmed a clean exit 0. `--keep-going` captures
  failures per step in a subshell instead of letting `set -e` abort the run, so
  the summary records what already passed.
- **Skips are never passes.** Android and gitleaks both skip on this machine
  (no SDK, no gitleaks), and each SKIP names what was skipped and why, with the
  summary listing them separately from the passes. A skip that reads as a green
  tick is how CI quietly stops testing anything.
- **Runs from any CWD.** `cargo` resolves a workspace from the CWD, not from
  the script's location, so `ci.sh` cds to the repo root. Verified by running it
  from `/tmp`.
- **`cargo` is on PATH.** rustup adds `~/.cargo/bin` via `~/.bashrc`, which a
  non-interactive shell does not read — so a script invoked from CI or by
  absolute path failed with "command not found" until `lib.sh` prepends it.
- **Size gate resolves absolute paths.** `CARGO_TARGET_DIR` is absolute by
  default, so joining it onto the repo root produced a doubled path; the default
  is now only joined when relative.

Build artifacts go to `$HOME/.blocker-target`, outside the repo, so `target/`
never appears in `git status` and can be deleted in one command.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 13:10:25 +00:00
SellPL c723efb186 feat(core): add workspace skeleton and the pure decision engine
Establishes the cargo workspace and the shared `blocker-core` crate. The
engine is deliberately written before any platform code, because CLAUDE.md
section 2 makes it the single source of decision truth and M1 acceptance
requires it to be pure.

**Workspace** — five crates plus a test crate, zero external dependencies.
Section 3 requires every crate to justify its binary-size cost, so M0 adds
none. [profile.release] is workspace-only (cargo rejects it in members) and
carries the section 5 settings that make the 5 MB stripped budget reachable.

**Purity contract** — `evaluate(&EngineInput) -> Decision` reads no clock, no
disk and no OS API; everything arrives in the input. `tests/purity.rs`
enforces this mechanically by asserting blocker-core has zero dependencies, so
a stray I/O reach-in fails the build rather than silently making the golden
tables useless as a parity contract.

Decisions worth calling out:

- **Monotonic time is a separate type from wall time.** Section 1.5.3 requires
  a system date change never to shorten a lock. Keeping them apart makes that
  structural rather than a rule to remember.
- **`start > end` means overnight.** No `crosses_midnight` flag: the comparison
  already encodes it and a second field is a second way to be wrong. The
  weekday for a window that started yesterday evening is yesterday's, which
  `overnight_window_survives_past_midnight` pins down.
- **`next_wakeup` is armed over every non-paused schedule, not only active
  ones.** Filtering to active conditions first would let the daemon sleep
  straight through the moment a schedule is due to start.
- **Allowlist needs the installed-app inventory.** `EngineInput` carries
  `installed_apps`; an empty slice fails open rather than guessing.
- **Wall-clock correction extends, never shortens, and clamps to the 24h cap.**
  Extending unbounded would satisfy anti-tamper and violate section 1.5.4.
- **`cast_possible_truncation` is denied workspace-wide.** Truncating a
  millisecond counter would corrupt a lock timer.

Verified: 22 tests pass, `cargo fmt --check` clean, `cargo clippy -D warnings`
clean, stripped release daemon 0.279 MB against the 5 MB budget.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 13:10:25 +00:00
SellPL aa1c582f3c chore: add repo hygiene config and project spec
Bootstrap the repository with the shared configuration every other piece
depends on:

- .gitignore: strict. Keeps build output, cargo caches, Android keystores,
  signing keys, .env files, local SQLite databases, IDE state and any
  credential-shaped filename out of history. CLAUDE.md 10.1 requires these
  never enter the repo.
- .editorconfig, rustfmt.toml, clippy.toml: formatting and lint policy.
  clippy.toml is written in the lints-file form so lints are not duplicated
  into every crate manifest.

CLAUDE.md is committed verbatim; it is the project spec and the parity
contract for every platform layer.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 13:10:25 +00:00
SellPL 81d5122e4b chore: initial empty commit
Establishes the main branch so feature branches have a base to merge
into. The repository was created empty, so main did not exist and no pull
request could target it.

This commit is deliberately empty. Nothing lands on main without review
(CLAUDE.md 10.2); it exists only so that m0/bootstrap has somewhere to be
merged from.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 16:16:51 +03:00
20 changed files with 295 additions and 3659 deletions
Generated
+1 -888
View File
@@ -2,149 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.2" version = "2.13.2"
@@ -160,7 +17,6 @@ name = "blocker-daemon"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"blocker-core", "blocker-core",
"blocker-platform-linux",
"blocker-storage", "blocker-storage",
] ]
@@ -169,9 +25,6 @@ name = "blocker-platform-linux"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"blocker-core", "blocker-core",
"nix",
"x11rb",
"zbus",
] ]
[[package]] [[package]]
@@ -200,25 +53,6 @@ dependencies = [
name = "blocker-ui" name = "blocker-ui"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "blocking"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.5.1" version = "1.5.1"
@@ -229,96 +63,6 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -331,12 +75,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.14"
@@ -349,70 +87,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -422,60 +96,15 @@ dependencies = [
"foldhash", "foldhash",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "hashlink" name = "hashlink"
version = "0.10.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [ dependencies = [
"hashbrown 0.15.5", "hashbrown",
] ]
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.35.0" version = "0.35.0"
@@ -487,131 +116,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.37.0" version = "0.37.0"
@@ -626,417 +136,20 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.2" version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "uuid"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys",
"winnow",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.6",
"winnow",
]
+18 -39
View File
@@ -214,7 +214,6 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
ScheduleMode::Blocklist => { ScheduleMode::Blocklist => {
for target in &schedule.targets { for target in &schedule.targets {
let reason = BlockReason { let reason = BlockReason {
target: target.clone(),
schedule_id: schedule.id.clone(), schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(), schedule_name: schedule.name.clone(),
condition: active[0], condition: active[0],
@@ -246,7 +245,6 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
continue; continue;
} }
blocked_apps.push(BlockReason { blocked_apps.push(BlockReason {
target: Target::App(id.clone()),
schedule_id: schedule.id.clone(), schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(), schedule_name: schedule.name.clone(),
condition: active[0], condition: active[0],
@@ -386,57 +384,38 @@ 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 /// Next boundary of a time window: either its end today, or its start if the
/// window is not currently active. /// window is not currently active.
fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option<CivilTime> { 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 minute = minute_of_day(now);
let today = weekday(now); let today = weekday(now);
if time_window_contains(window, now) { if time_window_contains(window, now) {
// Currently active. Where the END lands depends on which half of an // Currently active: the end depends on whether we're in the early or
// overnight window we are in. // late part of an overnight window.
// let end_today = if window.is_overnight() && minute < window.end {
// At 23:00 in a 22:00-06:00 window the end is 06:00 *tomorrow*, 420 // Early part — ends later today.
// minutes away — not 06:00 today, which is in the past. Computing window.end
// `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 { } else {
// Either a daytime window, or the early part of an overnight one. window.end
i64::from(window.end) - i64::from(minute)
}; };
let minutes_until = i64::from(end_today) - i64::from(minute);
return Some(CivilTime { return Some(CivilTime {
epoch_minutes: now.epoch_minutes + minutes_until.max(1), epoch_minutes: now.epoch_minutes + minutes_until.max(0),
utc_offset_minutes: now.utc_offset_minutes, utc_offset_minutes: now.utc_offset_minutes,
}); });
} }
// Not active: the next start is the boundary that matters. // 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; let mut best: Option<i64> = None;
for day_offset in 0..=7i64 { for day_offset in 0..=7 {
let candidate_day = ((today as i64 + day_offset) % 7) as u8; let candidate_day = (today as i32 + day_offset) % 7;
if !window.days.contains(&candidate_day) { if !window.days.contains(&(candidate_day as u8)) {
continue; continue;
} }
let mut delta = day_offset * 24 * 60 + i64::from(window.start) - i64::from(minute); 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);
}
if delta < 0 { if delta < 0 {
delta += 24 * 60; delta += 24 * 60;
} }
@@ -457,7 +436,7 @@ fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Op
/// Unlike [`condition_boundary`], which answers "when does a *firing* condition /// Unlike [`condition_boundary`], which answers "when does a *firing* condition
/// stop firing", this also covers a condition that is currently false — a time /// 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 /// window that has not started yet still has a start, and that start is exactly
/// when the daemon must wake to begin blocking. /// when the daemon must wake up to begin blocking.
/// ///
/// Returns `None` when nothing about the schedule is time-dependent, which is /// 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, /// the case for Wi-Fi and location schedules: only an OS event can change them,
+5 -30
View File
@@ -123,23 +123,12 @@ pub struct Schedule {
/// Why a target is currently blocked, and until when. /// Why a target is currently blocked, and until when.
/// ///
/// Section 1.3 requires the block screen to say which schedule blocked and when /// Section 1.3 requires the block screen to say which schedule blocked and
/// it ends, so the engine computes that here and the UI never re-derives it. /// when it ends, so the engine computes that here and the UI never re-derives
/// Keeping the explanation in the decision is what stops three platforms from /// it. Keeping the explanation in the decision is what stops three platforms
/// telling the user three different stories. /// from telling the user three different stories.
/// #[derive(Debug, Clone, PartialEq, Eq)]
/// 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 { pub struct BlockReason {
/// The specific target being blocked, not merely the schedule.
pub target: Target,
pub schedule_id: String, pub schedule_id: String,
pub schedule_name: String, pub schedule_name: String,
/// The specific condition that fired, named for display. /// The specific condition that fired, named for display.
@@ -149,20 +138,6 @@ pub struct BlockReason {
pub ends_at: Option<crate::engine::CivilTime>, 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. /// A rejection of a schedule that does not satisfy section 1.2.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError { pub enum ValidationError {
-1
View File
@@ -18,4 +18,3 @@ path = "src/main.rs"
[dependencies] [dependencies]
blocker-core = { path = "../../core" } blocker-core = { path = "../../core" }
blocker-storage = { path = "../../storage" } blocker-storage = { path = "../../storage" }
blocker-platform-linux = { path = "../platform-linux" }
+1 -135
View File
@@ -33,11 +33,7 @@
//! installed read-only in `/usr/bin`, while the database must be writable and //! installed read-only in `/usr/bin`, while the database must be writable and
//! must survive an upgrade. //! must survive an upgrade.
mod runner;
use std::path::PathBuf; use std::path::PathBuf;
use blocker_platform_linux::kwin::BUS_NAME;
use std::process::ExitCode; use std::process::ExitCode;
/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`. /// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
@@ -80,9 +76,6 @@ fn main() -> ExitCode {
); );
} }
let usage = blocker_core::engine::UsageSnapshot::default();
let context = blocker_core::engine::PlatformContext::default();
let schedules = match store.load_schedules() { let schedules = match store.load_schedules() {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
@@ -94,134 +87,7 @@ fn main() -> ExitCode {
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION")); println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
println!("database: {}", path.display()); println!("database: {}", path.display());
println!("schedules: {}", schedules.len()); println!("schedules: {}", schedules.len());
// Backend selection, reported rather than assumed: "why didn't it block X" println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
// 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.
//
// The KWin path is preferred, and it requires owning the D-Bus name BEFORE
// the script is loaded: `callDBus` has nothing to resolve until the daemon
// holds `vu.valiq.blocker`, so loading first would silently drop every
// focus event. If KWin is unavailable we fall back to X11 and say what that
// costs — on a Wayland session, XWayland clients only.
let (tx, rx) = std::sync::mpsc::channel();
if let Ok(backend) = blocker_platform_linux::kwin::KwinBackend::connect() {
println!("backend: kwin — native Wayland windows are visible");
let _keep_connection = match blocker_platform_linux::watcher::serve(tx) {
Ok(c) => c,
Err(e) => {
eprintln!("blocker-daemon: cannot own {BUS_NAME}: {e}");
return ExitCode::FAILURE;
}
};
println!("owning the D-Bus name; entering the event loop");
run_loop(&rx, &schedules, &usage, &context, blocker_platform_linux::Enforcement::Freeze);
backend.shutdown();
return ExitCode::SUCCESS;
}
println!("backend: kwin unavailable; falling back to x11");
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 ExitCode::SUCCESS
} }
/// The event loop, shared by both backends.
///
/// Both paths park here: the caller has already put a focus-event producer in
/// charge, and this consumes it. Two blocked threads and no CPU between events.
fn run_loop(
rx: &std::sync::mpsc::Receiver<blocker_platform_linux::FocusEvent>,
schedules: &[blocker_core::model::Schedule],
usage: &blocker_core::engine::UsageSnapshot,
context: &blocker_core::engine::PlatformContext,
enforcement: blocker_platform_linux::Enforcement,
) {
let ctx = runner::LoopContext {
schedules,
usage,
platform_context: context,
boot_id: blocker_core::engine::BootId(0),
enforcement,
};
let clock = blocker_platform_linux::SystemMonotonic;
println!("enforcement: {enforcement:?}; entering the event loop");
'events: loop {
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 {
break 'events;
}
}
}
/// 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
@@ -1,160 +0,0 @@
//! 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,26 +14,3 @@ path = "src/lib.rs"
[dependencies] [dependencies]
blocker-core = { path = "../../core" } 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
@@ -1,140 +0,0 @@
//! 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
@@ -1,113 +0,0 @@
//! 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
@@ -1,296 +0,0 @@
//! 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"))),
})
}
-12
View File
@@ -39,18 +39,6 @@
//! - None of this can be made tamper-proof against a user with root. Section //! - 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. //! 6.1 requires this be documented honestly rather than implied otherwise.
pub mod enforce;
pub mod kwin;
pub mod watcher;
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. /// A window that has taken focus, resolved to something a schedule can match.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForegroundWindow { pub struct ForegroundWindow {
-117
View File
@@ -1,117 +0,0 @@
//! The D-Bus watcher server: the daemon's public side of the KWin handshake.
//!
//! KWin calls *us*, not the other way round. The compositor has no way to push a
//! focus event to an arbitrary subscriber, so the daemon owns a bus name and
//! exports an interface that the loaded KWin script calls into. This module owns
//! that name and turns those calls into [`FocusEvent`]s on a channel.
//!
//! # Owning the name is not optional
//!
//! Without the daemon holding `vu.valiq.blocker`, KWin's `callDBus` has nothing
//! to resolve and every focus event is silently discarded. That is why
//! [`serve`] must succeed *before* the script is loaded.
//!
//! # Fail-open
//!
//! If the daemon is not running, the KWin script's `callDBus` fails inside a
//! signal handler and the compositor is left alone — no exception, no log spam.
//! Blocking stops; the desktop keeps working. That is section 1.5.4's rule and
//! it is why the script swallows its errors.
use std::sync::mpsc::Sender;
use zbus::blocking::{connection::Builder, Connection};
use super::kwin::{BUS_NAME, OBJECT_PATH};
use super::{parse_payload, FocusEvent};
/// Why the watcher could not start.
#[derive(Debug)]
pub enum WatcherError {
/// No session bus, so there is nothing to own a name on. Almost always
/// means the daemon was started outside a desktop session — over SSH, or
/// from a system unit rather than a user one.
NoSessionBus(String),
/// The name could not be acquired, which normally means another instance is
/// already running.
NameTaken(String),
/// Anything else zbus reports.
Other(String),
}
impl std::fmt::Display for WatcherError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoSessionBus(e) => write!(f, "no session bus: {e}"),
Self::NameTaken(e) => write!(f, "another watcher already owns {BUS_NAME}: {e}"),
Self::Other(e) => write!(f, "watcher could not start: {e}"),
}
}
}
impl std::error::Error for WatcherError {}
/// The object the KWin script calls into.
///
/// One method, `Focused`, whose payload is the tab-separated window description
/// built by `kwin::SCRIPT_JS`. An empty payload means focus was cleared.
struct Watcher {
sink: Sender<FocusEvent>,
}
#[zbus::interface(name = "vu.valiq.blocker.Watcher")]
impl Watcher {
/// Called by the KWin script on every focus change.
///
/// Payload is tab-separated: resourceClass, resourceName, caption,
/// internalId, pid — in the order `kwin::SCRIPT_JS` writes them.
fn focused(&self, payload: &str) {
let event = match parse_payload(payload) {
Some(window) => FocusEvent::Activated(window),
None => FocusEvent::Cleared,
};
// A send failure means the daemon has stopped. Nothing to escalate: the
// thread exits when the connection drops.
let _ = self.sink.send(event);
}
}
/// Acquire the bus name and export [`Watcher`], returning the live connection.
///
/// The connection must be held for the export to stay registered, so the caller
/// keeps it for the daemon's lifetime.
///
/// Note on ordering: the daemon calls [`serve`] *before*
/// [`super::kwin::KwinBackend::connect`]. Loading the script first would start a
/// handler calling a name nobody owns, and every event would be lost.
pub fn serve(sink: Sender<FocusEvent>) -> Result<Connection, WatcherError> {
Builder::session()
.map_err(|e| WatcherError::NoSessionBus(e.to_string()))?
.name(BUS_NAME)
.map_err(|e| WatcherError::NameTaken(e.to_string()))?
// `#[zbus::interface]` implements `Interface` for the struct, so the
// instance itself is what gets registered — it holds the channel.
.serve_at(OBJECT_PATH, Watcher { sink })
.map_err(|e| WatcherError::Other(e.to_string()))?
.build()
.map_err(|e| {
// zbus cannot distinguish "name already owned" from other failures
// at build time, so the message is what separates them. A name clash
// is the common case and deserves the clearer variant.
let rendered = e.to_string();
if rendered.contains("taken") || rendered.contains("already") {
WatcherError::NameTaken(rendered)
} else {
WatcherError::Other(rendered)
}
})
}
/// Drop the bus name, so a restarted daemon does not collide with a stale one.
///
/// Called before exiting. Not cosmetic: systemd `Restart=always` can start the
/// replacement before the old process is fully gone, and losing that race would
/// leave the new instance wedged on a name its predecessor still holds.
pub fn release(connection: &Connection) {
let _ = connection.release_name(BUS_NAME);
}
-370
View File
@@ -1,370 +0,0 @@
//! 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
}
+26 -55
View File
@@ -27,7 +27,7 @@ which parts to trust.
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted | | [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
| [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open | | [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 | | [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
| [6](#adr-6) | Desktop UI toolkit — egui | **Accepted** | | [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** |
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** | | [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
| [8](#adr-8) | Lock levels and explicit opt-in | Accepted | | [8](#adr-8) | Lock levels and explicit opt-in | Accepted |
| [9](#adr-9) | Monotonic clock for all lock timers | 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 | | [13](#adr-13) | Zero external dependencies in M0 | Accepted |
| [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted | | [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted |
| [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted | | [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted |
| [16](#adr-16) | Golden tables are Rust, not JSON | Accepted | | [16](#adr-16) | Golden tables are JSON, not TOML | Proposed |
| [17](#adr-17) | Licence not yet chosen | **Open — owner decision** | | [17](#adr-17) | Licence not yet chosen | **Open — owner decision** |
--- ---
@@ -169,41 +169,29 @@ to commit, and the measurement belongs to M3-Android.
--- ---
<a id="adr-6"></a> <a id="adr-6"></a>
## ADR-6: Desktop UI toolkit — egui ## ADR-6: Desktop UI toolkit — Slint vs egui
**Status:** Accepted (the owner chose egui, 2026-10-01) **Status:** **Proposed — awaiting the owner's decision**
**Context.** Section 3 requires the toolkit to be chosen on a measured **Context.** Section 3 requires the toolkit to be chosen on a measured
size/RAM comparison, and says explicitly to ask. size/RAM comparison, and says explicitly to ask.
**Decision.** **egui**, licensed MIT OR Apache-2.0. **Decision.** Not yet made.
**Why egui.** **What is already settled by measurement-independent constraints:**
- **The licence decides part of it.** Slint is GPL-3.0 or commercial, so choosing - **Slint is GPL-3.0 or commercial.** For a project intended to be published,
it would force the project under copyleft or require a paid licence. The owner the permissive alternatives are the safer default. If the size comparison comes
has left the project licence open (ADR-17), so this is a real constraint out close, licensing should decide it.
rather than a formality — and it is why ADR-17 is upstream of this decision. - Either way the daemon must stay under 5 MB stripped, and the UI must not hold a
- **The budget is adequate.** 2.9 MB of the 5 MB stripped daemon budget remained GL context resident while idle.
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 this does not decide.** The measurement the section asks for is still worth **What still needs measuring:** stripped binary size for a minimal window, idle
doing — it is recorded in `PERF.md` — but it is now confirmatory rather than RSS, dependency count and compile time, and behaviour on KDE Plasma 6 Wayland
decisive. specifically — this project must work on KDE, so winit/Slint quirks on that
compositor matter more than generic Wayland support.
**Constraints that apply to whatever is built** Recorded rather than decided because section 3 says to ask.
- 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.
--- ---
@@ -443,39 +431,22 @@ machine.
--- ---
<a id="adr-16"></a> <a id="adr-16"></a>
## ADR-16: Golden tables are Rust, not JSON ## ADR-16: Golden tables are JSON, not TOML
**Status:** Accepted and implemented. The original JSON rationale turned out to **Status:** Proposed — depends on ADR-4 and the serde decision
be wrong, and is recorded below rather than quietly replaced.
**Context.** Section 7 requires golden decision tables as the cross-platform **Context.** Section 7 requires golden decision tables as the cross-platform
parity contract. parity contract. The format must be serialisable and diffable in review.
**What was originally decided, and why it was wrong.** JSON, on the reasoning **Decision.** JSON, one file per concern, under `tests/golden/`.
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.
**Decision.** A builder DSL in Rust. `tests/src/golden.rs` holds the format and **Rationale.** `serde_json` is already in the dependency graph via `rusqlite` and
harness, `tests/src/tables.rs` the cases, `tests/src/coverage.rs` the coverage `uniffi`, so TOML would add a crate for no gain, and section 3 makes every crate
bookkeeping, `tests/src/run.rs` the runner. justify its cost. JSON is also what a review diff reads most naturally.
**Rationale.** The same declarations, with two properties JSON cannot offer: the **Not yet decided.** The parse layer needs serde, which is a dependency decision
compiler rejects a typo in a field name, and a wrong variant is a type error requiring the owner's sign-off. The format itself is written down in
rather than a test that quietly asserts the wrong thing. For a document whose `tests/src/golden.rs` now; the parse arrives in M1 with ADR-4.
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".
--- ---
+4 -8
View File
@@ -184,14 +184,10 @@ not mistaken for platform facts.
timers, the escalation, and the wall-clock correction, all tested. The state 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 machine is not yet driven from a UI, and `LockState::evaluate` currently returns
the Normal level unconditionally. the Normal level unconditionally.
- **DST coverage is partial, and the boundary is stated.** `CivilTime` is an - **The golden tables cannot be parsed.** The format is specified in
epoch value plus a UTC offset, with no IANA timezone database behind it — the `tests/src/golden.rs`; parsing needs serde, which is a dependency decision
`time` crate is not a section 3 dependency (ADR-16). The golden tables pin the awaiting sign-off (ADR-13, ADR-16). The behavioural coverage that exists today
observable rule, that a fixed local window stays fixed as the offset moves, is hand-written unit tests in the same spirit.
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 - **`LockState` still always evaluates to `Normal`** — persistence works, but no
UI drives the lock state machine yet. UI drives the lock state machine yet.
- **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather - **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather
-111
View File
@@ -1,111 +0,0 @@
//! 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()
}
+222 -403
View File
@@ -1,423 +1,242 @@
//! The golden decision tables (CLAUDE.md section 7). //! The golden decision tables (CLAUDE.md section 7).
//! //!
//! A table is a list of cases. Each case is schedules plus an input plus an //! A table is a list of cases. Each case is schedules plus an input plus an
//! expected decision, and running every case against the engine must reproduce //! expected decision, and running the same table against the engine must
//! the expectation exactly. //! reproduce the expectation exactly.
//! //!
//! # This is a parity contract, not a test suite //! # Format
//! //!
//! Unit tests check the engine against itself. These check that *every platform* //! JSON, one file per concern, under `golden/`. JSON rather than TOML because
//! agrees with the engine. Each platform implements a mapping from its own app //! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi`
//! identity — Android package name, Linux desktop-file id, Windows exe path — //! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain.
//! 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.
//! //!
//! # The rule that keeps the tables meaningful //! ```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"
//! }
//! }
//! ]
//! }
//! ```
//! //!
//! **A case is added before the engine changes.** A table written after the code //! # Status in M0
//! 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.
//! //!
//! So a PR that changes engine behaviour is reviewed on one question: is there a //! The shapes below are the **contract**, written down before any parse layer
//! case added or updated in the same commit? If not, the change is undocumented //! exists so M1 implements against a fixed format rather than retrofitting one.
//! behaviour. //! `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.
//! //!
//! # Why these are Rust, not JSON //! # The clock-tampering exception
//! //!
//! Originally specified as JSON on the reasoning that `serde_json` would already //! Most cases are pure data. Clock tampering is not: proving that moving the
//! be in the tree. That reasoning was wrong: `rusqlite` does not pull `serde` by //! system date never shortens a lock requires actually moving a clock, which a
//! default, and `uniffi` has not landed yet, so nothing in the graph provides it. //! data file cannot express. Those cases live in [`crate::purity`] as code
//! `serde` is not in CLAUDE.md section 3, and section 0 rule 3 requires asking //! fixtures instead, and the two coexist deliberately — the tables describe
//! before adding a dependency. //! decisions, the fixtures describe the passage of time.
//!
//! 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 blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot}; use std::collections::BTreeMap;
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// Sentinel meaning "the platform may sleep forever" in an expectation. /// One table, deserialised from one `golden/*.json` file.
/// #[derive(Debug, Clone)]
/// `"none"` as a string was ambiguous with a null; a distinct type is not. pub struct Table {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] /// Which concern this table covers, e.g. `overnight`.
pub enum Wakeup { pub concern: String,
/// Assert nothing about the wakeup. /// The cases in the table.
/// pub cases: Vec<Case>,
/// 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 expected blocked item, attributed to a schedule. /// One case: setup, input, expected decision.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone)]
pub struct Blocked { 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 {
/// The target that must be blocked. /// The target that must be blocked.
pub app: String, pub app: String,
/// The id of the schedule that must block it. /// The id of the schedule that must block it.
pub schedule: String, pub schedule: String,
} }
/// The decision a case expects. /// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one
#[derive(Debug, Clone, PartialEq, Eq)] /// `covers` field across the tables, and `coverage.rs` asserts it — so a
pub struct Expect { /// scenario cannot silently lose its test.
/// Apps that must be blocked. Order-independent; the engine's union order pub const MANDATORY_COVERAGE: &[&str] = &[
/// is not part of the contract. "7:overnight time ranges",
pub blocked_apps: Vec<String>, "7:DST changes",
/// Schedules expected to attribute each block. Parallel to `blocked_apps`. "7:timezone changes",
pub blocked_by: Vec<String>, "7:clock tampering",
/// Websites and keywords that must be blocked. "7:strictest-wins",
pub blocked_domains: Vec<String>, "7:allowlist mode",
/// When the platform must wake up next, if ever. "7:invalid condition combos",
pub next_wakeup: Wakeup, "7:strict mode lock rules",
} // The section 1.2 combination table, itemised so a missing pair is visible.
"1.2:time+usage rejected",
impl Default for Expect { "1.2:time+launch rejected",
fn default() -> Self { "1.2:usage+launch rejected",
Self { "1.2:usage+location rejected",
blocked_apps: Vec::new(), "1.2:launch+location rejected",
blocked_by: Vec::new(), // Section 1.5.4 safety guarantees.
blocked_domains: Vec::new(), "1.5.4:never-block list is unblockable",
next_wakeup: Wakeup::Any, "1.5.4:worst case 24h",
} ];
}
}
/// 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()
}
+16 -21
View File
@@ -5,38 +5,33 @@
//! //!
//! # Why this is a contract, not a test suite //! # Why this is a contract, not a test suite
//! //!
//! Unit tests check the engine against itself. These check that *every platform* //! Section 7 requires that the same table runs against the core and serves as
//! agrees with the engine. Android, Linux and Windows each map their own app //! the parity contract for all platforms. Android, Linux and Windows each
//! identity — package name, desktop-file id, exe path — onto a //! implement a mapping from a platform app identity to a `Target`, but they all
//! [`blocker_core::model::Target`], and every case here runs through that mapping //! hand the same [`blocker_core`] engine the same schedule set and get back the
//! too. If Linux resolves Discord to a different target than Android does, a case //! same decision. A platform crate runs this table against its own mapping, so
//! fails at build time instead of becoming a bug report from whichever platform //! if Linux disagrees with Android the build fails rather than the user filing
//! nobody was testing that week. //! a bug.
//! //!
//! # The rule that keeps it honest //! # The rule that keeps it honest
//! //!
//! **A case is added before the engine changes.** A table written after the code //! **A case is added before the engine changes.** If a table were written after
//! records current behaviour and can never catch a regression — it would be a //! the code, it would only record current behaviour and could never catch a
//! transcript, not a specification. Reviewing a PR that changes engine behaviour //! regression — it would be a transcript, not a specification. Reviewing a PR
//! therefore means checking that a case was added or updated in the same commit. //! that changes engine behaviour therefore means checking that a case was added
//! or updated in the same commit.
//! //!
//! # Layout //! # Layout
//! //!
//! - [`golden`] — the case format, the harness, and the builder DSL //! - `golden/` — the tables themselves, one JSON file per concern
//! - [`tables`] — the cases themselves, grouped by scenario //! - [`purity`] — a mechanical check that the core has not gained I/O
//! - [`coverage`] — which CLAUDE.md scenario each case claims, and the check that //!
//! nothing went uncovered //! The mandatory case list is in [`golden::MANDATORY_COVERAGE`].
//! - [`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 //! [`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`. //! library's unit-test build, not library API. They run under `cargo test`.
pub mod coverage;
pub mod golden; pub mod golden;
pub mod tables;
#[cfg(test)] #[cfg(test)]
mod purity; mod purity;
#[cfg(test)]
mod run;
-147
View File
@@ -1,147 +0,0 @@
#![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
@@ -1,588 +0,0 @@
//! 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),
]
}