8 Commits

Author SHA1 Message Date
SellPL 9e44bd4b32 feat(ipc): connect GUI to daemon via D-Bus reload and live status detection 2026-10-02 18:00:29 +03:00
SellPL f0107dbef8 fix(linux): fix KWin script startup crash and embed type-2 ELF runtime in AppImage 2026-10-02 17:25:29 +03:00
SellPL 2194c28ab5 feat(linux): integrate real civil time, boot id, next_wakeup timer, and dbus notifications 2026-10-02 17:00:31 +03:00
SellPL 3caabbff15 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>
2026-10-02 16:45:04 +03:00
SellPL 8ae043658c feat(linux): add enforcement actions and the daemon event loop
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
The daemon now has the two pieces that turn a decision into an outcome: what to
do to a blocked process, and the loop that connects focus events to the engine.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:21:42 +03:00
SellPL 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>
2026-10-01 15:02:20 +03:00