diff --git a/.gitea/ISSUE_TEMPLATE/bug.yml b/.gitea/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..e6967ea --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug.yml @@ -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. \ No newline at end of file diff --git a/.gitea/ISSUE_TEMPLATE/limitation.yml b/.gitea/ISSUE_TEMPLATE/limitation.yml new file mode 100644 index 0000000..35e7d0e --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/limitation.yml @@ -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). \ No newline at end of file diff --git a/.gitea/ISSUE_TEMPLATE/question.yml b/.gitea/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..c247285 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/question.yml @@ -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. \ No newline at end of file diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b40b90c --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,88 @@ + + +## What changed + + + +Refs # + +## Which requirement this covers + + + +Section 1: + +## Test evidence + + + +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 + + + +## 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. \ No newline at end of file diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md new file mode 100644 index 0000000..17842bf --- /dev/null +++ b/.gitea/workflows/README.md @@ -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 "" \ + --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. \ No newline at end of file diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..fdc8e9b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..70153de --- /dev/null +++ b/LICENSE.md @@ -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.* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..19341ac --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# 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; storage has no I/O yet | +| 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 +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: **0.279 MB** stripped daemon — a placeholder that prints two +lines. The meaningful number comes in M2-Linux. 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. \ No newline at end of file