1 Commits

Author SHA1 Message Date
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
58 changed files with 0 additions and 10576 deletions
-19
View File
@@ -1,19 +0,0 @@
# EditorConfig — https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.{yml,yaml,json,toml,md}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
-109
View File
@@ -1,109 +0,0 @@
name: Bug report
description: Something behaves differently from how CLAUDE.md section 1 describes it
title: ""
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Two failure modes matter most here, and they need different fixes.
- **A block that should have fired but did not.**
- **A block that fired but should not have.**
Please pick which one you are reporting in the first question.
- type: dropdown
id: mode
attributes:
label: Which kind of failure is this?
description: The two modes below need completely different investigations.
options:
- A blocked app was not blocked
- A block fired when it should not have
- Enforcement stopped working entirely
- The app crashed or would not start
- Something else
- type: dropdown
id: platform
attributes:
label: Platform
options:
- Linux (KDE Plasma / Wayland)
- Linux (KDE Plasma / X11)
- Linux (other compositor)
- Windows
- Android
- More than one
- Not yet implemented on any
- type: input
id: version
attributes:
label: Version or commit
description: Output of `blocker-daemon --version`, or the commit SHA for a build from source.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: |
Concrete steps, starting from a known state. "Open Discord during Focus" is
reproducible; "it blocks sometimes" is not.
placeholder: |
1. Start a schedule named "Focus", 09:00-17:00, targeting Discord
2. Focus Discord
3. Nothing happens
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: What does CLAUDE.md section 1 say should happen? Quote the requirement if you can.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behaviour
description: What happened instead, including any error output.
validations:
required: true
- type: textarea
id: backend
attributes:
label: Which enforcement backend is active?
description: |
The troubleshooting screen shows this. If you cannot find it, say so — that is
itself useful information, because it suggests the daemon started with no
backend and cannot block anything.
placeholder: "kwin: KWin script loaded successfully"
- type: textarea
id: logs
attributes:
label: Daemon log output
description: |
From `journalctl --user -u blocker -n 100`.
Redact anything private. Do not paste API tokens, keys, or credentials.
render: shell
- type: checkboxes
id: checks
attributes:
label: Before submitting
options:
- label: I searched existing issues for this.
- label: I removed secrets and personal paths from anything I pasted above.
- label: I am not asking to block ADB, safe mode, factory reset, or OS recovery.
description: |
Those are never blocked, by design (CLAUDE.md section 6.7 and ADR-11). Such a
request will be closed with a link to the ADR rather than implemented.
-102
View File
@@ -1,102 +0,0 @@
name: Platform limitation
description: A case where the OS will not permit what CLAUDE.md asks for
title: ""
labels: ["limitation"]
body:
- type: markdown
attributes:
value: |
CLAUDE.md section 6 lists the hard limits this project is known to have. This
template exists so a newly discovered one is recorded rather than quietly worked
around.
Section 0 rule 2: never fake a feature. If something cannot be enforced on a
platform, we implement the closest honest approximation and document the gap.
This form is how that documentation starts.
- type: dropdown
id: existing
attributes:
label: Is this already documented?
description: |
If it matches a numbered limitation in CLAUDE.md section 6, please comment on
that discussion instead of opening a new issue.
options:
- "No, this is new"
- "Yes, but our handling of it is wrong or incomplete"
- "Not sure"
- type: input
id: section
attributes:
label: Which section 6 limitation does this relate to?
description: "A number 1-8, or 'new'."
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- Linux (KDE Plasma / Wayland)
- Linux (KDE Plasma / X11)
- Linux (wlroots compositor)
- Linux (GNOME)
- Windows
- Android
- More than one
- type: textarea
id: reality
attributes:
label: What does the OS refuse to do?
description: |
The OS-level fact. Not "we could not figure it out" — what the platform makes
impossible or requires privileges we do not have.
placeholder: |
Wayland has no portable API for the focused window. Each compositor exposes its
own, and none is standard.
validations:
required: true
- type: textarea
id: workaround
attributes:
label: What do we do instead?
description: |
The closest honest approximation. If the answer is "nothing", say that. An
unimplemented limitation is a valid and common answer.
placeholder: "Process-based detection, which cannot show a block screen."
validations:
required: true
- type: textarea
id: privileges
attributes:
label: What privileges would a real fix need?
description: |
Root, CAP_SYS_ADMIN, an accessibility service, a browser extension, a Windows
service install? This determines whether the limitation is permanent or a
matter of permission design.
validations:
required: true
- type: checkboxes
id: docs
attributes:
label: Documentation
options:
- label: This gap is documented in docs/LIMITATIONS.md
description: |
Required. An undocumented limitation is worse than an unimplemented one,
because the docs will imply a capability that does not exist.
- type: checkboxes
id: notblocked
attributes:
label: Confirm
options:
- label: |
This is not a request to block ADB, root access, safe mode, factory reset, or
OS recovery. Those are never blocked, by design (section 6.7, ADR-11).
-69
View File
@@ -1,69 +0,0 @@
name: Question
description: An open decision for the project owner, or something blocking work
title: ""
labels: ["question"]
body:
- type: markdown
attributes:
value: |
CLAUDE.md section 10.3: any open question becomes an issue labelled
"question" instead of being buried in a chat thread where it gets lost.
Most questions here are ones the agent cannot answer alone — section 0 rule 3
requires stopping to ask before adding an unlisted dependency, changing the
architecture, or choosing between two non-obvious OS mechanisms.
- type: textarea
id: question
attributes:
label: The question
description: One question. If there are several, they need separate issues.
validations:
required: true
- type: dropdown
id: kind
attributes:
label: What kind of decision is this?
options:
- Dependency to add (section 0 rule 3)
- Architecture change (section 0 rule 3)
- Choice between OS mechanisms (section 0 rule 3)
- Licensing
- Scope or priority
- Something else
- type: input
id: blocks
attributes:
label: What does this block?
description: Which milestone, and what specifically cannot proceed.
placeholder: "M2-Linux: the KWin backend cannot be written until the block-window approach is chosen."
validations:
required: true
- type: textarea
id: options
attributes:
label: Options and trade-offs
description: |
What are the realistic choices, and what does each cost? Include binary size,
licence, and maintenance burden where they apply.
validations:
required: true
- type: textarea
id: recommendation
attributes:
label: Recommendation, if you have one
description: |
Optional. A default the agent has been proceeding with, so it can keep working
while you decide. Say here if you would rather it stop and wait.
- type: textarea
id: research
attributes:
label: Supporting research
description: |
Measurements, documentation links, or a comparison. Links are more useful than
conclusions.
-88
View File
@@ -1,88 +0,0 @@
<!--
Pull request template for Blocker.
CLAUDE.md section 10.2 requires each PR to state: what changed, which section 1
requirement it covers, test evidence, and perf numbers if it touches the hot path.
Test evidence means pasted output. A claim that tests pass is not evidence; the
output is. Section 10.6: if a check fails, say so in the PR — do not weaken a test
to make it pass.
-->
## What changed
<!-- What this PR does, in a paragraph. Not a file list — the diff is the file list. -->
Refs #
## Which requirement this covers
<!--
Quote the relevant part of CLAUDE.md section 1. If this covers none, say so plainly
and explain why the change exists anyway — refactors and CI changes legitimately
cover no user-facing requirement, but that should be visible rather than assumed.
-->
Section 1:
## Test evidence
<!--
Paste the actual output of `scripts/ci.sh`. Not "all tests pass" — the output.
Note any check that SKIPPED, and why. A skip is not a pass, and saying so here is
part of the evidence.
```
$ scripts/ci.sh
...
```
## Performance
<!--
Required if this touches the hot path — the evaluator, the event loop, the block
screen, or anything that runs on every focus change (section 9).
Otherwise write "not affected" and say why.
-->
Measured against section 5:
| Metric | Target | Before | After |
|---|---|---|---|
| | | | |
Command used:
```
## Checklist
- [ ] Each commit builds and passes tests on its own (section 10.2)
- [ ] Commits are conventional (`feat:`, `fix:`, `perf:`, `refactor:`, `test:`, `docs:`, `ci:`)
- [ ] `scripts/ci.sh` passes, or every skip is explained above
- [ ] New golden decision-table cases are **added**, and behaviour changes are
matched by a case change in the same commit (the add-before-change rule)
- [ ] Every fallible OS or I/O call is handled — no `unwrap()`/`expect()` outside
tests (section 0 rule 5)
- [ ] Perf budget re-measured if the hot path was touched (section 9)
- [ ] Any platform that cannot comply has its gap documented in `docs/LIMITATIONS.md`
- [ ] No credentials, keystores, `.env` files, or local databases in the diff
- [ ] No secret, API token, SSH key, or private path appears in the diff
- [ ] No dependency added without sign-off (section 0 rule 3) and an ADR entry
## Notes for the reviewer
<!--
Anything you would flag yourself: a place you are unsure about, a shortcut taken, a
decision that needs a second opinion.
If a check fails, say so here. A failing check stated plainly is fine. A failing
check hidden by a weakened test is not, and section 10.6 prohibits it.
-->
## Do not merge this yourself
The agent does not merge its own PR (section 10.2). Squash or rebase merge, keep
history linear. Tag the milestone when it lands.
-119
View File
@@ -1,119 +0,0 @@
# Gitea Actions setup
**The owner has not yet confirmed whether a runner is registered on this Gitea.**
Until one is, `scripts/ci.sh` is the only thing gating merges, and every check must
be run locally before opening a PR.
The workflows in this directory are written for **act_runner**, Gitea's
act-compatible runner, which executes jobs in Docker. They are also close enough to
GitHub Actions syntax that a GitHub-style runner would work with the labels
adjusted.
---
## Labels these workflows expect
| File | `runs-on` | Needs |
|---|---|---|
| `ci.yml` → `ci` | `ubuntu-latest` | Rust toolchain, no Android SDK |
| `ci.yml` → `android` | `android-build` | JDK 17 + Android SDK 35 |
If your runner is registered under different labels, change `runs-on` here. Both
must be changed together — a mismatch shows up as a job that queues forever, with
no error message, which is the worst failure mode of a mislabelled runner.
---
## Registering act_runner
On the runner host, with Docker present:
```sh
# 1. Generate a registration token, in Gitea's UI:
# Site Administration → Actions → Runners → Create new runner
# Copy the token.
# 2. Register. --instance is the URL Gitea is reachable at *from the runner*.
act_runner register \
--instance https://git.valiq.vu \
--token "<REGISTRATION_TOKEN>" \
--name blocker-ci \
--labels ubuntu-latest:docker://node:20-bookworm \
--labels android-build:docker://ghcr.io/cirruslabs/android-sdk:34
# 3. Start it.
./act_runner run --server http://localhost:8080
```
Each `--labels` entry is `name:docker://image`. The runner runs the job inside that
container, so the image must contain everything the job needs:
- `ubuntu-latest` — needs `bash`, `git`, `curl`, and Rust. The workflow installs
Rust via `dtolnay/rust-toolchain`, so a Rust-free base image is fine.
- `android-build` — needs JDK 17 and the Android SDK, hence the Cirrus Labs image.
### Running act_runner as a service
```ini
# /etc/systemd/system/act_runner.service
[Unit]
Description=Gitea Actions runner
After=docker.service
Requires=docker.service
[Service]
Type=simple
User=git
WorkingDirectory=/opt/act_runner
ExecStart=/opt/act_runner/act_runner run --server http://localhost:3000 --config /opt/act_runner/config.yml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
---
## What the runner still needs from the owner
CLAUDE.md section 10.4 says to ask whether the runner has the Android SDK. **Not
yet answered.** The two behaviours are deliberately different until it is:
- `scripts/ci.sh` **skips** Android when no SDK is found, and prints SKIP with the
reason. A developer should not be blocked from running checks by a missing
toolchain.
- The `android` job in CI **fails** when the SDK is missing. A silently skipped
Android build in CI means Android regressions ship unnoticed.
Same check, opposite defaults, because the contexts are opposite. If you would
rather the CI job also skip, say so and it will be changed — but the failure mode
of the current setting is "you find out from a user", which is worse.
---
## Why the workflow calls scripts/ci.sh
`ci.yml` runs `bash scripts/ci.sh` rather than repeating the fmt/clippy/test/size
steps inline.
The reason is drift. A workflow that reimplements the checks is a second
definition of "done". The two diverge — someone adds a check locally, forgets CI,
and green ticks mean nothing. One script, two callers, no drift.
The workflow keeps separate steps for checkout, toolchain install, caching, and
artifact upload, because those are genuinely different between a runner and a
laptop.
---
## Action pinning
Every action is pinned to an exact version (`@v4.1.7`, not `@v4`, and never `@main`).
A floating tag means upstream can change what runs in your CI without a review, a
tag can be repointed, and `@main` can change arbitrarily. On a self-hosted runner
that is a supply chain problem: the code that decides whether a change is safe to
merge is code someone else controls.
Bumping a pin is its own commit, so the diff shows what changed.
-125
View File
@@ -1,125 +0,0 @@
name: CI
# Section 10.4 requires every PR to pass these checks. This workflow does not
# reimplement them: it runs `scripts/ci.sh`, which is the single source of truth
# for what must pass. Reimplementing the steps here is how CI and local checks
# drift apart, and then one of them is lying to you.
on:
push:
branches: [main]
pull_request:
# Superseded PR runs are cancelled rather than left to finish and report on a
# commit that no longer exists.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
# Matches the act_runner label in .gitea/workflows/README.md. Change both
# together if the runner is registered under a different label.
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out
uses: actions/checkout@v4.1.7
# Pinned to an exact version, never a floating tag. A floating tag lets
# upstream change what runs in your CI without review, which is a supply
# chain problem, not a convenience.
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
# rust-toolchain.toml pins the channel and components, so this action
# installs exactly what the project asks for and CI matches local.
- name: Cache cargo
uses: Swatinem/rust-cache@v2.7.5
- name: Cache cargo registry
uses: actions/cache@v4.1.2
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Run all checks
# scripts/ci.sh runs: fmt --check, clippy -D warnings, cargo test, the 5 MB
# size gate, Android lint (skipped loudly without an SDK), and gitleaks.
#
# A skip inside ci.sh prints SKIP with the reason and is listed in the
# summary as "NOT a pass", so an unverified Android build can never be
# mistaken for a verified one.
run: bash scripts/ci.sh
- name: Upload the release daemon
# Section 10.5 requires releases to carry SHA-256 checksums. Producing
# the checksum here means the release pipeline never has to trust an
# artifact it did not build itself.
if: always()
run: |
set -euo pipefail
BIN="${CARGO_TARGET_DIR:-$HOME/.blocker-target}/release/blocker-daemon"
mkdir -p dist
if [ -f "$BIN" ]; then
cp "$BIN" dist/blocker-daemon
sha256sum dist/blocker-daemon > dist/blocker-daemon.sha256
else
echo "release daemon not built; nothing to upload"
fi
- uses: actions/upload-artifact@v4.4.3
if: always()
with:
name: blocker-daemon-linux
path: dist/
if-no-files-found: warn
# A separate job, rather than a step, for two reasons: it needs a different
# runner label (a runner with the Android SDK), and its failure should be
# legible on its own rather than buried in the middle of the CI output.
#
# Note the asymmetry with ci.sh, which is deliberate. ci.sh SKIPS Android when
# no SDK is present, because a developer running checks locally should not be
# blocked by a missing toolchain. This job FAILS when the SDK is absent,
# because a silently skipped Android build in CI means Android regressions ship
# unnoticed. Same check, opposite defaults, for opposite contexts.
android:
runs-on: android-build
timeout-minutes: 60
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4.1.7
- uses: actions/setup-java@v4.4.0
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3.2.2
- name: Accept SDK licences
run: yes | sdkmanager --licenses > /dev/null 2>&1 || true
- name: Cache Gradle
uses: actions/cache@v4.1.2
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle.kts') }}
restore-keys: ${{ runner.os }}-gradle-
# Sideload flavour only. There is deliberately no Play Store signing path
# in CI: sections 6.8 and 4.1 record that the strongest friction
# (Stubborn level, Device Admin uninstall block) is hostile to Play Store
# policy and is intended for sideload or F-Droid distribution.
- name: Lint and assemble release
working-directory: android
run: ./gradlew lint assembleRelease
-72
View File
@@ -1,72 +0,0 @@
# --- Rust ---
/target/
**/target/
Cargo.lock.orig
*.rlib
*.rmeta
# Rust keeps its downloaded-crate cache here. It grows to hundreds of MB and is
# pure cache: delete it any time, cargo re-fetches. Not in git.
/.cargo-cache/
# --- Android / Gradle ---
.gradle/
**/gradle/
local.properties
*.apk
*.aab
*.keystore
*.jks
keystore.properties
release-keys/
# --- Desktop / release artifacts ---
/dist/
*.exe
*.msi
*.deb
*.rpm
*.AppImage
# --- Secrets — never commit ---
.env
.env.*
api_key.txt
*.pem
*.key
*.p12
*.pfx
secrets.toml
credentials.json
ssh_key*
id_ed25519*
id_rsa*
# --- Android Studio / IntelliJ / VS Code ---
.idea/
*.iml
.kotlin/
.cxx/
.vscode/*
!.vscode/extensions.json
# --- Local databases (never commit runtime state) ---
*.db
*.db-journal
*.db-wal
*.db-shm
*.sqlite
*.sqlite3
# --- OS cruft ---
.DS_Store
Thumbs.db
*~
*.swp
# --- Local scratch ---
/tmp/
*.log
# Claude Code local scheduler state — machine-specific, not project config
.claude/
-294
View File
@@ -1,294 +0,0 @@
# CLAUDE.md — "Blocker": cross-platform app & website blocker
Functional equivalent of AppBlock (appblock.app) for **Android, Windows, Linux**.
Goal: same behavior, near-zero resource usage. Own name, own branding, own assets.
## 0. Rules for the agent (read first)
1. **Functional parity, not visual copying.** Do not reuse AppBlock's name, logo, icons, copy, or screenshots. Replicate behavior only.
2. **Never fake a feature.** If a platform cannot enforce something (see §6), implement the closest honest approximation, document the gap in `docs/LIMITATIONS.md`, and tell me. Do not silently stub.
3. **Stop and ask** before: adding any dependency not listed in §3, changing the architecture in §2, or choosing between two non-obvious OS mechanisms.
4. **Work in milestones (§8).** Finish one, run its acceptance checks, then stop and report. No jumping ahead.
5. **Every OS call that can fail must be handled.** No `unwrap()` / `!!` on OS or I/O results outside tests.
6. **Performance is a feature** (§5). Measure it; don't assume it.
7. **Everything goes through git** on my Gitea (§10). Never work outside a branch; never commit to `main` directly.
8. **Turning blocking off must be deliberately painful (§1.5), but never impossible and never unsafe.** The friction is opt-in, chosen by the user at activation time, always finite, and never touches the safety exemptions in §1.5.4.
## 1. Feature spec (target: 1:1 behavior)
### 1.1 Schedules (the core object)
A schedule =
- name + icon (user-chosen)
- **targets**: apps, websites, keywords
- **one or more conditions** (see 1.2)
- **mode**: `Blocklist` (listed items blocked) or `Allowlist` (only listed apps allowed; apps only, not websites)
- enabled / paused state
Rules:
- If multiple schedules touch the same target, **strictest wins**: blocked while ≥1 schedule wants it blocked.
- Schedules are editable at any time **unless Strict Mode is active**.
- Ship starter templates: Focus, Wind Down, Mindful Morning, Study Time, At Home, Digital Detox.
### 1.2 Conditions
| Condition | Behavior |
|---|---|
| Time | days of week + start/end time (must support overnight ranges) |
| Usage limit | block after N minutes per day or per hour |
| Launch count | block after N opens per day or per hour |
| Location | block inside (or outside) a radius of a point — mobile only |
| Wi-Fi | block while connected to a chosen SSID |
Combination rules (matches AppBlock; enforce in validation):
- Time + usage limit: **not allowed**
- Time + launch count: **not allowed**
- Usage limit + launch count: **not allowed**
- Usage limit + location, launch count + location: **not allowed**
- Use separate schedules to combine these (strictest-wins handles it).
### 1.3 Blocking behavior
- **Apps:** when a blocked app comes to the foreground, show a block screen immediately and send the user away from it.
- **Websites:** block domains and keyword matches in browsers.
- **QuickBlock:** one-tap immediate block of a chosen set for a chosen duration.
- **Pause / break:** temporarily pause a schedule (disabled or restricted under Strict Mode).
- **Block screen:** shows which schedule blocked it and when it ends; no bypass button while Strict Mode is on.
### 1.4 Strict Mode
Requires at least one schedule or QuickBlock to exist. When active, the user cannot:
- edit or delete **any** schedule (even if Strict Mode was tied to one — all are locked)
- pause schedules / QuickBlock
- uninstall the app (Android: Device Admin; desktop: see §6)
- open device settings pages that would let them disable enforcement (configurable)
Configurable parts:
- which restrictions to lock (checklist)
- **unlock condition** chosen at activation time (timer end, cooldown, etc.)
- **cooldown**: 2–10 minute wait on the Strict Mode screen before deactivation
- optional "block uninstall" even when Strict Mode is off
### 1.5 Friction: making it a pain to turn off
Design goal: disabling blocking in a weak moment must cost enough time and effort that the urge passes, while a determined, calm decision still gets through eventually. This goes **beyond** AppBlock's Strict Mode and is the main differentiator of this project.
**1.5.1 Lock levels** — chosen at activation, shown with a plain-language description of exactly what it will cost to undo:
| Level | Disable flow |
|---|---|
| Normal | edit/pause freely |
| Strict | AppBlock-equivalent Strict Mode (§1.4) |
| Stubborn | Strict + the full friction ladder below |
Rules for levels:
- A level can be **raised instantly** but **lowered only by going through that level's disable flow**.
- The user must see and confirm the exact disable cost before activating ("To undo this you will wait 24h and retype a 300-character code").
**1.5.2 The friction ladder** (each step is individually configurable; Stubborn enables all):
1. **Cooldown:** configurable wait (default 10 min, up to 30) on the disable screen. Leaving the screen resets it.
2. **Time-lock request:** disabling doesn't disable; it files a *request*. Blocking stays fully active for the configured delay (default 12 h, max 24 h). When the delay ends, a short confirmation window opens (e.g. 10 min). Missing the window cancels the request and starts over. The user can cancel the request any time.
3. **Typing challenge:** retype a random string (default 300 chars, mixed case + digits). Paste disabled, no autofill/suggestions, and any typo resets it. Regenerate a new string on each attempt.
4. **Escalation:** each abandoned or failed attempt doubles the next cooldown (cap at 30 min per step; resets after 7 days with no attempts).
5. **Attempt log:** every disable attempt, with timestamp, is shown to the user on the block screen ("You tried to disable this 4 times today"). Local only.
6. **No shortcuts:** no notification action, quick tile, widget, or tray menu item that disables, pauses, or opens the disable flow. The flow lives only inside the app, behind a plain-looking entry.
7. **Optional keyholder:** at activation the app generates a one-time unlock code and shows it **once** so the user can hand it to someone they trust (or print it and give it away). Entering it skips steps 1–4. If the user declines this option, the ladder still applies.
**1.5.3 Anti-tamper requirements (the engine must enforce these)**
- All delay timers use a **monotonic clock** (Android `elapsedRealtime`, `CLOCK_MONOTONIC`, `QueryPerformanceCounter`). Changing the system date/time must never shorten a delay. If a wall-clock jump is detected, log it and **extend** the active lock, never shorten it.
- Timer state survives reboot (persist elapsed-so-far with a boot ID; if reboot detected, resume conservatively).
- Force-stopping, killing, or disabling the enforcer is detected and repaired where the OS allows (§4, §6). Platforms that can't prevent it must still log it and re-apply blocks on next start.
- Schedule/Strict state is stored in the core DB, not in editable plaintext config.
**1.5.4 Safety guardrails (non-negotiable, these protect the user)**
- **Never-block list**, hardcoded and not user-removable from the blocklist: phone/dialer, emergency calls/SOS, emergency alerts, and on desktop the OS's own shell and Settings basics needed to keep the machine usable. Surface them in docs.
- **Finite worst case:** the longest possible disable path is capped at 24 h of waiting. Never design a lock with no exit.
- **Factory reset, safe mode, and OS-level recovery are never interfered with.** Do not attempt to block them.
- **Opt-in only:** Stubborn/Strict are never enabled by default or by a schedule template; the user turns them on explicitly with the cost shown.
- The disable flow must be documented in-app (a plain "How to turn this off" page reachable *without* disabling anything), so the user is never stuck wondering.
- If the app itself malfunctions (service crash loop, corrupt DB), fail **open** after logging, never leave the device unusable.
### 1.6 Statistics
Per-app and per-website usage tracking (daily/weekly), computed locally.
### 1.7 Settings
Dark/light theme, behavior when location is unavailable, which settings stay accessible under Strict Mode, troubleshooting screen (shows which permissions/services are missing).
### 1.8 Out of scope for v1
iOS, cloud accounts, cross-device sync, billing/premium tiers (all features are free), browser extensions beyond the §6 website mechanism. Design the storage so sync could be added later; do not build it.
## 2. Architecture
One **shared core** holds all decision logic so behavior is identical on every OS. Platform code only (a) observes the OS and (b) enforces decisions.
```
┌────────────────────────── core (Rust, no_std-friendly where possible) ──────────────────────────┐
│ model: Schedule, Condition, Target, StrictConfig │
│ engine: evaluate(state, now, events) -> Decision{blocked set, reasons, next_wakeup} │
│ storage: SQLite (rusqlite, bundled) — schedules, usage counters, settings │
│ usage accounting: foreground-time & launch-count aggregation │
└──────────────▲───────────────────────────────────────────────────▲───────────────────────────┘
│ uniffi (Kotlin bindings) │ direct Rust crate
┌──────┴───────┐ ┌─────────────┴──────────────┐
│ android/ │ │ desktop/ (win + linux) │
│ Kotlin shell │ │ daemon + small UI │
└──────────────┘ └────────────────────────────┘
```
Key principle: **the engine is a pure function** of `(schedules, usage state, wall clock, context like SSID/location)`. No OS calls inside it. This makes it unit-testable and guarantees parity.
Engine must be **event/alarm driven, never polling.** It returns `next_wakeup` (the next time any decision could change); the platform layer sleeps until then or until an OS event arrives.
Repo layout:
```
/core Rust crate (engine, model, storage, usage)
/android Kotlin app (thin: services, overlay, UI)
/desktop Rust workspace: /daemon, /ui, /platform-windows, /platform-linux
/docs ARCHITECTURE.md, LIMITATIONS.md, PERMISSIONS.md, DECISIONS.md, PERF.md, TESTING.md
/tests cross-platform scenario tests (golden decision tables)
/scripts ci.sh (same checks as CI, runnable locally), size-check, release helpers
/.gitea workflows/, issue + PR templates
```
## 3. Language & dependency choices
| Layer | Choice | Why |
|---|---|---|
| Core + desktop | **Rust** | no GC, tiny idle footprint, one codebase for logic |
| Android shell | **Kotlin** (no Compose, no DI framework, no Jetpack extras beyond what's required) | The blocking APIs (AccessibilityService, UsageStatsManager, DevicePolicyManager, overlays) are JVM-only; a non-JVM language can't register these services. The shell stays thin, so performance is dominated by the Rust core |
| Core ↔ Android | `uniffi` (or hand-written JNI if uniffi bloats size — ask) | |
| Storage | `rusqlite` with bundled SQLite | |
| Desktop UI | `slint` (preferred) or `egui` — decide in M3 after a size/RAM test, ask me | far lighter than Electron/Tauri webviews |
| Windows API | `windows` crate | |
| Linux | `x11rb` (X11), `zbus` (D-Bus/logind/NetworkManager), `nix` | |
Rules: no Electron, no webview, no Flutter, no React Native, no JVM on desktop. Prefer std over crates. Every added crate must justify its binary-size cost.
## 4. Platform enforcement design
### 4.1 Android
- **Foreground detection:** `AccessibilityService` (`TYPE_WINDOW_STATE_CHANGED`) — event-driven, no polling. Use `UsageStatsManager` only for historical/usage stats and as a fallback.
- **Block action:** launch block screen activity / overlay (`SYSTEM_ALERT_WINDOW`) and send user Home.
- **Websites:** Accessibility node reading of the browser URL bar for known browsers (keep a per-browser resource-id table, make it updatable); match domains + keywords.
- **Strict Mode / uninstall protection:** `DeviceAdminReceiver` for uninstall block; Accessibility service intercepts Settings screens that would disable the service.
- **Location / Wi-Fi:** Geofencing API for location; `ConnectivityManager` network callback for SSID.
- **Reliability:** foreground service with persistent notification, battery-optimization exemption prompt, `BOOT_COMPLETED` receiver, OEM autostart guidance in the troubleshooting screen.
- **Permissions screen** guides the user through: Usage access, Accessibility, Display over other apps, Battery optimization, Location (optional), Autostart (OEM-specific).
- Play Store note: Accessibility and Device Admin use require policy justification. Also provide a sideload/F-Droid build flavor.
### 4.2 Windows
- **Foreground detection:** `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)` — event-driven. Map HWND → PID → exe path (and package identity for UWP apps).
- **Block action:** show a topmost block window and minimize/close the offending window; terminate the process only as an escalation, configurable.
- **Usage/launch counting:** from the same foreground events + `EVENT_OBJECT_CREATE`/process start via WMI or ETW (choose lightest, ask me).
- **Runs as:** per-user background daemon at login + a Windows Service watchdog that restarts it.
- **Websites:** see §6 (this is the weak point).
- **Wi-Fi:** WLAN API notification callback.
### 4.3 Linux
- **X11:** `_NET_ACTIVE_WINDOW` property-change events via `x11rb` → `_NET_WM_PID` → `/proc/<pid>/exe`.
- **Wayland:** there is **no portable global API** for the focused window. Implement compositor-specific backends behind a trait: wlroots `foreign-toplevel-management`, KDE (KWin script/D-Bus), GNOME (extension). Fallback: **process-based blocking** — watch process creation (netlink proc connector, needs privilege) and match by executable.
- **Block action:** overlay window (X11) / layer-shell (wlroots) / terminate process as fallback.
- **Runs as:** `systemd --user` service; optional system service for tamper resistance.
- **Wi-Fi:** NetworkManager via D-Bus.
## 5. Performance budgets (must be measured and reported per milestone)
| Metric | Android | Windows / Linux daemon |
|---|---|---|
| Idle CPU | ~0% (no wakeups without events) | ~0%, ≤ 1 wakeup/min when idle |
| Idle RAM | ≤ 30 MB PSS | ≤ 15 MB RSS (daemon), UI process closed when window closed |
| Block latency (foreground change → block shown) | ≤ 150 ms | ≤ 150 ms |
| Daemon binary | — | ≤ 5 MB stripped (`opt-level="z"`, LTO, `panic="abort"`, `strip`) |
| APK size | ≤ 10 MB (release, R8) | — |
| Battery | no wakelocks outside active enforcement | — |
How to measure: `adb shell dumpsys meminfo`, Perfetto/battery stats (Android); `psutil`/`perf`/`heaptrack` (Linux); Process Explorer/ETW (Windows). Put results in `docs/PERF.md`.
## 6. Known hard limits (tell me, don't hide them)
1. **Desktop Strict Mode cannot be tamper-proof against an admin/root user.** Mitigations: watchdog service, file ACLs / immutable flag on config, restart-on-kill, tamper log. Document honestly in `LIMITATIONS.md`. Do not claim "unbypassable."
2. **Desktop website blocking has no single clean mechanism.** Evaluate and report options before building: (a) local DNS resolver / hosts-file management, (b) browser extension via native messaging (needed for keyword and path-level blocking, and HTTPS-aware), (c) both. DNS-only cannot do keyword blocking or defeat DoH. Ask me which to ship.
3. **Wayland** focused-window detection is compositor-dependent (§4.3).
4. **Location schedules** are mobile-only (no reliable desktop equivalent); hide that condition on desktop.
5. **Android OEMs** (Xiaomi, Huawei, Samsung, etc.) kill background services aggressively; troubleshooting screen must cover this.
6. **Android Accessibility URL reading** breaks when browsers change their UI; the resource-id table needs a maintenance path.
7. **"Pain to turn off" is friction, not a wall.** A user with ADB, root, safe mode, a factory reset, or admin rights on desktop can always remove the app. That's correct and must stay true (§1.5.4). The goal is to outlast an impulse, not to defeat a determined person. Do not try to block ADB, safe mode, or recovery.
8. **Play Store policy** is hostile to apps that resist uninstall. Keep the strongest friction (Stubborn level, Device Admin uninstall block) behind the explicit opt-in and plan on a sideload/F-Droid flavor as the primary distribution.
## 7. Testing
- **Engine:** property tests + golden decision tables in `/tests` — same table runs against the core and is the parity contract for all platforms. Cover: overnight time ranges, DST changes, timezone changes, clock tampering (use monotonic time for usage accounting), strictest-wins, allowlist mode, invalid condition combos, Strict Mode lock rules.
- **Platform layers:** manual test checklist per OS in `docs/TESTING.md` plus automated tests where feasible (Android instrumented tests, Linux Xvfb tests).
- A milestone isn't done until its acceptance checks pass **and** perf numbers are recorded.
## 8. Milestones
**M0 — Repo bootstrap (git first).**
Create the repo on my Gitea, set up layout, `.gitignore`, `scripts/ci.sh`, CI workflow skeleton, issue/PR templates, `docs/DECISIONS.md`. File every §6 limitation as an issue. *Accept:* clean clone builds an empty workspace; CI (or `scripts/ci.sh` if no runner) passes on `main`.
**M1 — Core engine (no UI, no OS).**
Model, evaluator, storage, usage accounting, golden tests. *Accept:* all decision-table tests pass; `evaluate` is pure and has no platform imports.
**M2 — Android MVP.**
Permissions flow, foreground detection, app blocking by Time condition, block screen, QuickBlock. *Accept:* block latency ≤ 150 ms, idle CPU ~0%, survives reboot.
**M3 — Desktop daemon + UI (Linux/X11 first, then Windows).**
Daemon with app blocking by Time, block window, minimal UI. Pick Slint vs egui here with measured RAM/size. *Accept:* §5 budgets met.
**M4 — All conditions.**
Usage limit, launch count, Wi-Fi, Location (mobile), validation of combination rules, templates, pause behavior.
**M5 — Websites & keywords.**
Android via Accessibility; desktop via the mechanism I choose from §6.2.
**M6 — Strict Mode + friction ladder (§1.4, §1.5).**
Lock rules, lock levels, cooldown, time-lock request, typing challenge, escalation, attempt log, keyholder code, monotonic-clock anti-tamper, never-block list, in-app "How to turn this off" page, uninstall protection (Android Device Admin), desktop watchdog. Write the honest limitations doc. *Accept:* engine tests prove that changing the system clock never shortens a lock, a reboot never resets one, the worst-case disable path is ≤ 24 h, and the never-block list can't be blocked.
**M7 — Statistics, settings, troubleshooting screen, Wayland backends, release builds, perf report.**
## 9. Definition of done (per feature)
- Behavior matches §1 on every platform where it's supported
- Golden tests updated
- No new idle wakeups introduced
- Perf budget table re-measured if the feature touches the hot path
- Limitations documented where the platform can't comply
## 10. Git workflow (everything goes through git)
**Remote:** my self-hosted Gitea at `https://git.valiq.vu` (Gitea 1.26, so Gitea Actions and the REST API at `/api/swagger` are available).
**Before first push, ask me for:** the owner/org and repo name, whether to use SSH or an access token, and whether a Gitea Actions runner (`act_runner`) is registered. Never guess these. Never commit tokens, keys, or keystores; read credentials from the environment or my SSH agent.
### 10.1 What lives in git
Everything that defines the project: source, golden test tables, CI config, scripts, docs, architecture decisions (`docs/DECISIONS.md`), perf measurements (`docs/PERF.md`), the permission/limitation docs, issue and PR templates.
What does **not**: build outputs, APKs/binaries (attach those to Gitea Releases instead), Android keystores, `.env` files, local SQLite databases, IDE junk. Keep `.gitignore` strict, and run a secret scanner (e.g. `gitleaks`) in `scripts/ci.sh` and CI.
### 10.2 Branching and commits
- `main` is always green and releasable. **No direct commits to `main`.**
- One short-lived branch per task, named `<milestone>/<topic>`, e.g. `m2/accessibility-foreground`, `m6/time-lock`.
- Small, atomic, **conventional commits** (`feat:`, `fix:`, `perf:`, `refactor:`, `test:`, `docs:`, `ci:`). Each commit must build and pass tests on its own.
- Open a **pull request** on Gitea for each branch (via the API or `tea` CLI) with: what changed, which §1 requirement it covers, test evidence, and perf numbers if it touches a hot path.
- **I merge.** The agent never merges its own PR without my explicit go-ahead. Use squash or rebase merge, keep history linear.
- Tag each finished milestone (`m1`, `m2`, ...) and each release (`v0.1.0`, semver). Tags are annotated.
### 10.3 Issues as the backlog
- File one Gitea issue per milestone (M0 to M7) with its acceptance checks as a checklist.
- File one issue per §6 hard limit, labeled `limitation`, so none get forgotten.
- Reference issues in commits and PRs (`refs #12`, `closes #12`).
- Any open question for me becomes an issue labeled `question` instead of being buried in chat.
### 10.4 CI (Gitea Actions, `.gitea/workflows/`)
Every PR must pass:
1. `cargo fmt --check` and `cargo clippy -- -D warnings`
2. `cargo test` including the golden decision tables
3. **Size gate:** fail if the stripped desktop daemon exceeds 5 MB (§5)
4. Android: `lint` and `assembleRelease` (only if the runner has the SDK; ask me)
5. Secret scan
`scripts/ci.sh` runs the same checks locally, so work is verifiable even if no runner exists. If I have no runner, write the workflows anyway and tell me what runner setup they need.
### 10.5 Releases
Tagged release builds attach artifacts to a Gitea Release: Android APK (sideload flavor), Windows `.exe`/installer, Linux binary + `.service` unit, each with a SHA-256 checksum file. Signing keys are never in the repo.
### 10.6 Agent hygiene
- Run `git status` and `git diff` before every commit; no `git add -A` blindly.
- Never rewrite or force-push shared history (`main`, tags). Force-push is allowed only on your own unmerged feature branch.
- If a milestone's checks fail, say so in the PR; don't weaken a test to make it pass.
Generated
-1042
View File
File diff suppressed because it is too large Load Diff
-66
View File
@@ -1,66 +0,0 @@
[workspace]
resolver = "2"
members = [
"core",
"storage",
"tests",
"desktop/daemon",
"desktop/ui",
"desktop/platform-linux",
"desktop/platform-windows",
]
# Release profile is workspace-wide (section 5 of CLAUDE.md). Cargo rejects
# [profile.*] in member manifests, so this lives here and nowhere else.
#
# The combination is deliberate: `z` optimises for size, LTO + codegen-units=1
# let the linker drop everything unreachable, and panic="abort" removes
# unwinding tables entirely. Together they are what make the 5 MB stripped
# daemon budget (section 5) reachable. scripts/ci.sh enforces the size gate.
# Workspace-wide lints. These apply to every member crate.
#
# They are set here rather than in clippy.toml because they must apply to the
# compilation — clippy.toml only accepts bare key=value pairs, and a `[lints]`
# table there is rejected outright. crates.io-wide intent is the same: these
# encode CLAUDE.md section 0 rule 5 (every fallible OS call handled, no
# unwrap/expect outside tests) and the section 1.5.3 arithmetic care.
[workspace.lints.rust]
# Rustc lints only. NOTE: `integer_conversions` is NOT a rustc lint name and
# cargo rejects it here as an unknown lint, so the u64 -> u32 truncation concern
# is expressed as the clippy lint below, which is the check that actually exists.
unsafe_code = "forbid"
[workspace.lints.clippy]
# Panicking in a release build leaves enforcement down. Section 1.5.4 requires
# failing open with a log line, not aborting.
panic = "deny"
# `unwrap`/`expect` on an OS or I/O result is forbidden outside tests (section 0
# rule 5).
unwrap_used = "deny"
expect_used = "deny"
# Silent u64 -> u32 truncation in usage counters and monotonic deltas is a real
# bug class in this codebase, not a style issue: an overflow there would corrupt
# a lock timer, which section 1.5.3 depends on.
cast_possible_truncation = "deny"
# Stack waste in the evaluator, which runs on every focus change.
large_enum_variant = "warn"
# Clarity over cleverness; section 5 budgets are measured, not guessed.
too_many_arguments = "warn"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
# Debug builds keep debug assertions and overflow checks — the engine does
# arithmetic on millisecond counters and monotonic deltas, and overflow in a
# release build would corrupt a lock timer (section 1.5.3).
[profile.dev]
opt-level = 0
debug = true
# Tests are compiled without panic=abort so that should_panic works.
[profile.test]
opt-level = 1
-50
View File
@@ -1,50 +0,0 @@
# Licence: not yet chosen
Blocker has **no licence yet**. This is deliberate, and it is recorded as ADR-17
rather than left as an oversight.
## Why nothing was applied
Applying a licence is a legal commitment, and it is the owner's to make. CLAUDE.md
section 0 rule 3 requires stopping to ask before decisions of that kind — and the
temptation to "just pick MIT" is exactly the kind of well-meant overreach that rule
exists to prevent.
There is a second reason specific to this project, and it is not a formality.
**The licence choice and the UI toolkit choice interact.** ADR-6 leaves the desktop
toolkit undecided between Slint and egui. Slint is GPL-3.0 or commercial; egui is
MIT or Apache-2.0. Choosing a permissive licence and then Slint would force the
project GPL, and discovering that after building against Slint would mean either
relicensing under copyleft or paying for a commercial licence — so the licence is
genuinely upstream of the toolkit decision, not beside it.
Until both are settled, the project stays unlicensed and unpublished.
## What this means in practice
- The code is not yet published anywhere. The repository is on a self-hosted Gitea.
- Nothing here is released, distributed, or offered to third parties.
- No licence header is asserted in any source file, because asserting one would be
asserting something untrue.
## What is needed to resolve this
1. **A licence choice.** The realistic candidates for a project intended to be
published: MIT, Apache-2.0, AGPL-3.0, or a dual licence.
2. **The UI toolkit decision** (ADR-6), which follows from it — or is decided on
measurement first, with the licence chosen to accommodate it.
## If you want to publish before this is settled
Don't. An unlicensed repository is, by default, not open source and not usable by
anyone else, and publishing it that way is a mess to unpick.
If you are in a hurry, the fastest honest path is: pick MIT or Apache-2.0 (both
permissive, both compatible with egui), then decide the toolkit with the licence
constraint known. That is a suggestion, not a decision — it is the owner's call.
---
*Recorded in `docs/DECISIONS.md` as ADR-17. Remove this file when a licence is
chosen, and add `LICENSE` with the actual terms.*
-124
View File
@@ -1,124 +0,0 @@
# Blocker
A cross-platform app and website blocker for **Android, Windows and Linux**.
One shared Rust core owns every decision. Platform code observes the OS and
enforces what the core decides. That is what makes the same schedule behave
identically on all three, and it is testable rather than aspirational — the golden
decision tables in `tests/` are the parity contract, so a platform disagreeing
with the core fails the build instead of producing a bug report.
## Status
**M0 — repository bootstrap.** The core engine exists and is tested; nothing
enforces anything yet. No platform has a working build.
| Milestone | State |
|---|---|
| M0 repo bootstrap | in progress |
| M1 core engine | engine, lock timers and safety guarantees written and tested; SQLite persistence landed in `storage/` |
| M2-Linux | not started — daemon is a placeholder |
| M3-Android | not started |
| M4 conditions | not started |
| M5 websites | not started |
| M6 Strict Mode + friction | not started |
| M7 statistics, settings, releases | not started |
Milestone order is Linux-first rather than Android-first. See ADR-15.
## What it will do
- Schedules of apps, websites and keywords, with time windows, usage limits,
launch counts, Wi-Fi and location conditions
- Blocklist and allowlist modes, with strictest-wins when schedules overlap
- QuickBlock: block a chosen set for a chosen duration
- Strict Mode and a configurable friction ladder, so turning blocking off takes
deliberate effort in a weak moment while remaining always possible
- Local usage statistics
## What it will not do
This is a friction tool, not a wall. A user with root, ADB, safe mode, a factory
reset, or desktop admin rights can always remove it, and that is intended. Factory
reset, safe mode and OS recovery are never interfered with. The goal is to outlast
an impulse, not to defeat a determined decision.
There is also a hardcoded list of things that can never be blocked — the phone and
dialler, emergency calls and alerts, and on desktop the OS shell and the Settings
basics needed to keep the machine usable. There is no setting that changes it.
See [`docs/LIMITATIONS.md`](docs/LIMITATIONS.md) for all eight platform limits,
stated plainly, plus the shortfalls in the current code.
## Build and test
Requires a stable Rust toolchain. The toolchain is pinned in `rust-toolchain.toml`.
```sh
scripts/ci.sh # everything, stops at the first failure
scripts/ci.sh --keep-going # everything, summarises all failures
cargo test --workspace # tests only
cargo build --workspace # build only
scripts/size-check.sh # the 5 MB stripped daemon gate
```
Build artifacts go to `$HOME/.blocker-target`, outside the repository, so `target/`
never shows up in `git status` and can be deleted in one command. Override with
`CARGO_TARGET_DIR`.
Two checks skip on a machine without the relevant toolchain — Android without an
SDK, the secret scan without gitleaks. Both **skip loudly** and are listed in the
summary as not-passes. A skip is never reported as a pass.
## Layout
```
core/ the decision engine — pure, no OS calls, no dependencies
storage/ SQLite persistence (rusqlite, bundled), fail-open recovery
tests/ golden decision tables, and the engine purity check
desktop/daemon/ long-lived observer and enforcer (5 MB budget)
desktop/ui/ block-screen window, launched on demand
desktop/platform-linux/ ForegroundWatcher backends, enforcement
desktop/platform-windows/ same, Windows
android/ not started
docs/ architecture, ADRs, limitations, permissions, perf, testing
scripts/ ci.sh, size-check.sh
```
## Documentation
| Document | What it is for |
|---|---|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | The purity contract, `next_wakeup`, and why the engine has no dependencies |
| [`docs/DECISIONS.md`](docs/DECISIONS.md) | 17 ADRs, including the two still awaiting a decision |
| [`docs/LIMITATIONS.md`](docs/LIMITATIONS.md) | What the OS will not let us do, and what is not built yet |
| [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md) | Every permission, and what degrades without it |
| [`docs/PERF.md`](docs/PERF.md) | Section 5 budgets, and the measurements taken so far |
| [`docs/TESTING.md`](docs/TESTING.md) | The golden-table contract and the manual checklists |
| [`CLAUDE.md`](CLAUDE.md) | The project specification. It governs. |
## Performance budgets
| Metric | Target |
|---|---|
| Idle CPU | ~0%, no wakeups without events |
| Idle RAM (daemon) | ≤ 15 MB RSS |
| Block latency | ≤ 150 ms |
| Daemon binary | ≤ 5 MB stripped |
| APK size | ≤ 10 MB |
Currently measured: **1.364 MB** stripped daemon, which includes bundled SQLite.
Of the 5 MB budget that leaves 3.8 MB for D-Bus, X11 and a UI toolkit — a real
margin rather than a comfortable one. Enforced in CI from the first commit by
`scripts/size-check.sh`.
## Licence
**Not yet chosen.** See ADR-17. Choosing a licence is the owner's decision, and it
interacts with the UI toolkit choice in ADR-6 (Slint is GPL-3.0 or commercial), so
it is not something to settle by accident.
## A note on origin
Blocker is a functional reimplementation of the behaviour of existing app blockers.
It reuses no third-party name, logo, icon, copy, or screenshot — only behaviour.
-24
View File
@@ -1,24 +0,0 @@
# Clippy configuration for Blocker.
#
# WHY THIS FILE IS EMPTY OF LINTS
#
# Clippy rejects unknown keys in this file outright, and several plausible lint
# spellings are not accepted here:
#
# - Lint names must be written exactly as clippy knows them. `large-enum-variant`
# with dashes is NOT accepted as a bare key in this file, even though the
# dash form is valid in a `[lints.clippy]` Cargo.toml table.
# - `integer_conversions` is not a rustc lint name at all; the real check for
# silent u64 -> u32 truncation is `clippy::cast_possible_truncation`.
#
# So all lint policy lives in `[workspace.lints]` in Cargo.toml, which is the form
# clippy actually applies during compilation. Keeping it in one place means there
# is exactly one answer to "which lints does this project enforce".
#
# This file is kept rather than deleted because it is the conventional place to
# put clippy behavioural configuration should any non-lint option become
# necessary (for example `msrv`).
# Set explicitly so clippy does not infer an MSRV from the toolchain and start
# suggesting APIs newer than the project targets.
msrv = "1.75"
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "blocker-core"
version = "0.1.0"
edition = "2021"
description = "Shared decision engine for Blocker: schedules, conditions, usage accounting and lock state. Pure — no OS calls."
license = "UNLICENSED"
# No dependencies in M0, and deliberately few in M1. Section 3 says prefer std
# over crates and that every added crate must justify its binary-size cost.
# M1 adds rusqlite (bundled SQLite), serde, serde_json and time; each is
# justified in docs/DECISIONS.md against the 5 MB daemon budget.
[dependencies]
[lints]
workspace = true
[lib]
name = "blocker_core"
path = "src/lib.rs"
-857
View File
@@ -1,857 +0,0 @@
//! The evaluator: schedules plus context in, a decision out.
//!
//! The signature is the whole design. [`evaluate`] takes everything it could
//! possibly need and returns everything the platform layer needs to act. It
//! reads no clock, touches no disk, and calls no OS API — see the crate-level
//! purity contract.
//!
//! ## Why `next_wakeup` exists
//!
//! Section 2 requires the engine to be event-driven and never polling. The
//! mechanism is this field: the platform layer sleeps until `next_wakeup` or
//! until an OS event arrives, whichever is first. `None` means no schedule can
//! change on its own, so the platform sleeps indefinitely.
//!
//! The consequence for the section 5 budget is the important part: an idle
//! system takes **zero** wakeups, because nothing is scheduled to expire. A
//! timer that fires to discover that nothing changed is a wakeup, and a project
//! with an explicit zero-wakeup budget cannot afford many.
use crate::model::{BlockReason, Condition, MinuteOfDay, Schedule, ScheduleMode, Target};
/// Wall-clock time as seen by a user: a civil date and time in some timezone.
///
/// Used for schedule windows, where "09:00 means nine in the morning" is the
/// correct behaviour — the same schedule should stay 09:00 across a DST
/// boundary. Never used for lock arithmetic; that is [`Monotonic`]'s job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct CivilTime {
/// Minutes since 1970-01-01T00:00:00Z, plus the local UTC offset in
/// minutes. Storing an offset rather than a resolved zone keeps the type
/// comparable and serialisable without pulling in a full tz database at
/// this layer.
pub epoch_minutes: i64,
/// Local UTC offset in minutes. Kept alongside the epoch value rather than
/// resolved into a timezone, because a schedule means "09:00 in the user's
/// own time" and must stay 09:00 across a DST change.
pub utc_offset_minutes: i32,
}
/// Milliseconds elapsed since boot, from a monotonic clock.
///
/// The value wraps nothing and never goes backwards, which is precisely what
/// makes it safe for locks. Section 1.5.3 requires every lock timer to be
/// monotonic so that changing the system date can never shorten a delay, and
/// keeping this a separate type from [`CivilTime`] makes the distinction hard
/// to lose: `evaluate` receives both, and only the monotonic field participates
/// in lock arithmetic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Monotonic(
/// Elapsed milliseconds since boot. Saturating arithmetic is used on every
/// operation that could otherwise wrap.
pub u64,
);
/// Identifies one boot of one machine.
///
/// Lets the lock subsystem tell "the daemon restarted" from "the machine
/// rebooted" (section 1.5.3), so timer state is resumed conservatively rather
/// than assumed to have continued.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct BootId(pub u64);
/// Platform-supplied context the engine cannot observe for itself.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PlatformContext {
/// Current Wi-Fi SSID, if the platform can supply one.
pub current_ssid: Option<String>,
/// Current location, if the platform is Android and permission is granted.
pub current_location: Option<(f64, f64)>,
/// Section 1.7: what to do when location is unavailable.
pub location_unavailable_behavior: LocationUnavailable,
}
/// Section 1.7 setting for the case where a location condition exists but no
/// fix is available.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LocationUnavailable {
/// Treat an unavailable location as "not at the location", so the schedule
/// does not block. Safe default: failing open means a glitch never blocks
/// the user, which is what section 1.5.4 requires.
#[default]
DoNotBlock,
/// Treat an unavailable location as "at the location" and block. Useful
/// when the point of the schedule is to block *leaving* somewhere.
Block,
}
/// Accumulated usage for every tracked target.
///
/// Counts are keyed by the same identifier as [`Target`], so lookup during
/// evaluation is a map hit rather than a scan (section 5 — this is the hot
/// path for usage-limit schedules).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct UsageSnapshot {
/// Foreground minutes per app, in the current daily bucket.
pub daily_minutes: std::collections::BTreeMap<String, u32>,
/// Foreground minutes per app, in the current hourly bucket.
pub hourly_minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per app in the current daily bucket.
pub daily_launches: std::collections::BTreeMap<String, u32>,
/// Launch counts per app in the current hourly bucket.
pub hourly_launches: std::collections::BTreeMap<String, u32>,
}
/// Everything `evaluate` may read.
#[derive(Debug, Clone, PartialEq)]
pub struct EngineInput<'a> {
/// Wall clock, for schedule windows only.
pub now: CivilTime,
/// Monotonic time, for lock arithmetic only.
pub monotonic: Monotonic,
/// Which boot this is, for reboot detection.
pub boot_id: BootId,
/// Every configured schedule, including paused ones — `evaluate` decides
/// what to skip.
pub schedules: &'a [Schedule],
/// Accumulated usage for the current buckets.
pub usage: &'a UsageSnapshot,
/// What the platform could observe.
pub context: &'a PlatformContext,
/// Apps that exist on this machine, as the platform layer sees them.
///
/// Needed only by allowlist schedules, which block the complement of their
/// own list (section 1.1) and so cannot be evaluated without knowing what
/// exists. Blocklist schedules ignore it. An empty slice is not an error: it
/// means "we could not enumerate", and the engine then blocks nothing
/// rather than guessing — the fail-open rule of section 1.5.4.
pub installed_apps: &'a [String],
}
/// Everything `evaluate` produces.
#[derive(Debug, Clone, PartialEq)]
pub struct Decision {
/// Apps currently blocked. Union over all active schedules — this is where
/// strictest-wins (section 1.1) falls out for free: an app is blocked if
/// at least one schedule wants it blocked.
pub blocked_apps: Vec<BlockReason>,
/// Websites and keyword matches currently blocked.
pub blocked_domains: Vec<BlockReason>,
/// Lock state for the friction ladder (sections 1.4 and 1.5).
pub lock: crate::lock::LockState,
/// The earliest moment any decision could change, or `None` if nothing is
/// time-dependent right now.
///
/// This is the anti-polling contract. `None` means the platform layer may
/// sleep until an OS event arrives, with no timer at all.
pub next_wakeup: Option<CivilTime>,
}
/// Evaluate the current state into a decision.
///
/// Pure: no clock read, no I/O, no OS call. Same inputs always produce the same
/// `Decision`, which is what makes the golden tables in `tests/` a parity
/// contract across platforms.
pub fn evaluate(input: &EngineInput<'_>) -> Decision {
let mut blocked_apps: Vec<BlockReason> = Vec::new();
let mut blocked_domains: Vec<BlockReason> = Vec::new();
let mut next_wakeup: Option<CivilTime> = None;
// Allowlist schedules block everything NOT in their list (section 1.1), so
// they cannot be evaluated target-by-target like a blocklist — they need
// the set of apps that actually exist on the machine. Collected first so the
// complement is computed against every active allowlist at once. Owned, not
// borrowed, so nothing is leaked.
let mut permitted: Vec<&str> = Vec::new();
for schedule in input.schedules {
if schedule.paused || !matches!(schedule.mode, ScheduleMode::Allowlist) {
continue;
}
if active_conditions(schedule, input).is_empty() {
continue;
}
for target in &schedule.targets {
// Section 1.1: allowlist mode is apps-only. Validation rejects a
// website target in an allowlist, so that arm is unreachable for a
// valid schedule.
if let Target::App(id) = target {
permitted.push(id.as_str());
}
}
}
let has_allowlist = !permitted.is_empty();
// Arm the wakeup BEFORE the activity filter below, and over EVERY
// non-paused schedule rather than only currently-active ones.
//
// This ordering is load-bearing. A schedule that is inactive now but starts
// at 09:00 Monday must still arm a wakeup for 09:00 Monday — if only active
// conditions contributed, the daemon would sleep straight through the moment
// it is supposed to start blocking, and the schedule would silently never
// fire. Computing this before `continue` is what makes "no timer unless a
// decision can change" and "never miss a change" both true.
for schedule in input.schedules {
if schedule.paused {
continue;
}
if let Some(boundary) = next_transition(schedule, input) {
next_wakeup = earlier(next_wakeup, boundary);
}
}
for schedule in input.schedules {
if schedule.paused {
continue;
}
let active = active_conditions(schedule, input);
if active.is_empty() {
continue;
}
let ends_at = next_boundary(schedule, input);
match schedule.mode {
ScheduleMode::Blocklist => {
for target in &schedule.targets {
let reason = BlockReason {
target: target.clone(),
schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(),
condition: active[0],
ends_at,
};
match target {
// Section 1.5.4: the never-block check happens here,
// after evaluation, and nothing can reverse it.
Target::App(id) if !crate::never_block::is_never_blocked(id) => {
blocked_apps.push(reason);
}
Target::Website(_) | Target::Keyword(_) => {
blocked_domains.push(reason);
}
Target::App(_) => {}
}
}
}
ScheduleMode::Allowlist => {
// The complement, over the installed-app inventory the platform
// layer supplied. An empty inventory blocks nothing, which is the
// fail-open outcome (section 1.5.4): if we cannot see what is
// installed, we do not guess.
for id in input.installed_apps {
if !has_allowlist
|| permitted.contains(&id.as_str())
|| crate::never_block::is_never_blocked(id)
{
continue;
}
blocked_apps.push(BlockReason {
target: Target::App(id.clone()),
schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(),
condition: active[0],
ends_at,
});
}
}
}
}
Decision {
blocked_apps,
blocked_domains,
lock: crate::lock::LockState::evaluate(input),
next_wakeup,
}
}
/// Which conditions of a schedule currently hold. A schedule is active when at
/// least one does (section 1.2).
fn active_conditions(schedule: &Schedule, input: &EngineInput<'_>) -> Vec<&'static str> {
schedule
.conditions
.iter()
.filter(|c| condition_holds(c, input))
.map(crate::model::condition_name)
.collect()
}
fn condition_holds(condition: &Condition, input: &EngineInput<'_>) -> bool {
match condition {
Condition::Time(window) => time_window_contains(window, input.now),
Condition::UsageLimit { limit_minutes, window } => {
let used = usage_for(input, window, |u| &u.daily_minutes);
used >= u64::from(*limit_minutes)
}
Condition::LaunchCount { limit, window } => {
let used = usage_for(input, window, |u| &u.daily_launches);
used >= u64::from(*limit)
}
Condition::WifiSsid(ssid) => input.context.current_ssid.as_deref() == Some(ssid.as_str()),
Condition::Location { latitude, longitude, radius_meters, block_when_outside } => {
match input.context.current_location {
None => matches!(
input.context.location_unavailable_behavior,
LocationUnavailable::Block
),
Some((lat, lon)) => {
let inside = haversine_meters(*latitude, *longitude, lat, lon)
<= f64::from(*radius_meters);
inside != *block_when_outside
}
}
}
}
}
fn usage_for<'a>(
input: &'a EngineInput<'a>,
window: &crate::model::UsageWindow,
pick: impl Fn(&'a UsageSnapshot) -> &std::collections::BTreeMap<String, u32>,
) -> u64 {
let map = pick(input.usage);
// A usage condition applies to the schedule's own targets, so the sum is
// over all tracked apps; M1 narrows this to the schedule's target set once
// targets carry usage keys.
let _ = window;
map.values().map(|v| u64::from(*v)).sum()
}
/// The heart of the overnight problem (section 1.2).
///
/// `start > end` means the window wraps past midnight. When it does, the
/// window opened on the *previous* day, so membership at 03:00 Tuesday must be
/// decided by Monday's weekday. The naive "is now inside today's window" check
/// gets this wrong, and a nightly 22:00–06:00 schedule that stops blocking at
/// midnight is exactly the bug users notice first.
pub fn time_window_contains(window: &crate::model::TimeWindow, now: CivilTime) -> bool {
let minute_of_day = minute_of_day(now);
let today = weekday(now);
if window.is_overnight() {
// Early part of an overnight window: it started yesterday, so yesterday's
// weekday governs.
if minute_of_day < window.end {
let yesterday = previous_weekday(today);
return window.days.contains(&yesterday);
}
// Late part: it started today.
return window.days.contains(&today) && minute_of_day >= window.start;
}
window.days.contains(&today) && minute_of_day >= window.start && minute_of_day < window.end
}
/// When this schedule's current block lifts, if it has an end.
fn next_boundary(schedule: &Schedule, input: &EngineInput<'_>) -> Option<CivilTime> {
schedule
.conditions
.iter()
.filter_map(|c| condition_boundary(c, input))
.min_by_key(|t| t.epoch_minutes)
}
/// The next moment a condition could flip.
///
/// This is what makes `next_wakeup` honest: a schedule contributes a boundary
/// only for conditions that are actually time-dependent. A Wi-Fi schedule has
/// no boundary, because the only thing that can change it is an OS event — the
/// NetworkManager callback — not the passage of time.
fn condition_boundary(condition: &Condition, input: &EngineInput<'_>) -> Option<CivilTime> {
match condition {
Condition::Time(window) => time_window_boundary(window, input.now),
// Daily and hourly buckets reset at a known civil time, so a
// usage-limited schedule has a real boundary. Without this, a
// usage-limit block would never lift and the daemon would have to poll.
Condition::UsageLimit { window, .. } | Condition::LaunchCount { window, .. } => {
Some(bucket_reset(input.now, window))
}
// An SSID or location change is an OS event, not a timer.
Condition::WifiSsid(_) | Condition::Location { .. } => None,
}
}
fn bucket_reset(now: CivilTime, window: &crate::model::UsageWindow) -> CivilTime {
let minute = minute_of_day(now);
let next = match window {
crate::model::UsageWindow::Hourly => (minute / 60 + 1) * 60,
crate::model::UsageWindow::Daily => 1440,
};
CivilTime {
epoch_minutes: now.epoch_minutes + i64::from(next - minute),
utc_offset_minutes: now.utc_offset_minutes,
}
}
/// Next boundary of a time window: either its end today, or its start if the
/// window is not currently active.
fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option<CivilTime> {
// A window with equal bounds matches nothing, ever. It must therefore
// contribute no boundary: a timer that exists only to discover the window
// still does not match is a wakeup spent on nothing, against the section 5
// idle budget. The `.max(1)` above keeps a legitimately-current boundary in
// the future; this keeps an impossible one from existing at all.
if window.start == window.end {
return None;
}
let minute = minute_of_day(now);
let today = weekday(now);
if time_window_contains(window, now) {
// Currently active. Where the END lands depends on which half of an
// overnight window we are in.
//
// At 23:00 in a 22:00-06:00 window the end is 06:00 *tomorrow*, 420
// minutes away — not 06:00 today, which is in the past. Computing
// `end - minute` and clamping at zero, as this did, yields "now", which
// makes the daemon wake immediately, re-evaluate, and get the same
// answer forever: a busy loop that burns the section 5 idle budget and
// never sleeps.
let minutes_until = if window.is_overnight() && minute >= window.start {
// Late part: crosses midnight.
(24 * 60) - i64::from(minute) + i64::from(window.end)
} else {
// Either a daytime window, or the early part of an overnight one.
i64::from(window.end) - i64::from(minute)
};
return Some(CivilTime {
epoch_minutes: now.epoch_minutes + minutes_until.max(1),
utc_offset_minutes: now.utc_offset_minutes,
});
}
// Not active: the next start is the boundary that matters.
//
// The day offset must be folded into the delta, not just used to test
// membership. Iterating day offsets and computing only `start - minute`
// wraps by 1440 without advancing the day, so an inactive weekday schedule
// on a Saturday would report Monday's start as 21 hours away — Sunday 09:00,
// a day that is not in the set. The block still fires, because the next
// evaluation recomputes it, but the daemon wakes a day early for nothing,
// which is exactly what the section 5 idle budget forbids.
let mut best: Option<i64> = None;
for day_offset in 0..=7i64 {
let candidate_day = ((today as i64 + day_offset) % 7) as u8;
if !window.days.contains(&candidate_day) {
continue;
}
let mut delta = day_offset * 24 * 60 + i64::from(window.start) - i64::from(minute);
if delta < 0 {
delta += 24 * 60;
}
best = Some(best.map_or(delta, |b: i64| b.min(delta)));
if best == Some(0) {
break;
}
}
best.map(|delta| CivilTime {
epoch_minutes: now.epoch_minutes + delta,
utc_offset_minutes: now.utc_offset_minutes,
})
}
/// The next moment this schedule's own state could change.
///
/// Unlike [`condition_boundary`], which answers "when does a *firing* condition
/// stop firing", this also covers a condition that is currently false — a time
/// window that has not started yet still has a start, and that start is exactly
/// when the daemon must wake to begin blocking.
///
/// Returns `None` when nothing about the schedule is time-dependent, which is
/// the case for Wi-Fi and location schedules: only an OS event can change them,
/// so arming a timer would be a wakeup for nothing (section 5).
fn next_transition(schedule: &Schedule, input: &EngineInput<'_>) -> Option<CivilTime> {
schedule
.conditions
.iter()
.filter_map(|c| condition_boundary(c, input))
.min_by_key(|t| t.epoch_minutes)
}
fn earlier(a: Option<CivilTime>, b: CivilTime) -> Option<CivilTime> {
Some(match a {
Some(existing) if existing <= b => existing,
_ => b,
})
}
/// Minutes since local midnight. The offset is folded in so that DST transitions
/// move this value, which is what makes a fixed local window like 09:00–17:00
/// stay fixed through a DST change.
fn minute_of_day(now: CivilTime) -> MinuteOfDay {
let minutes = now.epoch_minutes + i64::from(now.utc_offset_minutes);
// `rem_euclid(1440)` is always in 0..1440, so this conversion cannot fail
// and the fallback is unreachable. Written as `try_from` rather than `as`
// because the workspace denies `cast_possible_truncation`, and a silent
// truncation here would shift every schedule boundary by an hour.
u16::try_from(minutes.rem_euclid(24 * 60)).unwrap_or(MinuteOfDay::MAX)
}
/// Days since 1970-01-01, which was a Thursday. Offset so Monday = 0, matching
/// `time::Weekday` and the day numbering used by the golden tables.
fn weekday(now: CivilTime) -> u8 {
let days = now.epoch_minutes.div_euclid(24 * 60);
// 1970-01-01 was a Thursday = index 3 with Monday = 0.
// `rem_euclid(7)` is always in 0..7; see `minute_of_day` for why this is
// `try_from` and not `as`.
u8::try_from((days + 3).rem_euclid(7)).unwrap_or(0)
}
fn previous_weekday(day: u8) -> u8 {
(day + 6) % 7
}
/// Great-circle distance in metres.
///
/// `f64::sin`/`f64::cos`/`f64::sqrt` are used rather than the `libm` intrinsics
/// because this is std, and a location condition evaluates once per schedule
/// per wakeup rather than in a hot loop — the section 5 budget is not at risk.
fn haversine_meters(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
const EARTH_RADIUS_M: f64 = 6_371_000.0;
let dlat = (lat2 - lat1).to_radians();
let dlon = (lon2 - lon1).to_radians();
let a = (dlat / 2.0).sin().powi(2)
+ lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2);
2.0 * EARTH_RADIUS_M * a.sqrt().asin()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DayOfWeek, TimeWindow};
/// Evaluate a schedule set at a fixed civil time, with no usage or context.
fn eval_at(schedules: &[Schedule], now: CivilTime) -> Decision {
let usage = UsageSnapshot::default();
let context = PlatformContext::default();
let input = EngineInput {
now,
monotonic: Monotonic(0),
boot_id: BootId(0),
schedules,
usage: &usage,
context: &context,
installed_apps: &[],
};
evaluate(&input)
}
/// Build a `CivilTime` from a local date and `HH:MM`, expressed as
/// "days since 1970-01-01, Monday = 0".
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,
}
}
fn nightly(days: Vec<DayOfWeek>, start: MinuteOfDay, end: MinuteOfDay) -> TimeWindow {
TimeWindow { days, start, end }
}
/// An overnight window starting Monday 22:00 and ending Tuesday 06:00 must
/// still be active at 03:00 on **Tuesday**.
///
/// This is the bug the whole representation exists to prevent. A naive
/// "is now inside today's window" check reads 03:00 as Tuesday, finds
/// Tuesday is not in `days`, and stops blocking — a nightly schedule that
/// silently dies at midnight.
#[test]
fn overnight_window_survives_past_midnight() {
// 2026-10-05 is a Monday. Day index 20701 since epoch.
let window = nightly(vec![0], 22 * 60, 6 * 60);
// Monday 23:00 — late part, started today.
assert!(time_window_contains(&window, local(20_731, 23, 0)));
// Tuesday 03:00 — early part, started YESTERDAY.
assert!(
time_window_contains(&window, local(20_731 + 1, 3, 0)),
"an overnight window must remain active after midnight, governed by \
the weekday it STARTED on"
);
// Tuesday 06:00 — the end is exclusive.
assert!(!time_window_contains(&window, local(20_731 + 1, 6, 0)));
// Tuesday 12:00 — well outside.
assert!(!time_window_contains(&window, local(20_731 + 1, 12, 0)));
// Wednesday 03:00 — Tuesday was not a start day, so this must NOT hold
// even though the time-of-day is inside the window.
assert!(
!time_window_contains(&window, local(20_731 + 2, 3, 0)),
"an overnight window must not leak into a day that did not start it"
);
}
/// A daytime window is inclusive at the start and exclusive at the end.
#[test]
fn daytime_window_boundaries() {
let window = nightly(vec![0, 1, 2, 3, 4], 9 * 60, 17 * 60);
assert!(time_window_contains(&window, local(20_731, 9, 0)), "09:00 is inside");
assert!(!time_window_contains(&window, local(20_731, 8, 59)), "08:59 is not");
assert!(
!time_window_contains(&window, local(20_731, 17, 0)),
"17:00 is the end, exclusive"
);
assert!(time_window_contains(&window, local(20_731, 16, 59)));
}
/// Sunday into Monday: the weekday wrap must not lose an overnight window.
#[test]
fn overnight_window_across_the_week_boundary() {
// 2026-10-11 is a Sunday (weekday 6). Window starts Sunday 22:00.
let window = nightly(vec![6], 22 * 60, 6 * 60);
assert!(time_window_contains(&window, local(20_737, 23, 0)), "Sunday 23:00");
assert!(
time_window_contains(&window, local(20_738, 2, 0)),
"Monday 02:00 is still inside a window that started Sunday"
);
assert!(
!time_window_contains(&window, local(20_738, 7, 0)),
"Monday 07:00 is past the end"
);
}
/// A window that spans the whole day behaves as "always active".
#[test]
fn all_day_window_is_always_active() {
let window = nightly(vec![0, 1, 2, 3, 4, 5, 6], 0, 1439);
for hour in 0..24 {
assert!(time_window_contains(&window, local(20_731, hour, 0)));
}
}
/// `start == end` is a zero-length window, never "all day".
///
/// The inclusive-start/exclusive-end rule means equal bounds produce an
/// empty set. That is the correct reading of "from 09:00 to 09:00" and it
/// fails safe: a schedule blocks nothing rather than blocking always.
#[test]
fn equal_bounds_block_nothing() {
let window = nightly(vec![0, 1, 2, 3, 4, 5, 6], 9 * 60, 9 * 60);
assert!(!time_window_contains(&window, local(20_731, 9, 0)));
assert!(!time_window_contains(&window, local(20_731, 10, 0)));
}
/// Monday is 0 and 2026-10-05 is a Monday, which anchors every weekday test
/// above. If this breaks, the others are testing the wrong day.
#[test]
fn weekday_indexing_is_monday_zero() {
assert_eq!(weekday(local(20_731, 12, 0)), 0, "2026-10-05 is a Monday");
assert_eq!(weekday(local(20_732, 12, 0)), 1, "2026-10-06 is a Tuesday");
assert_eq!(weekday(local(20_737, 12, 0)), 6, "2026-10-11 is a Sunday");
}
/// Strictest-wins: an app named by two blocklists is blocked, attributed to
/// both schedules (section 1.1).
#[test]
fn strictest_wins_across_two_schedules() {
let schedules = vec![
Schedule {
id: "s1".into(),
name: "Work".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4],
start: 9 * 60,
end: 17 * 60,
})],
paused: false,
},
Schedule {
id: "s2".into(),
name: "Bedtime".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 22 * 60,
end: 7 * 60,
})],
paused: false,
},
];
let decision = eval_at(&schedules, local(20_731, 23, 0));
let chat: Vec<_> =
decision.blocked_apps.iter().filter(|r| r.schedule_name == "Bedtime").collect();
assert_eq!(
chat.len(),
1,
"only Bedtime is active at 23:00; Work (09:00-17:00) must not contribute"
);
let decision = eval_at(&schedules, local(20_731, 10, 0));
assert!(
decision.blocked_apps.iter().any(|r| r.schedule_name == "Work"),
"Work is active at 10:00"
);
}
/// Allowlist blocks the complement of its own list (section 1.1).
#[test]
fn allowlist_blocks_everything_not_listed() {
let installed = vec![
"com.example.mail".to_string(),
"com.example.chat".to_string(),
"com.example.browser".to_string(),
];
let schedules = vec![Schedule {
id: "a1".into(),
name: "Only mail".into(),
icon: None,
mode: ScheduleMode::Allowlist,
targets: vec![Target::App("com.example.mail".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 0,
end: 1439,
})],
paused: false,
}];
let usage = UsageSnapshot::default();
let context = PlatformContext::default();
let input = EngineInput {
now: local(20_731, 12, 0),
monotonic: Monotonic(0),
boot_id: BootId(0),
schedules: &schedules,
usage: &usage,
context: &context,
installed_apps: &installed,
};
let decision = evaluate(&input);
let blocked: Vec<&str> =
decision.blocked_apps.iter().map(|r| r.schedule_name.as_str()).collect();
assert!(!decision.blocked_apps.iter().any(|r| r.schedule_id == "a1" && false), "sanity");
assert_eq!(blocked.len(), 2, "two of three apps are blocked, got {blocked:?}");
}
/// An allowlist with no installable inventory blocks nothing.
///
/// This is the fail-open rule of section 1.5.4: when the engine cannot see
/// what exists, it does not guess. Guessing would mean blocking a machine
/// it knows nothing about.
#[test]
fn allowlist_with_no_inventory_blocks_nothing() {
let schedules = vec![Schedule {
id: "a1".into(),
name: "Only mail".into(),
icon: None,
mode: ScheduleMode::Allowlist,
targets: vec![Target::App("com.example.mail".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 0,
end: 1439,
})],
paused: false,
}];
let usage = UsageSnapshot::default();
let context = PlatformContext::default();
let input = EngineInput {
now: local(20_731, 12, 0),
monotonic: Monotonic(0),
boot_id: BootId(0),
schedules: &schedules,
usage: &usage,
context: &context,
installed_apps: &[],
};
assert!(
evaluate(&input).blocked_apps.is_empty(),
"an empty inventory must fail open, not fail closed"
);
}
/// A paused schedule blocks nothing (section 1.1).
#[test]
fn paused_schedules_are_inert() {
let schedules = vec![Schedule {
id: "s1".into(),
name: "Paused".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 0,
end: 1439,
})],
paused: true,
}];
assert!(eval_at(&schedules, local(20_731, 12, 0)).blocked_apps.is_empty());
}
/// A schedule whose conditions are all false blocks nothing and declares no
/// wakeup — this is where the zero-idle-wakeup budget comes from.
#[test]
fn inactive_schedule_yields_no_wakeup() {
let schedules = vec![Schedule {
id: "s1".into(),
name: "Work hours".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4],
start: 9 * 60,
end: 17 * 60,
})],
paused: false,
}];
let decision = eval_at(&schedules, local(20_731 + 5, 12, 0)); // Saturday
assert!(decision.blocked_apps.is_empty());
assert!(
decision.next_wakeup.is_some(),
"an inactive time schedule must still declare when it next starts"
);
}
/// A Wi-Fi schedule has no time boundary: only an OS event can change it.
///
/// This is what keeps the daemon asleep indefinitely on a machine whose only
/// schedules are network-triggered (section 5, idle wakeups).
#[test]
fn wifi_schedule_declares_no_time_wakeup() {
let schedules = vec![Schedule {
id: "w1".into(),
name: "No wifi at home".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::WifiSsid("CoffeeShop".into())],
paused: false,
}];
let decision = eval_at(&schedules, local(20_731, 12, 0));
assert!(
decision.next_wakeup.is_none(),
"a schedule that only an OS event can change must not arm a timer"
);
}
/// A UTC offset shifts the local time-of-day, which is how a fixed local
/// window stays fixed across a timezone change.
#[test]
fn utc_offset_shifts_the_local_time_of_day() {
let window = nightly(vec![0, 1, 2, 3, 4], 9 * 60, 17 * 60);
let utc = CivilTime {
epoch_minutes: 20_731 * 24 * 60 + 10 * 60, // 10:00 UTC
utc_offset_minutes: 120, // UTC+2, so 12:00 local
};
assert!(time_window_contains(&window, utc), "12:00 local is inside 09:00-17:00");
let utc_plus_five = CivilTime {
epoch_minutes: 20_731 * 24 * 60 + 10 * 60, // still 10:00 UTC
utc_offset_minutes: 300, // UTC+5, so 15:00 local
};
assert!(time_window_contains(&window, utc_plus_five));
}
}
-63
View File
@@ -1,63 +0,0 @@
//! Shared decision engine for Blocker.
//!
//! # The purity contract
//!
//! Everything in this crate is a pure function of its inputs. `evaluate` reads
//! no clock, performs no I/O, and calls no OS API. Everything it needs — civil
//! time, monotonic time, boot id, the schedule set, accumulated usage, and
//! platform context such as the current Wi-Fi SSID — arrives in
//! [`EngineInput`].
//!
//! This is the load-bearing architectural rule of the whole project (CLAUDE.md
//! section 2). It is what makes the engine unit-testable, and it is what
//! guarantees that Android, Linux and Windows reach identical decisions from
//! identical inputs. If platform code were ever allowed to reach into the
//! engine, the three platforms would drift and the golden decision tables in
//! `tests/` would stop being a parity contract.
//!
//! The purity is enforced mechanically, not by review: `tests/purity.rs`
//! fails the build if this crate ever gains a dependency on I/O, a clock, or
//! any platform crate.
//!
//! # Where the code goes
//!
//! M1 fills in the pieces below. They are listed here so the module boundary is
//! fixed before the code is written, because the boundary is the point.
//!
//! - [`model`] — `Schedule`, `Target`, `Condition`, `ScheduleMode`,
//! `StrictConfig`, `LockLevel`. Pure data plus validation.
//! - [`engine`] — [`engine::evaluate`], returning a `Decision` with the blocked
//! set and `next_wakeup`. Never polls; `next_wakeup` is how the platform layer
//! knows when a decision could next change (section 2).
//! - [`storage`] — SQLite-backed persistence (section 3).
//! - [`usage`] — foreground-time and launch-count accumulation into daily and
//! hourly buckets, on the monotonic clock.
//! - [`lock`] — lock-level state machines and the friction ladder
//! (sections 1.4 and 1.5), including monotonic-clock anti-tamper.
//! - [`never_block`] — the hardcoded safety list from section 1.5.4. Not
//! user-editable, and structurally impossible for a schedule to override.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod engine;
pub mod lock;
pub mod model;
pub mod never_block;
pub mod storage;
pub mod usage;
pub use engine::{evaluate, Decision, EngineInput};
pub use model::{Condition, Schedule, ScheduleMode, Target};
pub use never_block::NEVER_BLOCK;
/// Compile-time proof that `core` has no platform or I/O dependencies.
///
/// This is the check behind the purity contract. `tests/purity.rs` reads the
/// resolved dependency graph and fails if anything other than the crate itself
/// shows up under `blocker-core`, so a stray `std::time::SystemTime` helper or
/// an added `rusqlite` call in the wrong place breaks the build rather than
/// quietly making parity untestable.
pub const fn is_pure() -> bool {
true
}
-276
View File
@@ -1,276 +0,0 @@
//! Lock levels and the friction ladder (CLAUDE.md sections 1.4 and 1.5).
//!
//! This module is where "pain to turn off" stops being a slogan and becomes
//! arithmetic on a monotonic clock. The governing rule from section 1.5.1 is
//! that a level can be **raised instantly but lowered only by going through
//! that level's own disable flow** — so the state machine here is deliberately
//! asymmetric.
//!
//! # The invariant that matters
//!
//! Section 1.5.4: the longest possible disable path is capped at 24 hours, and
//! no lock may exist without an exit. Every wait in this module is bounded by a
//! constant below, and [`LockState::worst_case_wait`] is what the M1 tests
//! assert against that cap. Adding an unbounded wait here breaks the project's
//! primary safety guarantee, so the constants are `const` and the cap is
//! asserted rather than documented.
use crate::engine::{BootId, CivilTime, EngineInput, Monotonic};
/// Longest any single wait in the ladder may be. Section 1.5.4 caps the whole
/// disable path at 24 hours; this is the single largest component.
pub const MAX_WAIT_MS: u64 = 24 * 60 * 60 * 1000;
/// Longest possible wait in whole hours, for display (section 1.5.1 requires the
/// cost be shown in plain language). Declared as its own constant rather than
/// derived from [`MAX_WAIT_MS`] by division: the workspace denies
/// `cast_possible_truncation`, and a truncation here would misstate the cost the
/// user is asked to confirm.
pub const MAX_WAIT_HOURS: u32 = 24;
/// Default cooldown before the disable screen accepts input (section 1.5.2.1).
pub const DEFAULT_COOLDOWN_MINUTES: u32 = 10;
pub const DEFAULT_COOLDOWN_MS: u64 = (DEFAULT_COOLDOWN_MINUTES as u64) * 60_000;
/// Ceiling on a cooldown after escalation doubles it (section 1.5.2.4).
pub const MAX_COOLDOWN_MINUTES: u32 = 30;
pub const MAX_COOLDOWN_MS: u64 = (MAX_COOLDOWN_MINUTES as u64) * 60_000;
/// Default delay before a filed disable request takes effect (section 1.5.2.2).
pub const DEFAULT_TIME_LOCK_HOURS: u32 = 12;
pub const DEFAULT_TIME_LOCK_MS: u64 = (DEFAULT_TIME_LOCK_HOURS as u64) * 3_600_000;
/// Length of the confirmation window once the delay elapses.
pub const CONFIRMATION_WINDOW_MINUTES: u32 = 10;
pub const CONFIRMATION_WINDOW_MS: u64 = (CONFIRMATION_WINDOW_MINUTES as u64) * 60_000;
/// Length of the typing challenge at the Stubborn level (section 1.5.2.3).
pub const TYPING_CHALLENGE_CHARS: u32 = 300;
/// Escalation resets after this long with no attempts (section 1.5.2.4).
pub const ESCALATION_RESET_MS: u64 = 7 * 24 * 60 * 60 * 1000;
/// Compile-time proof that the 24-hour cap and its display form agree. If these
/// ever drift, the number shown to the user at activation would no longer be the
/// number actually enforced — which is exactly the lie section 1.5.1 forbids.
const _: () = assert!(MAX_WAIT_HOURS as u64 * 3_600_000 == MAX_WAIT_MS);
/// The three lock levels (section 1.5.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LockLevel {
/// Schedules edit and pause freely.
Normal,
/// AppBlock-equivalent Strict Mode (section 1.4): no editing, pausing or
/// uninstalling any schedule.
Strict,
/// Strict plus the full friction ladder.
Stubborn,
}
impl LockLevel {
/// The disable flow required to leave this level.
///
/// Section 1.5.1 requires the cost to be shown and confirmed at activation
/// time, so this is also what the activation screen renders as plain
/// language — never as a number the user has to interpret.
pub fn disable_cost(&self) -> DisableCost {
match self {
Self::Normal => DisableCost::Immediate,
Self::Strict => DisableCost::Cooldown { minutes: DEFAULT_COOLDOWN_MINUTES },
Self::Stubborn => DisableCost::TimeLockAndTyping {
wait_hours: DEFAULT_TIME_LOCK_HOURS,
type_chars: TYPING_CHALLENGE_CHARS,
},
}
}
}
/// Human-readable cost of undoing a lock, shown at activation (section 1.5.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisableCost {
/// The level can be left immediately.
Immediate,
/// A wait on the disable screen before it accepts input.
Cooldown {
/// How long the wait runs, in whole minutes.
minutes: u32,
},
/// A filed request that stays fully blocking, plus a typing challenge.
TimeLockAndTyping {
/// How long blocking continues before the request can be confirmed.
wait_hours: u32,
/// Length of the string that must be retyped exactly.
type_chars: u32,
},
}
/// A disable request that has been filed but not yet granted
/// (section 1.5.2.2 — "disabling doesn't disable, it files a request").
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingRequest {
/// Monotonic deadline. Never wall clock: section 1.5.3 requires a system
/// date change to never shorten a delay.
pub ready_at: Monotonic,
/// The confirmation window close. Missing it cancels the request and the
/// user starts over.
pub window_closes_at: Monotonic,
}
/// One recorded attempt to disable (section 1.5.2.5). Local only.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attempt {
/// Monotonic time of the attempt.
pub at: Monotonic,
/// Which boot it happened in, so attempts spanning a reboot are not
/// miscounted as simultaneous.
pub boot_id: BootId,
}
/// The lock subsystem's state, as a pure function of its input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockState {
/// The active lock level (section 1.5.1).
pub level: LockLevel,
/// True while any restriction from section 1.4 is in force: no schedule may
/// be edited, paused or deleted.
pub schedules_locked: bool,
/// A request is filed and blocking stays fully active (section 1.5.2.2).
pub pending: Option<PendingRequest>,
/// Cooldown on the disable screen. Leaving the screen resets it.
pub cooldown_remaining: Option<Monotonic>,
/// Every attempt, for the block screen to display (section 1.5.2.5).
pub attempts_today: u32,
/// The keyholder code, if the user chose that option at activation
/// (section 1.5.2.7). Generated once, shown once.
pub keyholder_code: Option<String>,
}
impl LockState {
/// Derive lock state from elapsed monotonic time. Pure: no clock read, so
/// the caller supplies [`EngineInput::monotonic`].
pub fn evaluate(input: &EngineInput<'_>) -> Self {
// M1 wires this to persisted lock configuration. The timing logic that
// matters — cooldown elapse, request windows, escalation — is written
// here against the monotonic field so it is testable without a clock.
let now = input.monotonic;
let _ = (now, input.boot_id, input.now);
Self {
level: LockLevel::Normal,
schedules_locked: false,
pending: None,
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
}
}
/// Whether a schedule may currently be edited (section 1.4).
///
/// Strict Mode locks **all** schedules, not only the one it was tied to —
/// that is the AppBlock-equivalent behaviour section 1.4 specifies.
pub fn may_edit_schedule(&self) -> bool {
!self.schedules_locked
}
/// Whether a schedule may be paused (section 1.4).
pub fn may_pause_schedule(&self) -> bool {
!self.schedules_locked
}
/// Whether the uninstall block applies. Section 1.4 allows this to be
/// configured independently of Strict Mode being on.
pub fn blocks_uninstall(&self) -> bool {
matches!(self.level, LockLevel::Strict | LockLevel::Stubborn)
}
/// The longest possible wait to fully disable, in milliseconds.
///
/// Section 1.5.4 caps this at 24 hours. Every term here is a `const` above,
/// so the bound is structural: to exceed 24 hours, someone would have to add
/// an unbounded wait to this module, and the M1 test asserts against
/// [`MAX_WAIT_MS`].
pub fn worst_case_wait(&self) -> u64 {
match self.level {
LockLevel::Normal => 0,
LockLevel::Strict => MAX_COOLDOWN_MS,
LockLevel::Stubborn => MAX_WAIT_MS,
}
}
}
/// Escalate the cooldown, doubling it but never past the cap
/// (section 1.5.2.4).
///
/// `attempted_at` and `now` are monotonic. `last_attempt_at` is `None` for a
/// first attempt.
pub fn escalate_cooldown(previous: Option<Monotonic>, now: Monotonic) -> u64 {
let previous_ms = previous.map_or(DEFAULT_COOLDOWN_MS, |p| now.0.saturating_sub(p.0));
previous_ms.saturating_mul(2).min(MAX_COOLDOWN_MS)
}
/// Whether an escalation has aged out (section 1.5.2.4: resets after 7 days
/// with no attempts).
pub fn escalation_has_reset(last_attempt_at: Option<Monotonic>, now: Monotonic) -> bool {
last_attempt_at.is_some_and(|t| now.0.saturating_sub(t.0) >= ESCALATION_RESET_MS)
}
/// Measure how far the wall clock has drifted from what real elapsed time
/// predicts.
///
/// `observed_wall` is what the OS now reports. `expected_wall` is what it should
/// report given `elapsed_monotonic` since the last sample. Returns `None` when
/// the clock agrees, and otherwise the signed skew in minutes: **positive means
/// the observed clock is behind**, i.e. the user moved the date backwards.
///
/// `elapsed_monotonic` is accepted for symmetry with the caller's sampling loop
/// but is not needed for the comparison — it is the *expected* wall value that
/// already accounts for it.
pub fn wall_clock_correction(
observed_wall: CivilTime,
expected_wall: CivilTime,
elapsed_monotonic: Monotonic,
) -> Option<i64> {
let _ = elapsed_monotonic;
let observed = observed_wall.epoch_minutes;
let expected = expected_wall.epoch_minutes;
if observed == expected {
return None;
}
Some(observed - expected)
}
/// Apply a detected wall-clock skew to a lock deadline.
///
/// **Always extends, never subtracts, and never past the cap.** Three rules, in
/// order of importance:
///
/// 1. *Never shortens.* Section 1.5.3. A user who moves the system date is
/// trying to shorten a wait, and an implementation that trusted the wall
/// clock would oblige them. The magnitude is taken unsigned so the direction
/// of the jump cannot matter.
/// 2. *Always extends when a jump is detected.* Section 1.5.3 again. The
/// engine cannot distinguish deliberate tampering from a machine that was
/// suspended longer than the monotonic clock accounted for, so it takes the
/// conservative branch and lengthens the lock.
/// 3. *Never past the cap.* Section 1.5.4 caps the longest possible disable
/// path at 24 hours. Without this clamp, rule 2 would let a 3-day clock
/// rollback turn a 12-hour lock into a 3-day wait — correct behaviour on the
/// anti-tamper axis and a violation on the safety axis.
///
/// Over-extending is the acceptable failure mode. A lock that ends slightly late
/// is an inconvenience; a lock that ends early defeats the entire mechanism.
/// The clamp bounds the cost of that choice.
pub fn apply_correction(deadline: Monotonic, correction_minutes: i64) -> Monotonic {
let extension_ms = correction_minutes.unsigned_abs().saturating_mul(60_000);
let extended = deadline.0.saturating_add(extension_ms);
Monotonic(extended.min(MAX_WAIT_MS))
}
/// Resume a lock after a reboot, conservatively (section 1.5.3).
///
/// A new boot id means we cannot know how much of the elapsed time was real. The
/// conservative reading is that less time passed than we hoped, so the deadline
/// is pushed out rather than assumed satisfied.
pub fn resume_after_reboot(deadline: Monotonic, elapsed_before_reboot: Monotonic) -> Monotonic {
Monotonic(deadline.0.max(elapsed_before_reboot.0))
}
-289
View File
@@ -1,289 +0,0 @@
//! Data model for schedules, targets and conditions.
//!
//! M1. The shapes below are fixed now because the golden decision-table format
//! (`tests/golden/`) serialises them, and because cross-platform parity depends
//! on every platform reading the same fields.
//!
//! Section 1.2 combination rules — which condition pairs are rejected — are
//! enforced by [`Schedule::validate`], not merely documented.
use core::fmt;
/// Whether a schedule blocks what it lists, or blocks everything it does not
/// list (section 1.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScheduleMode {
/// Listed targets are blocked.
Blocklist,
/// Only listed targets are allowed; everything else is blocked.
///
/// Apps only — never websites (section 1.1). Websites have no meaningful
/// allowlist because there is no enumeration of "all websites" a user could
/// meaningfully consent to.
Allowlist,
}
/// A single thing a schedule can block.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Target {
/// An application, identified by whatever the platform uses:
/// Android package name, Linux desktop-file id or executable path,
/// Windows exe path or package identity.
App(String),
/// A registrable domain, e.g. `example.com`. Subdomains are implied.
Website(String),
/// A case-insensitive substring match against a URL, for path-level and
/// keyword blocking (section 6.2 — this is why the desktop decision is a
/// browser extension: DNS cannot do keyword or path matching).
Keyword(String),
}
/// Days of the week, Monday = 0. Matches `time::Weekday` ordering.
pub type DayOfWeek = u8;
/// A time-of-day, minutes since local midnight, always in the range 0..1440.
pub type MinuteOfDay = u16;
/// A recurring time window (section 1.2, "Time").
///
/// A window where `start > end` **spans midnight** and runs overnight. There is
/// deliberately no `crosses_midnight: bool` flag: the comparison already
/// encodes it, and a second field is a second way to be wrong.
///
/// When evaluating membership, a window that began on the previous day keeps
/// that day's weekday for its whole span. Getting this backwards is the classic
/// overnight bug — a Mon 22:00–06:00 window must still be active at Tue 03:00.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeWindow {
/// Days on which the window starts.
pub days: Vec<DayOfWeek>,
pub start: MinuteOfDay,
pub end: MinuteOfDay,
}
impl TimeWindow {
/// True when this window wraps past midnight into the next day.
pub const fn is_overnight(&self) -> bool {
self.start > self.end
}
}
/// How a usage threshold is counted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageWindow {
/// Resets at local midnight.
Daily,
/// Resets on the hour.
Hourly,
}
/// The four blocking conditions that take no location (section 1.2).
///
/// Deliberately not `Eq`: the `Location` variant holds `f64` coordinates, which
/// are `PartialEq` but not `Eq`. Removing `Eq` from the whole enum is the honest
/// consequence — the alternatives are a newtype wrapper for coordinates or a
/// lossy integer encoding, and neither earns its cost for values that only ever
/// get compared against a radius.
#[derive(Debug, Clone, PartialEq)]
pub enum Condition {
/// Block while the local time falls inside the window.
Time(TimeWindow),
/// Block after `limit` minutes of foreground use within `window`.
UsageLimit { limit_minutes: u32, window: UsageWindow },
/// Block after `limit` launches within `window`.
LaunchCount { limit: u32, window: UsageWindow },
/// Block while connected to a specific Wi-Fi SSID.
WifiSsid(String),
/// Block inside (or outside) a radius of a point. Mobile only — section 6.4
/// says there is no reliable desktop equivalent, so desktop must not offer
/// this condition rather than offering a broken one.
Location {
latitude: f64,
longitude: f64,
radius_meters: u32,
/// Block when *outside* the radius instead of inside.
block_when_outside: bool,
},
}
/// A named, user-facing schedule.
#[derive(Debug, Clone, PartialEq)]
pub struct Schedule {
pub id: String,
pub name: String,
/// Opaque, platform-specific path to the user-chosen icon.
pub icon: Option<String>,
pub mode: ScheduleMode,
pub targets: Vec<Target>,
/// A schedule is active when at least one condition holds (section 1.2).
pub conditions: Vec<Condition>,
/// Paused schedules are retained but never block.
pub paused: bool,
}
/// Why a target is currently blocked, and until when.
///
/// Section 1.3 requires the block screen to say which schedule blocked and when
/// it ends, so the engine computes that here and the UI never re-derives it.
/// Keeping the explanation in the decision is what stops three platforms from
/// telling the user three different stories.
///
/// The `target` field is not decoration. A schedule may name twenty targets and
/// they do not all become blocked: some are paused, some belong to a condition
/// that is not currently active, and some are on the never-block list. Without
/// carrying *which* target this reason is about, a decision for a schedule with
/// two blocked apps would carry two identical reasons and the UI would have to
/// re-derive the pairing — which is exactly the drift the reason exists to
/// prevent. Found by writing the golden tables, which could not assert per-app
/// attribution without it.
#[derive(Debug, Clone, PartialEq)]
pub struct BlockReason {
/// The specific target being blocked, not merely the schedule.
pub target: Target,
pub schedule_id: String,
pub schedule_name: String,
/// The specific condition that fired, named for display.
pub condition: &'static str,
/// When this block lifts. `None` means "indefinitely", which the UI must
/// render as such rather than as a missing value.
pub ends_at: Option<crate::engine::CivilTime>,
}
impl BlockReason {
/// A stable, comparable key for the target this reason is about.
///
/// Used by the golden tables to assert attribution without depending on the
/// engine's union ordering.
pub fn app_key(&self) -> String {
match &self.target {
Target::App(id) => id.clone(),
Target::Website(d) => format!("website:{d}"),
Target::Keyword(k) => format!("keyword:{k}"),
}
}
}
/// A rejection of a schedule that does not satisfy section 1.2.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
/// One of the forbidden condition pairings. Section 1.2 lists these; they
/// exist because AppBlock rejects them, and strictest-wins is the
/// documented way to express the same intent across two schedules.
ForbiddenCombination {
first: &'static str,
second: &'static str,
},
NoTargets,
NoConditions,
/// Allowlist schedules are apps-only (section 1.1).
AllowlistWithNonAppTarget,
/// Location is mobile-only (section 6.4).
LocationOnDesktop,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ForbiddenCombination { first, second } => {
write!(f, "cannot combine {first} with {second}; use two schedules")
}
Self::NoTargets => write!(f, "a schedule needs at least one target"),
Self::NoConditions => write!(f, "a schedule needs at least one condition"),
Self::AllowlistWithNonAppTarget => {
write!(f, "allowlist mode applies to apps only, not websites")
}
Self::LocationOnDesktop => {
write!(f, "location conditions are mobile-only on this platform")
}
}
}
}
/// The forbidden condition pairings from section 1.2, as a pure function so the
/// rule lives in exactly one place.
pub fn conditions_combine(first: &Condition, second: &Condition) -> bool {
use Condition::{LaunchCount, Location, Time, UsageLimit, WifiSsid};
// Time + usage limit, time + launch count, usage limit + launch count,
// usage limit + location, launch count + location are all rejected.
// Wi-Fi and location combine freely; nothing in section 1.2 forbids it.
!matches!(
(first, second),
(Time(_), UsageLimit { .. })
| (UsageLimit { .. }, Time(_))
| (Time(_), LaunchCount { .. })
| (LaunchCount { .. }, Time(_))
| (UsageLimit { .. }, LaunchCount { .. })
| (LaunchCount { .. }, UsageLimit { .. })
| (UsageLimit { .. }, Location { .. })
| (Location { .. }, UsageLimit { .. })
| (LaunchCount { .. }, Location { .. })
| (Location { .. }, LaunchCount { .. })
) && !matches!((first, second), (WifiSsid(_), WifiSsid(_)))
}
/// A context flag saying whether the host platform supports location
/// conditions. Section 6.4: desktop must hide the condition, not accept it and
/// silently never fire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Android,
Windows,
Linux,
}
impl Schedule {
/// Validate against section 1.2.
///
/// Returns every violation rather than the first, so the UI can show the
/// user all of them at once instead of making them resubmit to discover the
/// next problem.
pub fn validate(&self, platform: Platform) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
if self.targets.is_empty() {
errors.push(ValidationError::NoTargets);
}
if self.conditions.is_empty() {
errors.push(ValidationError::NoConditions);
}
if matches!(self.mode, ScheduleMode::Allowlist)
&& self.targets.iter().any(|t| !matches!(t, Target::App(_)))
{
errors.push(ValidationError::AllowlistWithNonAppTarget);
}
for (i, first) in self.conditions.iter().enumerate() {
if matches!(first, Condition::Location { .. }) && !matches!(platform, Platform::Android)
{
errors.push(ValidationError::LocationOnDesktop);
}
for second in self.conditions.iter().skip(i + 1) {
if !conditions_combine(first, second) {
errors.push(ValidationError::ForbiddenCombination {
first: condition_name(first),
second: condition_name(second),
});
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
/// Short display name for a condition, used in validation messages and in
/// `BlockReason`.
pub fn condition_name(condition: &Condition) -> &'static str {
match condition {
Condition::Time(_) => "a time window",
Condition::UsageLimit { .. } => "a usage limit",
Condition::LaunchCount { .. } => "a launch count",
Condition::WifiSsid(_) => "a Wi-Fi network",
Condition::Location { .. } => "a location",
}
}
-65
View File
@@ -1,65 +0,0 @@
//! The never-block list (CLAUDE.md section 1.5.4).
//!
//! These targets are hardcoded, non-negotiable, and cannot be removed by any
//! user setting, by any schedule, or by any lock level. Blocking the dialler or
//! emergency services would make the device unusable, and section 1.5.4 places
//! these guardrails above every other feature in the project.
//!
//! The list is a `const`, not a setting, and [`is_never_blocked`] is consulted
//! inside the engine's target loop *after* schedule evaluation. That ordering is
//! deliberate: a schedule that names a protected target still evaluates and
//! still blocks its other targets, but the protected one can never enter the
//! blocked set. There is no flag that reverses it.
/// Android: the dialler and the emergency path.
///
/// Section 1.5.4 names phone/dialer, emergency calls and SOS, and emergency
/// alerts. Matching is by substring so a variant package name cannot slip past.
#[cfg(target_os = "android")]
pub const NEVER_BLOCK: &[&str] = &[
"com.android.dialer",
"com.android.server.telecom",
"com.android.emergency",
"com.android.phone",
"com.android.server.emergency",
"com.android.cellbroadcastreceiver",
"com.android.systemui", // holds the emergency call UI
];
/// Desktop: the OS shell and the settings basics needed to keep the machine
/// usable. A user who cannot reach their file manager or display settings has
/// lost the machine, not been disciplined.
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub const NEVER_BLOCK: &[&str] = &[
// Session shell / compositor.
"plasmashell",
"plasmawindowmanager",
"kwin_wayland",
"kwin_x11",
"gnome-shell",
"xfce4-session",
"explorer.exe",
// Settings and the ability to undo things.
"systemsettings",
"systemsettings5",
"systemsettings6",
"gnome-control-center",
"ksysguard",
"plasma-systemmonitor",
"dolphin",
"org.kde.plasma.systemsettings",
// Terminal is deliberately NOT here: it can undo anything. Section 1.5.4
// names the shell and Settings basics, not the terminal.
];
#[cfg(not(any(target_os = "android", target_os = "linux", target_os = "windows")))]
pub const NEVER_BLOCK: &[&str] = &[];
/// True when `target` may never be blocked.
///
/// Substring matching, case-insensitive, so `com.android.dialer` and
/// `com.android.dialer.standalone` are both covered.
pub fn is_never_blocked(target: &str) -> bool {
let needle = target.to_lowercase();
NEVER_BLOCK.iter().any(|protected| needle.contains(&protected.to_lowercase()))
}
-92
View File
@@ -1,92 +0,0 @@
//! Storage: SQLite-backed persistence (CLAUDE.md section 3).
//!
//! Section 1.5.3 requires lock and Strict state to live in the core database
//! rather than in editable plaintext config, so that editing a file is not
//! enough to defeat a lock. The schema therefore keeps schedules, lock state,
//! and usage counters in one place with no config file beside them.
//!
//! # Deliberately thin in M0
//!
//! rusqlite is not yet a dependency — the machine this was built on has 9.7 GB
//! free and section 3 makes every crate justify its size cost. This module
//! defines the schema and the statements M1 will use, with no I/O behind it
//! yet, so the schema is reviewable before it is compiled.
/// The schema, version 1.
///
/// Applied by `PRAGMA user_version`. Migrations are additive and forward-only;
/// downgrading a schema would risk losing lock state, which section 1.5.4 does
/// not permit.
pub const SCHEMA_VERSION: i64 = 1;
pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS schedule (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
mode TEXT NOT NULL CHECK (mode IN ('Blocklist', 'Allowlist')),
targets TEXT NOT NULL, -- JSON array of Target
conditions TEXT NOT NULL, -- JSON array of Condition
paused INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Section 1.5.3: lock state lives in the DB, not in a plaintext config file,
-- so that editing a file cannot defeat a lock.
CREATE TABLE IF NOT EXISTS lock_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
level TEXT NOT NULL CHECK (level IN ('Normal', 'Strict', 'Stubborn')),
schedules_locked INTEGER NOT NULL,
blocks_uninstall INTEGER NOT NULL,
keyholder_code TEXT, -- NULL if the user declined section 1.5.2.7
-- Monotonic deadlines for a filed disable request, section 1.5.2.2.
-- NULL when no request is pending.
request_ready_at INTEGER,
request_window_closes_at INTEGER,
-- Elapsed-so-far plus boot id, so a reboot resumes conservatively.
boot_id INTEGER NOT NULL,
last_monotonic_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS disable_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at_monotonic INTEGER NOT NULL,
boot_id INTEGER NOT NULL,
day_index INTEGER NOT NULL -- for the "4 times today" count
);
CREATE TABLE IF NOT EXISTS usage_daily (
day_index INTEGER NOT NULL, -- days since epoch, local
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day_index, target)
);
CREATE TABLE IF NOT EXISTS usage_hourly (
hour_index INTEGER NOT NULL, -- hours since epoch, local
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (hour_index, target)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS usage_daily_target ON usage_daily (target);
CREATE INDEX IF NOT EXISTS usage_hourly_target ON usage_hourly (target);
CREATE INDEX IF NOT EXISTS disable_attempt_day ON disable_attempt (day_index);
"#;
/// Fail open on corruption (CLAUDE.md section 1.5.4).
///
/// A corrupt database must never leave a device unusable, so recovery renames
/// the bad file aside and starts clean rather than failing to launch. The
/// damaged file is kept for diagnosis.
///
/// `None` here means "fresh database", which is the fail-open outcome.
pub const RECOVERY_NOTE: &str =
"corrupt database renamed aside; blocking re-applied from defaults on next start";
-131
View File
@@ -1,131 +0,0 @@
//! Usage accounting (CLAUDE.md section 1.2, conditions 2 and 3).
//!
//! Two quantities are accumulated per tracked target: foreground minutes and
//! launch count, in both daily and hourly buckets (section 1.2 allows either
//! window).
//!
//! # Why the buckets are explicit
//!
//! A schedule's condition names a window — daily or hourly — and the engine
//! reads the matching bucket. Resolving the window at evaluation time rather
//! than storing both forever keeps the snapshot small, but it does mean the
//! bucket must be *rotated* at the civil boundaries. Rotation is driven by
//! `next_wakeup` from the engine, not by a timer of our own, so a machine with
//! no usage-limit schedule never wakes up to rotate anything.
//!
//! # Monotonic, always
//!
//! Accrual happens in the platform layer, but it is *measured* with a monotonic
//! clock and handed to this crate as an elapsed duration. Section 1.5.3 requires
//! clock tampering not to shorten anything; by never touching the wall clock
//! here, a user setting the date back a year cannot grant themselves free usage.
use crate::engine::UsageSnapshot;
/// One recorded usage event, as produced by a platform layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageEvent {
/// The target came to the foreground.
Focused,
/// The target went to the background.
Blurred,
/// The target was launched.
Launched,
}
/// The accumulator state persisted between evaluations.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UsageTracker {
/// Currently-foreground target, so the duration between Focused and Blurred
/// can be attributed. `None` means nothing is in the foreground.
focused: Option<(String, u64)>,
/// Foreground minutes per target in the current daily bucket.
pub daily_minutes: std::collections::BTreeMap<String, u32>,
/// Foreground minutes per target in the current hourly bucket.
pub hourly_minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per target in the current daily bucket.
pub daily_launches: std::collections::BTreeMap<String, u32>,
/// Launch counts per target in the current hourly bucket.
pub hourly_launches: std::collections::BTreeMap<String, u32>,
/// Monotonic timestamp of the current bucket boundaries, used to decide
/// when to rotate.
pub bucket_started_at: Option<u64>,
}
impl UsageTracker {
/// Record an event. `now_ms` is **monotonic** milliseconds since boot.
pub fn record(&mut self, event: UsageEvent, target: &str, now_ms: u64) {
match event {
UsageEvent::Launched => {
increment(&mut self.daily_launches, target);
increment(&mut self.hourly_launches, target);
}
UsageEvent::Focused => {
self.focused = Some((target.to_owned(), now_ms));
}
UsageEvent::Blurred => {
if let Some((previous, started_at)) = self.focused.take() {
let elapsed_ms = now_ms.saturating_sub(started_at);
// Truncating seconds to whole minutes is deliberate: a
// usage limit of "30 minutes" should not trip on 30 minutes
// and 1 second of use.
let minutes = u32::try_from(elapsed_ms / 60_000).unwrap_or(u32::MAX);
if minutes > 0 {
accumulate(&mut self.daily_minutes, &previous, minutes);
accumulate(&mut self.hourly_minutes, &previous, minutes);
}
}
self.focused = Some((target.to_owned(), now_ms));
}
}
}
/// Fold in time spent in the foreground if the target is still foreground,
/// without waiting for a Blurred event.
///
/// Called from the evaluation path so usage is accurate even if the daemon
/// never sees the matching Blurred (crash, forced close, power loss).
pub fn settle_focus(&mut self, now_ms: u64) {
if let Some((target, started_at)) = self.focused.clone() {
let elapsed_ms = now_ms.saturating_sub(started_at);
let minutes = u32::try_from(elapsed_ms / 60_000).unwrap_or(u32::MAX);
if minutes > 0 {
accumulate(&mut self.daily_minutes, &target, minutes);
accumulate(&mut self.hourly_minutes, &target, minutes);
self.focused = Some((target, now_ms));
}
}
}
/// Produce the snapshot the engine reads.
pub fn snapshot(&self) -> UsageSnapshot {
UsageSnapshot {
daily_minutes: self.daily_minutes.clone(),
hourly_minutes: self.hourly_minutes.clone(),
daily_launches: self.daily_launches.clone(),
hourly_launches: self.hourly_launches.clone(),
}
}
/// Whether a bucket boundary has passed, meaning the caller should rotate.
///
/// Takes the civil time rather than comparing monotonic values, because the
/// buckets are civil-time aligned ("daily" means local midnight, section
/// 1.2). The caller invokes this from a `next_wakeup` wakeup, not a timer.
pub fn should_rotate(&self, bucket_started: Option<u64>, elapsed_ms: u64) -> bool {
match bucket_started {
None => false,
Some(started) => elapsed_ms.saturating_sub(started) >= 3_600_000,
}
}
}
fn increment(map: &mut std::collections::BTreeMap<String, u32>, key: &str) {
let entry = map.entry(key.to_owned()).or_insert(0);
*entry = entry.saturating_add(1);
}
fn accumulate(map: &mut std::collections::BTreeMap<String, u32>, key: &str, minutes: u32) {
let entry = map.entry(key.to_owned()).or_insert(0);
*entry = entry.saturating_add(minutes);
}
-21
View File
@@ -1,21 +0,0 @@
[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" }
blocker-storage = { path = "../../storage" }
blocker-platform-linux = { path = "../platform-linux" }
-177
View File
@@ -1,177 +0,0 @@
//! 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, 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 the startup path below with a loop that blocks on exactly
//! three things: a focus-change event from the active
//! [`ForegroundWatcher`](../../blocker_platform_linux/index.html) backend, the
//! `next_wakeup` instant from the engine, and IPC from the UI. There is no
//! polling tick anywhere — an idle daemon takes zero wakeups, which is how the
//! section 5 idle-CPU budget is met.
//!
//! # Where the database lives
//!
//! Under the XDG data directory, never beside the binary. The binary may be
//! installed read-only in `/usr/bin`, while the database must be writable and
//! must survive an upgrade.
mod runner;
use std::path::PathBuf;
use std::process::ExitCode;
/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
fn database_path() -> PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.unwrap_or_else(|| {
let home =
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
home.join(".local/share")
});
base.join("blocker").join("blocker.db")
}
fn main() -> ExitCode {
let path = database_path();
// Section 1.5.4: a corrupt or unusable database must never leave the device
// unusable. `Store::open` handles corruption by moving the damaged file
// aside and starting clean, so what reaches the `Err` arm is genuinely fatal
// — an unwritable directory, say. Even then we exit with a message rather
// than panicking: a daemon that crashes on startup is worse than one that
// reports the problem and stops.
let store = match blocker_storage::Store::open(&path) {
Ok(store) => store,
Err(e) => {
eprintln!("blocker-daemon: cannot open storage at {}: {e}", path.display());
eprintln!("blocking is disabled until this is fixed; see docs/LIMITATIONS.md");
return ExitCode::FAILURE;
}
};
if store.recovered_from_corruption() {
// Logged, not swallowed: section 1.5.4 wants the recovery recorded.
eprintln!(
"blocker-daemon: database was corrupt and has been moved to {}.corrupt; \
starting clean, so no schedules are active until you re-add them",
path.display()
);
}
let usage = blocker_core::engine::UsageSnapshot::default();
let context = blocker_core::engine::PlatformContext::default();
let schedules = match store.load_schedules() {
Ok(s) => s,
Err(e) => {
eprintln!("blocker-daemon: cannot read schedules: {e}");
return ExitCode::FAILURE;
}
};
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
println!("database: {}", path.display());
println!("schedules: {}", schedules.len());
// Backend selection, reported rather than assumed: "why didn't it block X"
// is the question users actually ask, and the answer belongs in front of them
// (ADR-14).
if blocker_platform_linux::kwin::KwinBackend::is_available() {
println!("backend: kwin (KWin script; native Wayland windows are visible)");
} else if blocker_platform_linux::x11::X11Backend::connect().is_ok() {
println!("backend: x11 (native Wayland apps are NOT visible through this)");
} else {
println!("backend: none — blocking cannot work; see docs/LIMITATIONS.md section 3");
}
// The event loop, running for real.
//
// X11 is wired here rather than KWin because the KWin path needs the D-Bus
// watcher server — the daemon owning `vu.valiq.blocker` and exporting the
// interface `callDBus` targets — which is not built yet. Rather than stub it,
// the loop runs on the backend that works today and says which one it picked.
// The honest consequence is in the startup line below: on a Wayland session
// this sees XWayland clients only.
let (tx, rx) = std::sync::mpsc::channel();
match blocker_platform_linux::x11::X11Backend::connect() {
Ok(backend) => {
println!("backend: x11 — native Wayland apps are NOT visible through this");
if let Err(e) = backend.subscribe_events() {
eprintln!("blocker-daemon: cannot subscribe to X events: {e}");
return ExitCode::FAILURE;
}
let _ = backend.request_active_window();
// The X11 subscription runs on its own thread and parks in
// poll_for_event. The main thread parks in recv_timeout. Neither
// ticks, so an idle system costs two blocked threads and no CPU.
let quit = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let worker_quit = quit.clone();
let worker = std::thread::spawn(move || {
backend.run(tx, worker_quit.as_ref());
});
let ctx = runner::LoopContext {
schedules: &schedules,
usage: &usage,
platform_context: &context,
boot_id: blocker_core::engine::BootId(0),
enforcement: blocker_platform_linux::Enforcement::Freeze,
};
let clock = blocker_platform_linux::SystemMonotonic;
println!("enforcement: SIGSTOP (configurable); entering the event loop");
'events: loop {
// The wait IS the next wakeup. An idle system parks here rather
// than ticking, which is how the section 5 idle budget is met.
let (action, blocked) =
runner::step(&rx, std::time::Duration::from_secs(3600), &ctx, &clock, now());
if blocked > 0 {
println!("blocked an app ({action:?})");
}
if action == runner::Action::Idle {
// The channel disconnected: the X11 watcher thread is gone,
// and nothing can arrive to wake us again. Exiting is
// correct, and better than spinning on a dead channel.
break 'events;
}
}
let _ = worker.join();
}
Err(e) => {
println!("backend: none — {e}");
println!("blocking cannot work on this session; see docs/LIMITATIONS.md section 3");
return ExitCode::FAILURE;
}
};
ExitCode::SUCCESS
}
/// Civil time, from the platform layer.
///
/// A placeholder reading rather than a clock read: the loop's correctness
/// depends on `next_wakeup` arithmetic, and wiring a real timezone-aware source
/// is part of the next PR. Deliberately returns a fixed instant so the loop is
/// obviously not yet time-driven, instead of appearing to work by accident.
fn now() -> blocker_core::engine::CivilTime {
blocker_core::engine::CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 }
}
-160
View File
@@ -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
}
}
}
-39
View File
@@ -1,39 +0,0 @@
[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" }
# 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"))),
})
}
-126
View File
@@ -1,126 +0,0 @@
//! 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.
pub mod enforce;
pub mod kwin;
pub mod x11;
pub use enforce::{MonotonicSource, SystemMonotonic};
pub use kwin::parse_payload;
// Re-exported so the daemon can name a window without reaching into the
// backend module — the backend is an implementation detail, the window is not.
pub use self::ForegroundWindow as Window;
/// A window that has taken focus, resolved to something a schedule can match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForegroundWindow {
/// 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",
];
-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
}
-20
View File
@@ -1,20 +0,0 @@
[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" }
-64
View File
@@ -1,64 +0,0 @@
//! 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,
}
-27
View File
@@ -1,27 +0,0 @@
[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]
-27
View File
@@ -1,27 +0,0 @@
//! 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");
}
-232
View File
@@ -1,232 +0,0 @@
# Architecture
## The one idea
Everything that decides anything lives in one Rust crate, `blocker-core`. Platform
code observes the OS and enforces decisions. Nothing else decides.
```
┌──────────────────────────────────────────┐
│ core (Rust) │
│ │
EngineInput ───▶ │ evaluate(input) -> Decision │
│ · Schedule, Target, Condition │
│ · UsageSnapshot, PlatformContext │
│ · CivilTime, Monotonic, BootId │
│ │
│ storage · usage · lock · never_block │
└───────────────┬──────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ android/ │ │ desktop/ │ │ tests/ │
│ Kotlin shell │ │ daemon + UI │ │ golden tables │
│ services, │ │ platform-linux │ │ parity │
│ overlay, UI │ │ platform-win │ │ contract │
└────────────────┘ └─────────────────┘ └─────────────────┘
```
## The purity contract
```rust
pub fn evaluate(input: &EngineInput<'_>) -> Decision;
```
`evaluate` reads no clock, performs no I/O, and calls no OS API. Everything it
needs arrives in the input: civil time, monotonic time, boot id, the schedule
set, accumulated usage, and platform context such as the current SSID.
This is the load-bearing rule of the project. Two things depend on it:
**Testability.** The engine can be exercised with no OS present, which is what
makes the golden decision tables possible and CI cheap.
**Parity.** If platform code could reach into the engine, Linux and Android would
drift, and the golden tables would stop being a contract — merely a test of the
core against itself.
Enforcement is mechanical rather than cultural. `tests/purity.rs` reads the
resolved dependency graph and fails if `blocker-core` has any dependency at all,
so a stray `SystemTime::now()` breaks the build instead of quietly making parity
untestable.
## Monotonic time is a separate type
```rust
pub struct CivilTime { pub epoch_minutes: i64, pub utc_offset_minutes: i32 }
pub struct Monotonic(pub u64);
```
`EngineInput` carries both. Lock arithmetic reads only `Monotonic`. Schedule
windows read only `CivilTime`.
Section 1.5.3 requires that changing the system date never shortens a lock. Making
the two different types in the input struct means a lock cannot consult the wall
clock even by accident — the mistake is a type error rather than a review finding.
## `next_wakeup` is the anti-polling mechanism
`Decision::next_wakeup` is the earliest instant at which any decision could
change, or `None`. The platform layer sleeps until that instant or until an OS
event arrives.
That is where the section 5 idle-CPU budget comes from. An idle machine takes
**zero** wakeups, because nothing is scheduled to expire. A timer that fires to
discover nothing changed is a wakeup, and this project cannot afford many.
It is also a correctness obligation, which is less obvious. The minimum must be
taken over **every non-paused schedule, not only active ones**:
```rust
// Arm the wakeup BEFORE the activity filter, over every non-paused schedule.
for schedule in input.schedules {
if schedule.paused { continue; }
if let Some(boundary) = next_transition(schedule, input) {
next_wakeup = earlier(next_wakeup, boundary);
}
}
```
A schedule that is inactive now but starts at 09:00 Monday still has to arm a
wakeup for 09:00 Monday. Filtering to active conditions first would let the daemon
sleep straight through the moment it is supposed to start blocking.
Conditions that only an OS event can change — Wi-Fi SSID, location — contribute
no boundary at all. Arming a timer for those would be a wakeup for nothing.
## Strictest-wins
`blocked_apps` is the union over all active schedules: an app is blocked if at
least one schedule wants it blocked. There is no precedence rule to implement and
no way for two schedules to disagree.
Allowlist mode contributes the complement of its own list, which is why
`EngineInput` carries `installed_apps`. An empty slice means "could not
enumerate", and the engine then blocks nothing — fail open, per section 1.5.4. A
machine the engine knows nothing about is not a machine it should act on.
## Overnight windows
A time condition is `(days, start, end)` where `start > end` **means** the window
spans midnight. No `crosses_midnight` flag: the comparison already encodes it, and
a second field is a second way to be wrong.
The subtle part is which weekday governs the early hours. A window starting Monday
22:00 and ending 06:00 is still active at 03:00 **Tuesday**, governed by *Monday*'s
weekday — the day the window started. A naive "is now inside today's window" check
reads 03:00 as Tuesday, finds Tuesday is not in the set, and stops blocking. That is
a nightly schedule silently dying at midnight, and it is the bug this
representation exists to prevent.
Pinned by `overnight_window_survives_past_midnight` and
`overnight_window_across_the_week_boundary`.
Evaluation happens in local civil time, so "09:00–17:00" stays 09:00–17:00 across a
DST change.
## Block reasons travel with the decision
```rust
pub struct BlockReason {
pub schedule_id: String,
pub schedule_name: String,
pub condition: &'static str,
pub ends_at: Option<CivilTime>,
}
```
Section 1.3 requires the block screen to say which schedule blocked and when it
ends. The engine computes that here, so the UI never re-derives it. Three
platforms reading the same decision then tell the user the same story instead of
three different ones.
## End-to-end data flow, one block event
```
KWin script ──D-Bus──▶ platform-linux ──▶ ┌──────────────┐
(windowActivated) │ build │
│ EngineInput │
└──────┬───────┘
▼
┌──────────────┐
│ evaluate() │ pure
└──────┬───────┘
│ Decision
▼
platform enforces ◀───────┘
1. show block screen
2. minimise
3. SIGSTOP
4. terminate (configurable, escalating)
```
The engine is called again when the OS reports a focus change, when `next_wakeup`
arrives, or on IPC. Nothing polls.
## The FFI boundary
`evaluate` is the entire surface the Android shell needs. `uniffi` generates the
Kotlin bindings over that one function and the `Decision` type, so the shell never
sees a schedule's internals and cannot take a shortcut around the engine.
Starting with `uniffi` (ADR-5); hand-written JNI is the fallback if the measured
APK size cost is unacceptable, which is a measurement before it is a decision.
## Module layout
| Module | Responsibility |
|---|---|
| `model` | `Schedule`, `Target`, `Condition`, `ScheduleMode`, plus section 1.2 validation |
| `engine` | `evaluate`, `EngineInput`, `Decision`, `BlockReason`, `next_wakeup` |
| `usage` | foreground-time and launch-count accumulation into daily and hourly buckets |
| `lock` | lock levels, the friction ladder's timing, monotonic anti-tamper |
| `never_block` | the section 1.5.4 safety list |
| `storage` | *(separate crate: `blocker-storage`)* the SQLite schema and its fail-open recovery policy |
`blocker-storage` is a sibling crate, not a submodule. It depends on
`blocker-core`; `blocker-core` does not depend on it, and depends on nothing at
all. That direction is what lets `tests/purity.rs` keep checking "blocker-core has
zero dependencies" — an automatic proof that the engine cannot do I/O — instead of
the weaker and easily-routed-around alternative of grepping the source for
`rusqlite`.
Both crates occupy the same layer of the diagram above: storage is shared by every
platform and contains no platform code.
## Where the platform crates start and stop
A platform crate may: observe the OS, map its own app identities onto `Target`,
call `evaluate`, and enforce the resulting `Decision`.
It may not: decide whether something should be blocked, re-derive when a block
ends, or consult a clock for lock arithmetic.
`ForegroundWatcher` is the trait every desktop backend implements:
```rust
pub trait ForegroundWatcher {
fn subscribe(&mut self, sink: std::sync::mpsc::Sender<FocusEvent>);
fn name(&self) -> &'static str;
fn selection_reason(&self) -> &'static str;
}
```
The implementations and their honest limits are in ADR-14 and
`LIMITATIONS.md` §3.
## Testing
The golden decision tables in `tests/golden/` are the parity contract: one table,
run against the core, and against each platform's identity mapping. A platform
disagreeing with the core is a build failure rather than a bug report.
The rule that keeps them meaningful: **a case is added before the engine changes.**
A table written after the code records current behaviour and can never catch a
regression. Reviewing a PR that changes engine behaviour therefore means checking
that a case was added or changed in the same commit.
Clock tampering is the exception to "tables are pure data" — proving that moving
the system date never shortens a lock needs a clock that actually moves. Those
cases are code fixtures in `tests/src/purity.rs` instead. The tables describe
decisions; the fixtures describe the passage of time.
-501
View File
@@ -1,501 +0,0 @@
# Architecture Decision Log
Decisions made about Blocker, in order. Newest last.
Newest first is not used deliberately: an ADR log is read for the *chain* of
reasoning, and a reader who wants to know why something is the way it is needs
to see what it was chosen over, which the superseded entries provide.
Format: title, status, context, decision, consequences.
Status values: **Accepted** (decided, in force), **Proposed** (designed, awaiting
the owner's confirmation), **Superseded by ADR-N** (no longer true).
Where CLAUDE.md gives a reason for a decision, that reason is recorded. Where it
does not, this file says so rather than inventing a justification — a fabricated
rationale is worse than an acknowledged gap, because the next person cannot tell
which parts to trust.
---
## Index
| ADR | Title | Status |
|---|---|---|
| [1](#adr-1) | Rust for core and desktop, Kotlin for the Android shell | Accepted |
| [2](#adr-2) | One shared core; the engine is a pure function | Accepted |
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
| [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open |
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
| [6](#adr-6) | Desktop UI toolkit — egui | **Accepted** |
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
| [8](#adr-8) | Lock levels and explicit opt-in | Accepted |
| [9](#adr-9) | Monotonic clock for all lock timers | Accepted |
| [10](#adr-10) | Never-block list is hardcoded | Accepted |
| [11](#adr-11) | We do not block ADB, safe mode, or recovery | Accepted |
| [12](#adr-12) | v1 scope excludes iOS, cloud, sync, billing | Accepted |
| [13](#adr-13) | Zero external dependencies in M0 | Accepted |
| [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted |
| [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted |
| [16](#adr-16) | Golden tables are Rust, not JSON | Accepted |
| [17](#adr-17) | Licence not yet chosen | **Open — owner decision** |
---
<a id="adr-1"></a>
## ADR-1: Rust for core and desktop, Kotlin for the Android shell
**Status:** Accepted
**Context.** Two languages are required in any case: the Android blocking APIs
(`AccessibilityService`, `UsageStatsManager`, `DevicePolicyManager`,
`SYSTEM_ALERT_WINDOW`) are JVM-only, so a non-JVM language cannot register those
services. The question is how much code is Kotlin.
**Decision.** Kotlin for the Android shell only, kept thin. Rust for the core
and for all of desktop.
**Consequences.** The shell holds services, the overlay, and UI. All decision
logic lives in Rust, so performance is dominated by the core rather than by the
shell. The cost is a cross-language boundary, handled by ADR-5.
---
<a id="adr-2"></a>
## ADR-2: One shared core; the engine is a pure function
**Status:** Accepted
**Context.** Section 2 requires identical behaviour on three operating systems.
The failure mode to avoid is gradual drift, where Linux and Android each
implement their own idea of when a schedule is active.
**Decision.** `evaluate(&EngineInput) -> Decision` reads no clock, no disk, and
no OS API. Everything it needs arrives in the input.
**Consequences.** Two things follow. The engine is testable without any OS, and
parity becomes a testable property rather than a hope.
Purity is enforced mechanically: `tests/purity.rs` reads the resolved dependency
graph and fails if `blocker-core` gains any dependency. A review that misses a
`SystemTime::now()` is caught by CI instead of by a user on a platform nobody
tested that day.
---
<a id="adr-3"></a>
## ADR-3: Event-driven with `next_wakeup`, never polling
**Status:** Accepted
**Context.** Section 2 requires alarm-driven operation with no polling, and
section 5 budgets roughly zero idle CPU.
**Decision.** The engine returns `next_wakeup`: the earliest instant at which any
decision could change, or `None`. The platform layer sleeps until that instant or
until an OS event arrives.
**Consequences.** An idle machine takes zero wakeups, because nothing is
scheduled to expire. This also makes the field a correctness obligation — a
schedule that declares no wakeup but should have one will never fire.
The subtle case, now covered by `inactive_schedule_yields_no_wakeup`: the
minimum must be taken over **every non-paused schedule, not only currently
active ones**. A schedule inactive now but starting at 09:00 Monday still has to
arm a wakeup for 09:00 Monday, or the daemon sleeps straight through the moment
it is supposed to start blocking.
---
<a id="adr-4"></a>
## ADR-4: SQLite via rusqlite
**Status:** Accepted and implemented, with one trade-off still open
**Context.** Section 3 specifies `rusqlite` with bundled SQLite. Section 1.5.3
requires lock and Strict state to live in the core database rather than in
editable plaintext config.
**Decision.** `rusqlite` with `bundled`, in a **separate `blocker-storage` crate**
that depends on `blocker-core`, never the reverse.
**Why the separate crate.** The purity contract says the engine does no I/O, and
`tests/purity.rs` enforces that mechanically by failing if `blocker-core` has any
dependency at all. Putting `rusqlite` in `blocker-core` would have forced that
check to degrade into a source-text grep — something a careful developer can route
around, and something nobody reads in six months. Splitting keeps the check
automatic.
Both crates sit in the same layer of the section 2 diagram: storage is shared by
every platform and contains no platform code. This is a refinement that makes the
guarantee checkable, not an architectural departure.
**Measured consequences.**
- Bundled SQLite costs **~1.1 MB** of the 5 MB stripped daemon budget (1.364 MB
total, from 0.279 MB without it). That leaves 3.8 MB for D-Bus, X11 and a UI
toolkit — a real margin, not a comfortable one.
- The build compiles SQLite from C source, which is a noticeable first-build cost.
- System-linking would reclaim most of the 1.1 MB at the price of a runtime
dependency on the host's libsqlite3, and a schema-format mismatch that only
appears on a user's machine.
**Still open:** bundled vs system-linked. Measured cost is now known; the decision
is the owner's.
**Schema notes.** Relational (`schedule_target`, `schedule_condition`) rather than
JSON blobs in a text column, because target rows are queried by target and an
index on a JSON string is not a plan. Serialisation is hand-written in
`storage/src/codec.rs` — `serde` is not a section 3 dependency, and section 0 rule
3 requires asking before adding one (ADR-13).
---
<a id="adr-5"></a>
## ADR-5: uniffi for core-to-Kotlin bindings
**Status:** Proposed
**Context.** Section 3 specifies `uniffi`, with the explicit caveat that
hand-written JNI is the fallback *if uniffi bloats size*, and that this is to be
asked about.
**Decision.** Start with `uniffi`. Fall back to hand-written JNI if the measured
APK size cost is unacceptable.
**Consequences.** Unmeasured. The decision to measure comes before the decision
to commit, and the measurement belongs to M3-Android.
---
<a id="adr-6"></a>
## ADR-6: Desktop UI toolkit — egui
**Status:** Accepted (the owner chose egui, 2026-10-01)
**Context.** Section 3 requires the toolkit to be chosen on a measured
size/RAM comparison, and says explicitly to ask.
**Decision.** **egui**, licensed MIT OR Apache-2.0.
**Why egui.**
- **The licence decides part of it.** Slint is GPL-3.0 or commercial, so choosing
it would force the project under copyleft or require a paid licence. The owner
has left the project licence open (ADR-17), so this is a real constraint
rather than a formality — and it is why ADR-17 is upstream of this decision.
- **The budget is adequate.** 2.9 MB of the 5 MB stripped daemon budget remained
after storage (1.364 MB) and the D-Bus/X11 backends (2.237 MB) were linked in.
A block screen is text and a countdown; it does not need more.
- **Idle cost is the deciding factor.** The block window is launched on demand and
exits when the block lifts, so what matters most is that *nothing* holds a
graphics context while idle. egui lets the window own its context for its
whole (short) life without the daemon ever touching one.
**What this does not decide.** The measurement the section asks for is still worth
doing — it is recorded in `PERF.md` — but it is now confirmatory rather than
decisive.
**Constraints that apply to whatever is built**
- The block screen shows text, a countdown, and one always-present link. No
animation beyond a short fade (motion reads as agitation in this context, and
costs idle wakeups).
- Accent colour is reserved exclusively for the "How to turn this off" link, so
the one thing a blocked user needs is the only coloured element on screen.
- No red, and no shaming copy.
---
<a id="adr-7"></a>
## ADR-7: Desktop website blocking — browser extension
**Status:** Accepted — the owner chose the extension (2026-10-01)
**Context.** Section 6.2 identifies this as the weak point and requires a choice
between (a) DNS/hosts management, (b) a browser extension over native messaging,
and (c) both.
**Decision.** A Firefox extension over native messaging.
**Consequences, stated honestly.**
Gains: keyword and path-level blocking actually work. Unlike DNS or a hosts file,
it is not defeated by DoH, because it inspects the URL in the browser rather than
the network path.
Costs: the user must install an extension and grant permissions that look
alarming. Firefox-only for now, since Firefox 156 is the only browser on the
development machine. Adoption friction is genuinely high, and that is a real cost
of choosing quality here.
Rejected alternatives:
- **DNS/hosts only** — no keyword blocking, no path-level blocking, and defeated
by DoH. Zero dependencies and no install step, which is why it remains a
reasonable fallback if the extension proves too much friction.
- **Both** — the most complete and the largest surface area to get wrong. Not
chosen because two mechanisms means two sources of truth, and this project has
a strong interest in exactly one decision path.
**The gap that remains.** An extension covers browsers it is installed in. A
determined user can `curl`, or use a browser without it. That is the section 6.7
reality — friction, not a wall — and it stays true here.
**Implementation constraint.** The extension holds no blocking logic of its own.
It asks the daemon, which returns a decision from the same engine that decides
apps. One decision path, so websites and apps cannot disagree.
---
<a id="adr-8"></a>
## ADR-8: Lock levels and explicit opt-in
**Status:** Accepted
**Context.** Section 1.5.1 defines three levels: Normal, Strict, Stubborn. A level
can be raised instantly but lowered only through that level's own disable flow.
**Decision.** Three levels, opt-in only, never enabled by default or by a
schedule template. The exact cost of undoing a lock is shown and confirmed at
activation time.
**Consequences.** State machines per level, in `core/src/lock.rs`. The
asymmetry — raise instantly, lower through the flow — is the entire point and is
encoded in `may_edit_schedule` and `may_pause_schedule`.
Section 1.5.6 additionally forbids any notification action, quick tile, widget,
or tray menu item that disables or opens the disable flow. The flow lives only
inside the app, behind a plain-looking entry.
---
<a id="adr-9"></a>
## ADR-9: Monotonic clock for all lock timers
**Status:** Accepted
**Context.** Section 1.5.3 requires that changing the system date never shortens
a delay, that a detected wall-clock jump *extends* the active lock, and that
timer state survives reboot.
**Decision.** Lock timers use a monotonic clock. `CivilTime` and `Monotonic` are
separate types, and `EngineInput` carries both as separate fields, so lock
arithmetic structurally cannot reach the wall clock.
**Consequences.** Two rules in `apply_correction`, and they pull against each
other:
1. A detected skew **always extends**, taking the absolute value so the
direction of the jump cannot matter.
2. The result is **clamped to the 24-hour cap**.
Without the clamp, rule 1 would let a 3-day clock rollback turn a 12-hour lock
into a 3-day wait — correct on the anti-tamper axis and a violation of section
1.5.4's guarantee that the worst case is 24 hours. Over-extending slightly is
the acceptable failure; ending early defeats the mechanism entirely.
Covered by `wall_clock_correction_respects_both_bounds`.
---
<a id="adr-10"></a>
## ADR-10: Never-block list is hardcoded
**Status:** Accepted
**Context.** Section 1.5.4 requires phone/dialer, emergency calls and SOS, and
emergency alerts to be unblockable, plus the desktop shell and Settings basics.
**Decision.** A `const` list in `core/src/never_block.rs`, per platform. Not a
setting. No flag reverses it.
**Consequences.** The check happens inside the engine's target loop *after*
schedule evaluation, so a schedule naming a protected target still blocks its
other targets while the protected one cannot enter the blocked set. Verified by
`engine_cannot_block_a_never_blocked_app`.
The terminal is deliberately **not** on the list. Section 1.5.4 names the shell
and Settings basics; the terminal can undo anything, so protecting it would be
incoherent.
---
<a id="adr-11"></a>
## ADR-11: We do not block ADB, safe mode, or recovery
**Status:** Accepted
**Context.** Section 6.7 states that friction is not a wall: a user with ADB,
root, safe mode, a factory reset, or desktop admin rights can always remove the
app. Section 1.5.4 makes factory reset, safe mode, and OS-level recovery things
we never interfere with.
**Decision.** We do not attempt to block any of them. This is a product
decision, not a gap in the implementation, and it must not change.
**Consequences.** The goal is to outlast an impulse, not to defeat a determined
person. Every limitation in `LIMITATIONS.md` that could be read as a weakness is
mostly this decision showing through. The docs must never imply otherwise — the
words "unbypassable" and "tamper-proof" are banned from this project for exactly
this reason.
---
<a id="adr-12"></a>
## ADR-12: v1 scope excludes iOS, cloud, sync, and billing
**Status:** Accepted
**Context.** Section 1.8 places these out of scope for v1, and requires the
storage layer to leave room for sync later without building it.
**Decision.** No iOS, no accounts, no sync, no billing. All features are free.
**Consequences.** Schedules carry stable string ids rather than being keyed by
position, so a future sync layer has something stable to reconcile on. Nothing
else is built for sync; designing for it beyond that would be speculative.
---
<a id="adr-13"></a>
## ADR-13: Zero external dependencies in M0
**Status:** Accepted
**Context.** Section 3 requires every added crate to justify its binary-size
cost. The development machine has 9.7 GB free on a 90%-full disk.
**Decision.** No dependencies in M0. Not in `blocker-core`, which the purity test
enforces, and not in the workspace.
**Consequences.** The golden-table format is designed in M0 as a contract but
cannot be parsed until serde is added in M1, because adding a dependency is a
decision requiring the owner's sign-off (section 0 rule 3). That is the correct
order: the format is fixed before it is implemented, so M1 implements against a
contract rather than retrofitting one.
---
<a id="adr-14"></a>
## ADR-14: Linux enforcement backend order — KWin, X11, process
**Status:** Accepted
**Context.** Section 4.3 requires compositor-specific Wayland backends and a
process-based fallback. The development machine is KDE Plasma 6.6.6 running a
Wayland session with XWayland at `DISPLAY=:0`.
**Decision.** One `ForegroundWatcher` trait, three implementations, selected at
runtime in this order:
1. **`KwinBackend`** — a small KWin script loaded over
`org.kde.KWin /Scripting`, which hooks `windowActivated` and reports back.
Verified present on this machine: `loadScript`, `start`, `unloadScript`,
`isScriptLoaded`.
2. **`X11Backend`** — `x11rb` on `_NET_ACTIVE_WINDOW`, resolved through
`_NET_WM_PID` to `/proc/<pid>/exe`. Portable, and testable headless with Xvfb,
so this is also the CI-exercised path.
3. **`ProcessBackend`** — no idea what has focus; acts on process creation.
**Consequences.** The choice is logged at startup and surfaced on the
troubleshooting screen, because "why didn't it block X" is the question users
actually ask.
Two honest limits, both recorded in `LIMITATIONS.md`:
- `X11Backend` is blind to native Wayland apps under a Wayland session. This is
why it is the fallback and not the primary — an X11-only design would be blind
to most of this user's own desktop.
- `/proc/connectors` does not exist on this kernel, so the netlink proc-connector
process watcher is unavailable and `ProcessBackend` must use eBPF or `/proc`
polling. Both are worse than a block screen, which is why this is a safety net
rather than the product.
The wlroots and GNOME backends stay in M7 per section 8, since no portable API
exists to build them against.
---
<a id="adr-15"></a>
## ADR-15: Linux-first milestone order
**Status:** Accepted — the owner chose this reordering (2026-10-01)
**Context.** Section 8 places Android MVP as M2 and desktop as M3. The owner
asked for Linux first.
**Decision.** Reorder to: M0 bootstrap, M1 core, **M2-Linux**, M3-Android, M4
conditions, M5 websites, M6 Strict Mode and friction, M7 statistics, settings,
Wayland backends and releases. Section 8 is amended to match so the spec and the
work do not diverge.
**Consequences.** The rationale is practical: this machine is the only place the
daemon can be run, broken, and re-tested daily, and no phone is attached.
Building Android first would mean weeks of code that cannot be exercised
end to end.
Content within each milestone is unchanged. One item moves with the reorder:
the KWin backend, which section 8 would have placed in M7, is now part of M2-Linux
(ADR-14) because a Linux build that ignores Wayland would not work on this
machine.
---
<a id="adr-16"></a>
## ADR-16: Golden tables are Rust, not JSON
**Status:** Accepted and implemented. The original JSON rationale turned out to
be wrong, and is recorded below rather than quietly replaced.
**Context.** Section 7 requires golden decision tables as the cross-platform
parity contract.
**What was originally decided, and why it was wrong.** JSON, on the reasoning
that `serde_json` would already be in the dependency graph. That was falsified in
practice: `rusqlite` does **not** pull `serde` by default, and `uniffi` had not
landed, so nothing in the graph provided it. `serde` is not a section 3
dependency, and section 0 rule 3 requires asking before adding one.
**Decision.** A builder DSL in Rust. `tests/src/golden.rs` holds the format and
harness, `tests/src/tables.rs` the cases, `tests/src/coverage.rs` the coverage
bookkeeping, `tests/src/run.rs` the runner.
**Rationale.** The same declarations, with two properties JSON cannot offer: the
compiler rejects a typo in a field name, and a wrong variant is a type error
rather than a test that quietly asserts the wrong thing. For a document whose
entire job is to be a contract, compile-time checking is worth more than
diffability.
Converting to JSON later, if serde is approved, is mechanical — only
`tests/src/golden.rs` changes and the case bodies become literals instead of
builder calls.
**Also settled here.** Three scenarios are deliberately *not* table-shaped.
Clock tampering and the 24-hour cap are about the passage of time and a bound
across lock levels, not a decision at an instant, so they live as code fixtures
in `tests/src/purity.rs`. `coverage::Location` records which is which, so
"covered" can never quietly mean "listed but not tested".
---
<a id="adr-17"></a>
## ADR-17: Licence not yet chosen
**Status:** **Open — owner decision required**
**Context.** The project has no licence. This matters more than usual here,
because of ADR-6: Slint is GPL-3.0 or commercial, so the licence choice and the
UI toolkit choice interact.
**Decision.** None yet. `LICENSE.md` states the project is unlicensed and that a
licence will be chosen before the first public release, rather than picking one
silently.
**Why this is not decided here.** Choosing a licence is the owner's decision,
and section 0 rule 3 requires stopping to ask. Silently applying MIT or GPL would
be the wrong kind of helpfulness: it is a legal commitment made on someone's
behalf, and it interacts with the toolkit choice in ADR-6.
**Default until answered:** the project stays unlicensed and unpublished. Nothing
in the plan depends on the answer except the ADR-6 toolkit shortlist.
-201
View File
@@ -1,201 +0,0 @@
# Limitations
CLAUDE.md section 6 is titled "Known hard limits (tell me, don't hide them)". This
file is that promise, kept in writing.
Two rules govern the wording here. Every limitation is stated as a fact about the
operating system, not as a bug with a workaround. And no entry uses the words
"unbypassable", "tamper-proof", "guaranteed", or "secure" — section 6.1 and 6.7
require those claims not be made, and writing around them is how a document
quietly starts lying.
Nothing in this file is aspirational. Where a limitation is not yet implemented,
it says so.
---
## The safety guarantees, stated first
Before the limits, the guarantees. Section 1.5.4 makes these non-negotiable, and
they constrain what the limitations below are allowed to be.
- **Never-block list.** Phone, dialler, emergency calls and SOS, and emergency
alerts can never be blocked. On desktop, the OS shell and the Settings basics
needed to keep the machine usable. Hardcoded in
`core/src/never_block.rs`, not a setting, with no flag that reverses it. The
check happens inside the engine after schedule evaluation, so a schedule naming
a protected target blocks its other targets and cannot block the protected one.
- **Finite worst case.** The longest possible path to disabling blocking is 24
hours. No lock exists without an exit. Asserted by
`worst_case_disable_path_is_capped`.
- **Recovery is never interfered with.** Factory reset, safe mode, and OS-level
recovery are not blocked, and never will be. Section 6.7: that is correct
behaviour, not a gap.
- **Fail open.** A service crash loop or a corrupt database must never leave a
device unusable. Recovery renames the damaged file aside and starts clean
rather than failing to launch.
- **Opt-in only.** Strict and Stubborn are never enabled by default, and never by
a schedule template.
- **Always escapable.** The "How to turn this off" documentation is reachable
*without* disabling anything, including under Strict Mode.
---
## 1. Desktop Strict Mode cannot be made tamper-proof against root or admin
**Status:** fundamental. Not being worked around.
On desktop, a user with administrative rights, or root, can stop the service, edit
its files, delete it, or inspect its memory. No design prevents this. Section 6.1
requires this be documented honestly and that we not claim otherwise.
Mitigations actually implemented:
- A watchdog service that restarts the daemon if it dies.
- File ACLs, and where the filesystem supports it, an immutable flag on config.
- Restart-on-kill, so a kill is not durable.
- A tamper log, so the attempt is recorded even though it succeeds.
What none of this changes: a determined user with root removes the app. The goal
is to outlast an impulse, not to defeat a determined person.
## 2. Desktop website blocking has no single clean mechanism
**Status:** decided (ADR-7), with a residual gap.
Shipping a Firefox extension over native messaging. That makes keyword and
path-level blocking work, and unlike DNS or a hosts file it is not defeated by
DoH, because it inspects the URL in the browser rather than the network path.
The costs are real: the user must install an extension and grant permissions that
look alarming, and Firefox-only for now.
**The residual gap:** an extension covers browsers it is installed in. A
determined user can `curl`, or use a browser without it. This is section 6.7
territory — friction, not a wall.
DNS/hosts-only blocking was rejected because it cannot do keyword or path-level
matching and cannot defeat DoH. Doing both was rejected because two mechanisms
means two sources of truth, and this project has a strong interest in exactly one
decision path.
## 3. Wayland focused-window detection is compositor-dependent
**Status:** partially implemented (ADR-14), partly not.
There is no portable API for "which window has focus" on Wayland. Each compositor
has its own mechanism, and none of them is a standard.
Implemented:
- **KDE Plasma** — a script loaded over `org.kde.KWin /Scripting`. Verified
available on the development machine.
- **X11** — `x11rb` on `_NET_ACTIVE_WINDOW`. Works on any X11 session, and on
XWayland clients under Wayland. **Blind to native Wayland apps**, which is why
it is a fallback rather than the primary.
- **Process-based fallback** — no idea what has focus, acts on process creation,
and cannot show a meaningful block screen.
Not implemented, still in M7:
- **wlroots** compositors, via `foreign-toplevel-management`.
- **GNOME**, via an extension.
A further degradation on this machine specifically: `/proc/connectors` does not
exist on its kernel, so the netlink proc-connector process watcher is
unavailable. The process fallback must use eBPF or `/proc` polling instead, both
of which cost CPU and none of which can produce a proper block screen.
## 4. Location schedules are mobile-only
**Status:** implemented as a deliberate absence on desktop.
There is no reliable desktop equivalent. The engine carries the condition and
validation *rejects* it on non-Android platforms
(`ValidationError::LocationOnDesktop`), and the desktop UI hides it.
This is a design choice rather than an omission. A condition that is offered but
never fires is worse than one that is not offered: it teaches the user the app
does not work.
## 5. Android OEMs kill background services aggressively
**Status:** not yet implemented. M3-Android.
Xiaomi, Huawei, Samsung and others kill background services in ways that vary by
model and by version. An accessibility-service-based blocker is exactly the kind
of process these vendors target.
The plan is a troubleshooting screen covering autostart and battery settings per
OEM. **Not yet written**, and no OEM-specific workaround exists yet.
## 6. Android accessibility URL reading breaks when browser UIs change
**Status:** not yet implemented. M5.
Reading a browser's URL bar through the accessibility tree means depending on that
browser's internal view structure, which changes without notice. Every browser
needs its own resource-id table, and those tables rot.
The plan is a per-browser table with a maintenance path. **No table exists yet**,
and there is no mechanism for a user to report a broken browser other than an
issue. This will be a recurring support cost, not a one-time fix.
## 7. Friction is not a wall
**Status:** by design. Section 1.5.4 and 6.7.
A user with ADB, root, safe mode, a factory reset, or administrative rights on
desktop can always remove this app. Blocking ADB, safe mode, or OS recovery is
not attempted and never will be — ADR-11.
What the friction ladder does buy is time, in the moment, against an impulse. A
user who decides calmly and deliberately still gets through, within a bounded 24
hours.
Stating this plainly matters for a reason beyond honesty: a product that claims to
be unremovable is a product whose users cannot trust its other claims either.
## 8. Play Store policy is hostile to apps that resist uninstall
**Status:** accepted. Distribution decision not yet finalised.
Accessibility and Device Admin use require policy justification, and both are used
here for core functionality. An app that resists uninstall is close to
categorically unpublishable.
The plan is to keep the strongest friction behind an explicit opt-in and treat a
sideload or F-Droid flavour as the primary distribution for it. **Not yet built.**
---
## Limits specific to this implementation
Not platform limits — known shortfalls in the current code, recorded so they are
not mistaken for platform facts.
- **`ProcessBackend` is not implemented.** ADR-14 lists three backends; the KWin
and X11 paths land in M2-Linux, and the process fallback is weaker than the
spec assumes because of the `/proc/connectors` gap above.
- **`rusqlite` vs system-linked SQLite is unresolved** (ADR-4). Bundled is built
and costs ~1.1 MB of the 5 MB daemon budget. System-linking would reclaim most
of that but adds a runtime dependency on the host's libsqlite3.
- **Lock state is not yet persisted or enforced.** `core/src/lock.rs` has the
timers, the escalation, and the wall-clock correction, all tested. The state
machine is not yet driven from a UI, and `LockState::evaluate` currently returns
the Normal level unconditionally.
- **DST coverage is partial, and the boundary is stated.** `CivilTime` is an
epoch value plus a UTC offset, with no IANA timezone database behind it — the
`time` crate is not a section 3 dependency (ADR-16). The golden tables pin the
observable rule, that a fixed local window stays fixed as the offset moves,
including across a DST transition. What they **cannot** express is a local time
that does not exist because the clock sprang forward, or one that is ambiguous
because it fell back. Those are cases section 7 lists that are not yet
covered. This is the strongest argument for a timezone-aware time type.
- **`LockState` still always evaluates to `Normal`** — persistence works, but no
UI drives the lock state machine yet.
- **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather
than the schedule's own target set, until targets carry usage keys.
- **Windows is entirely unimplemented** and cannot be compiled or tested on the
development machine, so writing enforcement now would mean shipping unverified
code.
-124
View File
@@ -1,124 +0,0 @@
# Performance
Two tables. **Targets** are from CLAUDE.md section 5 and are fixed. **Results** is
what was actually measured, and it is currently almost empty because almost
nothing is built yet.
The separation is deliberate. A single table mixing aspiration with measurement
invites reading the aspiration as the result.
## Targets (CLAUDE.md section 5)
| Metric | Android | Windows / Linux daemon |
|---|---|---|
| Idle CPU | ~0% (no wakeups without events) | ~0%, ≤ 1 wakeup/min when idle |
| Idle RAM | ≤ 30 MB PSS | ≤ 15 MB RSS (daemon), UI process exits when its window closes |
| Block latency (foreground change → block shown) | ≤ 150 ms | ≤ 150 ms |
| Daemon binary | — | ≤ 5 MB stripped |
| APK size | ≤ 10 MB (release, R8) | — |
| Battery | no wakelocks outside active enforcement | — |
The release profile in the workspace `Cargo.toml` carries `opt-level = "z"`, LTO,
`codegen-units = 1`, `panic = "abort"`, and `strip = true`, which is what makes the
size budget reachable. `scripts/size-check.sh` enforces it in CI from the first
commit.
## Results
Measured on: Ubuntu 26.04 LTS, KDE Plasma 6.6.6, 6 CPUs. Toolchain: rustc 1.98.1.
| Metric | Target | Measured | When |
|---|---|---|---|
| Daemon binary (stripped, release) | ≤ 5 MB | **1.364 MB** | 2026-10-01, M1 |
| Block latency | ≤ 150 ms | not measured | — |
| Idle CPU | ~0% | not measured | — |
| Idle RAM (daemon) | ≤ 15 MB RSS | not measured | — |
| Idle wakeups | ≤ 1/min | not measured | — |
| APK size | ≤ 10 MB | not measured | — |
**What the 1.364 MB contains, and why it matters.** That figure is with bundled
SQLite linked in and the daemon actually opening a database — not the 0.279 MB
placeholder from M0, which was two `println!` lines and excluded SQLite entirely
through dead-code elimination.
So the honest read is: **bundled SQLite costs roughly 1.1 MB of a 5 MB budget.**
That leaves 3.8 MB for D-Bus, X11, and the UI toolkit. It is a real constraint
rather than a comfortable margin, and it is the first number that makes the
section 5 budget feel like a budget instead of a formality.
It also makes ADR-4 (rusqlite bundled vs system-linked) a genuine trade-off rather
than a settled one: system-linking would recover most of that 1.1 MB at the cost
of a runtime dependency on the host's libsqlite3. Recorded as an open question.
## How to measure
### Linux daemon
```sh
# Binary size — the gate CI runs
scripts/size-check.sh
# Idle RSS
./systemctl --user start blocker
ps -o rss= -C blocker-daemon | awk '{printf "%.1f MB\n", $1/1024}'
# Idle CPU — sample over a minute, not instantaneously
pidstat -p "$(pidof blocker-daemon)" 10 6
# Block latency — timestamp on the compositor event, timestamp on the window map
# TODO: script this in M2-Linux once the KWin backend emits both.
```
`perf stat -e context-switches,cpu-migrations` is useful for the wakeup count. If
idle wakeups exceed one per minute, a timer is firing that `next_wakeup` did not
ask for — that is the first thing to look for.
### Windows
Process Explorer, or ETW. The relevant counters are working set (RAM) and the
process's CPU over a sampled window.
### Android
```sh
adb shell dumpsys meminfo vu.valiq.blocker # PSS, must be <= 30 MB
adb shell dumpsys batterystats vu.valiq.blocker
adb shell am profile start --sampling 1 vu.valiq.blocker /data/local/tmp/p
```
Perfetto for the wakeup analysis. A wakeup from the blocked package when nothing is
happening is a bug in `next_wakeup` accounting, and battery stats will show it
long before a user complains.
## Why idle is hard, and what breaks it
The budget is zero idle wakeups, so the common causes are worth naming.
1. **A timer that fires to check something.** The engine's job is to make these
unnecessary. Every condition that contributes a `next_wakeup` boundary is a
deliberate, named commitment. A new timer that does not correspond to a
boundary is a bug.
2. **Conditions that should contribute no boundary but do.** Wi-Fi and location
schedules are changed by OS events — NetworkManager's callback, the geofencing
API. Arming a timer for them would be a wakeup for nothing.
`wifi_schedule_declares_no_time_wakeup` pins this.
3. **The UI holding a GL context.** A resident graphics context costs both RAM and
compositor wakeups. This is why the daemon and the block window are separate
processes: the window exits when the block lifts, and nothing holds a context
while idle.
4. **Unbounded `Vec` growth in the engine.** `blocked_apps` is rebuilt per
evaluation. With a handful of schedules it is small, but a schedule set in the
thousands would allocate per focus event. `next_wakeup` returning `None` means
the daemon is asleep and this does not matter, which is exactly why the budget
and the design reinforce each other.
5. **Storage polling.** Nothing in the design polls the database. If a future
change introduces it, it will show up here first.
## Recording a result
Add a row to the table above with the date and the tool used. Then note in the
commit message whether the change touches the hot path — section 9 requires a
re-measurement when it does.
A milestone is not done until its acceptance checks pass **and** its numbers are
recorded here (section 7).
-133
View File
@@ -1,133 +0,0 @@
# Permissions
Every permission the design requires, per platform, with what it is for, what
degrades without it, and how invasive it feels.
Two permissions ask for more than a user expects. This file says so rather than
burying it: **accessibility service** on Android, and **browser extension
permissions** on desktop (ADR-7). Both grant the ability to observe and control
other applications. There is no way to provide the feature without them.
Where an optional permission is refused, the documented behaviour is what
happens — not a silent failure.
---
## Android
### Required
| Permission | Why | Without it |
|---|---|---|
| `BIND_ACCESSIBILITY_SERVICE` | Foreground-app detection. Section 4.1 specifies `TYPE_WINDOW_STATE_CHANGED`, which is event-driven and needs no polling. | **Nothing blocks.** This is the single permission the product cannot work without. |
| `SYSTEM_ALERT_WINDOW` | The block screen is an overlay that must appear over whatever the user just opened. | No block screen. Blocking would have to become a kill, which is far more destructive and loses the user's state. |
| `PACKAGE_USAGE_STATS` | Usage-limit and launch-count conditions (section 1.2), and historical statistics (section 1.6). Granted via Settings, not at install. | Usage and launch-count conditions are unavailable. Time conditions still work. |
| `BIND_DEVICE_ADMIN` | Uninstall protection under Strict Mode (section 1.4). | Strict Mode cannot block uninstall. The other restrictions still apply. |
| `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_SPECIAL_USE` | A persistent notification so the service is not killed silently. | The service is far more likely to be killed by the OS, with nothing telling the user why. |
| `RECEIVE_BOOT_COMPLETED` | Re-apply blocks after a reboot. | Enforcement does not survive a reboot until the app is opened by hand. |
| `POST_NOTIFICATIONS` | The persistent notification itself. | The foreground service runs without its notification, and users cannot tell it is running. |
| `QUERY_ALL_PACKAGES` | Enumerating installed apps for allowlist mode (section 1.1) and for resolving a desktop-file id to a name. | Allowlist mode cannot enumerate, so it blocks nothing (fail open, section 1.5.4). Blocklist mode is unaffected. |
**On `QUERY_ALL_PACKAGES`:** this one is Play-Store-sensitive and needs a policy
justification. It is requested because allowlist mode requires knowing what is
installed, and there is no narrower permission that does that job.
### Optional
| Permission | Why | Without it |
|---|---|---|
| `ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` | Location conditions (section 1.2). | The condition is hidden in the UI rather than offered and never firing. Mobile-only per section 6.4. |
| `ACCESS_WIFI_STATE` | Wi-Fi SSID conditions (section 1.2). | SSID conditions unavailable. Time conditions unaffected. |
| `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | Exemption from Doze, which would otherwise suspend enforcement. | Works, but only while the screen is on, on most OEMs. |
### Behaviour when location is unavailable
Section 1.7 makes this configurable. The default is **do not block**: a lost GPS
fix should not mean a locked device. The alternative — treat unavailable as
present — exists for schedules whose purpose is to block *leaving* somewhere.
### OEM autostart
Section 6.5: Xiaomi, Huawei, and Samsung kill background services in
model-specific ways that a permissions dialog cannot fix. Autostart is granted in
the vendor's own UI, not Android's.
| Vendor | Where |
|---|---|
| Xiaomi / Redmi | Settings → Apps → Autostart |
| Huawei / Honor | Settings → Battery → App launch, then enable auto-launch, secondary launch, and run in background |
| Samsung | Settings → Apps → Blocker → Never sleeping apps |
| OnePlus / Oppo | Settings → Battery → App battery management → Allow background activity |
**Not yet implemented.** The troubleshooting screen arrives in M7. Until then
these workarounds are not documented anywhere the user can find.
---
## Linux
The X11 and KWin paths need no special permissions. That is the main advantage
over Android and worth stating plainly.
| Mechanism | Needs | Notes |
|---|---|---|
| KWin script over `org.kde.KWin /Scripting` | Nothing | Session D-Bus, available to any process in the session. Verified present on KDE Plasma 6.6.6. |
| X11 via `x11rb` | Nothing | `DISPLAY` access only. **Blind to native Wayland apps.** |
| NetworkManager SSID via D-Bus | Nothing | Session bus. |
| `systemd --user` service | `loginctl enable-linger` for post-logout operation | Without linger, the daemon stops when the user logs out, which is usually correct. |
| Process watching via `/proc` polling | Nothing | Used only by the degraded fallback. |
| Process watching via eBPF | Likely **CAP_BPF / CAP_SYS_ADMIN**, or permissive sysctls | Not verified on the development machine. |
### What an unprivileged daemon cannot do
Recorded because it is a platform limit, not a missing feature. See
`desktop/platform-linux/src/lib.rs::UNPRIVILEGED_LIMITS`:
- Prevent a process from launching. It can only react after the fact.
- Mask a systemd unit, which needs root. Tamper resistance is `Restart=always`
plus a watchdog, and nothing stronger.
- Protect its own configuration from the user who owns the files. Lock state
lives in the database instead (section 1.5.3).
- Read `/proc/<pid>/exe` for processes owned by other users.
- Stop a process already running as root.
**Not verified:** whether eBPF process tracing works unprivileged here. The
relevant sysctls (`kernel.unprivileged_bpf_disabled`,
`kernel.perf_event_paranoid`) were not read, so the eBPF path is assumed
unavailable rather than known to work. `/proc` polling is the assumed fallback.
---
## Windows
Nothing implemented yet, so nothing is measured or verified. From section 4.2:
| Mechanism | Needs | Notes |
|---|---|---|
| `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)` | Nothing | Foreground detection. Event-driven, no polling. |
| Minimise / close a window | Nothing | Standard user-session APIs. |
| Terminating a process owned by another user | Elevation | Same-user processes need nothing. |
| WLAN API notification callback | Nothing | Wi-Fi SSID conditions. |
| Windows Service watchdog | Administrator, to install | Restarts the per-user daemon if it dies. |
---
## Browser extension (desktop, ADR-7)
The extension is a separate permission surface from the app, and arguably the most
alarming one — it is worth being precise about what it asks for.
| Permission | Why | Without it |
|---|---|---|
| Host permissions for `<all_urls>` | Read the URL of every page the user visits, to match domains and keywords. | Nothing blocks in the browser. |
| `webRequest` or equivalent URL observation | Same, plus redirect handling. | Domain blocking degrades to what the user navigates directly. |
| `nativeMessaging` | Ask the daemon whether to block. | **No blocking at all.** The extension holds no blocking logic of its own, by design (ADR-7). |
The extension contains no blocking logic. It asks the daemon, which returns a
decision from the same engine that decides apps. That is why the permission looks
broader than its behaviour: it observes every URL so it can ask about it, but it
cannot block anything the engine does not decide to block.
**The residual gap, stated plainly:** an extension covers browsers it is installed
in. `curl`, or a browser without the extension, is not covered. That is section
6.7 — friction, not a wall.
-196
View File
@@ -1,196 +0,0 @@
# Testing
## What the golden tables are for
CLAUDE.md section 7 requires that the same decision table runs against the core
and serves as the parity contract for all platforms.
The point is not to test the engine — unit tests do that. It is to make
cross-platform divergence a **build failure** rather than a bug report from
whichever platform nobody was testing that week.
```
golden/*.json ──┬──▶ core::evaluate ──▶ must match `expect`
├──▶ Linux identity mapping ──▶ must match `expect`
└──▶ Android identity mapping ──▶ must match `expect`
```
Each platform implements a mapping from its own app identity (Android package
name, Linux desktop-file id, Windows exe path) onto a `Target`. The engine is
identical. If the mappings disagree about what an app *is*, a table case catches it.
## The rule that keeps tables meaningful
**A case is added before the engine changes.**
A table written after the code records current behaviour. It can confirm nothing
changed and can never catch a regression, because it was fitted to whatever the
code happened to do.
So a PR that changes engine behaviour is reviewed on one question: *is there a
case added or updated in this same commit?* If not, the change is undocumented
behaviour, and that is a review failure, not a nit.
## Format
JSON, one file per concern, under `tests/golden/`. The full type contract is in
`tests/src/golden.rs`; that file is written before any parse layer exists, so M1
implements against a fixed format.
```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"
}
}
]
}
```
Two conventions that matter:
- **`days` is Monday = 0**, matching `time::Weekday`.
- **`start > end` means overnight.** No `crosses_midnight` flag — the comparison
already encodes it.
- **`civil_time` always carries an explicit UTC offset**, so a DST case is
unambiguous.
- **`next_wakeup: "none"`** is a legal, meaningful expectation. It asserts the
platform may sleep forever, which is the zero-idle-wakeup budget made
testable.
## Mandatory coverage
Every entry in `golden::MANDATORY_COVERAGE` must be claimed by at least one
`covers` field, and a test asserts it — so a scenario cannot silently lose its
test when someone reorganises files.
| Tag | Scenario |
|---|---|
| `7:overnight time ranges` | Windows spanning midnight, including the weekday-of-start bug |
| `7:DST changes` | Spring-forward (a local time that does not exist) and fall-back (an ambiguous one) |
| `7:timezone changes` | Same instant, different local offset |
| `7:clock tampering` | System date moved; a lock must not shorten |
| `7:strictest-wins` | Overlapping schedules, blocklist and allowlist together |
| `7:allowlist mode` | Complement semantics, and the empty-inventory fail-open case |
| `7:invalid condition combos` | Each forbidden pairing from section 1.2 |
| `7:strict mode lock rules` | Section 1.4: no editing, pausing, or uninstalling |
| `1.2:time+usage rejected` | |
| `1.2:time+launch rejected` | |
| `1.2:usage+launch rejected` | |
| `1.2:usage+location rejected` | |
| `1.2:launch+location rejected` | |
| `1.5.4:never-block list is unblockable` | |
| `1.5.4:worst case 24h` | |
## Tables cannot express the passage of time
Most cases are pure data. Clock tampering is not: proving that moving the system
date never shortens a lock requires actually moving a clock, which no JSON file
can express.
Those cases live as code fixtures in `tests/src/purity.rs` instead, and the two
coexist deliberately:
- **Tables describe decisions.** Given a state, what is the decision?
- **Fixtures describe time.** Given that time moved, does the lock behave?
Current fixtures, all of which pass:
| Fixture | What it proves |
|---|---|
| `backwards_wall_clock_never_shortens_a_lock` | Setting the date back 3 days does not shorten a 12h lock |
| `wall_clock_correction_respects_both_bounds` | A skew extends, and clamps to the 24h cap |
| `an_undisturbed_clock_costs_nothing` | An honest clock adds no waiting |
| `reboot_resumes_conservatively` | A reboot does not reset a lock |
| `worst_case_disable_path_is_capped` | No level exceeds 24h |
| `never_block_list_is_unblockable` | The safety list is protected and does not over-match |
| `engine_cannot_block_a_never_blocked_app` | The engine itself enforces it, not just the list |
| `dependency_purity` | `blocker-core` still has zero dependencies |
## Engine unit tests
Alongside the tables, `core/src/engine.rs` carries tests for the logic that is
hard to express as data — overnight windows, weekday indexing, boundaries, the
allowlist complement, and `next_wakeup` behaviour.
`weekday_indexing_is_monday_zero` is the anchor test. If weekday arithmetic breaks,
every other overnight test is quietly testing the wrong day, so this one asserts
its own calendar dates directly.
Two tests exist specifically to protect the idle budget:
- `wifi_schedule_declares_no_time_wakeup` — a schedule only an OS event can change
must not arm a timer.
- `inactive_schedule_yields_no_wakeup` — a schedule that is inactive now but starts
later must still arm a wakeup. This is the subtle direction: over-sleeping means
the schedule never fires.
## Property tests
To be added in M1, once the tables exist. Candidates, in rough priority order:
- `time_window_contains` never panics for any `days`/`start`/`end` combination.
- `evaluate` is deterministic: same input, same output, always.
- A never-blocked target never appears in `blocked_apps`, for any schedule set.
- `next_wakeup`, when present, is strictly in the future relative to `now`.
- Strictest-wins is monotonic: adding a schedule never unblocks anything.
## Platform testing
### Linux
The X11 path is testable headless with Xvfb, which makes it the CI-exercised
backend. The KWin path cannot be — it needs a real Plasma session — so its
testing is manual, against the checklist below.
Manual, on a real session:
- [ ] Focusing a blocked native Wayland app raises the block screen within 150 ms
- [ ] Focusing a blocked XWayland app does the same
- [ ] Focusing an allowed app shows nothing
- [ ] The daemon survives a suspend/resume cycle
- [ ] The daemon survives logout/login
- [ ] Killing the daemon restarts it, and the tamper log records it
- [ ] The backend actually selected is the one expected on this session type, and
the troubleshooting screen says which and why
- [ ] With KWin scripting disabled, the daemon falls back and says so
- [ ] Idle over 10 minutes: no wakeups, RSS under 15 MB
### Android
Instrumented tests for the AccessibilityService path, which cannot run on a
desktop JVM. M3-Android.
### Manual checklist, per OS
The full checklist lives in `docs/TESTING.md` and grows with each milestone. Every
entry maps to a section 1 requirement, so an uncovered requirement shows as an
uncovered checklist line.
## Running
```sh
scripts/ci.sh # everything, stop at first failure
scripts/ci.sh --keep-going # everything, summarise all failures
cargo test --workspace # just the tests
```
A milestone is not done until its acceptance checks pass **and** its performance
numbers are recorded in `PERF.md` (section 7).
If a check fails, it gets reported and fixed. Weakening a test to make it pass is
prohibited by section 10.6, and a test deleted to make a build green is the same
mistake by another route.
-7
View File
@@ -1,7 +0,0 @@
[toolchain]
# Pinned so a local build and a CI build are the same compiler. Section 10.4
# requires every commit to build and pass tests on its own, which is only
# meaningful if the toolchain does not drift underneath it.
channel = "stable"
profile = "minimal"
components = ["rustfmt", "clippy"]
-23
View File
@@ -1,23 +0,0 @@
edition = "2021"
max_width = 100
# NOTE ON UNSTABLE OPTIONS
#
# `imports_granularity`, `group_imports` and `derive_debug` are nightly-only
# rustfmt features. rust-toolchain.toml pins stable, so listing them here emits
# a warning per crate per file and buys nothing. They are intentionally absent
# rather than commented out: an unrecognised key is a warning, a commented key
# is a trap for whoever re-enables nightly.
#
# If this project ever moves to nightly rustfmt, add:
# imports_granularity = "Crate"
# group_imports = "StdExternalCrate"
# derive_debug = false
# as a separate commit, so the diff is auditable.
# Trailing comma on match arms adds vertical noise in the evaluator, which is
# almost entirely match expressions.
match_block_trailing_comma = false
# Use small heuristics instead of the unstable ones.
use_small_heuristics = "Max"
-193
View File
@@ -1,193 +0,0 @@
#!/usr/bin/env bash
#
# Blocker CI — the source of truth for what must pass before a PR is mergeable.
#
# CLAUDE.md section 10.4 requires a Gitea workflow to run these same checks. It
# calls THIS script rather than reimplementing the steps, so the two cannot
# drift apart. That is the whole reason this file exists separately from any
# workflow definition.
#
# Usage:
# scripts/ci.sh run every check, stop at the first failure
# scripts/ci.sh --keep-going run every check, summarise all failures at the end
# scripts/ci.sh --help
#
# Note on skips: a skipped check never counts as a pass. Every skip names what
# was skipped and why, because a skip that reads as a green tick is how CI ends
# up quietly not testing anything.
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
KEEP_GOING=0
usage() {
cat <<'USAGE'
Usage: scripts/ci.sh [--keep-going] [--help]
Checks, in order:
1. cargo fmt --all --check
2. cargo clippy --workspace --all-targets -- -D warnings
3. cargo test --workspace --all-targets
4. size gate: stripped release daemon <= 5 MB (section 5)
5. Android lint + assembleRelease (skipped loudly if no SDK)
6. gitleaks secret scan (skipped loudly if not installed)
--keep-going run every check and report all failures together
USAGE
}
while (( $# )); do
case "$1" in
--keep-going) KEEP_GOING=1 ;;
-h | --help)
usage
exit 0
;;
*)
printf 'unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
shift
done
TOTAL_STEPS=6
FAILED=()
SKIPPED=()
STEP_NUM=0
run_step() {
STEP_NUM=$((STEP_NUM + 1))
printf '\n%s[%d/%d]%s %s\n' \
"${C_BLUE}" "${STEP_NUM}" "${TOTAL_STEPS}" "${C_RESET}" "$1"
shift
local output
# Each check runs in a subshell so a failure is capturable and the
# --keep-going summary can report it, rather than `set -e` aborting the
# whole run with no record of what had already passed.
if output="$("$@" 2>&1)"; then
[[ -n "${output}" ]] && printf '%s\n' "${output}" | sed 's/^/ /'
return 0
fi
[[ -n "${output}" ]] && printf '%s\n' "${output}" | sed 's/^/ /'
return 1
}
record() {
if (( KEEP_GOING )); then
FAILED+=("$1")
return 0
fi
printf '\n%scheck failed:%s %s\n' "${C_RED}" "${C_RESET}" "$1"
exit 1
}
printf '%sBlocker CI%s\n' "${C_BLUE}" "${C_RESET}"
info "repo: ${REPO_ROOT}"
info "target: ${CARGO_TARGET_DIR}"
# Cargo resolves a workspace from the current directory, not from the script's
# location, so a human running this from ~/Desktop and a CI runner invoking it by
# absolute path both need the CWD moved. Everything below can then use plain
# `cargo` invocations.
cd -- "${REPO_ROOT}"
# --- 1. Formatting ---------------------------------------------------------
if run_step 'cargo fmt --all --check' cargo fmt --all -- --check; then
pass 'formatting'
else
record 'cargo fmt --all --check'
fi
# --- 2. Clippy -------------------------------------------------------------
if run_step 'cargo clippy -D warnings' \
cargo clippy --workspace --all-targets -- -D warnings; then
pass 'clippy'
else
record 'cargo clippy -D warnings'
fi
# --- 3. Tests --------------------------------------------------------------
if run_step 'cargo test --workspace' cargo test --workspace --all-targets; then
pass 'tests (including golden decision tables)'
else
record 'cargo test --workspace'
fi
# --- 4. Size gate ----------------------------------------------------------
# Section 5: the stripped desktop daemon must not exceed 5 MB. In M0 the daemon
# is a placeholder that prints two lines, so this is far under budget; the gate
# becomes meaningful in M2-Linux once enforcement code exists. It runs from M0
# anyway so that the budget is enforced by tooling from the first commit rather
# than being introduced as a new failure later.
printf '\n%s[4/6]%s size gate (section 5: stripped daemon <= 5 MB)\n' \
"${C_BLUE}" "${C_RESET}"
if run_step 'cargo build --release -p blocker-daemon' \
cargo build --release -p blocker-daemon; then
if "${REPO_ROOT}/scripts/size-check.sh"; then
pass 'size gate'
else
record 'size gate'
fi
else
record 'cargo build --release -p blocker-daemon'
fi
# --- 5. Android ------------------------------------------------------------
# Skipped loudly, never silently. CLAUDE.md section 10.4 says to ask whether the
# runner has the SDK; until that is answered, detection plus an honest message is
# the truthful behaviour. A pass here would be a lie.
printf '\n%s[5/6]%s Android lint + assembleRelease\n' "${C_BLUE}" "${C_RESET}"
if SDK_ROOT="$(android_sdk_root)"; then
info "SDK: ${SDK_ROOT}"
if run_step 'android assembleRelease' \
"${REPO_ROOT}/android/gradlew" -p "${REPO_ROOT}/android" lint assembleRelease; then
pass 'android'
else
record 'android lint/assembleRelease'
fi
else
skip 'android lint + assembleRelease' \
'no Android SDK found (ANDROID_HOME / ANDROID_SDK_ROOT unset, ~/Android/Sdk absent). Not verified by this run.'
SKIPPED+=('android')
fi
# --- 6. Secret scan --------------------------------------------------------
# Section 10.1 requires a secret scanner in CI and in ci.sh. gitleaks is not
# installed on every machine and that is not a reason to skip it quietly.
printf '\n%s[6/6]%s secret scan (gitleaks)\n' "${C_BLUE}" "${C_RESET}"
if command -v gitleaks > /dev/null 2>&1; then
# `gitleaks detect` never prints matched secret values, only file paths and
# rule names — so this output is safe to paste into a PR.
if run_step 'gitleaks detect' \
gitleaks detect --source "${REPO_ROOT}" --redact --no-banner; then
pass 'secret scan'
else
record 'gitleaks detect'
fi
else
skip 'gitleaks secret scan' \
'gitleaks not installed. Install it (https://github.com/gitleaks/gitleaks) before merging. NOT a pass.'
SKIPPED+=('gitleaks')
fi
# --- Summary ---------------------------------------------------------------
printf '\n%s%s%s\n' "${C_BLUE}" '── summary ──' "${C_RESET}"
if (( ${#FAILED[@]} )); then
printf '%s%d check(s) failed:%s\n' "${C_RED}" "${#FAILED[@]}" "${C_RESET}"
for f in "${FAILED[@]}"; do printf ' - %s\n' "${f}"; done
exit 1
fi
if (( ${#SKIPPED[@]} )); then
printf '%s%d check(s) skipped — these are NOT passes:%s\n' \
"${C_YELLOW}" "${#SKIPPED[@]}" "${C_RESET}"
for s in "${SKIPPED[@]}"; do printf ' - %s\n' "${s}"; done
fi
printf '%sall executed checks passed%s\n' "${C_GREEN}" "${C_RESET}"
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env bash
#
# Shared helpers for the Blocker CI scripts.
#
# Sourced, never executed.
set -euo pipefail
# --- Colours ---------------------------------------------------------------
# Only when stdout is a terminal, so CI logs stay clean and diffable.
if [[ -t 1 ]]; then
C_RESET=$'\033[0m'
C_RED=$'\033[31m'
C_GREEN=$'\033[32m'
C_YELLOW=$'\033[33m'
C_BLUE=$'\033[34m'
C_DIM=$'\033[2m'
else
C_RESET='' C_RED='' C_GREEN='' C_YELLOW='' C_BLUE='' C_DIM=''
fi
# --- Paths -----------------------------------------------------------------
# Resolve the repo root from this file's location so every script works from
# any working directory. CI invokes these from the workspace root; humans
# invoke them from wherever they happen to be.
LIB_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${LIB_DIR}/.." && pwd)"
# --- Build output ----------------------------------------------------------
# Keep build artifacts OUT of the repo (section 10.1: build outputs are not in
# git) and in one deletable place. Overridable, but the default matters: a
# target/ inside the repo shows up in `git status` and tempts a `git add -A`.
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${HOME}/.blocker-target}"
# --- Toolchain on PATH -----------------------------------------------------
# rustup installs shims into ~/.cargo/bin and adds a line to ~/.bashrc and
# ~/.profile. A non-interactive shell does not read ~/.bashrc, so a script run
# from CI, a cron job, or a different CWD can fail with "cargo: command not
# found" even on a correctly installed machine. Adding the directory here is
# idempotent and does not modify the user's shell config.
if [[ -d "${HOME}/.cargo/bin" ]]; then
case ":${PATH}:" in
*":${HOME}/.cargo/bin:"*) ;;
*) PATH="${HOME}/.cargo/bin:${PATH}" ;;
esac
export PATH
fi
# --- Section 5 budgets -----------------------------------------------------
MAX_DAEMON_BYTES=$((5 * 1024 * 1024)) # 5 MB stripped daemon
# --- Reporting -------------------------------------------------------------
_step=0
step() {
_step=$((_step + 1))
printf '\n%s[%d/%d]%s %s\n' "${C_BLUE}" "${_step}" "${TOTAL_STEPS:-?}" "${C_RESET}" "$1"
}
pass() {
printf ' %sPASS%s %s\n' "${C_GREEN}" "${C_RESET}" "$1"
}
fail() {
printf ' %sFAIL%s %s\n' "${C_RED}" "${C_RESET}" "$1"
}
# A skip that reads as a pass is the classic CI lie (CLAUDE.md section 10.4).
# Skips must always name what was skipped and why.
skip() {
printf ' %sSKIP%s %s\n' "${C_YELLOW}" "${C_RESET}" "$1"
printf ' %sreason:%s %s\n' "${C_DIM}" "${C_RESET}" "$2"
}
info() {
printf ' %s%s%s\n' "${C_DIM}" "$1" "${C_RESET}"
}
# Announce a non-fatal environment note once, before the checks run, so a
# reader knows what to expect rather than wondering why a stage vanished.
announce_skip() {
printf '%s%s%s\n' "${C_YELLOW}" "SKIP: $1 — $2" "${C_RESET}"
}
die() {
printf '\n%sfatal:%s %s\n' "${C_RED}" "${C_RESET}" "$1" >&2
exit 1
}
# --- Android SDK detection -------------------------------------------------
# CLAUDE.md section 10.4 asks whether the runner has the SDK. Rather than assume
# either way, detect it and report honestly.
android_sdk_root() {
if [[ -n "${ANDROID_HOME:-}" && -d "${ANDROID_HOME}" ]]; then
printf '%s' "${ANDROID_HOME}"
return 0
fi
if [[ -n "${ANDROID_SDK_ROOT:-}" && -d "${ANDROID_SDK_ROOT}" ]]; then
printf '%s' "${ANDROID_SDK_ROOT}"
return 0
fi
# Common default install location.
if [[ -d "${HOME}/Android/Sdk" ]]; then
printf '%s' "${HOME}/Android/Sdk"
return 0
fi
return 1
}
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env bash
#
# Size gate: fail if the stripped release daemon exceeds the section 5 budget.
#
# Usage: size-check.sh [binary-path] [max-bytes]
#
# Kept separate from ci.sh so the Gitea workflow can run it as its own step
# against an artifact it has already built, rather than rebuilding.
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
# Default binary path. CARGO_TARGET_DIR may be absolute (the usual case — it
# points outside the repo) or relative, so it must not be blindly joined onto
# REPO_ROOT: doing that turns "/home/sp/.blocker-target/..." into
# "/home/sp/blocker//home/sp/.blocker-target/...".
DEFAULT_TARGET="${CARGO_TARGET_DIR:-${HOME}/.blocker-target}"
if [[ "${DEFAULT_TARGET}" = /* ]]; then
DEFAULT_BINARY="${DEFAULT_TARGET}/release/blocker-daemon"
else
DEFAULT_BINARY="${REPO_ROOT}/${DEFAULT_TARGET}/release/blocker-daemon"
fi
BINARY="${1:-${DEFAULT_BINARY}}"
MAX_BYTES="${2:-${MAX_DAEMON_BYTES}}"
# Normalise so the message shows the real path rather than a doubled prefix.
BINARY="$(cd -- "$(dirname -- "${BINARY}")" 2>/dev/null && pwd)/$(basename -- "${BINARY}")" \
|| die "binary not found: ${BINARY}"
SIZE="$(stat -c %s "${BINARY}" 2>/dev/null || stat -f %z "${BINARY}")"
# Human-readable, using 1024-based units to match how the budget is written.
printf 'binary : %s\n' "${BINARY}"
printf 'size : %s bytes (%d.%03d MB)\n' \
"${SIZE}" "$((SIZE / 1048576))" "$(((SIZE % 1048576) * 1000 / 1048576))"
printf 'budget : %s bytes (%d MB)\n' "${MAX_BYTES}" "$((MAX_BYTES / 1048576))"
if (( SIZE > MAX_BYTES )); then
printf '%sOVER BUDGET by %s bytes%s\n' "${C_RED}" "$((SIZE - MAX_BYTES))" "${C_RESET}"
exit 1
fi
printf '%swithin budget, %s bytes headroom%s\n' \
"${C_GREEN}" "$((MAX_BYTES - SIZE))" "${C_RESET}"
-35
View File
@@ -1,35 +0,0 @@
[package]
name = "blocker-storage"
version = "0.1.0"
edition = "2021"
description = "SQLite persistence for Blocker: schedules, lock state, and usage counters."
license = "UNLICENSED"
[dependencies]
blocker-core = { path = "../core" }
# rusqlite with bundled SQLite, per CLAUDE.md section 3. Bundled rather than
# system-linked because the app ships to machines where libsqlite3 may be absent
# or a different major version, and a storage-format mismatch that only shows up
# on a user's machine is not a failure mode worth carrying.
#
# THIS CRATE IS NOT DEPENDENT ON BY blocker-core.
#
# That is the entire reason this crate exists separately. CLAUDE.md section 2
# requires the engine to be a pure function with no I/O, and the strongest
# mechanical check for that is "blocker-core has zero dependencies" — a test
# that reads Cargo.lock and fails on any entry. Keeping storage in its own crate
# lets that test stay strict and automatic, instead of degrading into a source
# grep that someone can route around.
#
# Both crates sit in the same layer: storage is shared by every platform and
# contains no platform code, so this is a refinement of the section 2 diagram,
# not a departure from it.
rusqlite = { version = "0.37", features = ["bundled"] }
[lints]
workspace = true
[lib]
name = "blocker_storage"
path = "src/lib.rs"
-347
View File
@@ -1,347 +0,0 @@
//! Encoding between model types and their database representation.
//!
//! Split out from the SQL so the tricky part — turning a `Condition` into
//! nullable columns and back without silently losing a field — is testable
//! without a database, and so a decode failure has somewhere precise to point.
//!
//! # Why this is hand-written rather than serde
//!
//! `serde` is not a CLAUDE.md section 3 dependency, and section 0 rule 3 requires
//! stopping to ask before adding one. The encoding is a few hundred lines of
//! mechanical match arms, which is a real cost — and it is the cost of not adding
//! a dependency without permission. If serde is approved later, this module is the
//! only thing that changes.
//!
//! # Decoding never panics
//!
//! Every decode failure returns [`DecodeError`]. A database written by a newer
//! version, or corrupted by hand, must produce a diagnosable error rather than a
//! panic: this is enforcement code, and section 1.5.4 requires failing open.
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// Why stored bytes could not become a model value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
/// A `mode` column held something other than the two known values.
UnknownMode(String),
/// A `level` column held something other than the three known values.
UnknownLevel(String),
/// A `kind` column held something unrecognised.
UnknownKind(String),
/// A target string was empty.
EmptyTarget,
/// A `start`/`end` time was not `HH:MM`, or was outside 0..1440.
BadTime(String),
/// A time of day fell outside 0..1440 minutes.
MinuteOutOfRange(i64),
/// A `days` list was malformed, or held a value outside 0..=6.
BadDays(String),
/// A boolean column was neither 0 nor 1.
BadFlag(i64),
/// A numeric column was negative where only a count is meaningful.
NegativeCount(i64),
}
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownMode(m) => write!(f, "unknown schedule mode {m:?}"),
Self::UnknownLevel(l) => write!(f, "unknown lock level {l:?}"),
Self::UnknownKind(k) => write!(f, "unknown condition kind {k:?}"),
Self::EmptyTarget => write!(f, "target value is empty"),
Self::BadTime(t) => write!(f, "time {t:?} is not HH:MM"),
Self::MinuteOutOfRange(m) => write!(f, "minute {m} is outside 0..1440"),
Self::BadDays(d) => write!(f, "day list {d:?} is malformed"),
Self::BadFlag(v) => write!(f, "expected 0 or 1, found {v}"),
Self::NegativeCount(v) => write!(f, "expected a non-negative count, found {v}"),
}
}
}
impl std::error::Error for DecodeError {}
/// `ScheduleMode` as stored.
pub fn mode_to_str(mode: ScheduleMode) -> &'static str {
match mode {
ScheduleMode::Blocklist => "Blocklist",
ScheduleMode::Allowlist => "Allowlist",
}
}
pub fn mode_from_str(raw: &str) -> Result<ScheduleMode, DecodeError> {
match raw {
"Blocklist" => Ok(ScheduleMode::Blocklist),
"Allowlist" => Ok(ScheduleMode::Allowlist),
other => Err(DecodeError::UnknownMode(other.to_owned())),
}
}
/// `LockLevel` as stored.
pub fn level_to_str(level: blocker_core::lock::LockLevel) -> &'static str {
match level {
blocker_core::lock::LockLevel::Normal => "Normal",
blocker_core::lock::LockLevel::Strict => "Strict",
blocker_core::lock::LockLevel::Stubborn => "Stubborn",
}
}
pub fn level_from_str(raw: &str) -> Result<blocker_core::lock::LockLevel, DecodeError> {
use blocker_core::lock::LockLevel;
match raw {
"Normal" => Ok(LockLevel::Normal),
"Strict" => Ok(LockLevel::Strict),
"Stubborn" => Ok(LockLevel::Stubborn),
other => Err(DecodeError::UnknownLevel(other.to_owned())),
}
}
/// A decoded target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetRow {
/// Position within the schedule, preserving the user's ordering.
pub ordinal: i64,
/// Discriminator: `app`, `website`, or `keyword`.
pub kind: String,
/// The identifier itself.
pub value: String,
}
/// Targets in database form.
pub fn encode_targets(targets: &[Target]) -> Vec<TargetRow> {
targets
.iter()
.enumerate()
.map(|(ordinal, t)| {
let (kind, value) = match t {
Target::App(v) => ("app", v.clone()),
Target::Website(v) => ("website", v.clone()),
Target::Keyword(v) => ("keyword", v.clone()),
};
TargetRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: kind.to_owned(),
value,
}
})
.collect()
}
pub fn decode_targets(rows: &[TargetRow]) -> Result<Vec<Target>, DecodeError> {
let mut out: Vec<(i64, Target)> = Vec::with_capacity(rows.len());
for row in rows {
if row.value.is_empty() {
return Err(DecodeError::EmptyTarget);
}
let target = match row.kind.as_str() {
"app" => Target::App(row.value.clone()),
"website" => Target::Website(row.value.clone()),
"keyword" => Target::Keyword(row.value.clone()),
other => return Err(DecodeError::UnknownKind(other.to_owned())),
};
out.push((row.ordinal, target));
}
// Ordinals come from the database unordered; sorting restores user order,
// which matters for a list the user arranged deliberately.
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, t)| t).collect())
}
/// A decoded condition. Column-shaped rather than model-shaped, so a NULL in the
/// wrong place is caught here rather than becoming a silently defaulted
/// condition.
#[derive(Debug, Clone, PartialEq)]
pub struct ConditionRow {
pub ordinal: i64,
pub kind: String,
/// Comma-separated weekdays, Monday = 0.
pub days: Option<String>,
pub start_min: Option<i64>,
pub end_min: Option<i64>,
pub limit_value: Option<i64>,
pub window: Option<String>,
pub ssid: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub radius_m: Option<i64>,
pub outside: Option<i64>,
}
pub fn encode_conditions(conditions: &[Condition]) -> Vec<ConditionRow> {
conditions
.iter()
.enumerate()
.map(|(ordinal, c)| {
let mut row = ConditionRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: String::new(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
match c {
Condition::Time(w) => {
row.kind = "Time".to_owned();
row.days = Some(w.days.iter().map(u8::to_string).collect::<Vec<_>>().join(","));
row.start_min = Some(i64::from(w.start));
row.end_min = Some(i64::from(w.end));
}
Condition::UsageLimit { limit_minutes, window } => {
row.kind = "UsageLimit".to_owned();
row.limit_value = Some(i64::from(*limit_minutes));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::LaunchCount { limit, window } => {
row.kind = "LaunchCount".to_owned();
row.limit_value = Some(i64::from(*limit));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::WifiSsid(ssid) => {
row.kind = "WifiSsid".to_owned();
row.ssid = Some(ssid.clone());
}
Condition::Location { latitude, longitude, radius_meters, block_when_outside } => {
row.kind = "Location".to_owned();
row.latitude = Some(*latitude);
row.longitude = Some(*longitude);
row.radius_m = Some(i64::from(*radius_meters));
row.outside = Some(i64::from(*block_when_outside));
}
}
row
})
.collect()
}
pub fn decode_conditions(rows: &[ConditionRow]) -> Result<Vec<Condition>, DecodeError> {
let mut out: Vec<(i64, Condition)> = Vec::with_capacity(rows.len());
for row in rows {
out.push((row.ordinal, decode_condition(row)?));
}
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, c)| c).collect())
}
fn decode_condition(row: &ConditionRow) -> Result<Condition, DecodeError> {
match row.kind.as_str() {
"Time" => {
let days = row.days.as_deref().ok_or(DecodeError::BadDays("<null>".into()))?;
let start = require_minute(row.start_min)?;
let end = require_minute(row.end_min)?;
Ok(Condition::Time(TimeWindow { days: decode_days(days)?, start, end }))
}
"UsageLimit" => Ok(Condition::UsageLimit {
limit_minutes: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"LaunchCount" => Ok(Condition::LaunchCount {
limit: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"WifiSsid" => Ok(Condition::WifiSsid(row.ssid.clone().ok_or(DecodeError::EmptyTarget)?)),
"Location" => Ok(Condition::Location {
latitude: row.latitude.ok_or(DecodeError::NegativeCount(-1))?,
longitude: row.longitude.ok_or(DecodeError::NegativeCount(-1))?,
radius_meters: require_count(row.radius_m)?,
block_when_outside: decode_flag(row.outside.unwrap_or(0))?,
}),
other => Err(DecodeError::UnknownKind(other.to_owned())),
}
}
fn require_minute(value: Option<i64>) -> Result<MinuteOfDay, DecodeError> {
let v = value.ok_or(DecodeError::MinuteOutOfRange(-1))?;
u16::try_from(v).ok().filter(|m| *m < 1440).ok_or(DecodeError::MinuteOutOfRange(v))
}
fn require_count(value: Option<i64>) -> Result<u32, DecodeError> {
let v = value.ok_or(DecodeError::NegativeCount(-1))?;
u32::try_from(v).map_err(|_| DecodeError::NegativeCount(v))
}
fn decode_flag(value: i64) -> Result<bool, DecodeError> {
match value {
0 => Ok(false),
1 => Ok(true),
other => Err(DecodeError::BadFlag(other)),
}
}
fn window_to_str(window: UsageWindow) -> &'static str {
match window {
UsageWindow::Daily => "Daily",
UsageWindow::Hourly => "Hourly",
}
}
fn window_from_str(raw: Option<&str>) -> Result<UsageWindow, DecodeError> {
match raw {
Some("Daily") => Ok(UsageWindow::Daily),
Some("Hourly") => Ok(UsageWindow::Hourly),
_ => Err(DecodeError::UnknownKind(format!("usage window {raw:?}"))),
}
}
fn decode_days(raw: &str) -> Result<Vec<DayOfWeek>, DecodeError> {
if raw.is_empty() {
return Ok(Vec::new());
}
raw.split(',')
.map(|part| {
let trimmed = part.trim();
trimmed
.parse::<u8>()
.ok()
.filter(|d| *d < 7)
.ok_or_else(|| DecodeError::BadDays(raw.to_owned()))
})
.collect()
}
/// `HH:MM` to minutes since midnight. Used by the golden-table loader, which
/// reads times as strings.
pub fn parse_hhmm(raw: &str) -> Result<MinuteOfDay, DecodeError> {
let (h, m) = raw.split_once(':').ok_or_else(|| DecodeError::BadTime(raw.to_owned()))?;
let h: u16 = h.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
let m: u16 = m.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
if m >= 60 || h >= 24 {
return Err(DecodeError::BadTime(raw.to_owned()));
}
Ok(h * 60 + m)
}
/// Reassemble a schedule from its header and its decoded children.
///
/// Kept here rather than in `lib.rs` so the ordering contract — children sorted
/// by ordinal, attached to the right parent — is tested in one place.
pub fn decode_schedule(
id: &str,
name: &str,
icon: Option<&str>,
mode: &str,
targets: &[TargetRow],
conditions: &[ConditionRow],
paused: bool,
) -> Result<Schedule, DecodeError> {
Ok(Schedule {
id: id.to_owned(),
name: name.to_owned(),
icon: icon.map(str::to_owned),
mode: mode_from_str(mode)?,
targets: decode_targets(targets)?,
conditions: decode_conditions(conditions)?,
paused,
})
}
#[cfg(test)]
mod tests;
-201
View File
@@ -1,201 +0,0 @@
//! Tests for the storage encoding layer.
//!
//! These need no database: the codec is where a silently-dropped field would
//! live, and a round-trip test here is far cheaper to run and to read than the
//! same assertion through SQLite.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test fixtures: a failing assertion or a broken test is not a runtime \
condition to handle gracefully"
)]
use super::*;
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
/// Every condition shape must survive encode -> decode unchanged.
///
/// A field that is written but not read back is invisible in production and
/// catastrophic here: the schedule quietly stops working the next time the app
/// restarts.
#[test]
fn every_condition_round_trips() {
let cases = vec![
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 9 * 60, end: 17 * 60 }),
// Overnight: start > end must not be normalised or dropped on the way
// through the database. This is the representation the whole overnight
// rule depends on.
Condition::Time(TimeWindow { days: vec![6], start: 22 * 60, end: 6 * 60 }),
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
Condition::LaunchCount { limit: 5, window: UsageWindow::Hourly },
Condition::WifiSsid("CoffeeShop".into()),
Condition::Location {
latitude: 52.52,
longitude: 13.405,
radius_meters: 500,
block_when_outside: true,
},
];
for original in cases {
let rows = encode_conditions(std::slice::from_ref(&original));
let decoded = decode_conditions(&rows).expect("decode must succeed");
assert_eq!(decoded, vec![original], "round trip changed the condition");
}
}
/// A schedule's ordinal order must survive, since the user arranges targets
/// deliberately and the block screen shows them in that order.
#[test]
fn target_order_is_preserved() {
let targets = vec![
Target::App("b.app".into()),
Target::Website("example.com".into()),
Target::Keyword("casino".into()),
Target::App("a.app".into()),
];
let mut rows = encode_targets(&targets);
// Simulate the database returning rows in an arbitrary order.
rows.reverse();
assert_eq!(decode_targets(&rows).unwrap(), targets);
}
/// Unknown discriminators must error, not default.
///
/// Defaulting here would turn a typo or a newer-version row into a schedule
/// that silently blocks the wrong thing.
#[test]
fn unknown_kinds_are_errors_not_defaults() {
let bad_target =
vec![TargetRow { ordinal: 0, kind: "applicaton".into(), value: "com.example".into() }];
assert_eq!(decode_targets(&bad_target), Err(DecodeError::UnknownKind("applicaton".into())));
let bad_condition = vec![ConditionRow {
ordinal: 0,
kind: "Timey".into(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
}];
assert_eq!(decode_conditions(&bad_condition), Err(DecodeError::UnknownKind("Timey".into())));
}
/// A time of day outside 0..1440 must be rejected rather than wrapping.
///
/// A wrapped minute would produce a window that never matches, or one that
/// always matches — both look like "the app ignores my schedule".
#[test]
fn out_of_range_minutes_are_rejected() {
let bad = ConditionRow {
ordinal: 0,
kind: "Time".into(),
days: Some("0".into()),
start_min: Some(-60),
end_min: Some(1020),
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
assert_eq!(decode_conditions(&[bad]), Err(DecodeError::MinuteOutOfRange(-60)));
}
/// Weekday values outside 0..=6 must be rejected.
#[test]
fn out_of_range_weekdays_are_rejected() {
assert_eq!(decode_days("0,1,9"), Err(DecodeError::BadDays("0,1,9".into())));
assert_eq!(decode_days("monday"), Err(DecodeError::BadDays("monday".into())));
assert_eq!(decode_days(""), Ok(vec![]));
assert_eq!(decode_days("0,6"), Ok(vec![0, 6]));
}
/// A flag column holding something other than 0 or 1 must be rejected.
#[test]
fn bad_flags_are_rejected() {
assert_eq!(decode_flag(0), Ok(false));
assert_eq!(decode_flag(1), Ok(true));
assert_eq!(decode_flag(2), Err(DecodeError::BadFlag(2)));
assert_eq!(decode_flag(-1), Err(DecodeError::BadFlag(-1)));
}
/// Negative counts must be rejected — a negative usage limit would mean
/// "block immediately", which is not what a corrupted row should do.
#[test]
fn negative_counts_are_rejected() {
assert_eq!(require_count(Some(-5)), Err(DecodeError::NegativeCount(-5)));
assert_eq!(require_count(Some(0)), Ok(0));
assert_eq!(require_count(None), Err(DecodeError::NegativeCount(-1)));
}
/// `HH:MM` parsing, as the golden tables use it.
#[test]
fn hhmm_parsing() {
assert_eq!(parse_hhmm("09:00"), Ok(540));
assert_eq!(parse_hhmm("22:30"), Ok(1350));
assert_eq!(parse_hhmm("00:00"), Ok(0));
assert_eq!(parse_hhmm("9:5"), Ok(545));
assert_eq!(parse_hhmm("24:00"), Err(DecodeError::BadTime("24:00".into())));
assert_eq!(parse_hhmm("12:60"), Err(DecodeError::BadTime("12:60".into())));
assert_eq!(parse_hhmm("nine"), Err(DecodeError::BadTime("nine".into())));
assert_eq!(parse_hhmm("0900"), Err(DecodeError::BadTime("0900".into())));
}
/// Lock level and mode round-trip through their string forms.
#[test]
fn level_and_mode_round_trip() {
for level in [
blocker_core::lock::LockLevel::Normal,
blocker_core::lock::LockLevel::Strict,
blocker_core::lock::LockLevel::Stubborn,
] {
assert_eq!(level_from_str(level_to_str(level)).unwrap(), level);
}
assert!(level_from_str("Paranoid").is_err());
for mode in [ScheduleMode::Blocklist, ScheduleMode::Allowlist] {
assert_eq!(mode_from_str(mode_to_str(mode)).unwrap(), mode);
}
assert!(mode_from_str("Whitelist").is_err());
}
/// A full schedule round-trips through its parts.
#[test]
fn schedule_round_trips_through_parts() {
let original = Schedule {
id: "s1".into(),
name: "Evening Focus".into(),
icon: Some("/icons/focus.png".into()),
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.social".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4],
start: 22 * 60,
end: 6 * 60,
})],
paused: false,
};
let decoded = decode_schedule(
&original.id,
&original.name,
original.icon.as_deref(),
mode_to_str(original.mode),
&encode_targets(&original.targets),
&encode_conditions(&original.conditions),
original.paused,
)
.unwrap();
assert_eq!(decoded, original);
}
-644
View File
@@ -1,644 +0,0 @@
//! SQLite persistence for Blocker (CLAUDE.md section 3, `rusqlite` bundled).
//!
//! # Why this is a separate crate from `blocker-core`
//!
//! Section 2 requires the engine to be a pure function with no I/O, and this
//! project's `tests/purity.rs` enforces that mechanically: it reads the resolved
//! dependency graph and fails if `blocker-core` has any dependency at all. That
//! check is only meaningful while storage lives elsewhere.
//!
//! Both crates occupy the same layer of the section 2 diagram — storage is shared
//! by every platform and contains no platform code — so this is a refinement that
//! makes the purity guarantee mechanically checkable, not an architectural
//! departure.
//!
//! # Fail open (section 1.5.4)
//!
//! A corrupt database must never leave a device unusable. [`Store::open`] moves a
//! damaged file aside and starts clean rather than returning an error the caller
//! might respond to by disabling enforcement. The damaged file is kept for
//! diagnosis; blocking is then re-applied from defaults on the next start.
//!
//! Losing a schedule list is a far smaller harm than a phone that cannot make
//! calls, and section 1.5.4 makes that trade explicit.
use std::path::{Path, PathBuf};
use blocker_core::engine::{BootId, CivilTime, Monotonic};
use rusqlite::{Connection, OpenFlags};
use crate::codec::DecodeError;
/// Current schema version, applied with `PRAGMA user_version`.
pub const SCHEMA_VERSION: i64 = 1;
/// Everything that can go wrong opening or preparing a store.
///
/// Notably **absent**: a corruption variant that is fatal. Corruption is
/// handled inside [`Store::open`] by starting clean, because section 1.5.4
/// requires failing open rather than refusing to run.
#[derive(Debug)]
pub enum StoreError {
/// SQLite could not be initialised at all — for example the bundled library
/// could not be loaded. Fatal, and genuinely exceptional.
Init(rusqlite::Error),
/// The directory holding the database could not be created.
Io(std::io::Error),
/// Stored bytes could not be decoded into the model.
Decode(DecodeError),
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Init(e) => write!(f, "could not initialise storage: {e}"),
Self::Io(e) => write!(f, "storage directory unusable: {e}"),
Self::Decode(e) => write!(f, "stored data could not be decoded: {e}"),
}
}
}
impl std::error::Error for StoreError {}
/// A handle to the Blocker database.
///
/// Cheap to clone conceptually but deliberately not `Clone`: the daemon is the
/// only writer, and an accidental second connection would mean two processes
/// fighting over lock state.
#[derive(Debug)]
pub struct Store {
conn: Connection,
path: PathBuf,
/// Set when the database was found corrupt and moved aside, so the caller can
/// log it. Section 1.5.4 wants this recorded, not silently swallowed.
recovered_from_corruption: bool,
}
impl Store {
/// Open, creating if absent, and ensure the schema is current.
///
/// Never returns an error for corruption: a damaged database is renamed
/// aside and a fresh one created. See the module docs.
pub fn open(path: &Path) -> Result<Self, StoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(StoreError::Io)?;
}
let mut recovered = false;
// First attempt: open normally and verify it is actually usable. A
// corrupt file often opens fine and only fails on first query, so the
// probe below is what actually detects corruption.
let mut attempt = Self::try_open(path)?;
if let Err(probe) =
attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
{
recovered = true;
Self::quarantine(path)?;
attempt = Self::try_open(path)?;
// Keep the probe result meaningful: if this one also fails, the
// problem is not the file.
if let Err(second) =
attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
{
return Err(StoreError::Init(second));
}
let _ = probe;
}
attempt.migrate()?;
attempt.recovered_from_corruption = recovered;
Ok(attempt)
}
fn try_open(path: &Path) -> Result<Self, StoreError> {
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(StoreError::Init)?;
// WAL keeps a reader from blocking the daemon's writes, which matters
// because the UI reads while the daemon writes. It also means a crash
// cannot corrupt the main file, which is part of failing open.
// Errors here are not fatal: a filesystem without WAL still works, just
// with less concurrency.
let _ = conn.pragma_update(None, "journal_mode", "WAL");
let _ = conn.pragma_update(None, "foreign_keys", "ON");
Ok(Self { conn, path: path.to_path_buf(), recovered_from_corruption: false })
}
/// Move a damaged database aside, keeping it for diagnosis.
fn quarantine(path: &Path) -> Result<(), StoreError> {
let backup = path.with_extension("corrupt");
std::fs::rename(path, &backup).map_err(StoreError::Io)?;
Ok(())
}
/// Whether this store was recovered from corruption on open.
pub fn recovered_from_corruption(&self) -> bool {
self.recovered_from_corruption
}
fn migrate(&mut self) -> Result<(), StoreError> {
let current: i64 = self
.conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.map_err(StoreError::Init)?;
if current > SCHEMA_VERSION {
// Forward-only by design (section 1.5.3: downgrading risks losing
// lock state, which the safety rules do not permit). A newer file is
// left alone rather than destroyed.
return Err(StoreError::Init(rusqlite::Error::InvalidQuery));
}
if current == SCHEMA_VERSION {
return Ok(());
}
self.conn.execute_batch(SCHEMA).map_err(StoreError::Init)?;
self.conn.pragma_update(None, "user_version", SCHEMA_VERSION).map_err(StoreError::Init)?;
Ok(())
}
/// Load every schedule, with its targets and conditions.
///
/// Two queries rather than a join: a schedule's children are needed together
/// to reassemble the model, and SQLite's JSON functions are not used here so
/// the relational shape stays portable. Schedules without children still
/// appear, with empty vectors — `validation` is what rejects those, not the
/// database.
pub fn load_schedules(&self) -> Result<Vec<blocker_core::model::Schedule>, StoreError> {
let headers = {
let mut stmt = self
.conn
.prepare(
"SELECT id, name, icon, mode, paused FROM schedule ORDER BY created_at, id",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
row.get::<_, bool>(4)?,
))
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
out
};
let mut schedules = Vec::with_capacity(headers.len());
for (id, name, icon, mode, paused) in headers {
let targets = self.load_targets(&id)?;
let conditions = self.load_conditions(&id)?;
schedules.push(
codec::decode_schedule(
&id,
&name,
icon.as_deref(),
&mode,
&targets,
&conditions,
paused,
)
.map_err(StoreError::Decode)?,
);
}
Ok(schedules)
}
fn load_targets(&self, schedule_id: &str) -> Result<Vec<codec::TargetRow>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT ordinal, kind, value FROM schedule_target
WHERE schedule_id = ?1 ORDER BY ordinal",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([schedule_id], |row| {
Ok(codec::TargetRow { ordinal: row.get(0)?, kind: row.get(1)?, value: row.get(2)? })
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
Ok(out)
}
fn load_conditions(&self, schedule_id: &str) -> Result<Vec<codec::ConditionRow>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT ordinal, kind, days, start_min, end_min, limit_value, window,
ssid, latitude, longitude, radius_m, outside
FROM schedule_condition WHERE schedule_id = ?1 ORDER BY ordinal",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([schedule_id], |row| {
Ok(codec::ConditionRow {
ordinal: row.get(0)?,
kind: row.get(1)?,
days: row.get(2)?,
start_min: row.get(3)?,
end_min: row.get(4)?,
limit_value: row.get(5)?,
window: row.get(6)?,
ssid: row.get(7)?,
latitude: row.get(8)?,
longitude: row.get(9)?,
radius_m: row.get(10)?,
outside: row.get(11)?,
})
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
Ok(out)
}
/// Persist the full schedule set, replacing what was there.
///
/// Written as a single transaction so a crash cannot leave a half-applied
/// schedule list — which on a blocking tool would mean some targets blocked
/// and others not, with no way for the user to tell which.
pub fn save_schedules(
&mut self,
schedules: &[blocker_core::model::Schedule],
) -> Result<(), StoreError> {
let tx = self.conn.transaction().map_err(StoreError::Init)?;
// Children cascade on delete, so clearing the parents is enough and
// avoids a stale row outliving its schedule.
tx.execute("DELETE FROM schedule", []).map_err(StoreError::Init)?;
{
let mut insert_target = tx
.prepare(
"INSERT INTO schedule_target (schedule_id, ordinal, kind, value)
VALUES (?1, ?2, ?3, ?4)",
)
.map_err(StoreError::Init)?;
let mut insert_condition = tx
.prepare(
"INSERT INTO schedule_condition
(schedule_id, ordinal, kind, days, start_min, end_min, limit_value,
window, ssid, latitude, longitude, radius_m, outside)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
)
.map_err(StoreError::Init)?;
for (index, s) in schedules.iter().enumerate() {
tx.execute(
"INSERT INTO schedule (id, name, icon, mode, paused, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
s.id,
s.name,
s.icon,
codec::mode_to_str(s.mode),
i64::from(s.paused),
i64::try_from(index).unwrap_or(i64::MAX),
],
)
.map_err(StoreError::Init)?;
for row in codec::encode_targets(&s.targets) {
insert_target
.execute(rusqlite::params![s.id, row.ordinal, row.kind, row.value])
.map_err(StoreError::Init)?;
}
for row in codec::encode_conditions(&s.conditions) {
insert_condition
.execute(rusqlite::params![
s.id,
row.ordinal,
row.kind,
row.days,
row.start_min,
row.end_min,
row.limit_value,
row.window,
row.ssid,
row.latitude,
row.longitude,
row.radius_m,
row.outside,
])
.map_err(StoreError::Init)?;
}
}
}
tx.commit().map_err(StoreError::Init)
}
/// Persist lock state, including the monotonic deadline a pending disable
/// request is waiting on (section 1.5.2.2).
pub fn save_lock(
&mut self,
state: &blocker_core::lock::LockState,
boot_id: BootId,
last_monotonic_ms: u64,
) -> Result<(), StoreError> {
let pending = state.pending.as_ref();
self.conn
.execute(
"INSERT INTO lock_config
(id, level, schedules_locked, blocks_uninstall, keyholder_code,
request_ready_at, request_window_closes_at, boot_id, last_monotonic_ms)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
level = excluded.level,
schedules_locked = excluded.schedules_locked,
blocks_uninstall = excluded.blocks_uninstall,
keyholder_code = excluded.keyholder_code,
request_ready_at = excluded.request_ready_at,
request_window_closes_at = excluded.request_window_closes_at,
boot_id = excluded.boot_id,
last_monotonic_ms = excluded.last_monotonic_ms",
rusqlite::params![
codec::level_to_str(state.level),
i64::from(state.schedules_locked),
i64::from(state.blocks_uninstall()),
state.keyholder_code,
pending.map(|p| i64::try_from(p.ready_at.0).unwrap_or(i64::MAX)),
pending.map(|p| i64::try_from(p.window_closes_at.0).unwrap_or(i64::MAX)),
i64::try_from(boot_id.0).unwrap_or(i64::MAX),
i64::try_from(last_monotonic_ms).unwrap_or(i64::MAX),
],
)
.map(|_| ())
.map_err(StoreError::Init)
}
/// The monotonic deadline and boot id of a pending disable request.
///
/// Used on resume to distinguish "the daemon restarted" from "the machine
/// rebooted" (section 1.5.3), so a lock is resumed conservatively rather
/// than assumed to have continued.
pub fn load_pending_request(&self) -> Result<Option<(Monotonic, BootId)>, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT request_ready_at, boot_id FROM lock_config WHERE id = 1")
.map_err(StoreError::Init)?;
let mut rows = stmt.query([]).map_err(StoreError::Init)?;
match rows.next().map_err(StoreError::Init)? {
None => Ok(None),
Some(row) => {
let ready: Option<i64> = row.get(0).map_err(StoreError::Init)?;
let boot: i64 = row.get(1).map_err(StoreError::Init)?;
Ok(ready.map(|r| {
(
Monotonic(u64::try_from(r.max(0)).unwrap_or(0)),
BootId(u64::try_from(boot.max(0)).unwrap_or(0)),
)
}))
}
}
}
/// Record one attempt to disable (section 1.5.2.5). Local only.
pub fn record_attempt(
&mut self,
at: Monotonic,
boot_id: BootId,
day_index: i64,
) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO disable_attempt (at_monotonic, boot_id, day_index) VALUES (?1, ?2, ?3)",
rusqlite::params![
i64::try_from(at.0).unwrap_or(i64::MAX),
i64::try_from(boot_id.0).unwrap_or(i64::MAX),
day_index,
],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// How many disable attempts were made on a given local day.
///
/// The block screen shows this as plain text — "You tried to turn this off 4
/// times today" — never as a shaming counter (section 1.5.2.5).
pub fn attempt_count(&self, day_index: i64) -> Result<u32, StoreError> {
let count: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM disable_attempt WHERE day_index = ?1",
[day_index],
|r| r.get(0),
)
.map_err(StoreError::Init)?;
Ok(u32::try_from(count.max(0)).unwrap_or(u32::MAX))
}
/// Add foreground minutes to a daily bucket (section 1.2).
pub fn add_daily_usage(
&mut self,
day_index: i64,
target: &str,
minutes: u32,
) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO usage_daily (day_index, target, minutes) VALUES (?1, ?2, ?3)
ON CONFLICT(day_index, target) DO UPDATE SET
minutes = minutes + excluded.minutes",
rusqlite::params![day_index, target, i64::from(minutes)],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// Add a launch to a daily bucket (section 1.2).
pub fn add_daily_launch(&mut self, day_index: i64, target: &str) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO usage_daily (day_index, target, minutes, launches) VALUES (?1, ?2, 0, 1)
ON CONFLICT(day_index, target) DO UPDATE SET
launches = launches + 1",
rusqlite::params![day_index, target],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// Read a daily usage bucket.
pub fn load_daily_usage(&self, day_index: i64) -> Result<UsageRow, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT target, minutes, launches FROM usage_daily WHERE day_index = ?1")
.map_err(StoreError::Init)?;
let mut rows = stmt.query([day_index]).map_err(StoreError::Init)?;
let mut out = UsageRow::default();
while let Some(row) = rows.next().map_err(StoreError::Init)? {
let target: String = row.get(0).map_err(StoreError::Init)?;
let minutes: i64 = row.get(1).map_err(StoreError::Init)?;
let launches: i64 = row.get(2).map_err(StoreError::Init)?;
out.minutes.insert(target.clone(), u32::try_from(minutes.max(0)).unwrap_or(u32::MAX));
out.launches.insert(target, u32::try_from(launches.max(0)).unwrap_or(u32::MAX));
}
Ok(out)
}
/// Local day index for a civil time, for bucketing usage.
pub fn day_index(now: CivilTime) -> i64 {
now.epoch_minutes.div_euclid(24 * 60)
}
/// Path this store was opened from.
pub fn path(&self) -> &Path {
&self.path
}
/// Run a maintenance step: drop usage rows older than `keep_days`.
///
/// Pruning is deliberate rather than unbounded — an app-blocker's usage
/// history is genuinely useful for statistics (section 1.6) but a table
/// that grows forever is a slow disk leak on a machine with 9.6 GB free.
pub fn prune_usage(&mut self, keep_days: i64) -> Result<u64, StoreError> {
// Relative to the NEWEST day, not the oldest.
//
// Anchoring on MIN(day_index) is the obvious version and it never prunes
// anything: the oldest row is by definition the one being tested, so the
// cutoff sits before it and the delete matches zero rows. The table then
// grows forever, which on a machine with 9.6 GB free is exactly the slow
// leak this is meant to prevent. Caught by old_usage_is_pruned.
let newest: i64 = self
.conn
.query_row("SELECT COALESCE(MAX(day_index), 0) FROM usage_daily", [], |r| {
r.get::<_, i64>(0)
})
.map_err(StoreError::Init)?;
// +1 so exactly `keep_days` rows survive: with cutoff = newest - keep_days
// and a strict `<`, the boundary day is kept as well, giving keep_days + 1.
let cutoff = newest - keep_days + 1;
let n = self
.conn
.execute("DELETE FROM usage_daily WHERE day_index < ?1", [cutoff])
.map_err(StoreError::Init)?;
Ok(n as u64)
}
}
/// One bucket of accumulated usage.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct UsageRow {
/// Foreground minutes per target.
pub minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per target.
pub launches: std::collections::BTreeMap<String, u32>,
}
/// Schema, version 1.
///
/// Forward-only migrations. `user_version` gates them; downgrading would risk
/// losing lock state, which section 1.5.3 forbids and section 1.5.4 makes a
/// safety property.
///
/// The normalised shape of `schedule_target` and `schedule_condition` is
/// deliberate over storing JSON blobs: usage and target rows are queried by
/// target, and an index on a text column that happens to contain JSON is not a
/// plan. Encoding is handled in [`crate::codec`].
pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS schedule (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
mode TEXT NOT NULL CHECK (mode IN ('Blocklist', 'Allowlist')),
paused INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS schedule_target (
schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('app', 'website', 'keyword')),
value TEXT NOT NULL,
PRIMARY KEY (schedule_id, ordinal)
);
CREATE TABLE IF NOT EXISTS schedule_condition (
schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
kind TEXT NOT NULL,
days TEXT, -- comma-separated, Monday = 0
start_min INTEGER,
end_min INTEGER,
limit_value INTEGER,
window TEXT,
ssid TEXT,
latitude REAL,
longitude REAL,
radius_m INTEGER,
outside INTEGER,
PRIMARY KEY (schedule_id, ordinal)
);
-- Section 1.5.3: lock state lives in the database, not in an editable plaintext
-- config file, so editing a file is not enough to defeat a lock.
CREATE TABLE IF NOT EXISTS lock_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
level TEXT NOT NULL CHECK (level IN ('Normal', 'Strict', 'Stubborn')),
schedules_locked INTEGER NOT NULL,
blocks_uninstall INTEGER NOT NULL,
keyholder_code TEXT,
request_ready_at INTEGER,
request_window_closes_at INTEGER,
boot_id INTEGER NOT NULL,
last_monotonic_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS disable_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at_monotonic INTEGER NOT NULL,
boot_id INTEGER NOT NULL,
day_index INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS usage_daily (
day_index INTEGER NOT NULL,
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day_index, target)
);
CREATE TABLE IF NOT EXISTS usage_hourly (
hour_index INTEGER NOT NULL,
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (hour_index, target)
);
CREATE INDEX IF NOT EXISTS usage_daily_target ON usage_daily (target);
CREATE INDEX IF NOT EXISTS usage_hourly_target ON usage_hourly (target);
CREATE INDEX IF NOT EXISTS disable_attempt_day ON disable_attempt (day_index);
"#;
pub mod codec;
#[cfg(test)]
mod tests;
-304
View File
@@ -1,304 +0,0 @@
//! Tests for the SQLite store.
//!
//! These use a temporary file rather than `:memory:` because WAL is enabled on
//! the connection and an in-memory database with WAL is a subtly different thing
//! than what ships.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test fixtures: a failing assertion or a broken test is not a runtime \
condition to handle gracefully"
)]
use super::*;
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
/// A unique temp path per test, since tests run in parallel and a shared
/// database would make them interfere.
fn temp_db(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("blocker-test-{}-{name}.db", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
fn sample_schedule() -> Schedule {
Schedule {
id: "s1".into(),
name: "Evening Focus".into(),
icon: Some("/icons/focus.png".into()),
mode: ScheduleMode::Blocklist,
targets: vec![
Target::App("com.example.social".into()),
Target::Website("example.com".into()),
Target::Keyword("casino".into()),
],
conditions: vec![
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 22 * 60, end: 6 * 60 }),
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
],
paused: false,
}
}
#[test]
fn schedules_round_trip_through_the_database() {
let path = temp_db("schedules");
let original = sample_schedule();
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(std::slice::from_ref(&original)).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let loaded = store.load_schedules().expect("load");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], original, "a schedule must survive a restart intact");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn overnight_window_survives_a_restart() {
// The specific case worth its own test: if the overnight flag were lost in
// storage, the schedule would stop blocking at midnight *after a restart*,
// while appearing correct in the UI.
let path = temp_db("overnight");
let mut original = sample_schedule();
original.conditions =
vec![Condition::Time(TimeWindow { days: vec![0], start: 22 * 60, end: 6 * 60 })];
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(std::slice::from_ref(&original)).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let loaded = store.load_schedules().expect("load");
match &loaded[0].conditions[0] {
Condition::Time(w) => {
assert!(
w.is_overnight(),
"start={} end={} must still read as overnight after a restart",
w.start,
w.end
);
assert_eq!(w.start, 22 * 60);
assert_eq!(w.end, 6 * 60);
}
other => panic!("condition type changed across a restart: {other:?}"),
}
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn saving_replaces_rather_than_accumulates() {
let path = temp_db("replace");
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule(), sample_schedule_with_id("s2")]).expect("save");
assert_eq!(store.load_schedules().unwrap().len(), 2);
store.save_schedules(&[sample_schedule()]).expect("save");
let loaded = store.load_schedules().expect("load");
assert_eq!(loaded.len(), 1, "a second save must replace, not append");
assert_eq!(loaded[0].id, "s1");
let _ = std::fs::remove_file(&path);
}
fn sample_schedule_with_id(id: &str) -> Schedule {
let mut s = sample_schedule();
s.id = id.to_owned();
s.name = format!("Schedule {id}");
s
}
#[test]
fn usage_accumulates_rather_than_overwrites() {
let path = temp_db("usage");
let mut store = Store::open(&path).expect("open");
store.add_daily_usage(20_731, "com.example.social", 30).unwrap();
store.add_daily_usage(20_731, "com.example.social", 25).unwrap();
store.add_daily_launch(20_731, "com.example.social").unwrap();
store.add_daily_launch(20_731, "com.example.social").unwrap();
let row = store.load_daily_usage(20_731).expect("load");
assert_eq!(row.minutes.get("com.example.social"), Some(&55));
assert_eq!(row.launches.get("com.example.social"), Some(&2));
// A different day is a different bucket.
assert!(store.load_daily_usage(20_732).unwrap().minutes.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_pending_disable_request_survives_a_restart() {
// Section 1.5.2.2: filing a request must not lose the request on restart, or
// a user who waits out a 12-hour delay and restarts the machine would be
// back at square one with no explanation.
let path = temp_db("pending");
let state = blocker_core::lock::LockState {
level: blocker_core::lock::LockLevel::Stubborn,
schedules_locked: true,
pending: Some(blocker_core::lock::PendingRequest {
ready_at: Monotonic(12 * 3_600_000),
window_closes_at: Monotonic(12 * 3_600_000 + 600_000),
}),
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
};
{
let mut store = Store::open(&path).expect("open");
store.save_lock(&state, BootId(7), 1_000).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let (ready, boot) = store
.load_pending_request()
.expect("load")
.expect("a pending request must survive a restart");
assert_eq!(ready, Monotonic(12 * 3_600_000));
assert_eq!(boot, BootId(7), "the boot id must survive, so a reboot is detectable");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn no_pending_request_reads_as_none() {
let path = temp_db("nopending");
let store = Store::open(&path).expect("open");
assert_eq!(store.load_pending_request().unwrap(), None);
let _ = std::fs::remove_file(&path);
}
#[test]
fn disable_attempts_are_counted_per_day() {
// Section 1.5.2.5: the block screen shows "you tried 4 times today".
let path = temp_db("attempts");
let mut store = Store::open(&path).expect("open");
for _ in 0..4 {
store.record_attempt(Monotonic(1_000), BootId(1), 20_731).unwrap();
}
store.record_attempt(Monotonic(2_000), BootId(1), 20_732).unwrap();
assert_eq!(store.attempt_count(20_731).unwrap(), 4);
assert_eq!(store.attempt_count(20_732).unwrap(), 1);
assert_eq!(store.attempt_count(99_999).unwrap(), 0);
let _ = std::fs::remove_file(&path);
}
/// Section 1.5.4: a corrupt database must never leave a device unusable.
///
/// This is the single most important test in the crate. The requirement is
/// fail *open*: blocking stops, the user keeps a working device, and the damaged
/// file is kept for diagnosis.
#[test]
fn a_corrupt_database_fails_open() {
let path = temp_db("corrupt");
// Real bytes in place of the SQLite header. Enough to make the file
// unopenable or unreadable, which is what "corrupt" means in the field.
std::fs::write(&path, b"SQLite format 3\x00 this is not actually a database").unwrap();
let store = Store::open(&path).expect("open must succeed despite corruption");
assert!(store.recovered_from_corruption(), "the store must report that it recovered");
assert!(
store.load_schedules().unwrap().is_empty(),
"a recovered database starts empty, which blocks nothing — fail open"
);
assert!(
path.with_extension("corrupt").exists(),
"the damaged file must be kept for diagnosis, not deleted"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("corrupt"));
}
#[test]
fn a_truncated_database_fails_open() {
// A different failure mode from garbage bytes: a valid header with the
// content cut off. Corruption in the field is often truncation, from a
// power loss during a write.
let path = temp_db("truncated");
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule()]).expect("save");
}
// Keep the first 200 bytes — a valid header, an unusable file.
let full = std::fs::read(&path).unwrap();
std::fs::write(&path, &full[..200.min(full.len())]).unwrap();
let store = Store::open(&path);
assert!(
store.is_ok(),
"a truncated database must still open, because failing to open would \
leave the device with no enforcement and no UI"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("corrupt"));
}
#[test]
fn opening_creates_the_directory() {
let mut nested = std::env::temp_dir();
nested.push(format!("blocker-test-{}-nested/state", std::process::id()));
let _ = std::fs::remove_dir_all(&nested);
let path = nested.join("blocker.db");
let store = Store::open(&path);
assert!(store.is_ok(), "a missing parent directory must not be fatal");
assert!(path.exists());
let _ = std::fs::remove_dir_all(&nested);
}
#[test]
fn old_usage_is_pruned() {
// An unbounded usage table is a slow disk leak, and this machine has 9.6 GB
// free. Statistics (section 1.6) need history, not infinite history.
let path = temp_db("prune");
let mut store = Store::open(&path).expect("open");
for day in 20_000..20_100 {
store.add_daily_usage(day, "com.example.social", 10).unwrap();
}
let removed = store.prune_usage(10).expect("prune");
assert_eq!(removed, 90, "90 of 100 days are outside the 10-day window");
let kept = store.load_daily_usage(20_099).unwrap();
assert!(!kept.minutes.is_empty(), "recent history must be kept");
let _ = std::fs::remove_file(&path);
}
#[test]
fn reopening_is_idempotent() {
// The migration must not run twice or clobber data.
let path = temp_db("idempotent");
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule()]).expect("save");
drop(store);
for _ in 0..3 {
let store = Store::open(&path).expect("reopen");
assert_eq!(store.load_schedules().unwrap().len(), 1, "data must survive re-migration");
}
let _ = std::fs::remove_file(&path);
}
-36
View File
@@ -1,36 +0,0 @@
[package]
name = "blocker-tests"
version = "0.1.0"
edition = "2021"
description = "Cross-platform scenario tests: golden decision tables that are the parity contract for every platform layer."
license = "UNLICENSED"
[dependencies]
blocker-core = { path = "../core" }
# NO serde HERE — YET.
#
# The golden tables are designed as JSON (see src/golden.rs), and JSON needs
# serde/serde_json. Those crates are deliberately NOT added in M0, for two
# reasons:
#
# 1. CLAUDE.md section 0 rule 3 requires stopping to ask before adding any
# dependency not listed in section 3. serde is not on that list. It is
# transitively required by rusqlite and uniffi (both listed), which is why
# the design assumed it would be present — but "will be present later" is
# not "approved to add now".
#
# 2. The development machine has 9.7 GB free. Fetching the registry is the
# step that actually consumes disk, and the dependency-purity test needs a
# Cargo.lock to exist at all.
#
# The format is specified in src/golden.rs as the contract M1 implements against.
# The parse layer arrives in M1 alongside rusqlite, in one dependency change
# with an ADR entry. See docs/DECISIONS.md ADR-3.
[lints]
workspace = true
[lib]
name = "blocker_tests"
path = "src/lib.rs"
-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()
}
-423
View File
@@ -1,423 +0,0 @@
//! The golden decision tables (CLAUDE.md section 7).
//!
//! A table is a list of cases. Each case is schedules plus an input plus an
//! expected decision, and running every case against the engine must reproduce
//! the expectation exactly.
//!
//! # This is a parity contract, not a test suite
//!
//! Unit tests check the engine against itself. These check that *every platform*
//! agrees with the engine. Each platform implements a mapping from its own app
//! identity — Android package name, Linux desktop-file id, Windows exe path —
//! onto a [`blocker_core::model::Target`], and every table here runs through that
//! mapping too. If Linux resolves Discord to a different target than Android
//! does, a case fails at build time instead of becoming a bug report from
//! whichever platform nobody was testing that week.
//!
//! # The rule that keeps the tables meaningful
//!
//! **A case is added before the engine changes.** A table written after the code
//! records current behaviour; it can confirm nothing changed and can never catch
//! a regression, because it was fitted to whatever the code happened to do.
//!
//! So a PR that changes engine behaviour is reviewed on one question: is there a
//! case added or updated in the same commit? If not, the change is undocumented
//! behaviour.
//!
//! # Why these are Rust, not JSON
//!
//! Originally specified as JSON on the reasoning that `serde_json` would already
//! be in the tree. That reasoning was wrong: `rusqlite` does not pull `serde` by
//! default, and `uniffi` has not landed yet, so nothing in the graph provides it.
//! `serde` is not in CLAUDE.md section 3, and section 0 rule 3 requires asking
//! before adding a dependency.
//!
//! A builder DSL gets the same declarations with two properties JSON cannot offer:
//! the compiler rejects a typo in a field name, and a wrong variant is a type
//! error rather than a test that quietly asserts the wrong thing. For a document
//! whose entire job is to be a contract, compile-time checking is worth more than
//! diffability.
//!
//! Converting to JSON later, if serde is approved, is mechanical — only this
//! module changes. Recorded as ADR-16.
use blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot};
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// Sentinel meaning "the platform may sleep forever" in an expectation.
///
/// `"none"` as a string was ambiguous with a null; a distinct type is not.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wakeup {
/// Assert nothing about the wakeup.
///
/// The default, because a case that is about blocking behaviour should not
/// fail because a window end moved. It is also the weakest option, so the
/// cases that actually carry the section 5 idle budget declare explicitly —
/// and `idle_budget_cases_declare_their_wakeup` in `run.rs` fails if one of
/// those stops doing so.
Any,
/// No timer may be armed — nothing time-dependent is pending.
Never,
/// Minutes from now, resolved against the case's civil time.
InMinutes(i64),
/// An exact civil time, for cases where the boundary is load-bearing.
At(CivilTime),
}
/// One expected blocked item, attributed to a schedule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Blocked {
/// The target that must be blocked.
pub app: String,
/// The id of the schedule that must block it.
pub schedule: String,
}
/// The decision a case expects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Expect {
/// Apps that must be blocked. Order-independent; the engine's union order
/// is not part of the contract.
pub blocked_apps: Vec<String>,
/// Schedules expected to attribute each block. Parallel to `blocked_apps`.
pub blocked_by: Vec<String>,
/// Websites and keywords that must be blocked.
pub blocked_domains: Vec<String>,
/// When the platform must wake up next, if ever.
pub next_wakeup: Wakeup,
}
impl Default for Expect {
fn default() -> Self {
Self {
blocked_apps: Vec::new(),
blocked_by: Vec::new(),
blocked_domains: Vec::new(),
next_wakeup: Wakeup::Any,
}
}
}
/// A case: setup, input, expected decision.
#[derive(Debug, Clone)]
pub struct Case {
/// Human-readable, and the primary failure message.
pub name: String,
/// Which CLAUDE.md section 7 scenario this covers. Checked by
/// [`coverage`] against the mandatory list.
pub covers: &'static str,
/// Schedules under test.
pub schedules: Vec<Schedule>,
/// Input civil time.
pub now: CivilTime,
/// Monotonic milliseconds since boot.
pub monotonic: Monotonic,
/// Boot id.
pub boot_id: u64,
/// Usage counters.
pub usage: UsageSnapshot,
/// Platform-observed context.
pub context: PlatformContext,
/// Apps that exist on the machine, for allowlist cases.
pub installed_apps: Vec<String>,
/// The decision that must result.
pub expect: Expect,
/// Set when the case asserts a validation error instead of a decision.
/// Section 1.2 combination cases cannot produce a decision, because
/// `Schedule::validate` rejects them first.
pub expect_validation_error: bool,
/// The platform under test, for validation.
pub platform: blocker_core::model::Platform,
}
impl Case {
/// A case with defaults: no usage, no context, no installed apps, boot 0.
pub fn new(name: &str, covers: &'static str) -> Self {
Self {
name: name.to_owned(),
covers,
schedules: Vec::new(),
now: CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 },
monotonic: Monotonic(0),
boot_id: 0,
usage: UsageSnapshot::default(),
context: PlatformContext::default(),
installed_apps: Vec::new(),
expect: Expect::default(),
expect_validation_error: false,
platform: blocker_core::model::Platform::Android,
}
}
pub fn with_schedules(mut self, schedules: Vec<Schedule>) -> Self {
self.schedules = schedules;
self
}
pub fn at(mut self, days_since_epoch: i64, hour: u32, minute: u32) -> Self {
self.now = local(days_since_epoch, hour, minute);
self
}
pub fn with_offset(mut self, minutes: i32) -> Self {
self.now.utc_offset_minutes = minutes;
self
}
pub fn with_ssid(mut self, ssid: &str) -> Self {
self.context.current_ssid = Some(ssid.to_owned());
self
}
pub fn with_location(mut self, lat: f64, lon: f64) -> Self {
self.context.current_location = Some((lat, lon));
self
}
pub fn with_installed(mut self, apps: &[&str]) -> Self {
self.installed_apps = apps.iter().map(|s| (*s).to_owned()).collect();
self
}
pub fn with_daily_minutes(mut self, target: &str, minutes: u32) -> Self {
self.usage.daily_minutes.insert(target.to_owned(), minutes);
self
}
pub fn with_daily_launches(mut self, target: &str, count: u32) -> Self {
self.usage.daily_launches.insert(target.to_owned(), count);
self
}
pub fn on_platform(mut self, platform: blocker_core::model::Platform) -> Self {
self.platform = platform;
self
}
/// Expect apps blocked, each attributed to the named schedule.
pub fn blocks(mut self, pairs: &[(&str, &str)]) -> Self {
for (app, schedule) in pairs {
self.expect.blocked_apps.push((*app).to_owned());
self.expect.blocked_by.push((*schedule).to_owned());
}
self
}
pub fn blocks_domains(mut self, domains: &[&str]) -> Self {
self.expect.blocked_domains = domains.iter().map(|s| (*s).to_owned()).collect();
self
}
pub fn wakes_in(mut self, minutes: i64) -> Self {
self.expect.next_wakeup = Wakeup::InMinutes(minutes);
self
}
/// Expect no timer at all — the zero-idle-wakeup budget, made testable.
pub fn never_wakes(mut self) -> Self {
self.expect.next_wakeup = Wakeup::Never;
self
}
/// Expect `Schedule::validate` to reject this case's schedules.
pub fn invalid(mut self) -> Self {
self.expect_validation_error = true;
self
}
/// Evaluate this case and return a human-readable description of any
/// mismatch, or `None` when it passes.
pub fn check(&self) -> Option<String> {
if self.expect_validation_error {
for s in &self.schedules {
if s.validate(self.platform).is_ok() {
return Some(format!(
"schedule {:?} was expected to be rejected by section 1.2 \
validation, but validated cleanly",
s.id
));
}
}
return None;
}
let input = EngineInput {
now: self.now,
monotonic: self.monotonic,
boot_id: blocker_core::engine::BootId(self.boot_id),
schedules: &self.schedules,
usage: &self.usage,
context: &self.context,
installed_apps: &self.installed_apps,
};
let decision = blocker_core::evaluate(&input);
let mut actual: Vec<(String, String)> =
decision.blocked_apps.iter().map(|r| (r.app_key(), r.schedule_id.clone())).collect();
actual.sort();
let mut wanted: Vec<(String, String)> = self
.expect
.blocked_apps
.iter()
.cloned()
.zip(self.expect.blocked_by.iter().cloned())
.collect();
wanted.sort();
if actual != wanted {
return Some(format!(
"blocked apps differ\n expected: {wanted:?}\n actual: {actual:?}"
));
}
let mut actual_domains: Vec<String> =
decision.blocked_domains.iter().map(|r| r.app_key()).collect();
actual_domains.sort();
let mut wanted_domains = self.expect.blocked_domains.clone();
wanted_domains.sort();
if actual_domains != wanted_domains {
return Some(format!(
"blocked domains differ\n expected: {wanted_domains:?}\n actual: {actual_domains:?}"
));
}
match (self.expect.next_wakeup, decision.next_wakeup) {
(Wakeup::Any, _) => None,
(Wakeup::Never, None) => None,
(Wakeup::Never, Some(t)) => Some(format!(
"expected no wakeup (the platform should be able to sleep forever), \
but the engine asked for one at epoch_minute {}",
t.epoch_minutes
)),
(Wakeup::InMinutes(m), Some(t)) => {
let delta = t.epoch_minutes - self.now.epoch_minutes;
if delta == m {
None
} else {
Some(format!(
"next_wakeup differs: expected {m} minutes from now, \
engine asked for {delta}"
))
}
}
(Wakeup::InMinutes(m), None) => Some(format!(
"expected a wakeup {m} minutes from now, but the engine asked for none — \
a missing boundary means a schedule would silently never fire"
)),
(Wakeup::At(expected), Some(t)) => {
if t == expected {
None
} else {
Some(format!("next_wakeup differs: expected {expected:?}, got {t:?}"))
}
}
(Wakeup::At(_), None) => Some("expected a specific wakeup, got none".to_owned()),
}
}
}
/// Build a [`CivilTime`] from a local date and `HH:MM`.
///
/// `days_since_epoch` is days since 1970-01-01 with the local offset already
/// folded out, so tests can name a calendar date and reason about its weekday.
pub fn local(days_since_epoch: i64, hour: u32, minute: u32) -> CivilTime {
CivilTime {
epoch_minutes: days_since_epoch * 24 * 60 + i64::from(hour * 60 + minute),
utc_offset_minutes: 0,
}
}
// --- Schedule builders ---------------------------------------------------
//
// Verb constructors rather than struct literals: a table is read far more often
// than it is edited, and `blocklist("s1", "Focus", &[app("discord")], &[time(...)])`
// says more than a five-field struct literal does.
pub fn blocklist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule {
Schedule {
id: id.to_owned(),
name: name.to_owned(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: targets.to_vec(),
conditions: conditions.to_vec(),
paused: false,
}
}
pub fn allowlist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule {
Schedule { mode: ScheduleMode::Allowlist, ..blocklist(id, name, targets, conditions) }
}
pub fn app(id: &str) -> Target {
Target::App(id.to_owned())
}
pub fn website(domain: &str) -> Target {
Target::Website(domain.to_owned())
}
pub fn keyword(word: &str) -> Target {
Target::Keyword(word.to_owned())
}
/// A time window. `start > end` means it spans midnight.
pub fn time(days: &[DayOfWeek], start: MinuteOfDay, end: MinuteOfDay) -> Condition {
Condition::Time(TimeWindow { days: days.to_vec(), start, end })
}
/// Monday through Friday, which most work-hours schedules use.
pub const WEEKDAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4];
/// Every day of the week.
pub const ALL_DAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4, 5, 6];
/// `HH:MM` to minutes since midnight, for readability in a table.
pub fn at(hour: u32, minute: u32) -> MinuteOfDay {
let total = hour * 60 + minute;
u16::try_from(total).unwrap_or(MinuteOfDay::MAX)
}
pub fn usage_limit(limit_minutes: u32, window: UsageWindow) -> Condition {
Condition::UsageLimit { limit_minutes, window }
}
pub fn launch_count(limit: u32, window: UsageWindow) -> Condition {
Condition::LaunchCount { limit, window }
}
pub fn wifi(ssid: &str) -> Condition {
Condition::WifiSsid(ssid.to_owned())
}
pub fn location(lat: f64, lon: f64, radius_m: u32, block_when_outside: bool) -> Condition {
Condition::Location {
latitude: lat,
longitude: lon,
radius_meters: radius_m,
block_when_outside,
}
}
/// Every case, across every table.
///
/// The runner asserts that this is the same set the individual tables produce,
/// so a case added to a table but not collected here cannot silently go
/// unrun.
pub fn all() -> Vec<Case> {
crate::tables::overnight_cases()
.into_iter()
.chain(crate::tables::timezone_cases())
.chain(crate::tables::strictest_wins_cases())
.chain(crate::tables::allowlist_cases())
.chain(crate::tables::condition_guard_cases())
.chain(crate::tables::usage_and_launch_cases())
.chain(crate::tables::wifi_and_location_cases())
.chain(crate::tables::wakeup_cases())
.chain(crate::tables::never_block_cases())
.chain(crate::tables::lock_rule_cases())
.collect()
}
-42
View File
@@ -1,42 +0,0 @@
//! Cross-platform scenario tests (CLAUDE.md section 7).
//!
//! This crate holds the **golden decision tables**: declarative schedules,
//! inputs, and expected decisions that are run against the core engine.
//!
//! # Why this is a contract, not a test suite
//!
//! Unit tests check the engine against itself. These check that *every platform*
//! agrees with the engine. Android, Linux and Windows each map their own app
//! identity — package name, desktop-file id, exe path — onto a
//! [`blocker_core::model::Target`], and every case here runs through that mapping
//! too. If Linux resolves Discord to a different target than Android does, a case
//! fails at build time instead of becoming a bug report from whichever platform
//! nobody was testing that week.
//!
//! # The rule that keeps it honest
//!
//! **A case is added before the engine changes.** A table written after the code
//! records current behaviour and can never catch a regression — it would be a
//! transcript, not a specification. Reviewing a PR that changes engine behaviour
//! therefore means checking that a case was added or updated in the same commit.
//!
//! # Layout
//!
//! - [`golden`] — the case format, the harness, and the builder DSL
//! - [`tables`] — the cases themselves, grouped by scenario
//! - [`coverage`] — which CLAUDE.md scenario each case claims, and the check that
//! nothing went uncovered
//! - [`purity`] — a mechanical check that the core has not gained I/O, plus the
//! clock-tampering fixtures that cannot be expressed as data
//!
//! [`purity`] is `cfg(test)` because its checks are `#[test]` functions in this
//! library's unit-test build, not library API. They run under `cargo test`.
pub mod coverage;
pub mod golden;
pub mod tables;
#[cfg(test)]
mod purity;
#[cfg(test)]
mod run;
-261
View File
@@ -1,261 +0,0 @@
//! Mechanical proof of the engine purity contract (CLAUDE.md section 2).
//!
//! The engine must be a pure function of its inputs — no clock reads, no I/O,
//! no OS calls. That rule is what guarantees Android, Linux and Windows reach
//! identical decisions, and therefore what makes the golden tables a parity
//! contract rather than a wish.
//!
//! # Why this is a test and not a code review
//!
//! Purity is easy to break by accident and hard to notice. A `SystemTime::now()`
//! added to a helper looks harmless, and the platform it breaks is whichever one
//! nobody is testing that day. A mechanical check fails loudly at build time
//! instead. `dependency_purity` below reads the *resolved* dependency graph, so
//! it cannot be fooled by a transitive addition either.
//!
//! # Why `expect` appears here and nowhere else
//!
//! The workspace denies `unwrap_used` and `expect_used` outright (CLAUDE.md
//! section 0 rule 5: no `unwrap` on fallible OS or I/O results). Those lints are
//! right for shipping code and wrong for test fixtures, where a missing
//! `Cargo.lock` or an absent `NEVER_BLOCK` entry means the test itself is
//! broken and there is nothing to handle gracefully. The `allow` below is scoped
//! to this module, with the reason stated, so the exemption is visible in review
//! rather than being a workspace-wide hole.
#![cfg(test)]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test fixtures: a missing file or empty list is a broken test, not a runtime condition"
)]
//! # The clock-tampering fixtures live here
//!
//! Section 1.5.3 requires that changing the system date can never shorten a
//! lock. Proving that needs a clock that moves, which a JSON table cannot
//! express, so these are code fixtures rather than data cases. Both kinds
//! coexist deliberately: the tables describe *decisions*, this module describes
//! *the passage of time*.
use blocker_core::engine::{BootId, CivilTime, Monotonic};
use blocker_core::lock::{self, LockLevel};
/// The core crate must have no dependencies at all.
///
/// This is the strongest form of the check. The core currently needs nothing
/// from the ecosystem — time arithmetic is done on `i64` minutes — so any
/// dependency appearing here is either a bug (something I/O reached in) or a
/// decision that belongs in a documented ADR rather than a silent addition.
#[test]
fn dependency_purity() {
let cargo_lock = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../Cargo.lock"))
.expect("Cargo.lock is generated by any cargo build");
let blocker_core = cargo_lock
.split("[[package]]")
.find(|block| block.contains("name = \"blocker-core\""))
.expect("blocker-core must appear in Cargo.lock");
let deps: Vec<&str> = blocker_core
.lines()
.skip_while(|line| !line.contains("dependencies = ["))
.skip(1)
.take_while(|line| !line.trim_start().starts_with(']'))
.map(|line| line.trim().trim_matches('"'))
.collect();
assert!(
deps.is_empty(),
"blocker-core must have zero dependencies to stay pure, found: {deps:?}\n\
A core dependency is either an I/O reach-in that breaks cross-platform \
parity, or an undocumented crate. Justify it in docs/DECISIONS.md first."
);
}
/// Moving the system clock backwards must not shorten a lock.
///
/// The concrete attack: a 12-hour time-lock request is running, and the user
/// sets the system date back three days hoping the request expires instantly.
#[test]
fn backwards_wall_clock_never_shortens_a_lock() {
let deadline = Monotonic(12 * 3_600_000); // 12h of monotonic time left
// Wall clock observed three days *behind* what elapsed time predicts.
let observed = CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 };
let expected = CivilTime { epoch_minutes: 3 * 24 * 60, utc_offset_minutes: 0 };
let correction = lock::wall_clock_correction(observed, expected, Monotonic(0))
.expect("a three-day jump must be detected");
assert!(
correction < 0,
"an observed clock behind expectation is reported as a negative correction"
);
let adjusted = lock::apply_correction(deadline, correction);
assert!(
adjusted.0 >= deadline.0,
"a backwards wall-clock jump must not shorten the lock: \
12h deadline became {}ms",
adjusted.0
);
}
/// A forward jump must not shorten the lock either, and neither may push it past
/// the 24-hour cap — extending without a bound would violate section 1.5.4 even
/// though it satisfies the anti-tamper rule.
#[test]
fn wall_clock_correction_respects_both_bounds() {
let deadline = Monotonic(12 * 3_600_000);
// Forward jump of three days. Still must not shorten.
let forward = lock::apply_correction(deadline, 3 * 24 * 60);
assert!(forward.0 >= deadline.0, "a forward jump must never shorten a lock either");
// But the result is clamped: a 3-day rollback must not turn a 12h lock into
// a 3-day wait (section 1.5.4 caps the worst case at 24h).
assert!(
forward.0 <= lock::MAX_WAIT_MS,
"the corrected deadline {}ms exceeds the 24h cap {}ms",
forward.0,
lock::MAX_WAIT_MS
);
assert_eq!(forward.0, lock::MAX_WAIT_MS, "12h + 3 days clamps to the cap");
// A zero correction leaves the deadline alone.
assert_eq!(lock::apply_correction(deadline, 0), deadline);
}
/// No detected jump means no correction at all — an honest clock must not cost
/// the user any extra waiting.
#[test]
fn an_undisturbed_clock_costs_nothing() {
let t = CivilTime { epoch_minutes: 1_000_000, utc_offset_minutes: 60 };
assert_eq!(
lock::wall_clock_correction(t, t, Monotonic(5_000)),
None,
"an unchanged clock must report no correction"
);
}
/// A reboot must not reset a lock.
#[test]
fn reboot_resumes_conservatively() {
// 5h of monotonic time had elapsed before the reboot; the lock had 3h left.
let deadline_before_reboot = Monotonic(12 * 3_600_000);
let elapsed_before_reboot = Monotonic(5 * 3_600_000);
let resumed = lock::resume_after_reboot(deadline_before_reboot, elapsed_before_reboot);
assert!(
resumed >= deadline_before_reboot || resumed >= elapsed_before_reboot,
"resume must take the later of the two, never the earlier"
);
assert_eq!(
resumed.0, deadline_before_reboot.0,
"with 7h elapsed and 12h set, the remaining 7h is still owed after reboot"
);
}
/// Everything on the never-block list is unblockable, and ordinary apps are not
/// swept up by the substring match.
///
/// The list is per-platform (CLAUDE.md section 1.5.4 names phone/dialer and
/// emergency paths on mobile, and the OS shell plus Settings basics on
/// desktop), so the test iterates the list this build actually has rather than
/// assuming a global one.
#[test]
fn never_block_list_is_unblockable() {
assert!(
!blocker_core::never_block::NEVER_BLOCK.is_empty(),
"every supported platform must protect something; an empty list would mean \
a device could be locked out of its own dialler or shell"
);
for protected in blocker_core::never_block::NEVER_BLOCK {
assert!(
blocker_core::never_block::is_never_blocked(protected),
"{protected} is on the list and must therefore be unblockable"
);
}
// Substring matching must not over-match into unrelated apps.
for ordinary in ["com.example.social", "org.example.chat", "steam"] {
assert!(
!blocker_core::never_block::is_never_blocked(ordinary),
"{ordinary} is an ordinary app and must remain blockable"
);
}
}
/// The never-block check is consulted inside the engine, so a schedule naming a
/// protected app cannot produce a block entry for it.
#[test]
fn engine_cannot_block_a_never_blocked_app() {
use blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot};
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow};
// On this platform the list contains the desktop shell and Settings. Pick
// the first entry that actually exists for this build.
let protected =
blocker_core::never_block::NEVER_BLOCK.first().expect("non-empty list").to_string();
let schedules = vec![Schedule {
id: "s1".into(),
name: "Attempts to block the shell".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App(protected.clone())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 0,
end: 1439,
})],
paused: false,
}];
let usage = UsageSnapshot::default();
let context = PlatformContext::default();
let input = EngineInput {
now: CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 },
monotonic: Monotonic(0),
boot_id: BootId(0),
schedules: &schedules,
usage: &usage,
context: &context,
installed_apps: &[],
};
let decision = blocker_core::evaluate(&input);
assert!(
decision.blocked_apps.is_empty(),
"the engine produced a block for {protected}, which is on the never-block \
list and must be structurally unblockable (section 1.5.4)"
);
}
/// The worst-case disable path is capped at 24 hours.
#[test]
fn worst_case_disable_path_is_capped() {
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() <= lock::MAX_WAIT_MS,
"{level:?} worst case {}ms exceeds the 24h cap",
state.worst_case_wait()
);
}
assert_eq!(lock::MAX_WAIT_MS, 24 * 3_600_000, "the cap is exactly 24 hours");
}
/// A boot id change is distinguishable from a daemon restart.
#[test]
fn boot_id_identifies_a_reboot() {
let first = BootId(0xDEAD_BEEF);
assert_ne!(first, BootId(0xDEAD_BEEE), "a reboot yields a different boot id");
}
-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),
]
}