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.

bash
# 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.

tree
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

tsx
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

FieldWhat it does
name
The label on the tab.
icon
Any lucide-react icon.
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.
removable
false pins the plugin on, so Settings can't switch it off.
widgets
The portions of itself this plugin offers Home, each with an id, a Component and 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:

bash
$ 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:

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

tsx
// 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,
  },
]
tsx
// 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.

FieldWhat it does
id
Unique within the plugin. slug:id is 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:

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

Instance idWhat it is
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 #3 after that. Removing #2 frees 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:

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

PieceWhat it does
useWidgetConfig(defaults)
[config, write, reset]. config is 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.

PluginWidget
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

ImportWhat it gives you
@/lib/bridge
The desktop, over the RPC schema in src/shared: openExternal, notify for a native notification, fetchUrl for a request no origin policy applies to, publishTrayStatus for the menu bar title, setWindowSize, onPopoverShown — and the three that reach the machine itself, below.
@/lib/plugin-status
setPluginStatus(slug, { tone, count, label }) — the live badge on your tab, in one of five tones. null clears it.
@/lib/commands
registerCommands(() => [...]). The provider runs each time ⌘K opens, so the rows it returns can be live data rather than a fixed list.
@/lib/settings-registry
registerSettings({ id, name, icon, hint, Component }) — your own section of Settings.
@/lib/refresh
registerRefresh(fn) puts your refetch behind the toolbar's refresh button; beginFetch/endFetch let the status bar show that something is in flight.
@/lib/store
createStore(key, fallback) and useStore — 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/surface
useCompact() and useSurface() — how much room the widget you are rendering has.
@/lib/widget-config
useWidgetConfig(defaults) — the settings behind one tile's gear, filed under that tile rather than shared with the plugin's tab.
@/lib/widget-instance
widgetKeyOf(id) and newInstanceId(key, taken) — the arithmetic behind putting one widget on Home more than once.
@/lib/navigation
openPlugin(slug) and openHome(), for a widget that wants to hand off to a full tab.
@/lib/secrets
getSecret("github") and friends, synchronously, out of the login keychain. The three names are fixed in src/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/ui and 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

tsx
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 PATH lookup, so a binary planted earlier in PATH changes 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:

tsx
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

tsx
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:

tsx
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

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

tree
├── 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.