TimMikeladze / menubar
#menubar
platformmacOS plugins16 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. Settings → Shortcuts records a different combination —
press it and it is yours — or switches the shortcut off entirely, since the tray
icon is always there. Built with Electrobun, so the
whole thing is a webview and a Bun process rather than a bundled Chromium.
Drag the popover's bottom corners, or any of its three free edges, to size it —
macOS gives a borderless window no handles of its own, so the view draws them
and hands the arithmetic to the Bun side, which keeps the top edge under the
menu bar and the whole thing on the display. The size survives a restart.
Settings → Appearance still has Compact, Standard and Tall for a size you don't
have to aim at, and ⌘K lists them too.
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
16 plugins ship in the app. 14 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 Playing plugin reads the players over AppleScript from the bun process,
since a webview cannot see another application. It checks with pgrep before
it asks: tell application "Spotify" launches Spotify when it isn't
running, which is the last thing a poller should do every two seconds. Polling
follows what is happening — every two seconds while a track plays, every six
when one is paused, and not at all while the popover is shut unless you asked
for the track in the menu bar. Apple Music hands back artwork as raw image data
rather than a URL, so a missing cover is looked up once per album on Apple's
public search endpoint, and drawn from the album's own name when that finds
nothing.
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.
## Writing a Plugin
A plugin is a folder with an index.tsx that exports a React component and a
meta object. There is no manifest, no registration call and no lifecycle to
implement: src/mainview/lib/plugins.ts globs two directories, and the folder
name becomes the slug the app switches tabs by.
src/mainview/plugins/ # the ones that ship
~/.menubar/plugins/ # yours, on this machine
Both are read the same way — thing.tsx or thing/index.tsx — and a built-in
wins if the two collide. The glob is Vite's, resolved when the app is built, so
a plugin dropped into ~/.menubar/plugins shows up the next time you run
bun run dev or bun run build, not the next time you open the popover.
The smallest one that works
import { Rocket } from "lucide-react"
export const meta = {
name: "My Plugin",
icon: Rocket,
description: "What it does",
}
export default function MyPlugin() {
return <div className="p-3">Hello</div>
}
That is already a tab in the switcher, a row in ⌘K, a line in Settings you can
switch off, and a portion Home can place. Everything past it is opt-in.
What meta takes
name- The label on the tab.
icon- Any
lucide-reacticon. description- One line, shown in Settings and
⌘K. placement"main"for a tab of its own,"overlay"for a panel that slides over the app — what Settings uses. Defaults to"main".order- Lower sorts first in the tab bar. Defaults to
100. removablefalsepins the plugin on, so Settings can't switch it off.widgets- The portions of itself this plugin offers Home, each with an
id, aComponentand a default size. Declare none and the whole view becomes one. docs{ blurb, setup }— the two cells this plugin gets in the table above, and its card on menubar.sh. Built-ins only; see below.
Where the plugin list comes from
The plugin table at the top of this file, the widget catalog below, and the
plugin list on menubar.sh are all written by bun run docs, out of each
plugin's own meta:
$ bun run docs --write # rewrite README.md and site/plugins.json
$ bun run docs # exit non-zero if either is stale — what CI runs
They used to be three hand-maintained lists and all three had drifted: the
catalog was four plugins behind, both of its counts were wrong, and the site
mapped the Playing row to a plugin called playing when the folder is
nowplaying, so that row's popover asked the demo for something that isn't
there. A slug read from the folder can't be wrong that way, and a count nobody
types can't be stale.
scripts/docs.ts reads the source rather than importing it — a plugin's module
body starts pollers and wants a DOM — by finding the meta literal, stripping
the TypeScript with bun's own transpiler, and evaluating it in a scope where
every free identifier is undefined. icon and Component evaluate to
nothing, which is all they are worth in a table.
A plugin in ~/.menubar/plugins needs no docs: it isn't in anyone's README.
Home
Home is the first tab and it is always there — src/mainview/plugins/home, a
plugin like any other, pinned to the front by order: 0 and kept on by
removable: false. It holds nothing of its own. Every plugin registers the
portions of itself worth glancing at, and the person using the app decides which
of them are on Home and in what order.
Home has two layouts, picked by the shell the view was loaded into, each with its own saved arrangement:
- Window
- Twelve-column grid
- Popover
- One column of cards
Registering a widget
A widget is a component plus a few lines of metadata, pointed at by
meta.widgets. Every built-in keeps its catalog in a widgets.tsx beside its
index.tsx — the components a widget draws are usually the plugin's own rows,
trimmed, and keeping them out of index.tsx keeps the tab file about the tab.
// src/mainview/plugins/vercel/widgets.tsx
import { Rocket } from "lucide-react"
import type { PluginWidget } from "@/types/plugin"
function DeploymentsWidget() {
const data = useVercelData() // the service's cache, never a fetch
return <div className="py-1">{/* rows */}</div>
}
export const vercelWidgets: PluginWidget[] = [
{
id: "deployments",
name: "Deployments",
icon: Rocket,
description: "Latest builds, in-progress ones first",
defaultSize: { w: 4, h: 4 }, // grid cells, window only
minSize: { w: 3, h: 2 },
compactHeight: 128, // pixels, popover only
Component: DeploymentsWidget,
},
]
// src/mainview/plugins/vercel/index.tsx
import { vercelWidgets } from "./widgets"
export const meta = {
name: "Vercel",
icon: Triangle,
widgets: vercelWidgets,
}
Typing the array as PluginWidget[] is worth the import: the catalog is data,
and a mistyped field or a missing Component is otherwise a broken tile at
runtime rather than a red squiggle.
id- Unique within the plugin.
slug:idis what a saved layout stores. name,icon,description- The card header, and the row in the picker.
defaultSize,minSize- Grid cells, for the window layout.
compactHeight- Pixel height for the popover card. Defaults to
168. home- Whether a Home nobody has arranged yet includes it. Defaults to true for a declared widget, false for a whole-view fallback. Declare a third and fourth tile with
home: false— a plugin that seeds all of them turns a fresh Home into a wall. Compact- A different component for Home, when trimming isn't enough. Most widgets don't need one.
Settings- Your own controls behind the tile's gear. Every tile already has a gear for its name, so this is the widget's half of that panel.
Widgets never fetch. Several are on screen at once and the plugin's own tab may be closed, so a widget reads what the plugin's background service already holds and renders it.
A widget that wants to know how much room it has asks:
import { useCompact } from "@/lib/surface"
const compact = useCompact() // true in the popover card
const rows = compact ? all.slice(0, 4) : all
useSurface() gives the full answer — "tab", "home-grid" or "home-stack" —
which matters for a component a plugin renders in both its own tab and a widget.
The same widget, more than once
Home stores instances, not widgets. Add World clocks twice and the second tile is a copy with its own id, its own size and its own settings — one on your zones, one on the team's. The picker never hides what is already placed; it counts it.
time:clocks- The first copy. It is the widget key itself, which is why every layout and setting saved before instances existed still resolves.
time:clocks#2- The second, and
#3after that. Removing#2frees the number for the next copy.
widgetKeyOf(id) from @/lib/widget-instance turns an instance back into the
widget it renders — the layouts, the picker and the config store all key off
one or the other, never both. Copies are told apart by name: every tile's gear
opens a panel with a Name field, and an unnamed tile keeps the widget's own.
Settings for one tile
A plugin's own settings section belongs to the plugin: every surface it draws
reads the same stores, which is right for a token or a poll interval. A tile
often wants something narrower — the whole Clocks tab, but only two of its zones
on Home, and the copy next to it on three others. Declare a Settings panel and
it joins the tile's gear:
import { useWidgetConfig } from "@/lib/widget-config"
/** What the tile does before anybody narrows it. */
const DEFAULTS = { feedId: "", limit: 40 }
function UnreadWidget() {
const [config] = useWidgetConfig(DEFAULTS)
const rows = items.filter((item) => !config.feedId || item.feedId === config.feedId)
// …
}
function UnreadSettings() {
const [config, write, reset] = useWidgetConfig(DEFAULTS)
return <button onClick={() => write({ limit: 10 })}>Ten rows</button>
}
export const rssWidgets: PluginWidget[] = [
{ id: "unread", name: "Unread", Component: UnreadWidget, Settings: UnreadSettings, /* … */ },
]
Both sides call the same hook and neither names a key: the panel and the widget
body are rendered inside the tile's instance id, and everything written lands
under it in one menubar:widget-config store. Two copies of one widget are two
ids, so they configure apart. A widget rendered anywhere else — a plugin reusing
the component in its own tab — has no id and gets the defaults.
useWidgetConfig(defaults)[config, write, reset].configis the defaults with anything saved laid over them; fields the widget no longer declares are ignored.write(patch)- Merges a patch. Pass a function —
write((current) => ({ … }))— when the new value is computed from the old one, or two clicks inside one frame will overwrite each other. reset()- Forgets this tile's settings. The panel also gets a Back to defaults button whenever anything is saved.
getWidgetConfig(key, defaults)- The same read from outside React.
The gear is on every tile — the name alone is worth one — and it tints when the tile has been configured. Taking a tile off Home forgets what it was told, and so does Reset, for the tiles it drops; adding one back is a fresh copy.
Five built-ins ship with a panel, which are the ones marked ⚙ below: World clocks (which zones), Next cron runs (its own expression and zone), My pull requests (failing only, drafts), Deployments (one project), and Unread (one feed, and how many rows).
A widget hands off rather than growing a second copy of the tab. openPlugin(slug)
from @/lib/navigation opens the plugin the tile came from, and the stores the
tab reads its filters out of are ordinary stores — the Reader's feed tile sets
feedFilterStore on the way through, so the timeline opens on the feed you
clicked.
A plugin that declares no widgets still reaches Home: its whole view becomes one
tile, marked view. That is how anything in ~/.menubar/plugins gets there
without knowing widgets exist — it just isn't on Home until you add it, because
a wall of shrunken tabs is a worse first impression than a short Home.
The built-in catalog
Forty-two tiles ship, of which fourteen are on a Home nobody has arranged — the one or two per plugin worth a glance. ★ marks those; everything else is one click away in the picker, and ⚙ marks the ones with a settings panel of their own. Home offers none, and Settings is an overlay rather than a workspace, so neither is here.
- GitHub
- ★ Awaiting review
- ★ ⚙ My pull requests
- Assigned issues
- Notifications
- Commit activity
- Vercel
- ★ ⚙ Deployments
- Projects
- Build health
- Failed builds
- Status
- ★ Service health
- Open incidents
- Status summary
- Reader
- ★ ⚙ Unread
- Feeds
- Saved
- Time
- ★ ⚙ World clocks
- Timestamp
- ⚙ Next cron runs
- Clipboard
- ⚙ Clipboard
- Color
- Swatch
- Scale
- Contrast
- Harmony
- Focus
- ★ Focus
- Focus dial
- Focus today
- Caffeine
- ★ Caffeine
- Vitals
- ★ Vitals
- Top processes
- Network
- Disk
- Garden
- ★ Garden
- Garden today
- Needs water
- Playing
- ★ Now playing
- Now playing (strip)
- Cover art
- Wallpapers
- ★ Wallpaper
- Docker
- ★ Containers
- Docker summary
- Fly
- ★ Fly apps
- Fly summary
The Commits tile is the one exception to "widgets never fetch", and it earns it
the same way the Commits tab does: that query is four year-scale requests the
plugin deliberately never polls until something asks, so the tile calls
ensureCommits() on mount and when the popover comes back — never on render.
What a plugin can reach
@/lib/bridge- The desktop, over the RPC schema in
src/shared:openExternal,notifyfor a native notification,fetchUrlfor a request no origin policy applies to,publishTrayStatusfor the menu bar title,setWindowSize,onPopoverShown— and the three that reach the machine itself, below. @/lib/plugin-statussetPluginStatus(slug, { tone, count, label })— the live badge on your tab, in one of five tones.nullclears it.@/lib/commandsregisterCommands(() => [...]). The provider runs each time⌘Kopens, so the rows it returns can be live data rather than a fixed list.@/lib/settings-registryregisterSettings({ id, name, icon, hint, Component })— your own section of Settings.@/lib/refreshregisterRefresh(fn)puts your refetch behind the toolbar's refresh button;beginFetch/endFetchlet the status bar show that something is in flight.@/lib/storecreateStore(key, fallback)anduseStore— a localStorage-backed value you can read from React or from a plain module. menubar has no server and no router, so this is the whole state layer.@/lib/surfaceuseCompact()anduseSurface()— how much room the widget you are rendering has.@/lib/widget-configuseWidgetConfig(defaults)— the settings behind one tile's gear, filed under that tile rather than shared with the plugin's tab.@/lib/widget-instancewidgetKeyOf(id)andnewInstanceId(key, taken)— the arithmetic behind putting one widget on Home more than once.@/lib/navigationopenPlugin(slug)andopenHome(), for a widget that wants to hand off to a full tab.@/lib/secretsgetSecret("github")and friends, synchronously, out of the login keychain. The three names are fixed insrc/shared/rpc-schema.ts; a plugin can't mint a fourth.@/components- The primitives the built-ins are drawn with —
SectionLabel,SubTabs,Toolbar,Segmented,Dot,Pill,EmptyState,RowsSkeleton,ErrorState— over shadcn/ui under@/components/uiand the same Tailwind theme.
registerSettings and registerCommands are called at module load, beside
meta, rather than inside the component. Every plugin's module body runs at
startup — that is what the eager glob buys — but menubar only ever mounts the
plugin whose tab is open, so anything registered in a useEffect wouldn't exist
until a visitor happened to go looking for it.
Reaching the machine
Most of what a menu bar app is for is something the OS already knows and the
webview cannot ask: how much battery is left, what is listening on 3000, what
was just copied, whether a screenshot landed. Three capabilities in
@/lib/bridge cover it, and each is deliberately narrower than the general
version of itself.
runCommand — a fixed list of local commands
import { runCommand } from "@/lib/bridge"
const { code, stdout, error } = await runCommand("battery")
if (error) return // unknown id, bad arguments, missing binary, timeout
The name is the only thing a plugin chooses. Which binary that is, where it
lives, what flags go in front of the arguments, what environment it runs in and
how long it may take are fixed in src/bun/exec.ts, and any arguments are
checked by that entry's own validator. Three rules hold it up:
- argv, never a shell, so there is no quoting to get wrong.
- Absolute paths, never a
PATHlookup, so a binary planted earlier inPATHchanges nothing. - A validator per entry, refusing anything that starts with
-— an argument that becomes a flag is the one way argv-only execution still bites.
A non-zero code is an answer, not a failure: git status outside a repository
and pmset on a desktop both mean something. error is set only when the
command never ran or never finished.
The list today covers power and devices (battery, battery-detail,
bluetooth), the machine (uptime, memory, processes, disk,
time-machine), security posture (filevault, sip, gatekeeper), the
network (listening-ports, net-interfaces, dns, wifi, ping), developer
tooling (brew-outdated, docker-ps, docker-stats, tailscale-status,
simulators, git-status, git-branches) and the rest of the desktop
(shortcuts-list, shortcuts-run, audio-devices, audio-set).
Adding one is two edits — the id in COMMAND_IDS in src/shared/rpc-schema.ts,
and its spec in src/bun/exec.ts. They are a Record keyed by that union, so an
id with no spec fails the build rather than shipping a hole.
Some commands are the effect rather than a source of one — caffeinate holds
the machine awake for exactly as long as it runs. Those are held instead:
const { handle } = await startHold("caffeinate", ["1800"]) // seconds, optional
await stopHold(handle)
await listHolds() // what is held now; the bun process outlives your view
Everything held is killed when the app exits, because a caffeinate that
outlives it is a machine that never sleeps again with no UI admitting to why.
listHolds is the other half of that: a hold survives a view rebuild, so the
Caffeine plugin asks what is actually held on start rather than trusting the
deadline it wrote down — see src/mainview/plugins/caffeine/service.ts.
readClipboard — the pasteboard, and every change to it
const snapshot = await readClipboard(lastChangeCount)
if (snapshot.unchanged) return // nothing copied; contents not read
await setClipboardWatch(true) // then, for every copy:
const stop = onClipboardChanged((s) => { if (!s.concealed && !s.transient) keep(s.text) })
await setClipboardWatch(true, 800, true) // opt in to concealed copies too
await setClipboardWatch(true, 800, false, true) // ...and to images and files
changeCount is NSPasteboard's own counter, which ticks once per copy from any
application. Handing back the last one seen is what makes polling this cheap:
the answer comes back unchanged and the contents are never read at all.
A copy marked org.nspasteboard.ConcealedType — the convention by which a
password manager asks every clipboard manager on the machine not to record it —
arrives with concealed: true and an empty text, because the bun side does
not read it. Passing includeConcealed reads it anyway; that is off by default
and the Clipboard plugin puts it behind a setting, because what it unlocks is
passwords in plain text. transient marks a copy that is fine to show and not
to keep.
A copy is not always text. Passing media reports what else was on the
pasteboard as snapshot.media — paths, a size, pixel dimensions and a rendered
thumbnail, never bytes. Copying a file in Finder puts paths there, so the
entry points at a file the user already has and menubar touches nothing;
copying a screenshot puts bytes there instead, and those are gone the moment
the next copy happens, so the watcher writes them into ~/.menubar/clipboard
and owned says so. Thumbnails come from sips and QuickLook, which is why a
video gets a poster frame and a PDF its first page. The view is served from
views:// and cannot load a path, so a picture is fetched inline when there is
a row to draw it:
const thumb = await readClipboardMedia(snapshot.media.thumbPath) // a data: URL
await writeClipboardMedia(snapshot.media.files) // put it back as what it was
await forgetClipboardMedia(stillWanted) // delete the rest of the folder
await revealFile(snapshot.media.files[0]) // show it in Finder
forgetClipboardMedia only ever deletes inside ~/.menubar/clipboard, so a
clip pointing at one of the user's own files cannot name a path it would touch.
Like the concealed rule, media lives in the watcher's arguments: with it off
the script is given no folder, reads nothing and writes nothing.
The watch is the one thing in the app that polls with nothing on screen, which
is deliberate: a clipboard history that only recorded copies made while the
popover happened to be open would miss everything worth keeping. It stays off
until a plugin turns it on, and a plugin that can be switched off should turn it
off again. The Clipboard plugin is the one that does — see
src/mainview/plugins/clipboard/service.ts for the full lifecycle, including
turning the watch back off when the plugin is disabled in Settings.
watchDir — a folder, as it changes
const { id, entries } = await watchDir({ path: "~/Desktop", extensions: ["png"] })
const stop = onDirChanged((watchId, events) => { /* added | changed | removed */ })
await unwatchDir(id)
entries is what is in the folder already, newest first — a shelf that only
knew about arrivals would be empty after every restart. Events are coalesced
over 200ms, so one save is one event, and a file that appeared and vanished
inside the same window produces none.
Watches are refused outside the home directory, on the home directory itself,
and on the folders that hold credentials or messages — ~/.ssh, ~/.aws,
~/Library/Keychains and the rest of the list in src/bun/watch.ts. Symlinks
are resolved before that check rather than after, since ~/link-to-root is a
real directory entry and a string comparison believes it. What comes back is
names, sizes and timestamps; reading a watched file is a capability that
deliberately doesn't exist.
Anything that fetches belongs in a service
For the same reason, a plugin that polls does it from a module-level service: a
plain module holding the state, the listeners and the interval, with the
component only subscribing through useSyncExternalStore. A poller owned by a
component would stop the moment you switched tabs, and the badge and tray title
would freeze with it.
src/mainview/plugins/status/service.ts is the short one to read. It caches its
last payload in a store, so reopening the popover paints rows instead of
skeletons; it stops its interval whenever the popover is off screen and refetches
on the way back if what it holds has gone stale; and it publishes both a tab
badge and a tray count on every change.
A plugin that throws on render is caught by PluginErrorBoundary and shown as
one failed tab with a Reload button. The rest of the app carries on.
## Project structure
A Bun process and a webview, talking over one typed RPC schema.
├── src/
│ ├── bun/
│ │ ├── index.ts # Main process: tray, popover, RPC handlers
│ │ ├── exec.ts # The allowlist of local commands
│ │ ├── clipboard.ts # Pasteboard reads, and the change watcher
│ │ ├── clipboard-media.ts # Copied images and files: thumbnails, pruning
│ │ └── watch.ts # Folder watching, bounded to your home directory
│ ├── 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.svg # The mark, as vector
├── icon.iconset/ # App icon, converted to .icns at build time
├── scripts/icon.ts # Draws every icon asset — `bun run icons`
├── electrobun.config.ts # App metadata, bundle, release channel
└── vite.config.ts
## Tokens
Keychain, not local storage
Borrowing a CLI login
Both connections can reuse a login you already have. menubar checks whether gh
and vercel are installed and look signed in, and if so offers that instead of a
token field — in Settings → Connections and on the plugin's own empty state.
The check is deliberately blind: it looks at PATH and the CLI's config file,
never at the token. Nothing is read until you press the button, and "Not now"
is remembered so the offer stops appearing. Once adopted, the token is
re-read on every launch, so a gh auth login or a token the Vercel CLI has
rotated flows through without a trip back to Settings.
gh auth token is what GitHub's side runs. Vercel's has no equivalent command,
so its token is read from the CLI's own auth.json under
~/Library/Application Support/com.vercel.cli — expired logins are refused
rather than handed over.
Storage
The GitHub and Vercel tokens live in the login keychain, as menubar (github),
menubar (github via gh), menubar (vercel) and menubar (vercel via cli) —
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.