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>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "blocker-daemon"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Blocker desktop daemon: observes the OS, enforces decisions made by blocker-core."
|
||||
license = "UNLICENSED"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "blocker-daemon"
|
||||
path = "src/main.rs"
|
||||
|
||||
# No dependencies in M0. Section 5 budgets the stripped daemon at 5 MB, and
|
||||
# section 3 requires every crate to justify its size cost. The runtime pieces
|
||||
# (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry.
|
||||
[dependencies]
|
||||
blocker-core = { path = "../../core" }
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Blocker desktop daemon.
|
||||
//!
|
||||
//! The daemon observes the OS and enforces the decisions `blocker-core` makes.
|
||||
//! It contains **no decision logic of its own** (CLAUDE.md section 2): if the
|
||||
//! daemon ever decides that something should be blocked, the Android and
|
||||
//! desktop builds will drift and the golden tables stop being a parity contract.
|
||||
//!
|
||||
//! # Shape
|
||||
//!
|
||||
//! Two processes, deliberately:
|
||||
//!
|
||||
//! - the **daemon** — small, long-lived, stays resident, holds the block window
|
||||
//! machinery and the engine connection. This is the binary with the 5 MB
|
||||
//! stripped budget (section 5).
|
||||
//! - the **UI** (`blocker-ui`) — a separate process launched to show a block
|
||||
//! screen, which exits when the block lifts.
|
||||
//!
|
||||
//! Splitting them keeps resident memory low and means a crash in the UI cannot
|
||||
//! take enforcement down with it.
|
||||
//!
|
||||
//! # Event loop
|
||||
//!
|
||||
//! M2-Linux replaces this placeholder with a loop that blocks on exactly three
|
||||
//! things: a focus-change event from the active
|
||||
//! [`ForegroundWatcher`](../../blocker_core/index.html) backend, the
|
||||
//! `next_wakeup` instant from the engine, and IPC from the UI. There is no
|
||||
//! polling tick anywhere — an idle daemon takes zero wakeups, which is how the
|
||||
//! section 5 idle-CPU budget is met.
|
||||
|
||||
fn main() {
|
||||
println!("blocker-daemon {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
|
||||
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "blocker-platform-linux"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Linux platform layer: foreground-window detection, enforcement, and system integration for Blocker."
|
||||
license = "UNLICENSED"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
name = "blocker_platform_linux"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
blocker-core = { path = "../../core" }
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Linux platform layer.
|
||||
//!
|
||||
//! Observes the OS and enforces decisions. Contains **no decision logic**
|
||||
//! (CLAUDE.md section 2).
|
||||
//!
|
||||
//! # The trait this crate exists to implement
|
||||
//!
|
||||
//! Linux has no portable way to ask "which window has focus" under Wayland.
|
||||
//! The [`ForegroundWatcher`] trait below is the seam, and it will have three
|
||||
//! implementations, tried in order at startup:
|
||||
//!
|
||||
//! 1. **`KwinBackend`** — the primary path on this machine. Loads a small
|
||||
//! KWin script over the `org.kde.KWin /Scripting` D-Bus interface, which
|
||||
//! hooks `windowActivated` and reports back to the daemon. Verified
|
||||
//! available on KDE Plasma 6.6.6: `loadScript`, `start`, `unloadScript`,
|
||||
//! `isScriptLoaded` are all present.
|
||||
//!
|
||||
//! 2. **`X11Backend`** — the portable fallback. `x11rb` subscribes to
|
||||
//! `_NET_ACTIVE_WINDOW` property changes, resolves `_NET_WM_PID`, then reads
|
||||
//! `/proc/<pid>/exe`. Works on any X11 session and is testable headless with
|
||||
//! Xvfb, so this is also the path CI exercises.
|
||||
//!
|
||||
//! 3. **`ProcessBackend`** — a degraded last resort with no idea what has focus,
|
||||
//! which acts on process creation instead. It cannot show a meaningful block
|
||||
//! screen and should never be reached on a working KDE session.
|
||||
//!
|
||||
//! # Honest limits (CLAUDE.md section 6)
|
||||
//!
|
||||
//! - `X11Backend` is **blind to native Wayland apps** under a Wayland session.
|
||||
//! Under XWayland it sees only XWayland clients. This is why it is a fallback
|
||||
//! and not the primary.
|
||||
//! - `/proc/connectors` does not exist on the development kernel, so the
|
||||
//! netlink proc-connector process watcher is unavailable and
|
||||
//! `ProcessBackend` must use eBPF or `/proc` polling — a worse experience
|
||||
//! than a block screen, by construction.
|
||||
//! - The KWin backend is KDE-specific. wlroots
|
||||
//! (`foreign-toplevel-management`) and GNOME backends remain in M7 per
|
||||
//! section 8, because no portable API exists to build them against.
|
||||
//! - 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.
|
||||
|
||||
/// A window that has taken focus, resolved to something a schedule can match.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ForegroundWindow {
|
||||
/// Backend-native window handle. KWin gives a `uuid` string; X11 gives an
|
||||
/// XID. Kept as a string so the trait is not tied to one backend.
|
||||
pub handle: String,
|
||||
/// Resolved process id, where the backend can supply one.
|
||||
pub pid: Option<u32>,
|
||||
/// Absolute path of the executable, read from `/proc/<pid>/exe`.
|
||||
pub exe_path: Option<String>,
|
||||
/// Window title, for display on the block screen.
|
||||
pub title: Option<String>,
|
||||
/// Desktop-file id or WM_CLASS, which is what a Linux user actually
|
||||
/// recognises as "the app" and what schedules should match against.
|
||||
pub desktop_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Events a watcher can raise. The daemon blocks on this channel; there is no
|
||||
/// polling (section 2).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FocusEvent {
|
||||
/// A window gained focus.
|
||||
Activated(ForegroundWindow),
|
||||
/// The focused window closed, so there is no longer a foreground window.
|
||||
Cleared,
|
||||
}
|
||||
|
||||
/// Source of focus events.
|
||||
///
|
||||
/// Implemented once per backend. M2-Linux fills these in.
|
||||
pub trait ForegroundWatcher {
|
||||
/// Begin delivering events to `sink`.
|
||||
///
|
||||
/// Implementations must be event-driven: they subscribe to an X11 property
|
||||
/// change or a compositor callback, never to a timer. An implementation
|
||||
/// that polls would break the section 5 idle budget even if it functioned.
|
||||
fn subscribe(&mut self, sink: std::sync::mpsc::Sender<FocusEvent>);
|
||||
|
||||
/// Which backend this is, for the troubleshooting screen and the log.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Why this backend was chosen, in plain language for the user.
|
||||
fn selection_reason(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// Enforcement actions, in escalating order and configurable by the user
|
||||
/// (CLAUDE.md section 4.3).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Enforcement {
|
||||
/// Show the block screen. The default, and the only non-destructive option.
|
||||
BlockScreen,
|
||||
/// Ask the compositor to minimise the window.
|
||||
Minimise,
|
||||
/// `SIGSTOP` the process. Freezes the app rather than closing it, so the
|
||||
/// user's state is preserved and the resume is honest.
|
||||
Freeze,
|
||||
/// Terminate the process. Destructive, and the last resort.
|
||||
Terminate,
|
||||
}
|
||||
|
||||
/// What the platform layer may and may not do unprivileged.
|
||||
///
|
||||
/// Recorded here because section 0 rule 2 requires honesty rather than a stub
|
||||
/// that pretends. Each of these is a real limit of an unprivileged user
|
||||
/// daemon, not a missing feature.
|
||||
pub const UNPRIVILEGED_LIMITS: &[&str] = &[
|
||||
"cannot prevent a process from launching, only react to it after the fact",
|
||||
"cannot mask a systemd unit (that needs root); tamper resistance is \
|
||||
Restart=always plus a watchdog, nothing stronger",
|
||||
"cannot make its own config tamper-proof against the same user, who owns the \
|
||||
files — lock state lives in the DB instead (section 1.5.3)",
|
||||
"cannot read /proc/<pid>/exe for processes owned by other users",
|
||||
"cannot block a process that is already running as root",
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "blocker-platform-windows"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Windows platform layer: foreground-window detection, enforcement, and system integration for Blocker."
|
||||
license = "UNLICENSED"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
name = "blocker_platform_windows"
|
||||
path = "src/lib.rs"
|
||||
|
||||
# The `windows` crate (CLAUDE.md section 3) is intentionally NOT added in M0:
|
||||
# it is large and nothing here compiles on Linux, so adding it would grow the
|
||||
# lockfile and CI time for code that cannot be verified here. It lands in the
|
||||
# Windows milestone, with an ADR entry.
|
||||
[dependencies]
|
||||
blocker-core = { path = "../../core" }
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Windows platform layer.
|
||||
//!
|
||||
//! Observes the OS and enforces decisions. Contains **no decision logic**
|
||||
//! (CLAUDE.md section 2).
|
||||
//!
|
||||
//! Nothing is implemented here in M0 — this crate cannot be compiled or tested
|
||||
//! on the development machine, so writing enforcement code now would mean
|
||||
//! shipping unverified code (section 0 rule 2: never fake a feature). The
|
||||
//! design is recorded so the Windows milestone has a fixed target.
|
||||
//!
|
||||
//! # Planned shape (CLAUDE.md section 4.2)
|
||||
//!
|
||||
//! - **Foreground detection:** `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)` —
|
||||
//! event-driven, no polling. The hook resolves `HWND` to a PID, then to the
|
||||
//! exe path, with package identity for UWP apps.
|
||||
//! - **Block action:** show a topmost block window, then minimise or close the
|
||||
//! offending window. Termination only as a configurable escalation.
|
||||
//! - **Runs as:** a per-user background daemon at login, plus a Windows Service
|
||||
//! watchdog that restarts it if it dies.
|
||||
//! - **Wi-Fi:** the WLAN API notification callback.
|
||||
//! - **Website blocking:** the weak point. The decision is a Firefox extension
|
||||
//! over native messaging (section 6.2), which is what makes keyword and
|
||||
//! path-level blocking possible — DNS and hosts-file approaches cannot do
|
||||
//! either, and are defeated by DoH.
|
||||
|
||||
/// A window that has taken focus, resolved to something a schedule can match.
|
||||
///
|
||||
/// Mirrors `blocker_platform_linux::ForegroundWindow` deliberately: the two
|
||||
/// platform crates implement the same shape so a schedule means the same thing
|
||||
/// on both.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ForegroundWindow {
|
||||
/// `HWND` as a string, so this type is not tied to the `windows` crate.
|
||||
pub handle: String,
|
||||
pub pid: Option<u32>,
|
||||
pub exe_path: Option<String>,
|
||||
pub title: Option<String>,
|
||||
/// UWP package family name, where the app is packaged.
|
||||
pub package_identity: Option<String>,
|
||||
}
|
||||
|
||||
/// Events a watcher can raise. The daemon blocks on this channel; no polling.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FocusEvent {
|
||||
Activated(ForegroundWindow),
|
||||
Cleared,
|
||||
}
|
||||
|
||||
/// Source of focus events, backed by `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)`.
|
||||
pub trait ForegroundWatcher {
|
||||
fn subscribe(&mut self, sink: std::sync::mpsc::Sender<FocusEvent>);
|
||||
fn name(&self) -> &'static str;
|
||||
fn selection_reason(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// Enforcement actions, escalating and configurable (section 4.2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Enforcement {
|
||||
BlockScreen,
|
||||
Minimise,
|
||||
Close,
|
||||
/// Terminate the process. Destructive, last resort.
|
||||
Terminate,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "blocker-ui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Blocker block-screen window. Launched on demand, exits when the block lifts."
|
||||
license = "UNLICENSED"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "blocker-ui"
|
||||
path = "src/main.rs"
|
||||
|
||||
# NO UI TOOLKIT CHOSEN YET — deliberately.
|
||||
#
|
||||
# CLAUDE.md section 3 requires Slint vs egui to be decided on a measured
|
||||
# size/RAM comparison, and explicitly says to ask the owner. That decision is
|
||||
# still open (see docs/DECISIONS.md, ADR-6).
|
||||
#
|
||||
# Two constraints already rule out some options, for the record:
|
||||
# - Slint is GPL-3.0 or commercial. For a project intended to be published,
|
||||
# the permissive licences are the safer default. If sizes come out close,
|
||||
# that should decide it.
|
||||
# - Either way the daemon must not grow past 5 MB stripped (section 5), and
|
||||
# the UI must not hold a GL context resident while idle.
|
||||
[dependencies]
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Blocker block-screen UI.
|
||||
//!
|
||||
//! Placeholder. No UI toolkit is bound here yet — see ADR-6 in
|
||||
//! `docs/DECISIONS.md` for why that is an open decision rather than an oversight.
|
||||
//!
|
||||
//! # What this process is for
|
||||
//!
|
||||
//! It is a short-lived, separately-executable window. The daemon stays resident
|
||||
//! and small; this launches when a block fires and exits when it lifts. Keeping
|
||||
//! them separate means a crash in window rendering cannot take enforcement down.
|
||||
//!
|
||||
//! # Design constraints that will apply (docs/UX-BLOCK-SCREEN.md)
|
||||
//!
|
||||
//! - Calm, not punitive. No red-alert styling, no shaming copy, no streak
|
||||
//! counters. The screen exists to end an argument with the user's own
|
||||
//! intention, so it must lower arousal.
|
||||
//! - It always states which schedule blocked and when it lifts. Uncertainty is
|
||||
//! what makes people fight the tool.
|
||||
//! - It is never a dead end: a plain-looking "How to turn this off" entry is
|
||||
//! always present, including under Strict Mode (CLAUDE.md section 1.5.4).
|
||||
//! - Under Strict Mode the *absence* of a bypass button is stated explicitly.
|
||||
//! Silently missing controls read as a bug and breed distrust.
|
||||
|
||||
fn main() {
|
||||
println!("blocker-ui {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
|
||||
println!("UI toolkit undecided — ADR-6 in docs/DECISIONS.md");
|
||||
}
|
||||
Reference in New Issue
Block a user