TimMikeladze / menubar
#menubar
platformmacOS plugins8 licenseMIT built withElectrobun
A macOS menu bar app for the things you check between builds: pull requests, deployments, service status, feeds, a focus timer, colour maths, time zones, and a garden that grows on whichever metric you point it at.
Lives in the menu bar — no Dock icon, no window to manage. ⌘⇧D from anywhere,
or click the tray icon. Built with Electrobun, so the
whole thing is a webview and a Bun process rather than a bundled Chromium.
brew install --cask timmikeladze/menubar/menubar
⌘⇧D from anywhere · macOS 11+, Apple silicon
Live The app itself, built from this repo and pointed at invented data. Click anything.
## Getting started
Download the signed build, or run it from source — Bun and a Mac are the whole toolchain.
# Install — Apple silicon, macOS 11+
$ brew install --cask timmikeladze/menubar/menubar
# Or from source
$ bun install
# Develop — one command, HMR included
$ bun run dev
# Build a release
$ bun run build # stable channel
$ bun run build:canary # canary channel
# Cut a release — sets both version fields, then tag
$ bun run version 0.2.0
## Built-in Plugins
8 plugins ship in the app. 6 of them need no account, no token and no network beyond a public endpoint. Pick one to open it in the popover.
Same app, same data. It just starts on whichever plugin you picked.
## How it works
Every plugin that fetches does it from a background service rather than its tab: menubar only mounts the active plugin, so a component-owned poller would leave the badge and tray title frozen until you happened to open that tab. Each service caches its last payload, so reopening the popover paints rows instead of skeletons, and polling pauses whenever the popover is off screen.
The GitHub plugin reads via the GraphQL API — three concurrent searches, ~3s, because GitHub runs aliased searches serially inside one document. The only writes it makes are marking notifications read.
Feeds are fetched by the bun process rather than the webview: a site that
publishes a feed almost never sends Access-Control-Allow-Origin, and the
conditional If-None-Match that keeps polling cheap would be preflighted away
even where it does. Feed markup is rewritten against a tag-and-attribute
allowlist before it's rendered, and images are off until you ask for them.
The Garden reduces every metric to one number per local day, so growth, wilting
and death are a fold over a plain series and live in garden.ts with no React,
storage or network in sight. Sources that GitHub can hand back with history
(commits, pull requests, reviews, issues, releases) are fetched — and only the
ones something is actually planted on, so a garden of pomodoros makes no request
at all. Sources with no history to fetch (pomodoros, focus minutes, Vercel
deployments, hand-counted habits) are written to a local ledger as menubar sees
them, and the ledger always wins over a fetched day. Plants are drawn
procedurally from ten silhouettes and a palette, so the catalog costs kilobytes
rather than sprite sheets.
## Project structure
A Bun process and a webview, talking over one typed RPC schema.
├── src/
│ ├── bun/
│ │ └── index.ts # Main process: tray, popover, RPC handlers
│ ├── shared/
│ │ └── rpc-schema.ts # The contract between the two sides
│ └── mainview/
│ ├── plugins/ # One folder per plugin
│ ├── lib/ # Bridge, stores, plugin registry
│ ├── components/ # Shared UI
│ ├── App.tsx # Tab shell
│ └── index.css # Tailwind theme
├── icon.iconset/ # App icon, converted to .icns at build time
├── electrobun.config.ts # App metadata, bundle, release channel
└── vite.config.ts
## Tokens
Keychain, not local storage
The GitHub and Vercel tokens live in the login keychain, as menubar (github),
menubar (github via gh) and menubar (vercel) — not in the webview's own
storage, which is an unencrypted file inside the app's data folder. Anything an
older build left there is moved across on first launch, and the plaintext copy
is deleted once the keychain has taken it. Settings → Connections removes
them. The view holds them in memory for the session and never writes them back
to disk itself.