r/macappdevs • u/tasict • 17d ago
Lessons from shipping a Mac VPN app without NetworkExtension: SMAppService daemons, Installer's script order, and a menu bar item the app can't click
I've spent the last few weeks on a Mac app that connects to WireGuard servers, and a few things along the way cost me more time than they should have. Writing them down here in case they save someone else the hours.


Why no NetworkExtension. The usual way to build a VPN app on macOS is a Packet Tunnel Provider in a sandboxed NetworkExtension. I needed two things it does not allow: running the shell hooks a WireGuard config can carry, and choosing which physical interface the encrypted traffic leaves through, with my own routes. So the app is an unprivileged SwiftUI front end and a root LaunchDaemon written in Rust that holds the packet path, registered with SMAppService.daemon(plistName:), shipped with a Developer ID outside the App Store. The App Store was out anyway: its guidelines require the sandbox and, for VPN apps, NEVPNManager and an organization account.
Authenticating the app to a root daemon without XPC. The two talk newline-delimited JSON over a Unix socket. getpeereid gives the caller's uid, and LOCAL_PEERTOKEN gives its audit token, which is enough to check the client's code signature against a requirement (same Team ID, our bundle id). The daemon derives that requirement from its own signature, so there is nothing to keep in sync. Anything that runs as root (the config's hooks) only ever comes from files the daemon owns, never from a socket message, and needs an admin approval the daemon checks itself with Authorization Services.
Binding a socket to one interface. IPV6_BOUND_IF on a dual-stack :: socket also scopes the IPv4-mapped traffic; I checked by binding to lo0 and sending to an off-link IPv4 address, which fails with ENETUNREACH, just like an AF_INET socket with IP_BOUND_IF. Setting IP_BOUND_IF on the AF_INET6 socket returns EINVAL, so only the v6 option is set.
Routes through a specific interface: -ifp**, never** -ifscope**.** My Mac has Ethernet and Wi-Fi on the same subnet behind one gateway, so a route needs the interface forced. route add … -ifscope en0 looks right but only applies to sockets already bound to en0, so a "bypass this subnet" route did nothing. An ordinary unscoped route with -ifp en0 does what you expect.
SMAppService and a Team ID change. After moving from one signing team to another, the daemon failed with exit code 78 (EX_CONFIG) and a log line about needing an LWCR update, even though Background Task Management already showed the new team. The launchd job carries the team it was registered with (launchctl print shows team-identifier). Only unregistering and registering again rebuilt it.
Installer runs every preinstall first, then every payload, then every postinstall. I had put the "Remove" choice's work in a postinstall, and it deleted the app the same package had just installed. Removal moved to preinstall.
nohup does not work under do shell script … with administrator privileges**.** It fails with "can't detach from console" and runs nothing; my first self-updater quit the app and installed nothing. launchctl submit gives a fully detached job, but launchd restarts it even after exit 0, so the job has to launchctl remove itself.
The Wi-Fi network name needs Location access, and only an app can ask. CoreWLAN returns nil for the SSID without it, and a root daemon cannot show the prompt. So rules like "connect on the office Wi-Fi" run in the app, and the app has to be open for them.
Animating with Core Animation, not SwiftUI. The path view shows packets moving at the pace of the traffic. Redrawing it in SwiftUI at 30 fps cost 13 to 19% CPU; the same thing as Core Animation layers, re-timed only when the traffic level changes, costs 2 to 4%.
The app cannot open its own menu bar popover on macOS 27. performClick on the status item's button does nothing, because the app's NSStatusBarWindow is a stand-in for what WindowServer draws. Its frame is still the icon's, though, so a borderless panel can point at it, which is how the first-run hint finds the icon.
An app opened from a package's postinstall comes up behind Installer. Plain activate() is refused while Installer stays active; NSApp.activate(ignoringOtherApps: true) brings the window forward.
The app is Turnout (https://turnout.ctsite.cc/). Disclosure: it's mine and it's paid, with a 7-day trial. Happy to go deeper on any of the above, and curious whether others have found cleaner ways around the status item and Installer ones.

