Compare commits
7 Commits
81d5122e4b
...
a43a8e8231
| Author | SHA1 | Date | |
|---|---|---|---|
| a43a8e8231 | |||
| 610d6b4190 | |||
| da3ce160b4 | |||
| 8a521e760e | |||
| 5c50742a61 | |||
| c723efb186 | |||
| aa1c582f3c |
@@ -0,0 +1,19 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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).
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!--
|
||||||
|
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.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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
@@ -0,0 +1,72 @@
|
|||||||
|
# --- 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/
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# 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
+155
@@ -0,0 +1,155 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocker-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocker-daemon"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"blocker-core",
|
||||||
|
"blocker-storage",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocker-platform-linux"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"blocker-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocker-platform-windows"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"blocker-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocker-storage"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"blocker-core",
|
||||||
|
"rusqlite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocker-tests"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"blocker-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocker-ui"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.35.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
[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
@@ -0,0 +1,50 @@
|
|||||||
|
# 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.*
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 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
@@ -0,0 +1,24 @@
|
|||||||
|
# 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"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[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"
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
//! 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 {
|
||||||
|
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 {
|
||||||
|
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> {
|
||||||
|
let minute = minute_of_day(now);
|
||||||
|
let today = weekday(now);
|
||||||
|
|
||||||
|
if time_window_contains(window, now) {
|
||||||
|
// Currently active: the end depends on whether we're in the early or
|
||||||
|
// late part of an overnight window.
|
||||||
|
let end_today = if window.is_overnight() && minute < window.end {
|
||||||
|
// Early part — ends later today.
|
||||||
|
window.end
|
||||||
|
} else {
|
||||||
|
window.end
|
||||||
|
};
|
||||||
|
let minutes_until = i64::from(end_today) - i64::from(minute);
|
||||||
|
return Some(CivilTime {
|
||||||
|
epoch_minutes: now.epoch_minutes + minutes_until.max(0),
|
||||||
|
utc_offset_minutes: now.utc_offset_minutes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not active: the next start is the boundary that matters.
|
||||||
|
let mut best: Option<i64> = None;
|
||||||
|
for day_offset in 0..=7 {
|
||||||
|
let candidate_day = (today as i32 + day_offset) % 7;
|
||||||
|
if !window.days.contains(&(candidate_day as u8)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut delta = i64::from(window.start) - i64::from(minute);
|
||||||
|
if window.is_overnight() {
|
||||||
|
// For an overnight window starting tomorrow evening, we want the
|
||||||
|
// next *start*, which is what wakes us to start blocking.
|
||||||
|
delta = i64::from(window.start) - i64::from(minute);
|
||||||
|
}
|
||||||
|
if delta < 0 {
|
||||||
|
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 up 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
//! 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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! 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.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BlockReason {
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! 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()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! 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";
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[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" }
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
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 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());
|
||||||
|
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
|
||||||
|
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "blocker-platform-linux"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Linux platform layer: foreground-window detection, enforcement, and system integration for Blocker."
|
||||||
|
license = "UNLICENSED"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "blocker_platform_linux"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
blocker-core = { path = "../../core" }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! Linux platform layer.
|
||||||
|
//!
|
||||||
|
//! Observes the OS and enforces decisions. Contains **no decision logic**
|
||||||
|
//! (CLAUDE.md section 2).
|
||||||
|
//!
|
||||||
|
//! # The trait this crate exists to implement
|
||||||
|
//!
|
||||||
|
//! Linux has no portable way to ask "which window has focus" under Wayland.
|
||||||
|
//! The [`ForegroundWatcher`] trait below is the seam, and it will have three
|
||||||
|
//! implementations, tried in order at startup:
|
||||||
|
//!
|
||||||
|
//! 1. **`KwinBackend`** — the primary path on this machine. Loads a small
|
||||||
|
//! KWin script over the `org.kde.KWin /Scripting` D-Bus interface, which
|
||||||
|
//! hooks `windowActivated` and reports back to the daemon. Verified
|
||||||
|
//! available on KDE Plasma 6.6.6: `loadScript`, `start`, `unloadScript`,
|
||||||
|
//! `isScriptLoaded` are all present.
|
||||||
|
//!
|
||||||
|
//! 2. **`X11Backend`** — the portable fallback. `x11rb` subscribes to
|
||||||
|
//! `_NET_ACTIVE_WINDOW` property changes, resolves `_NET_WM_PID`, then reads
|
||||||
|
//! `/proc/<pid>/exe`. Works on any X11 session and is testable headless with
|
||||||
|
//! Xvfb, so this is also the path CI exercises.
|
||||||
|
//!
|
||||||
|
//! 3. **`ProcessBackend`** — a degraded last resort with no idea what has focus,
|
||||||
|
//! which acts on process creation instead. It cannot show a meaningful block
|
||||||
|
//! screen and should never be reached on a working KDE session.
|
||||||
|
//!
|
||||||
|
//! # Honest limits (CLAUDE.md section 6)
|
||||||
|
//!
|
||||||
|
//! - `X11Backend` is **blind to native Wayland apps** under a Wayland session.
|
||||||
|
//! Under XWayland it sees only XWayland clients. This is why it is a fallback
|
||||||
|
//! and not the primary.
|
||||||
|
//! - `/proc/connectors` does not exist on the development kernel, so the
|
||||||
|
//! netlink proc-connector process watcher is unavailable and
|
||||||
|
//! `ProcessBackend` must use eBPF or `/proc` polling — a worse experience
|
||||||
|
//! than a block screen, by construction.
|
||||||
|
//! - The KWin backend is KDE-specific. wlroots
|
||||||
|
//! (`foreign-toplevel-management`) and GNOME backends remain in M7 per
|
||||||
|
//! section 8, because no portable API exists to build them against.
|
||||||
|
//! - None of this can be made tamper-proof against a user with root. Section
|
||||||
|
//! 6.1 requires this be documented honestly rather than implied otherwise.
|
||||||
|
|
||||||
|
/// A window that has taken focus, resolved to something a schedule can match.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ForegroundWindow {
|
||||||
|
/// Backend-native window handle. KWin gives a `uuid` string; X11 gives an
|
||||||
|
/// XID. Kept as a string so the trait is not tied to one backend.
|
||||||
|
pub handle: String,
|
||||||
|
/// Resolved process id, where the backend can supply one.
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
/// Absolute path of the executable, read from `/proc/<pid>/exe`.
|
||||||
|
pub exe_path: Option<String>,
|
||||||
|
/// Window title, for display on the block screen.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Desktop-file id or WM_CLASS, which is what a Linux user actually
|
||||||
|
/// recognises as "the app" and what schedules should match against.
|
||||||
|
pub desktop_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events a watcher can raise. The daemon blocks on this channel; there is no
|
||||||
|
/// polling (section 2).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FocusEvent {
|
||||||
|
/// A window gained focus.
|
||||||
|
Activated(ForegroundWindow),
|
||||||
|
/// The focused window closed, so there is no longer a foreground window.
|
||||||
|
Cleared,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source of focus events.
|
||||||
|
///
|
||||||
|
/// Implemented once per backend. M2-Linux fills these in.
|
||||||
|
pub trait ForegroundWatcher {
|
||||||
|
/// Begin delivering events to `sink`.
|
||||||
|
///
|
||||||
|
/// Implementations must be event-driven: they subscribe to an X11 property
|
||||||
|
/// change or a compositor callback, never to a timer. An implementation
|
||||||
|
/// that polls would break the section 5 idle budget even if it functioned.
|
||||||
|
fn subscribe(&mut self, sink: std::sync::mpsc::Sender<FocusEvent>);
|
||||||
|
|
||||||
|
/// Which backend this is, for the troubleshooting screen and the log.
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Why this backend was chosen, in plain language for the user.
|
||||||
|
fn selection_reason(&self) -> &'static str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforcement actions, in escalating order and configurable by the user
|
||||||
|
/// (CLAUDE.md section 4.3).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Enforcement {
|
||||||
|
/// Show the block screen. The default, and the only non-destructive option.
|
||||||
|
BlockScreen,
|
||||||
|
/// Ask the compositor to minimise the window.
|
||||||
|
Minimise,
|
||||||
|
/// `SIGSTOP` the process. Freezes the app rather than closing it, so the
|
||||||
|
/// user's state is preserved and the resume is honest.
|
||||||
|
Freeze,
|
||||||
|
/// Terminate the process. Destructive, and the last resort.
|
||||||
|
Terminate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the platform layer may and may not do unprivileged.
|
||||||
|
///
|
||||||
|
/// Recorded here because section 0 rule 2 requires honesty rather than a stub
|
||||||
|
/// that pretends. Each of these is a real limit of an unprivileged user
|
||||||
|
/// daemon, not a missing feature.
|
||||||
|
pub const UNPRIVILEGED_LIMITS: &[&str] = &[
|
||||||
|
"cannot prevent a process from launching, only react to it after the fact",
|
||||||
|
"cannot mask a systemd unit (that needs root); tamper resistance is \
|
||||||
|
Restart=always plus a watchdog, nothing stronger",
|
||||||
|
"cannot make its own config tamper-proof against the same user, who owns the \
|
||||||
|
files — lock state lives in the DB instead (section 1.5.3)",
|
||||||
|
"cannot read /proc/<pid>/exe for processes owned by other users",
|
||||||
|
"cannot block a process that is already running as root",
|
||||||
|
];
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "blocker-platform-windows"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Windows platform layer: foreground-window detection, enforcement, and system integration for Blocker."
|
||||||
|
license = "UNLICENSED"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "blocker_platform_windows"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
# The `windows` crate (CLAUDE.md section 3) is intentionally NOT added in M0:
|
||||||
|
# it is large and nothing here compiles on Linux, so adding it would grow the
|
||||||
|
# lockfile and CI time for code that cannot be verified here. It lands in the
|
||||||
|
# Windows milestone, with an ADR entry.
|
||||||
|
[dependencies]
|
||||||
|
blocker-core = { path = "../../core" }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//! Windows platform layer.
|
||||||
|
//!
|
||||||
|
//! Observes the OS and enforces decisions. Contains **no decision logic**
|
||||||
|
//! (CLAUDE.md section 2).
|
||||||
|
//!
|
||||||
|
//! Nothing is implemented here in M0 — this crate cannot be compiled or tested
|
||||||
|
//! on the development machine, so writing enforcement code now would mean
|
||||||
|
//! shipping unverified code (section 0 rule 2: never fake a feature). The
|
||||||
|
//! design is recorded so the Windows milestone has a fixed target.
|
||||||
|
//!
|
||||||
|
//! # Planned shape (CLAUDE.md section 4.2)
|
||||||
|
//!
|
||||||
|
//! - **Foreground detection:** `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)` —
|
||||||
|
//! event-driven, no polling. The hook resolves `HWND` to a PID, then to the
|
||||||
|
//! exe path, with package identity for UWP apps.
|
||||||
|
//! - **Block action:** show a topmost block window, then minimise or close the
|
||||||
|
//! offending window. Termination only as a configurable escalation.
|
||||||
|
//! - **Runs as:** a per-user background daemon at login, plus a Windows Service
|
||||||
|
//! watchdog that restarts it if it dies.
|
||||||
|
//! - **Wi-Fi:** the WLAN API notification callback.
|
||||||
|
//! - **Website blocking:** the weak point. The decision is a Firefox extension
|
||||||
|
//! over native messaging (section 6.2), which is what makes keyword and
|
||||||
|
//! path-level blocking possible — DNS and hosts-file approaches cannot do
|
||||||
|
//! either, and are defeated by DoH.
|
||||||
|
|
||||||
|
/// A window that has taken focus, resolved to something a schedule can match.
|
||||||
|
///
|
||||||
|
/// Mirrors `blocker_platform_linux::ForegroundWindow` deliberately: the two
|
||||||
|
/// platform crates implement the same shape so a schedule means the same thing
|
||||||
|
/// on both.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ForegroundWindow {
|
||||||
|
/// `HWND` as a string, so this type is not tied to the `windows` crate.
|
||||||
|
pub handle: String,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
pub exe_path: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// UWP package family name, where the app is packaged.
|
||||||
|
pub package_identity: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events a watcher can raise. The daemon blocks on this channel; no polling.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FocusEvent {
|
||||||
|
Activated(ForegroundWindow),
|
||||||
|
Cleared,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source of focus events, backed by `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)`.
|
||||||
|
pub trait ForegroundWatcher {
|
||||||
|
fn subscribe(&mut self, sink: std::sync::mpsc::Sender<FocusEvent>);
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn selection_reason(&self) -> &'static str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforcement actions, escalating and configurable (section 4.2).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Enforcement {
|
||||||
|
BlockScreen,
|
||||||
|
Minimise,
|
||||||
|
Close,
|
||||||
|
/// Terminate the process. Destructive, last resort.
|
||||||
|
Terminate,
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "blocker-ui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Blocker block-screen window. Launched on demand, exits when the block lifts."
|
||||||
|
license = "UNLICENSED"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "blocker-ui"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# NO UI TOOLKIT CHOSEN YET — deliberately.
|
||||||
|
#
|
||||||
|
# CLAUDE.md section 3 requires Slint vs egui to be decided on a measured
|
||||||
|
# size/RAM comparison, and explicitly says to ask the owner. That decision is
|
||||||
|
# still open (see docs/DECISIONS.md, ADR-6).
|
||||||
|
#
|
||||||
|
# Two constraints already rule out some options, for the record:
|
||||||
|
# - Slint is GPL-3.0 or commercial. For a project intended to be published,
|
||||||
|
# the permissive licences are the safer default. If sizes come out close,
|
||||||
|
# that should decide it.
|
||||||
|
# - Either way the daemon must not grow past 5 MB stripped (section 5), and
|
||||||
|
# the UI must not hold a GL context resident while idle.
|
||||||
|
[dependencies]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! Blocker block-screen UI.
|
||||||
|
//!
|
||||||
|
//! Placeholder. No UI toolkit is bound here yet — see ADR-6 in
|
||||||
|
//! `docs/DECISIONS.md` for why that is an open decision rather than an oversight.
|
||||||
|
//!
|
||||||
|
//! # What this process is for
|
||||||
|
//!
|
||||||
|
//! It is a short-lived, separately-executable window. The daemon stays resident
|
||||||
|
//! and small; this launches when a block fires and exits when it lifts. Keeping
|
||||||
|
//! them separate means a crash in window rendering cannot take enforcement down.
|
||||||
|
//!
|
||||||
|
//! # Design constraints that will apply (docs/UX-BLOCK-SCREEN.md)
|
||||||
|
//!
|
||||||
|
//! - Calm, not punitive. No red-alert styling, no shaming copy, no streak
|
||||||
|
//! counters. The screen exists to end an argument with the user's own
|
||||||
|
//! intention, so it must lower arousal.
|
||||||
|
//! - It always states which schedule blocked and when it lifts. Uncertainty is
|
||||||
|
//! what makes people fight the tool.
|
||||||
|
//! - It is never a dead end: a plain-looking "How to turn this off" entry is
|
||||||
|
//! always present, including under Strict Mode (CLAUDE.md section 1.5.4).
|
||||||
|
//! - Under Strict Mode the *absence* of a bypass button is stated explicitly.
|
||||||
|
//! Silently missing controls read as a bug and breed distrust.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("blocker-ui {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
|
||||||
|
println!("UI toolkit undecided — ADR-6 in docs/DECISIONS.md");
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
# 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 — Slint vs egui | **Proposed, needs owner** |
|
||||||
|
| [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 JSON, not TOML | Proposed |
|
||||||
|
| [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 — Slint vs egui
|
||||||
|
|
||||||
|
**Status:** **Proposed — awaiting the owner's decision**
|
||||||
|
|
||||||
|
**Context.** Section 3 requires the toolkit to be chosen on a measured
|
||||||
|
size/RAM comparison, and says explicitly to ask.
|
||||||
|
|
||||||
|
**Decision.** Not yet made.
|
||||||
|
|
||||||
|
**What is already settled by measurement-independent constraints:**
|
||||||
|
|
||||||
|
- **Slint is GPL-3.0 or commercial.** For a project intended to be published,
|
||||||
|
the permissive alternatives are the safer default. If the size comparison comes
|
||||||
|
out close, licensing should decide it.
|
||||||
|
- Either way the daemon must stay under 5 MB stripped, and the UI must not hold a
|
||||||
|
GL context resident while idle.
|
||||||
|
|
||||||
|
**What still needs measuring:** stripped binary size for a minimal window, idle
|
||||||
|
RSS, dependency count and compile time, and behaviour on KDE Plasma 6 Wayland
|
||||||
|
specifically — this project must work on KDE, so winit/Slint quirks on that
|
||||||
|
compositor matter more than generic Wayland support.
|
||||||
|
|
||||||
|
Recorded rather than decided because section 3 says to ask.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<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 JSON, not TOML
|
||||||
|
|
||||||
|
**Status:** Proposed — depends on ADR-4 and the serde decision
|
||||||
|
|
||||||
|
**Context.** Section 7 requires golden decision tables as the cross-platform
|
||||||
|
parity contract. The format must be serialisable and diffable in review.
|
||||||
|
|
||||||
|
**Decision.** JSON, one file per concern, under `tests/golden/`.
|
||||||
|
|
||||||
|
**Rationale.** `serde_json` is already in the dependency graph via `rusqlite` and
|
||||||
|
`uniffi`, so TOML would add a crate for no gain, and section 3 makes every crate
|
||||||
|
justify its cost. JSON is also what a review diff reads most naturally.
|
||||||
|
|
||||||
|
**Not yet decided.** The parse layer needs serde, which is a dependency decision
|
||||||
|
requiring the owner's sign-off. The format itself is written down in
|
||||||
|
`tests/src/golden.rs` now; the parse arrives in M1 with ADR-4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<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.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# 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.
|
||||||
|
- **The golden tables cannot be parsed.** The format is specified in
|
||||||
|
`tests/src/golden.rs`; parsing needs serde, which is a dependency decision
|
||||||
|
awaiting sign-off (ADR-13, ADR-16). The behavioural coverage that exists today
|
||||||
|
is hand-written unit tests in the same spirit.
|
||||||
|
- **`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
@@ -0,0 +1,124 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 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
@@ -0,0 +1,196 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[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"]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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"
|
||||||
Executable
+193
@@ -0,0 +1,193 @@
|
|||||||
|
#!/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}"
|
||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/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
|
||||||
|
}
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/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}"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[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"
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
//! 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;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
//! 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;
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[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"
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
//! 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 the same table against the engine must
|
||||||
|
//! reproduce the expectation exactly.
|
||||||
|
//!
|
||||||
|
//! # Format
|
||||||
|
//!
|
||||||
|
//! JSON, one file per concern, under `golden/`. JSON rather than TOML because
|
||||||
|
//! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi`
|
||||||
|
//! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain.
|
||||||
|
//!
|
||||||
|
//! ```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"
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ]
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Status in M0
|
||||||
|
//!
|
||||||
|
//! The shapes below are the **contract**, written down before any parse layer
|
||||||
|
//! exists so M1 implements against a fixed format rather than retrofitting one.
|
||||||
|
//! `serde::Deserialize` is deliberately not derived yet — adding serde is a
|
||||||
|
//! dependency decision (CLAUDE.md section 0 rule 3) and is deferred to M1,
|
||||||
|
//! where it arrives with `rusqlite` under one ADR entry.
|
||||||
|
//!
|
||||||
|
//! # The clock-tampering exception
|
||||||
|
//!
|
||||||
|
//! 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 a
|
||||||
|
//! data file cannot express. Those cases live in [`crate::purity`] as code
|
||||||
|
//! fixtures instead, and the two coexist deliberately — the tables describe
|
||||||
|
//! decisions, the fixtures describe the passage of time.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// One table, deserialised from one `golden/*.json` file.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Table {
|
||||||
|
/// Which concern this table covers, e.g. `overnight`.
|
||||||
|
pub concern: String,
|
||||||
|
/// The cases in the table.
|
||||||
|
pub cases: Vec<Case>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One case: setup, input, expected decision.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Case {
|
||||||
|
/// Human-readable, and the primary test failure message.
|
||||||
|
pub name: String,
|
||||||
|
/// Which CLAUDE.md section 7 scenario this covers, so coverage is auditable
|
||||||
|
/// rather than assumed. Checked against [`MANDATORY_COVERAGE`].
|
||||||
|
pub covers: String,
|
||||||
|
/// The schedules under test.
|
||||||
|
pub schedules: Vec<ScheduleSpec>,
|
||||||
|
/// The input to the engine.
|
||||||
|
pub input: InputSpec,
|
||||||
|
/// The decision that must result.
|
||||||
|
pub expect: ExpectSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A schedule as it appears in a table.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScheduleSpec {
|
||||||
|
/// Stable id, referenced by `BlockedSpec::schedule`.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name, shown on the block screen.
|
||||||
|
pub name: String,
|
||||||
|
/// `"Blocklist"` or `"Allowlist"`.
|
||||||
|
pub mode: String,
|
||||||
|
/// What this schedule targets.
|
||||||
|
pub targets: Vec<TargetSpec>,
|
||||||
|
/// Its conditions. At least one must hold for the schedule to be active.
|
||||||
|
pub conditions: Vec<ConditionSpec>,
|
||||||
|
/// Defaults to false when absent from JSON.
|
||||||
|
pub paused: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A target, tagged by type so the JSON is self-describing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TargetSpec {
|
||||||
|
/// An app, by platform-native identifier.
|
||||||
|
App {
|
||||||
|
/// Package name on Android, desktop-file id or exe path elsewhere.
|
||||||
|
app: String,
|
||||||
|
},
|
||||||
|
/// A registrable domain. Subdomains are implied.
|
||||||
|
Website {
|
||||||
|
/// e.g. `example.com`.
|
||||||
|
website: String,
|
||||||
|
},
|
||||||
|
/// A case-insensitive substring matched against the URL.
|
||||||
|
Keyword {
|
||||||
|
/// The substring to match.
|
||||||
|
keyword: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A condition, tagged by type.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ConditionSpec {
|
||||||
|
/// Block while local time is inside the window.
|
||||||
|
Time {
|
||||||
|
/// Weekdays on which the window **starts**. Monday = 0, matching
|
||||||
|
/// `time::Weekday`.
|
||||||
|
days: Vec<u8>,
|
||||||
|
/// `HH:MM`. When `start > end` the window spans midnight — there is
|
||||||
|
/// deliberately no `crosses_midnight` flag, because the comparison
|
||||||
|
/// already encodes it and a second field is a second way to be wrong.
|
||||||
|
start: String,
|
||||||
|
/// `HH:MM`. See `start`.
|
||||||
|
end: String,
|
||||||
|
},
|
||||||
|
/// Block after a foreground-time threshold.
|
||||||
|
UsageLimit {
|
||||||
|
/// Threshold in minutes.
|
||||||
|
limit_minutes: u32,
|
||||||
|
/// `"Daily"` or `"Hourly"`.
|
||||||
|
window: String,
|
||||||
|
},
|
||||||
|
/// Block after a launch-count threshold.
|
||||||
|
LaunchCount {
|
||||||
|
/// Threshold count.
|
||||||
|
limit: u32,
|
||||||
|
/// `"Daily"` or `"Hourly"`.
|
||||||
|
window: String,
|
||||||
|
},
|
||||||
|
/// Block while connected to a named network.
|
||||||
|
WifiSsid {
|
||||||
|
/// The SSID to match exactly.
|
||||||
|
ssid: String,
|
||||||
|
},
|
||||||
|
/// Block inside or outside a radius. Mobile only (section 6.4).
|
||||||
|
Location {
|
||||||
|
/// Circle centre latitude in degrees.
|
||||||
|
latitude: f64,
|
||||||
|
/// Circle centre longitude in degrees.
|
||||||
|
longitude: f64,
|
||||||
|
/// Radius in metres.
|
||||||
|
radius_meters: u32,
|
||||||
|
/// Invert the test: block when *outside* the radius. Defaults false.
|
||||||
|
block_when_outside: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine input for a case.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct InputSpec {
|
||||||
|
/// RFC 3339 with an explicit offset, e.g. `2026-10-06T03:00:00+02:00`.
|
||||||
|
/// The offset is mandatory so a DST case is unambiguous.
|
||||||
|
pub civil_time: String,
|
||||||
|
/// Monotonic milliseconds since boot. Absent means 0.
|
||||||
|
pub monotonic_ms: u64,
|
||||||
|
/// Boot id. Absent means 0.
|
||||||
|
pub boot_id: u64,
|
||||||
|
/// Accumulated usage.
|
||||||
|
pub usage: UsageSpec,
|
||||||
|
/// Platform-observed context.
|
||||||
|
pub context: ContextSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usage counters for a case. All fields default to empty.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct UsageSpec {
|
||||||
|
/// Foreground minutes per target, daily bucket.
|
||||||
|
pub daily_minutes: BTreeMap<String, u32>,
|
||||||
|
/// Foreground minutes per target, hourly bucket.
|
||||||
|
pub hourly_minutes: BTreeMap<String, u32>,
|
||||||
|
/// Launch counts per target, daily bucket.
|
||||||
|
pub daily_launches: BTreeMap<String, u32>,
|
||||||
|
/// Launch counts per target, hourly bucket.
|
||||||
|
pub hourly_launches: BTreeMap<String, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Platform context for a case.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ContextSpec {
|
||||||
|
/// Current Wi-Fi SSID, if any.
|
||||||
|
pub current_ssid: Option<String>,
|
||||||
|
/// Current `(latitude, longitude)`, if any.
|
||||||
|
pub current_location: Option<(f64, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decision a case expects.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExpectSpec {
|
||||||
|
/// Apps that must be blocked, each attributed to a schedule.
|
||||||
|
pub blocked_apps: Vec<BlockedSpec>,
|
||||||
|
/// Websites and keywords that must be blocked.
|
||||||
|
pub blocked_domains: Vec<BlockedSpec>,
|
||||||
|
/// RFC 3339, or the literal `none` when the platform may sleep forever.
|
||||||
|
pub next_wakeup: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One expected blocked item.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BlockedSpec {
|
||||||
|
/// The target that must be blocked.
|
||||||
|
pub app: String,
|
||||||
|
/// The id of the schedule that must block it.
|
||||||
|
pub schedule: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one
|
||||||
|
/// `covers` field across the tables, and `coverage.rs` asserts it — so a
|
||||||
|
/// scenario cannot silently lose its test.
|
||||||
|
pub const MANDATORY_COVERAGE: &[&str] = &[
|
||||||
|
"7:overnight time ranges",
|
||||||
|
"7:DST changes",
|
||||||
|
"7:timezone changes",
|
||||||
|
"7:clock tampering",
|
||||||
|
"7:strictest-wins",
|
||||||
|
"7:allowlist mode",
|
||||||
|
"7:invalid condition combos",
|
||||||
|
"7:strict mode lock rules",
|
||||||
|
// The section 1.2 combination table, itemised so a missing pair is visible.
|
||||||
|
"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",
|
||||||
|
// Section 1.5.4 safety guarantees.
|
||||||
|
"1.5.4:never-block list is unblockable",
|
||||||
|
"1.5.4:worst case 24h",
|
||||||
|
];
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//! 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
|
||||||
|
//!
|
||||||
|
//! Section 7 requires that the same table runs against the core and serves as
|
||||||
|
//! the parity contract for all platforms. Android, Linux and Windows each
|
||||||
|
//! implement a mapping from a platform app identity to a `Target`, but they all
|
||||||
|
//! hand the same [`blocker_core`] engine the same schedule set and get back the
|
||||||
|
//! same decision. A platform crate runs this table against its own mapping, so
|
||||||
|
//! if Linux disagrees with Android the build fails rather than the user filing
|
||||||
|
//! a bug.
|
||||||
|
//!
|
||||||
|
//! # The rule that keeps it honest
|
||||||
|
//!
|
||||||
|
//! **A case is added before the engine changes.** If a table were written after
|
||||||
|
//! the code, it would only record current behaviour and could 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 tables themselves, one JSON file per concern
|
||||||
|
//! - [`purity`] — a mechanical check that the core has not gained I/O
|
||||||
|
//!
|
||||||
|
//! The mandatory case list is in [`golden::MANDATORY_COVERAGE`].
|
||||||
|
//!
|
||||||
|
//! [`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 golden;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod purity;
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
//! 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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user