Skip to content

Repository files navigation

Vestibule

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

Home  ·  Tasks  ·  Stats

The idea

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:

  1. 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.
  2. 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.

How it works

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.

Features

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: SIGSTOP on the process, SIGCONT when 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

Privileges

The security story is the reason most of this code is shaped the way it is.

  • The app runs unprivileged. Only /etc/hosts needs root, so only the thing that writes /etc/hosts has 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 with getpeereid — a different UID is refused even if the permissions were loosened by someone else.
  • Process freezing deliberately lives outside the helper. SIGSTOP on 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.

Stack

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

Project structure

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

Build

Requires Rust 1.85+, Node 24 and pnpm.

pnpm install
pnpm tauri dev      # dev window
pnpm tauri build    # bundle

Install the helper

Site 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   # remove

No 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.

Tests

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.

Not built yet

Kept honest on purpose — these are visible in the schema or the settings screen but do nothing yet:

  • Device sync. sync_enabled is 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_quota table 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_stats is 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.

License

MIT

About

Desktop focus tracker that gives distraction a price — Rust workspace of four crates, privileged helper over a Unix socket, site blocking via /etc/hosts and app freezing via SIGSTOP, 196 tests

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages