052b2a496943029622869c7696b181e4ca7dd7ae
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
052b2a4969 |
fix(daemon): call shutdown directly rather than binding a unit value
Clippy's let_unit_value, caught in review of the previous commit: shutdown() returns (), so the let-binding was noise. Co-Authored-By: Claude Code <noreply@anthropic.com> |
||
|
|
69602636b1 |
feat(linux): wire the KWin path end to end, and verify it live
The daemon now owns `vu.valiq.blocker`, loads the KWin script, and enters the event loop on the primary backend. Verified against the running session: ``` $ blocker-daemon blocker-daemon 0.1.0 (M0/M1) database: /tmp/bsmoke/blocker/blocker.db backend: kwin — native Wayland windows are visible owning the D-Bus name; entering the event loop enforcement: Freeze; entering the event loop ``` The process then parked until killed — which is the point. There is no tick; the loop blocks on a focus event and on the engine's `next_wakeup`. **Ordering is load-bearing and worth stating.** `callDBus` has nothing to resolve until the daemon holds the bus name, so the name must be acquired *before* the script is loaded. Loading first means a handler calling a name nobody owns, and every focus event silently dropped. **A real defect found by running it rather than reading it.** After the daemon was killed, `isScriptLoaded` still returned `true` — the script was left in KWin. `Drop` does not run on SIGTERM, so the cleanup path never executes. It is fail-open and harmless (the script's `callDBus` fails inside the compositor, which is exactly the section 1.5.4 behaviour), and `KwinBackend::connect` unloads any stale script before loading a fresh one, so a restart recovers. But "harmless" is not "correct", and the honest fix — a systemd `ExecStopPost` or a signal handler that unloads — is not in this PR. Also fixes the duplicated loop body: both backends now share one `run_loop`, so the X11 and KWin paths cannot drift into enforcing differently. Verified: clippy `-D warnings` clean, `fmt --check` clean, all tests passing, stripped daemon within the 5 MB budget. Co-Authored-By: Claude Code <noreply@anthropic.com> |
||
|
|
8ae043658c |
feat(linux): add enforcement actions and the daemon event loop
The daemon now has the two pieces that turn a decision into an outcome: what to do to a blocked process, and the loop that connects focus events to the engine. **The enforcement ladder is escalating and non-destructive by default.** `SIGSTOP` freezes an app; it does not kill it. That is a deliberate choice, and the reason is section 1.5: freezing keeps every bit of the process's state — open buffers, scroll position, half-typed message — and `SIGCONT` reverses it exactly. Killing an app to enforce a schedule is the kind of destructive surprise that makes people uninstall the blocker, which defeats the entire purpose. Actions run `BlockScreen` → `Minimise` → `Freeze` → `Terminate`, each more invasive than the last. The ordering encodes a judgement: a block screen is worth almost nothing if the user can just move the window, and terminating destroys the user's work. Start with the least destructive thing that could work. **The event loop has no tick.** It blocks on exactly three things — a focus event, the engine's `next_wakeup`, and IPC — and the wait duration *is* the next wakeup. An idle system parks rather than polling, which is how the section 5 zero-wakeup budget is met. Two threads, both blocked, both costing nothing. **The monotonic clock moved into the platform layer**, behind a trait. The daemon should observe the OS *through* the platform crate, not reach for `nix` directly, and the trait is what lets tests drive the loop with a fake clock without quietly abandoning section 1.5.3's guarantee that locks never touch the wall clock. **Five enforcement tests, all against real processes.** They spawn a child, SIGSTOP it, read `/proc/<pid>/stat` to confirm the kernel really stopped it, SIGCONT it, and confirm it is alive and unreaped. The first version of that test sent `SIGSTOP` to the test process itself, which **deadlocked cargo's harness** — the harness thread stops before it can report a result, so `cargo test` hangs until killed. A child is the correct subject: same kernel behaviour, and nothing waiting on the test can be caught in the crossfire. **What is not wired yet, stated plainly.** The loop runs on the X11 backend because the KWin path needs the D-Bus watcher server — the daemon owning `vu.valiq.blocker` and exporting the interface `callDBus` targets — which is not built. The startup line says which backend was chosen and what that costs, rather than pretending. On a Wayland session this means XWayland clients only. Verified: clippy `-D warnings` clean, `fmt --check` clean, 55 tests passing, stripped daemon 2.237 MB against the 5 MB budget. Co-Authored-By: Claude Code <noreply@anthropic.com> |
||
|
|
9f1acb1421 |
test(linux): verify the KWin script against the live Plasma session
The KWin JavaScript had never been executed. It is now.
A probe script was loaded into the running Plasma 6.6.6 compositor, which
reported:
js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole
caption=... internalId={6866aecf-...} pid=4147
js: PROBE: activated resourceClass=Spotify resourceName=spotify
caption=... internalId={c99b88b4-...} pid=74378
The probe was unloaded immediately afterwards and `isScriptLoaded` confirmed
`false`.
**This proved one thing I had wrong.** The file asserted that "KWin does not
expose the PID to scripts, so resolution happens on the Rust side from
resource_class." It does expose it — `pid=4147` for Konsole, `pid=74378` for
Spotify. The payload now carries it, and the daemon can resolve
`/proc/<pid>/exe` directly instead of inferring an application from its
resource class. Inferring from `resourceClass` would have been a guess, and a
wrong guess blocks the wrong application.
Also confirmed: `workspace.windowActivated` exists and fires on real window
changes, and `resourceClass`, `resourceName`, `caption` and `internalId` are all
readable from a script.
**Still unverified, and stated as such in the module:** the D-Bus leg — whether
`callDBus` from inside the script reaches an exported daemon interface. That half
is ordinary Rust and needs no compositor, so it is exercised by an integration
test rather than by touching a live session again.
Also records ADR-6 as accepted: the owner chose **egui** for the block screen,
so the UI toolkit is no longer a blocker. Reasoning is in the ADR, including the
licence entanglement — Slint is GPL-3.0, which is why ADR-17 sits upstream of
this decision.
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
||
|
|
a79d262986 |
feat(linux): add KWin and X11 focus-detection backends
The first enforcement-path code. CLAUDE.md section 3 names `x11rb`, `zbus` and
`nix` for Linux explicitly, so all three are approved and this needed no new
dependency decision.
**KWin backend (primary).** Wayland has no portable focus API (section 6.3), and
this machine runs Plasma 6.6.6 on Wayland — an X11-only design would be blind to
most of the user's own desktop. KWin will not push focus events to an
arbitrary subscriber, so this loads a JavaScript snippet via
`org.kde.KWin /Scripting`, which hooks `workspace.windowActivated` and calls
back over D-Bus with KWin's `callDBus` helper. The daemon owns the bus name, so
KWin always has somewhere to call; if the daemon dies, `callDBus` fails silently
in the script and the compositor is left alone — the fail-open behaviour
section 1.5.4 requires.
The D-Bus surface was introspected on the live session, not read from docs:
$ busctl --user introspect org.kde.KWin /Scripting org.kde.kwin.Scripting
.loadScript method s->i .start method - .unloadScript method s->b
**X11 backend (fallback).** `_NET_ACTIVE_WINDOW` property subscription, resolved
through `_NET_WM_PID` to `/proc/<pid>/exe`. Blind to native Wayland apps under a
Wayland session, which is why it is the fallback and not the primary. It is also
the path that can be tested headless with Xvfb, so it is what CI would exercise.
**No polling anywhere.** Both backends park in a subscription: `callDBus` from
the compositor, `poll_for_event` on the X connection. Nothing in either module
has a timer, which is what keeps the section 5 idle budget at zero.
**zbus is blocking on async-io, deliberately.** `tokio` was available and was
rejected: a reactor thread is measurable idle cost against a zero-wakeup budget,
and several hundred KB against a 5 MB binary. Noted in the manifest so the
decision is visible next to the version pin.
**Size, measured rather than assumed:** the stripped daemon goes 1.364 MB ->
**2.237 MB**, so D-Bus, X11 and the signal handling cost ~870 KB. 2.9 MB of the
5 MB budget remains.
**What is verified, and what is not.** The D-Bus interface is introspected from
the live session. The KWin *JavaScript* API — `workspace.windowActivated`,
`client.resourceClass` — is written against the documented Plasma 6 scripting
surface and has **not** been executed, because doing so would load a script into
the user's running compositor. That is the honest status and LIMITATIONS.md
records it. Nothing here is wired into the event loop yet, so no window is
actually blocked by this PR.
Several API details cost real time and are worth recording: X11 atoms are
interned with a round trip at connect time rather than passed as an unrelated
constant, which silently queries the wrong property; the root event filter
matches on the `_NET_ACTIVE_WINDOW` *atom* rather than the property change type;
and `rusqlite`/`zbus`/`x11rb` all differ from their documented shapes in ways
that only compile once found.
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
||
|
|
9a5c529a8e |
feat(storage): add SQLite persistence in a separate crate
Implements ADR-4 (CLAUDE.md section 3, `rusqlite` bundled). Schedules, lock state, disable attempts and usage buckets now persist across restarts. **Why a new crate rather than a module inside `blocker-core`.** The purity contract says the engine does no I/O, and `tests/purity.rs` enforces that mechanically by failing if `blocker-core` has any dependency at all. Putting rusqlite in `blocker-core` would have forced that check to degrade into a source-text grep — something a careful developer can route around and nobody reads in six months. `blocker-storage` depends on `blocker-core`; the reverse edge does not exist, so the zero-dependency proof stays automatic. Both crates sit in the same layer of the section 2 diagram: storage is shared by every platform and contains no platform code. **Schema is relational, not JSON-in-a-text-column.** `schedule_target` and `schedule_condition` are separate tables because target rows are queried by target, and an index on a JSON string is not a plan. Serialisation is hand-written in `storage/src/codec.rs` — `serde` is not a section 3 dependency, and section 0 rule 3 requires asking before adding one. **Fail open, verified end to end** (section 1.5.4). Corruption moves the damaged file aside and starts clean, keeping the original for diagnosis. Confirmed against the real binary, not only in tests: ``` $ printf 'SQLite format 3\x00 not a database' > .../blocker.db $ blocker-daemon blocker-daemon: database was corrupt and has been moved to .../blocker.db.corrupt; starting clean, so no schedules are active until you re-add them EXIT=0 ``` A truncated file is covered separately — that is what a power loss during a write actually produces, and it fails differently from garbage bytes. **Two bugs this PR's own tests caught:** 1. `prune_usage` anchored its cutoff on `MIN(day_index)`, the oldest row. Since that row is by definition the one being tested, the cutoff always sat before it and the delete matched nothing — the table grew forever, which is the slow disk leak the function exists to prevent. Now anchored on `MAX`. 2. An off-by-one in the same function: with a strict `<` against `newest - keep_days`, the boundary day was kept too, yielding `keep_days + 1` rows. Both are covered by `old_usage_is_pruned`. **Cost, measured rather than assumed:** the stripped daemon goes from 0.279 MB to **1.364 MB** with SQLite actually linked in — bundled SQLite is ~1.1 MB of the 5 MB budget, leaving 3.8 MB for D-Bus, X11 and a UI toolkit. That is a real margin rather than a comfortable one, and it makes ADR-4's bundled-vs-system trade-off a genuine open question rather than a settled one. The M0 figure of 0.279 MB excluded SQLite entirely, because dead-code elimination dropped a crate the placeholder binary never referenced. Co-Authored-By: Claude Code <noreply@anthropic.com> |
||
|
|
585fd30440 |
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> |