https://github.com/nicholaspsmith/vpn-dns-menubar
A single macOS menu-bar icon for Mullvad VPN + Tailscale (SwiftBar), plus a launchd watcher that keeps DNS working when both run at once.
https://github.com/nicholaspsmith/vpn-dns-menubar
dns macos menu-bar mullvad swiftbar tailscale vpn
Last synced: 10 days ago
JSON representation
A single macOS menu-bar icon for Mullvad VPN + Tailscale (SwiftBar), plus a launchd watcher that keeps DNS working when both run at once.
- Host: GitHub
- URL: https://github.com/nicholaspsmith/vpn-dns-menubar
- Owner: nicholaspsmith
- License: mit
- Created: 2026-06-22T04:55:51.000Z (17 days ago)
- Default Branch: main
- Last Pushed: 2026-06-22T06:04:04.000Z (17 days ago)
- Last Synced: 2026-06-22T08:06:51.973Z (17 days ago)
- Topics: dns, macos, menu-bar, mullvad, swiftbar, tailscale, vpn
- Language: Shell
- Size: 22.5 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# vpn-dns-menubar
One macOS menu-bar icon that consolidates **Mullvad VPN** and **Tailscale** into a
single status dot, with a click-through dropdown to each app — plus a small launchd
watcher that keeps DNS working when both run at once.
It's a [SwiftBar](https://github.com/swiftbar/SwiftBar) plugin. Hide the two native
Mullvad/Tailscale menu-bar icons (e.g. with [Ice](https://github.com/jordanbaird/Ice))
and let this be the only one.
## What you see
The menu bar shows **one icon**: a single status dot that tracks Mullvad.
| State | Dot |
|-------|-----|
| Connected |  |
| Connecting / Disconnecting |  |
| Blocked |  |
| Off / disconnected |  |
Clicking it opens a dropdown:
```
● ← the menu-bar title (status dot)
──────────────────────────────────────
accept-dns (MagicDNS): ON/OFF (non-clickable status)
──────────────────────────────────────
Mullvad: Connected — us-bos-wg-001 → click opens the NATIVE Mullvad menu
Tailscale: Running → click opens the Tailscale app
```
The two bottom rows' **labels are the live status**, and clicking them acts: Mullvad
opens its real popover (location picker, etc.); Tailscale opens its app. Text is
green when connected/running and grey when off/stopped.
Below the Mullvad and Tailscale rows the menu shows two **fastest-city sections**:
"Fastest US (No-ID)" and "Fastest Non-US (No-ID · torrent-safe)" — the top-3 cities
from the candidate list ranked by latency. Clicking a city connects Mullvad to that
city (setting the relay location then running `mullvad connect`); clicking the
currently-active city disconnects (toggle behavior). A checkmark (✓) marks the city
you're connected to. A freshness footer shows when the latencies were last measured.
Latency is measured by direct ICMP pings (`/sbin/ping`) only while Mullvad is
**disconnected** — pinging through the tunnel is unreliable. On first run, and until
a live measurement completes, the app falls back to seed values baked into
`Resources/bundle/candidates.json`. Measurements persist across restarts in
`~/Library/Application Support/VPNDNSMenuBar/latency.json`. To refresh the
candidate list (update which cities qualify under No-ID rules):
```sh
scripts/refresh-candidates.sh
```
## Requirements
- macOS 11+ (the dot uses an SF Symbol)
- [SwiftBar](https://github.com/swiftbar/SwiftBar) — `brew install --cask swiftbar`
- [Mullvad VPN](https://mullvad.net/) (CLI at `/usr/local/bin/mullvad`) and
[Tailscale](https://tailscale.com/) (the Mac app, not the standalone CLI)
- Optional: [Ice](https://github.com/jordanbaird/Ice) to hide the native icons;
ImageMagick (`brew install imagemagick`) only if you rebuild the fallback PNGs
## Install
```sh
git clone https://github.com/nicholaspsmith/vpn-dns-menubar.git
cd vpn-dns-menubar
./install.sh
```
`install.sh` is idempotent and:
1. **Symlinks** `vpn-dns-control.5s.sh` into `~/.config/SwiftBar/` (override the
target with `SWIFTBAR_PLUGIN_DIR`). The repo stays the source of truth — the
plugin finds its `assets/` via its own real path, so nothing else is copied.
2. Generates the launchd plist from the template and **bootstraps the DNS-sync
agent** (`com.nicholassmith.mullvad-tailscale-dns`).
3. Refreshes SwiftBar.
Then grant **SwiftBar** Accessibility + Automation permission (System Settings →
Privacy & Security) so the Mullvad row can open the native popover, and hide the
native Mullvad/Tailscale icons.
## Repo layout
| Path | Role |
|------|------|
| `vpn-dns-control.5s.sh` | **The plugin.** Symlinked into SwiftBar's plugin dir; refresh interval (`.5s.`) is in the filename. |
| `assets/open-native-menu.sh` | Helper: `… mullvad\|tailscale` → AX-clicks the app's menu-bar item to open its native menu. |
| `assets/mullvad.png`, `tailscale.png` | App icons shown on the dropdown rows. |
| `assets/menubar-{green,orange,red,grey}.png` | Dot-only icons (24×44, 16px dot). **Unused fallback** — the bar is now an SF Symbol; kept in case the PNG route is wanted again. |
| `dns-watcher/mullvad-tailscale-dns-sync.sh` | The launchd watcher (driven by `mullvad status listen`). |
| `dns-watcher/com.nicholassmith.mullvad-tailscale-dns.plist` | LaunchAgent template (`__SCRIPT__` filled in by `install.sh`). |
| `install.sh` | Symlink the plugin + load the agent. |
> ⚠️ **Only the plugin may live in SwiftBar's plugin dir.** SwiftBar loads *every*
> file there as its own menu-bar item, so a stray script/PNG/README would create
> phantom icons. That's why the assets live in `assets/` and only the plugin is
> symlinked.
## The dot: size & color
The dot is an **inline SF Symbol token** — the literal `:circle.fill:` in the
plugin's title *text* — colored with `sfcolor` and sized with `sfsize=6`:
```sh
echo ":circle.fill: | sfcolor=${mv_color} sfsize=6"
```
Knobs: change `sfsize` to resize, add `valign=-1` (or similar) if it sits too
high/low.
> **Hard-won gotcha.** The `sfimage=` *parameter* ignores both `size=` and
> `sfsize=` — SwiftBar forces it to `SymbolConfiguration(scale: .large)`, i.e. a
> giant dot. Only SF Symbols written **inline** as a `:token:` in the title text
> honor `sfsize` (verified in SwiftBar's source: `symbolize()` builds the symbol
> with `SymbolConfiguration(pointSize: sfsize ?? font.pointSize, ...)`).
## Native menu opening (the AX trick)
macOS has no API to *re-open* another app's menu-bar dropdown, so
`assets/open-native-menu.sh` simulates a click on the status item via System Events:
```applescript
tell application "System Events" to tell process "Mullvad VPN" to click menu bar item 1 of menu bar 2
```
This needs SwiftBar granted Accessibility + Automation. **Caveat:** a native menu
anchors to its icon's on-screen position, so if Ice hides the icon off-screen the
menu can pop off-screen. Tailscale therefore uses `open -a Tailscale` instead of its
native menu; Mullvad still uses the native popover.
## The DNS watcher (separate but related — the original problem)
Connecting Mullvad while Tailscale runs broke **all** DNS (no web, no iMessage):
Tailscale's DNS proxy (`accept-dns` / CorpDNS) forwards every query to a resolver
that's unreachable through Mullvad's tunnel. The fix is a launchd watcher that
disables Tailscale `accept-dns` while Mullvad is up and restores it the moment
Mullvad disconnects — event-driven via `mullvad status listen`, no polling.
While Mullvad is connected, MagicDNS is off and the tailnet is unreachable (Mullvad
split-tunnel can't exclude Tailscale's system network extension — tested, doesn't
work). So reaching a tailnet host means `mullvad disconnect` → do the thing →
`mullvad connect`.
## Rebuilding the icons
The menu-bar **dot** needs no rebuild — it's an SF Symbol; resize via `sfsize=`.
Only the **dropdown-row** icons are PNGs (run from the repo root):
```sh
sips -s format png -z 36 36 "/Applications/Mullvad VPN.app/Contents/Resources/icon.icns" --out assets/mullvad.png
sips -s format png -z 36 36 "/Applications/Tailscale.app/Contents/Resources/AppIcon.icns" --out assets/tailscale.png
open "swiftbar://refreshallplugins"
```
Fallback: rebuilding the dot-only menu-bar PNGs (unused)
Only needed if you switch the plugin's title line back to an `image=` PNG. Resize
via the circle radius (gap between the two points, here 22−14=8px) and/or the canvas
height, keeping the dot vertically centered.
```sh
for nc in green:#30d158 orange:#ff9f0a red:#ff453a grey:#98989d; do
n=${nc%%:*}; c=${nc##*:}
magick -size 24x44 xc:none \
-fill "$c" -stroke "#00000040" -strokewidth 1 -draw "circle 12,22 12,14" \
"assets/menubar-$n.png"
done
open "swiftbar://refreshallplugins"
```
## Standalone Swift app
The repo also ships a standalone Swift menu-bar app, `VPNDNSMenuBar`
(bundle **"VPN & DNS.app"**), built on
[StatusItemKit](https://github.com/nicholaspsmith/StatusItemKit). It's a
native AppKit reimplementation of the SwiftBar plugin: it polls
`mullvad`/`tailscale` every 5s, shows a colored status dot tracking Mullvad
state, and drops down a three-row menu (accept-dns/MagicDNS state, a Mullvad
row, and a Tailscale row) plus Start at Login and Quit. All output parsing
lives in a pure, unit-tested `VPNDNSCore` library.
```sh
./scripts/build-app.sh # produces build/VPN & DNS.app (ad-hoc signed)
open "build/VPN & DNS.app"
```
Clicking the Mullvad row opens Mullvad's native popover via an AppleScript
AX-click, so the app needs **Accessibility + Automation** permission granted
to "VPN & DNS" (you'll be prompted on first use). The SwiftBar plugin remains
available and unchanged, and the launchd DNS-sync agent under `dns-watcher/`
is shared and untouched.
### Start at Login
Two ways to launch it automatically (use **one**, not both, or it may start twice):
- **In-app toggle** — the menu's **Start at Login** item registers the app via
`SMAppService` (bundle-ID based, not a LaunchAgent). macOS requires the app to
live in `/Applications` or `~/Applications`, so point a symlink there first
(e.g. `~/Applications/VPN & DNS.app` → `build/VPN & DNS.app`), then toggle it.
- **macOS Login Items** — add the app under System Settings → General → Login Items
("Open at Login"). Same effect, and it doesn't require the in-app toggle.
## Uninstall
```sh
# plugin
rm ~/.config/SwiftBar/vpn-dns-control.5s.sh
# DNS watcher
launchctl bootout "gui/$(id -u)/com.nicholassmith.mullvad-tailscale-dns"
rm ~/Library/LaunchAgents/com.nicholassmith.mullvad-tailscale-dns.plist
tailscale set --accept-dns=true # restore default
# then re-show the native icons (relaunch the apps or drag them out of Ice)
```
## License
[MIT](LICENSE)