A desktop focus tracker that gives distraction a price. Work on a task and points accrue; spend them on a timed slot of YouTube, Twitch or a game. Outside a paid slot the sites resolve to 0.0.0.0 and the apps sit frozen with SIGSTOP.
Built for personal use, macOS-first. The blocking half needs a small root helper installed by hand — see Privileges. Device sync, daily quota and the entry bonus exist in the schema and the settings screen but are not wired into the worker yet; see Not built yet.
Home · Tasks · Stats
Blockers fail in the same way: the block is absolute, so the first time you genuinely need the blocked thing you turn it off, and it never comes back on. Vestibule never says no. It says how much — and the price is paid in work you already did.
Two rules carry the whole thing:
- Points come from focused work only. Not from a task being open, not from the app running in the background. From a focus session with the keyboard actually being touched.
- The less you participate, the more it costs. A feed you scroll without thinking is the most expensive minute you can buy. Something that makes you think is the cheapest. Rates are per engagement tier —
passive/mixed/active— and every tier is editable, as is any single entry.
Vue 3 in the webview
│ invoke()
▼
commands.rs ────────► vestibule-core pure domain — economy, task state machine
│ vestibule-db SQLite: migrations, pool, repositories
│
├── worker thread every 5 s: accrue points, tick the open slot,
│ re-enforce blocking, emit state to the UI
│
├── helper client ──unix socket──► vestibule-helper (root, launchd)
│ └──► /etc/hosts
│
└── appblock.rs ──SIGSTOP / SIGCONT──► entertainment app processes
Everything is local. One SQLite file in the app data directory is the source of truth; nothing is sent anywhere.
Work
- Task lifecycle as an explicit state machine — paused → in progress → manager review → code review → awaiting deploy → awaiting payment → done, plus both revision branches and archive
- One task in focus at a time — the invariant is a partial unique index in SQLite, not caller discipline
- Focus sessions with idle detection: no input for longer than the timeout and accrual stops
- Projects, urgency, per-task hour log, full status history
Economy
- Points accrue per hour of focused work, at a rate you set
- Entertainment priced per minute by engagement tier, with a per-entry override
- Slot purchase fixes the rate at the moment of purchase — later tariff edits never re-price time already bought
- Early stop refunds the unused remainder
- Optional ceiling on slot length, so a large balance still cannot buy an unbroken evening
- Accrual is persisted every tick, not at session end — a crash never costs earned points
Blocking
- Sites: a managed block in
/etc/hosts, written by a small root helper. Everything outside the markers is copied through byte for byte - Apps:
SIGSTOPon the process,SIGCONTwhen a slot opens. DNS cannot stop a game that is already installed - The registry is the source of truth and enforcement is best-effort: with no helper installed the app degrades to a tracker instead of refusing to start
- Onboarding seeds the registry from a preset list of the usual suspects
Interface
- Four screens — home, tasks, stats, settings — plus a first-run wizard
- Stats: activity by day, work-to-rest ratio, spend by entity
- The day rolls over at an hour you choose, not at midnight
The security story is the reason most of this code is shaped the way it is.
- The app runs unprivileged. Only
/etc/hostsneeds root, so only the thing that writes/etc/hostshas it: a separate ~500-line binary launched by launchd. - The helper accepts exactly four operations — version, block, unblock, sync — and only over domains. There is no "run this command", no "write this file", no "kill this process". Nothing it receives ever reaches a shell; the file is edited in Rust.
- Two access barriers. The socket is owned by the app's user with mode
0600, and every connection is additionally checked withgetpeereid— a different UID is refused even if the permissions were loosened by someone else. - Process freezing deliberately lives outside the helper.
SIGSTOPon your own processes needs no privileges at all, so putting it in a root daemon would widen the attack surface for nothing. - Matching is exact. A process matches by full executable name or full path, never by substring — freezing the wrong process is the worst thing this feature can do, so it is made hard to do by accident. Own UID only, and never itself.
- No global input hooks. Idle time is read from the system counter. An app that asks for accessibility permissions to run a timer looks exactly like the thing you should not grant them to.
- The hosts editing functions are pure — content in, content out. Not one test had to run as root.
| Layer | Technology |
|---|---|
| Shell | Tauri 2 |
| Frontend | Vue 3.5 + Vue Router (hash history) + Pinia 3 |
| Build | Vite 6, TypeScript 5.6, vue-tsc |
| Backend | Rust, edition 2024 (MSRV 1.85), workspace of four crates |
| Storage | SQLite via rusqlite (bundled) + r2d2 pool + refinery migrations |
| IPC to helper | Unix domain socket, one JSON line per message |
| Fonts | Inter Variable + IBM Plex Mono, self-hosted via Fontsource |
| Package manager | pnpm |
crates/
core/ pure domain — no Tauri, no SQLite, no UI
economy.rs accrual, per-minute rates, slot cost, refunds
task_state.rs status state machine — status + action in, transition out
enums.rs sql_enum! — TEXT ↔ enum, so the DB reads by eye
models.rs domain types
settings.rs economy validation — a zero rate would mean free YouTube
db/ storage
migrations/ V1…V6, all tables STRICT, UUID v7 keys, RFC3339 UTC text
repositories/ tasks, projects, focus, transactions, entertainment,
entities, settings, stats
ledger.rs points ledger
helper/ the privileged daemon
protocol.rs four operations, whitelisted
server.rs socket, getpeereid, root side
hosts.rs pure functions over /etc/hosts content
client.rs the app's side — absence of the helper is a normal state
src-tauri/ the app
commands.rs the only surface the frontend can reach; no logic here
worker.rs background thread: accrual, slot countdown, enforcement
blocking.rs keeps /etc/hosts in agreement with the registry
appblock.rs process freezing
idle.rs seconds since last input
src/ Vue 3
pages/ HomePage, TasksPage, StatsPage, SettingsPage
components/ OnboardingWizard, AppMark
stores/ Pinia — app, home, tasks, stats, settings, onboarding
lib/ typed invoke() wrappers, formatting, presets
Requires Rust 1.85+, Node 24 and pnpm.
pnpm install
pnpm tauri dev # dev window
pnpm tauri build # bundleSite blocking does nothing until the daemon is installed. It asks for a password once, then comes up with the system.
./scripts/install-helper.sh # build and install
sudo ./scripts/uninstall-helper.sh # removeNo Developer ID signature needed — launchd runs any binary out of /Library/LaunchDaemons; a signature would only be required to install the helper programmatically via SMAppService.
App freezing and everything else work without it. The UI shows a banner when the helper is missing or speaks an older protocol version.
196 unit and integration tests, no mocking framework — the domain crate has nothing to mock, and the hosts logic is pure.
cargo test --workspace| Crate | Tests |
|---|---|
core |
53 |
db |
80 |
helper |
26 |
src-tauri |
37 |
CI runs on every push and pull request: prettier --check, eslint, vue-tsc, vite build, then cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace, and a debug tauri build to prove the config, icons, capabilities and linking are intact.
Kept honest on purpose — these are visible in the schema or the settings screen but do nothing yet:
- Device sync.
sync_enabledis stored; there is no sync outbox table and no server. An empty table nobody writes to is worse than no table. - Daily quota. The
daily_quotatable and the quota settings exist; the worker does not compute quotas or award the bonus. - Entry bonus. Stored and validated, not applied.
- Input statistics.
track_input_statsis stored; nothing collects them. - Platforms. Idle detection is macOS-only, and the helper installer is a launchd script. Process freezing works on any Unix. Windows is not supported.
MIT


