A very small native macOS menu bar client for WireGuard, built on the wireguard-tools
CLI you already have. No NetworkExtension, no WireGuardKit, no VPN profiles — just
wg-quick driven from the menu bar, with any number of tunnels up at the same time.
┌─────────────────────────────────┐
│ WireGuard 4 active │
├─────────────────────────────────┤
│ ● office 10.10.0.2 [ ON ] │
│ ○ home 10.20.0.5 [OFF ] │
│ ● home-dev 10.20.1.5 [ ON ] │
│ ○ test 10.99.0.9 [OFF ] │
├─────────────────────────────────┤
│ Reload configs │
│ Open config folder │
│ Settings… │
│ Quit │
└─────────────────────────────────┘
SwiftUI MenuBarExtra (WGMenu.app, LSUIElement)
│ sudo -n
▼
/usr/local/libexec/wgmenu-helper up | down | remove | status | list | configs
│ root
▼
wg-quick up/down <conf> + wg show <utun> dump
State is not guessed. wg-quick on Darwin writes the tunnel → device mapping itself:
/var/run/wireguard/office.name -> "utun4"
/var/run/wireguard/utun4.sock
<name>.name exists + matching <utun>.sock exists ⇒ the tunnel is up.
The .name files are 0400 root:daemon, so reading the utun device needs root, but the
directory itself is world-readable — which means on/off state (all the menu needs) is
detected with no privileges at all, on a 2 s timer. Only per-tunnel details
(utun, endpoint, handshake, RX/TX) cost one sudo call, and they are fetched only
for a row you actually expand.
- macOS 14+
brew install wireguard-tools(provideswg,wg-quick,wireguard-go)- Xcode command line tools (Swift 5.9+)
make app # -> dist/WGMenu.app
make run # build + launch
make install # copy to ~/Applications (INSTALL_DIR=/Applications to override)Then install the privileged helper (asks for your password once):
make install-helperThat does two things:
- installs
helper/wgmenu-helperto/usr/local/libexec/wgmenu-helper,root:wheel 0755 - writes
/etc/sudoers.d/wgmenu(validated withvisudo -c):
dmytro ALL=(root) NOPASSWD: /usr/local/libexec/wgmenu-helper
Check everything:
make doctorThe same search paths Darwin wg-quick uses, in the same order (first match wins):
/etc/wireguard
/opt/homebrew/etc/wireguard
/usr/local/etc/wireguard
The helper always passes the full path to wg-quick, so search-path behaviour never
matters. Names must match ^[A-Za-z0-9_+=.-]{1,15}$ — wg-quick's own limit.
wg-quick runs PreUp / PostUp / PreDown / PostDown from a .conf as root.
A NOPASSWD rule that could be pointed at an arbitrary config would therefore be an
arbitrary-root-code-execution rule. So the helper:
- accepts exactly
up,down,remove,status,list,configs,version— nothing is forwarded to a shell - never accepts a pathname; it builds
<dir>/<name>.conffrom a fixed directory list - validates the tunnel name against wg-quick's character class and 15-char limit
- refuses a config that is a symlink, not owned by
root, or group/other-writable — and refuses the same for the directory containing it - clears
WG_*andDYLD_*from the environment (WG_QUICK_USERSPACE_IMPLEMENTATIONalone would otherwise be a root exec primitive) and sets its ownPATH - never prints private or preshared keys (
wg show dumpline 1 field 1 is dropped), andconfigsreturns only theAddresslines of an[Interface]section — nothing else from the file can reach the GUI
To satisfy the ownership rule for existing configs:
make secure-configs # chown root:wheel + chmod 600 on every *.confAfter that, editing a config needs sudo — that is the point.
Residual risk worth knowing: Homebrew's bin is user-writable, so root executing
/opt/homebrew/bin/wg-quick trusts whatever is in that directory. That is inherent to
the wg-quick approach (sudo wg-quick up … by hand has exactly the same property),
not something WGMenu adds.
A tunnel does not have to be up to be worth looking at, so the expanded row falls back to
the config: endpoint, allowed IPs, persistent keepalive (per [Peer]), plus DNS, MTU and
listen port. When the tunnel is up, the live wg show numbers replace the peer rows and
DNS/MTU still come from the config.
configs returns a fixed whitelist of fields — Address, DNS, MTU, ListenPort,
Endpoint, AllowedIPs, PersistentKeepalive — so PrivateKey and PresharedKey
cannot reach the GUI even from a config that holds something unexpected.
Each row shows the tunnel's own IP in that network, taken from Address in the config's
[Interface] section (IPv4 preferred, mask stripped; the full list, IPv6 included, is in
the expanded row). The app reads the config directly when it can, and once
make secure-configs has made the files root:wheel 0600 it falls back to a single
wgmenu-helper configs call, cached per config mtime — so an edited config is picked up,
and idle refreshes cost no sudo at all.
The filter field matches the name, any of the tunnel's addresses, or its utun device,
so 10.42, fd42 and utun7 all narrow the list.
The address sits in a fixed-width right-aligned column, and the disclosure chevron has a
pinned width too: chevron.right is 7 pt wide and chevron.down is 10 pt, so without
that the address slid sideways by 3 pt every time a row was expanded.
The expanded row has a Delete config button, which asks for confirmation inline (no modal that would dismiss the popover). Three guards, because a WireGuard config holds a private key that cannot be regenerated:
- the helper refuses while the tunnel is up — its config is also what
wg-quick downreads, so deleting it first would leave a tunnel nothing can cleanly stop. The row says "Turn the tunnel off to delete its config" instead of offering the button. - nothing is ever unlinked: the file is renamed to
<name>.conf.deleted-YYYYmmdd-HHMMSSnext to where it was, keeping its root ownership and mode. WGMenu andwg-quickboth stop seeing it, and the menu reports the new path. - symlinks are refused, and the tunnel name is validated exactly as for
up/down.
To reclaim the space for real: sudo rm /etc/wireguard/*.conf.deleted-*.
Everything clickable in the popover — the ON/OFF badge, the delete buttons, the footer
items, the row itself — shows the link pointer on hover, via pointerStyle(.link) on
macOS 15+. On macOS 14 it falls back to NSCursor.pointingHand.push(), which tracks its
own push and pops on onDisappear: the list rebuilds every two seconds, so an unbalanced
push would leave the cursor stuck as a hand system-wide. A busy (starting/stopping)
toggle gets no pointer, since it is not clickable.
The list is capped at what the screen can show rather than at a fixed number of rows: usable screen height minus the header, the footer, the filter field and any banner. With 39 configs at 13 pt that is 727 pt (~31 rows) on a 944 pt screen instead of the fixed 420 pt (~17 rows) it used to be, and on a taller external display the whole list fits without scrolling.
"The screen" is the one under the pointer, not NSScreen.main: the status item is
clicked with the pointer, so that is where the popover opens, while NSScreen.main
follows keyboard focus and even answers differently before and after AppKit starts up.
An outer maxHeight keeps the window on that screen regardless of what the list cap
computes.
The subtracted constants are measured, not guessed — WGMenu render --filter <no match>
gives the chrome height at a given font size — and WGMenu doctor lays out the real
popover in an NSHostingView and reports whether it fits:
✓ list height up to 727 pt of 944 pt usable (~31 rows at 13 pt)
✓ popover fits 440 × 910 pt on a 944 pt screen
Those are ideal-size numbers. To check the window that actually opens, read it from
CGWindowListCopyWindowInfo while the popover is up — a real measurement caught a
regression that doctor could not see, where the list collapsed because the panel had
a flexible height and the window squeezed it.
doctor takes two what-if overrides, so any display and text size can be checked from
wherever you happen to be sitting:
./.build/release/WGMenu doctor -listFontSize 18 -screenHeight 1403Settings has a List text size slider (11–18 pt, default 13). Everything in the popover — names, addresses, the ON/OFF badge, the detail rows, the footer — is derived from that one value. Names are monospaced like the addresses, and the panel's width comes from real font metrics for the worst row a config can produce — a 15-character name (wg-quick's own limit) beside a 15-character address such as 192.168.100.254 — so nothing truncates at any text size: 440 pt of content at 13 pt, 453 pt at 18 pt. The popover window adds about 16 pt of padding on each side.
Settings has Start at login (an SMAppService login item) and, next to it, Say so when
started at login. With the second one on, a launch WGMenu did not get from the user —
the login item, or a saved-state resume — posts a "WGMenu is running" notification a
second and a half later, pointing at the shield in the menu bar. A launch by hand, from
Finder or open, stays silent: the user just clicked the app, so they know.
What tells the two apart is NSApplicationLaunchIsDefaultLaunchKey, which AppKit sets to
false for exactly those automatic launches.
The banner goes through Notification Center, so the first automatic start asks for permission — flipping the toggle on in Settings asks right then instead, while you are looking at the window rather than in the middle of a login. If permission is refused, or Notification Center will not talk to the bundle at all, WGMenu draws its own small panel under the status item for six seconds instead; clicking it dismisses it. The same thing a banner would say, minus the permission.
Neither needs a logout to try out:
WGMENU_LAUNCH_NOTICE=1 dist/WGMenu.app/Contents/MacOS/WGMenu # as if launched at login
WGMENU_LAUNCH_NOTICE=toast dist/WGMenu.app/Contents/MacOS/WGMenu # the fallback panel
WGMENU_LAUNCH_NOTICE=0 dist/WGMenu.app/Contents/MacOS/WGMenu # silentIf a menu bar manager is hiding the status item, the notification is the only sign the app came up at all — which is most of the reason it exists.
The same binary is a small CLI, which is also how the state logic is tested:
./.build/release/WGMenu state # configs + ON/OFF, no root needed
./.build/release/WGMenu list # details of running tunnels (via helper)
./.build/release/WGMenu doctor # check install, sudo rule, config ownership
./.build/release/WGMenu up office # same code path the menu uses
./.build/release/WGMenu down office
./.build/release/WGMenu remove office # move its config aside (must be down)
./.build/release/WGMenu render out.png --font 16 --filter 10.42 \
--expand office --pending-delete office # draw the menu to a PNGIncluded: menu bar app, config auto-discovery, unlimited simultaneous tunnels, ON/OFF
per tunnel, the tunnel's own address next to its name, starting/stopping states, real state from /var/run/wireguard/*.name, 2 s
auto-refresh, wg-quick error text shown inline, per-tunnel details (interface, endpoint,
last handshake, RX/TX), a filter field (name / IP / utun) once there
are more than 12 configs, a configurable list text size, deleting a config
(confirmed inline, moved aside not erased), Reload configs, Open config folder,
Start at login with an optional "WGMenu is running" notification, Quit.
Deliberately not included: config editor, adding configs from the GUI, QR import, traffic graphs, routing/peer editing, WireGuardKit, NetworkExtension.
The menu bar icon is missing. If you run Bartender / Ice / another menu bar manager,
it is probably hiding it — check its hidden-items list first. To confirm the status item
exists at all, pgrep -x WGMenu and look for a WGMenu window in the status layer.
"Helper not installed" / "sudo needs a password". Run make install-helper, then
make doctor.
must be owned by root. make secure-configs, or sudo chown root:wheel that one file.
"Helper is outdated". The helper reports a protocol version (wgmenu-helper version
→ wgmenu-helper 0.3.0 protocol 2) and the app refuses to guess when the installed copy
is older than the output it parses: it says so in a banner rather than quietly showing
fewer fields, and clicking the banner re-checks after make install-helper. An old helper
still supplies what it can — addresses keep working, only the newer fields go missing.
The sudoers wrapper can be replaced by an SMAppService-registered LaunchDaemon talking
XPC (SMJobBless is deprecated), without changing anything above the WGService layer.
That buys provisioning, signing and helper lifecycle work — and zero new VPN features —
so it is not in v0.1.