{"id":51228286,"url":"https://github.com/nicholaspsmith/vpn-dns-menubar","last_synced_at":"2026-06-28T13:31:01.055Z","repository":{"id":366515003,"uuid":"1276556034","full_name":"nicholaspsmith/vpn-dns-menubar","owner":"nicholaspsmith","description":"A single macOS menu-bar icon for Mullvad VPN + Tailscale (SwiftBar), plus a launchd watcher that keeps DNS working when both run at once.","archived":false,"fork":false,"pushed_at":"2026-06-22T06:04:04.000Z","size":23,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-22T08:06:51.973Z","etag":null,"topics":["dns","macos","menu-bar","mullvad","swiftbar","tailscale","vpn"],"latest_commit_sha":null,"homepage":null,"language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/nicholaspsmith.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-22T04:55:51.000Z","updated_at":"2026-06-22T06:04:08.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/nicholaspsmith/vpn-dns-menubar","commit_stats":null,"previous_names":["nicholaspsmith/vpn-dns-menubar"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/nicholaspsmith/vpn-dns-menubar","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nicholaspsmith%2Fvpn-dns-menubar","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nicholaspsmith%2Fvpn-dns-menubar/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nicholaspsmith%2Fvpn-dns-menubar/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nicholaspsmith%2Fvpn-dns-menubar/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/nicholaspsmith","download_url":"https://codeload.github.com/nicholaspsmith/vpn-dns-menubar/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nicholaspsmith%2Fvpn-dns-menubar/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34890795,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-28T02:00:05.809Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["dns","macos","menu-bar","mullvad","swiftbar","tailscale","vpn"],"created_at":"2026-06-28T13:31:00.177Z","updated_at":"2026-06-28T13:31:01.050Z","avatar_url":"https://github.com/nicholaspsmith.png","language":"Shell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# vpn-dns-menubar\n\nOne macOS menu-bar icon that consolidates **Mullvad VPN** and **Tailscale** into a\nsingle status dot, with a click-through dropdown to each app — plus a small launchd\nwatcher that keeps DNS working when both run at once.\n\nIt's a [SwiftBar](https://github.com/swiftbar/SwiftBar) plugin. Hide the two native\nMullvad/Tailscale menu-bar icons (e.g. with [Ice](https://github.com/jordanbaird/Ice))\nand let this be the only one.\n\n## What you see\n\nThe menu bar shows **one icon**: a single status dot that tracks Mullvad.\n\n| State | Dot |\n|-------|-----|\n| Connected | ![connected](screenshots/menubar-connected.png) |\n| Connecting / Disconnecting | ![connecting](screenshots/menubar-connecting.png) |\n| Blocked | ![blocked](screenshots/menubar-blocked.png) |\n| Off / disconnected | ![off](screenshots/menubar-off.png) |\n\nClicking it opens a dropdown:\n\n```\n●                                    ← the menu-bar title (status dot)\n──────────────────────────────────────\naccept-dns (MagicDNS): ON/OFF        (non-clickable status)\n──────────────────────────────────────\nMullvad: Connected — us-bos-wg-001   → click opens the NATIVE Mullvad menu\nTailscale: Running                   → click opens the Tailscale app\n```\n\nThe two bottom rows' **labels are the live status**, and clicking them acts: Mullvad\nopens its real popover (location picker, etc.); Tailscale opens its app. Text is\ngreen when connected/running and grey when off/stopped.\n\nBelow the Mullvad and Tailscale rows the menu shows two **fastest-city sections**:\n\"Fastest US (No-ID)\" and \"Fastest Non-US (No-ID · torrent-safe)\" — the top-3 cities\nfrom the candidate list ranked by latency. Clicking a city connects Mullvad to that\ncity (setting the relay location then running `mullvad connect`); clicking the\ncurrently-active city disconnects (toggle behavior). A checkmark (✓) marks the city\nyou're connected to. A freshness footer shows when the latencies were last measured.\n\nLatency is measured by direct ICMP pings (`/sbin/ping`) only while Mullvad is\n**disconnected** — pinging through the tunnel is unreliable. On first run, and until\na live measurement completes, the app falls back to seed values baked into\n`Resources/bundle/candidates.json`. Measurements persist across restarts in\n`~/Library/Application Support/VPNDNSMenuBar/latency.json`. To refresh the\ncandidate list (update which cities qualify under No-ID rules):\n\n```sh\nscripts/refresh-candidates.sh\n```\n\n## Requirements\n\n- macOS 11+ (the dot uses an SF Symbol)\n- [SwiftBar](https://github.com/swiftbar/SwiftBar) — `brew install --cask swiftbar`\n- [Mullvad VPN](https://mullvad.net/) (CLI at `/usr/local/bin/mullvad`) and\n  [Tailscale](https://tailscale.com/) (the Mac app, not the standalone CLI)\n- Optional: [Ice](https://github.com/jordanbaird/Ice) to hide the native icons;\n  ImageMagick (`brew install imagemagick`) only if you rebuild the fallback PNGs\n\n## Install\n\n```sh\ngit clone https://github.com/nicholaspsmith/vpn-dns-menubar.git\ncd vpn-dns-menubar\n./install.sh\n```\n\n`install.sh` is idempotent and:\n\n1. **Symlinks** `vpn-dns-control.5s.sh` into `~/.config/SwiftBar/` (override the\n   target with `SWIFTBAR_PLUGIN_DIR`). The repo stays the source of truth — the\n   plugin finds its `assets/` via its own real path, so nothing else is copied.\n2. Generates the launchd plist from the template and **bootstraps the DNS-sync\n   agent** (`com.nicholassmith.mullvad-tailscale-dns`).\n3. Refreshes SwiftBar.\n\nThen grant **SwiftBar** Accessibility + Automation permission (System Settings →\nPrivacy \u0026 Security) so the Mullvad row can open the native popover, and hide the\nnative Mullvad/Tailscale icons.\n\n## Repo layout\n\n| Path | Role |\n|------|------|\n| `vpn-dns-control.5s.sh` | **The plugin.** Symlinked into SwiftBar's plugin dir; refresh interval (`.5s.`) is in the filename. |\n| `assets/open-native-menu.sh` | Helper: `… mullvad\\|tailscale` → AX-clicks the app's menu-bar item to open its native menu. |\n| `assets/mullvad.png`, `tailscale.png` | App icons shown on the dropdown rows. |\n| `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. |\n| `dns-watcher/mullvad-tailscale-dns-sync.sh` | The launchd watcher (driven by `mullvad status listen`). |\n| `dns-watcher/com.nicholassmith.mullvad-tailscale-dns.plist` | LaunchAgent template (`__SCRIPT__` filled in by `install.sh`). |\n| `install.sh` | Symlink the plugin + load the agent. |\n\n\u003e ⚠️ **Only the plugin may live in SwiftBar's plugin dir.** SwiftBar loads *every*\n\u003e file there as its own menu-bar item, so a stray script/PNG/README would create\n\u003e phantom icons. That's why the assets live in `assets/` and only the plugin is\n\u003e symlinked.\n\n## The dot: size \u0026 color\n\nThe dot is an **inline SF Symbol token** — the literal `:circle.fill:` in the\nplugin's title *text* — colored with `sfcolor` and sized with `sfsize=6`:\n\n```sh\necho \":circle.fill: | sfcolor=${mv_color} sfsize=6\"\n```\n\nKnobs: change `sfsize` to resize, add `valign=-1` (or similar) if it sits too\nhigh/low.\n\n\u003e **Hard-won gotcha.** The `sfimage=` *parameter* ignores both `size=` and\n\u003e `sfsize=` — SwiftBar forces it to `SymbolConfiguration(scale: .large)`, i.e. a\n\u003e giant dot. Only SF Symbols written **inline** as a `:token:` in the title text\n\u003e honor `sfsize` (verified in SwiftBar's source: `symbolize()` builds the symbol\n\u003e with `SymbolConfiguration(pointSize: sfsize ?? font.pointSize, ...)`).\n\n## Native menu opening (the AX trick)\n\nmacOS has no API to *re-open* another app's menu-bar dropdown, so\n`assets/open-native-menu.sh` simulates a click on the status item via System Events:\n\n```applescript\ntell application \"System Events\" to tell process \"Mullvad VPN\" to click menu bar item 1 of menu bar 2\n```\n\nThis needs SwiftBar granted Accessibility + Automation. **Caveat:** a native menu\nanchors to its icon's on-screen position, so if Ice hides the icon off-screen the\nmenu can pop off-screen. Tailscale therefore uses `open -a Tailscale` instead of its\nnative menu; Mullvad still uses the native popover.\n\n## The DNS watcher (separate but related — the original problem)\n\nConnecting Mullvad while Tailscale runs broke **all** DNS (no web, no iMessage):\nTailscale's DNS proxy (`accept-dns` / CorpDNS) forwards every query to a resolver\nthat's unreachable through Mullvad's tunnel. The fix is a launchd watcher that\ndisables Tailscale `accept-dns` while Mullvad is up and restores it the moment\nMullvad disconnects — event-driven via `mullvad status listen`, no polling.\n\nWhile Mullvad is connected, MagicDNS is off and the tailnet is unreachable (Mullvad\nsplit-tunnel can't exclude Tailscale's system network extension — tested, doesn't\nwork). So reaching a tailnet host means `mullvad disconnect` → do the thing →\n`mullvad connect`.\n\n## Rebuilding the icons\n\nThe menu-bar **dot** needs no rebuild — it's an SF Symbol; resize via `sfsize=`.\nOnly the **dropdown-row** icons are PNGs (run from the repo root):\n\n```sh\nsips -s format png -z 36 36 \"/Applications/Mullvad VPN.app/Contents/Resources/icon.icns\" --out assets/mullvad.png\nsips -s format png -z 36 36 \"/Applications/Tailscale.app/Contents/Resources/AppIcon.icns\"  --out assets/tailscale.png\nopen \"swiftbar://refreshallplugins\"\n```\n\n\u003cdetails\u003e\u003csummary\u003eFallback: rebuilding the dot-only menu-bar PNGs (unused)\u003c/summary\u003e\n\nOnly needed if you switch the plugin's title line back to an `image=` PNG. Resize\nvia the circle radius (gap between the two points, here 22−14=8px) and/or the canvas\nheight, keeping the dot vertically centered.\n\n```sh\nfor nc in green:#30d158 orange:#ff9f0a red:#ff453a grey:#98989d; do\n  n=${nc%%:*}; c=${nc##*:}\n  magick -size 24x44 xc:none \\\n    -fill \"$c\" -stroke \"#00000040\" -strokewidth 1 -draw \"circle 12,22 12,14\" \\\n    \"assets/menubar-$n.png\"\ndone\nopen \"swiftbar://refreshallplugins\"\n```\n\n\u003c/details\u003e\n\n## Standalone Swift app\n\nThe repo also ships a standalone Swift menu-bar app, `VPNDNSMenuBar`\n(bundle **\"VPN \u0026 DNS.app\"**), built on\n[StatusItemKit](https://github.com/nicholaspsmith/StatusItemKit). It's a\nnative AppKit reimplementation of the SwiftBar plugin: it polls\n`mullvad`/`tailscale` every 5s, shows a colored status dot tracking Mullvad\nstate, and drops down a three-row menu (accept-dns/MagicDNS state, a Mullvad\nrow, and a Tailscale row) plus Start at Login and Quit. All output parsing\nlives in a pure, unit-tested `VPNDNSCore` library.\n\n```sh\n./scripts/build-app.sh          # produces build/VPN \u0026 DNS.app (ad-hoc signed)\nopen \"build/VPN \u0026 DNS.app\"\n```\n\nClicking the Mullvad row opens Mullvad's native popover via an AppleScript\nAX-click, so the app needs **Accessibility + Automation** permission granted\nto \"VPN \u0026 DNS\" (you'll be prompted on first use). The SwiftBar plugin remains\navailable and unchanged, and the launchd DNS-sync agent under `dns-watcher/`\nis shared and untouched.\n\n### Start at Login\n\nTwo ways to launch it automatically (use **one**, not both, or it may start twice):\n\n- **In-app toggle** — the menu's **Start at Login** item registers the app via\n  `SMAppService` (bundle-ID based, not a LaunchAgent). macOS requires the app to\n  live in `/Applications` or `~/Applications`, so point a symlink there first\n  (e.g. `~/Applications/VPN \u0026 DNS.app` → `build/VPN \u0026 DNS.app`), then toggle it.\n- **macOS Login Items** — add the app under System Settings → General → Login Items\n  (\"Open at Login\"). Same effect, and it doesn't require the in-app toggle.\n\n## Uninstall\n\n```sh\n# plugin\nrm ~/.config/SwiftBar/vpn-dns-control.5s.sh\n\n# DNS watcher\nlaunchctl bootout \"gui/$(id -u)/com.nicholassmith.mullvad-tailscale-dns\"\nrm ~/Library/LaunchAgents/com.nicholassmith.mullvad-tailscale-dns.plist\ntailscale set --accept-dns=true   # restore default\n\n# then re-show the native icons (relaunch the apps or drag them out of Ice)\n```\n\n## License\n\n[MIT](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnicholaspsmith%2Fvpn-dns-menubar","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnicholaspsmith%2Fvpn-dns-menubar","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnicholaspsmith%2Fvpn-dns-menubar/lists"}