![]() |
A native SwiftUI menu bar + window app for managing dnscrypt-proxy on Apple Macs π.
- Start / stop / restart the
dnscrypt-proxyservice via a privileged XPC helper β no AppleScript prompts, no per-action password dialogs. - Live status indicator in the menu bar and main window. The menu bar icon distinguishes "proxy running and DNS routed through it" from "proxy running but system DNS still points elsewhere," so a partial-routing leak is visible at a glance.
- Resolver browser with protocol filter (DNSCrypt, DoH, DoT, DoQ, Anonymized DNSCrypt relay, ODoH target, ODoH relay, plain).
- TOML config editor with validation and backup-on-save.
- Live log viewer with a picker for the three dnscrypt-proxy streams β server log (default), query log (
[query_log], every resolved query), and NXDOMAIN log ([nx_log], blocked / cloaked names). The picker reflects the live TOML so adding a section and restarting the proxy lights up the corresponding stream without restarting the app. - Auto-selection mode (let
dnscrypt-proxypick the fastest servers per protocol). - One-click Homebrew install / upgrade of
dnscrypt-proxy. - Toggle system DNS to
127.0.0.1(and::1) on the active network service. - Launch GUI at login (
SMAppService, macOS 13+). The menu bar status and connection check populate at login β opening the dashboard isn't required. - Connection probe β runs a canary lookup against the loopback to confirm the proxy is responsive, then verifies the system DNS list contains only loopback entries. A mixed list like
["8.8.8.8", "::1"]is reported as unrouted, because macOS will send most queries to Google rather than the proxy. - Coming soon to the AppStore
The app is split into three Swift targets:
DNSCryptGUI/ # SwiftUI app target (runs as user)
βββ DNSCryptGUIApp.swift # @main, Window + MenuBarExtra scenes
βββ AppState.swift # @MainActor ObservableObject β app-wide state
βββ Models/
β βββ DNSStamp.swift # sdns:// stamp parser
β βββ Resolver.swift # Resolver metadata
β βββ ProxyStatus.swift # running / stopped / errored / unknown
β βββ ProxyConfig.swift # TOML-backed config model
βββ Services/
β βββ HelperClient.swift # NSXPCConnection wrapper for the helper
β βββ ProxyService.swift # Routes start/stop/status through helper
β βββ BrewService.swift # Detect / install / upgrade dnscrypt-proxy
β βββ ConfigService.swift # Read/write dnscrypt-proxy.toml (write via helper)
β βββ LogService.swift # File-tail log reader
β βββ NetworkService.swift # System DNS toggle (write via helper)
β βββ ResolverService.swift # Fetches public-resolvers.md / relays.md
β βββ LaunchAtLoginService.swift # SMAppService.mainApp wrapper
β βββ PrivilegedShell.swift # Non-privileged Process runner
βββ Views/
βββ ContentView.swift, DashboardView.swift, ResolversView.swift,
βββ ConfigEditorView.swift, LogsView.swift, SettingsView.swift, MenuBarView.swift
DNSCryptGUIHelper/ # Privileged daemon target (runs as root)
βββ HelperTool.swift # NSXPCListener delegate, validates clients
βββ main.swift # Daemon entry point β listener + dispatchMain
βββ HelperInfo.plist # Embedded into binary; SMAuthorizedClients
βββ com.hlincore.DNSCryptGUI.Helper.plist # launchd plist
DNSCryptGUIShared/ # Common code, in BOTH targets
βββ HelperProtocol.swift # @objc XPC protocol + mach service constants
The privileged helper is registered via SMAppService.daemon(plistName:) (macOS 13+), which is Apple's modern replacement for SMJobBless. The user approves the helper once in System Settings β Login Items, after which the GUI calls into it over XPC for any operation that needs root: starting/stopping the proxy, writing the TOML config, toggling system DNS.
Each side validates the other:
- GUI β helper:
SMPrivilegedExecutablesin the app's Info.plist names the helper's bundle ID and a designated requirement string.SMAppServiceenforces this at install time. - Helper β GUI:
SMAuthorizedClientsin the helper's Info.plist + a runtimeSecCodecheck inverifyClient(Team ID + bundle ID).
Read-only status (launchctl list) routes through the helper too, because querying a system-domain launchd service requires root on modern macOS.
You'll need an Xcode project. The repo is organised as plain source folders so it can drop into a new project cleanly.
- File β New β Project β macOS β App. Name DNSCryptGUI, Interface SwiftUI, Language Swift, Minimum Deployment macOS 13.0.
- Delete the default
ContentView.swiftand the default@mainapp file. - Drag
Sources/DNSCryptGUI/into the project. Add to the app target only. - File β New β Target β macOS β Command Line Tool. Name DNSCryptGUIHelper, Language Swift.
- Drag
Sources/DNSCryptGUIHelper/into the helper target. Add to the helper target only. - Drag
Sources/DNSCryptGUIShared/HelperProtocol.swiftinto the project and check both targets in Target Membership.
App target:
- Signing & Capabilities β App Sandbox off. (The app shells out to
brew,networksetup,digand cannot be sandboxed.) - Build Settings β
INFOPLIST_FILE=Sources/DNSCryptGUI/Resources/Info.plist. - Build Settings β
CODE_SIGN_ENTITLEMENTS=Sources/DNSCryptGUI/Resources/DNSCryptGUI.entitlements.
Helper target:
- Build Settings β
CREATE_INFOPLIST_SECTION_IN_BINARY= YES (embeds Info.plist into the Mach-O β required for command-line-tool helpers). - Build Settings β
INFOPLIST_FILE=Sources/DNSCryptGUIHelper/HelperInfo.plist. - Build Settings β
GENERATE_INFOPLIST_FILE= NO. - Build Settings β
SKIP_INSTALL= NO.
Both targets:
- Signing & Capabilities β Hardened Runtime on (required for notarization).
- Signing & Capabilities β Same Team and signing identity. For shipping, use Developer ID Application.
On the app target, Build Phases β + β New Copy Files Phase:
- Destination: Wrapper (or "Absolute Path") with subpath
Contents/Library/LaunchDaemons. - Add the helper product (
DNSCryptGUIHelper) to the phase. - Add
com.hlincore.DNSCryptGUI.Helper.plistto the phase.
After a successful build, the app bundle layout should be:
DNSCryptGUI.app/
βββ Contents/
βββ MacOS/
β βββ DNSCryptGUI
βββ Library/
βββ LaunchDaemons/
βββ DNSCryptGUIHelper # signed Mach-O
βββ com.hlincore.DNSCryptGUI.Helper.plist # launchd plist
This is the layout SMAppService.daemon(plistName:) reads from.
If you fork this project, you must change the bundle IDs and update every reference together:
- App:
com.hlincore.DNSCryptGUIβ<your-id> - Helper:
com.hlincore.DNSCryptGUI.Helperβ<your-id>.Helper - Helper plist filename:
com.hlincore.DNSCryptGUI.Helper.plistβ<your-id>.Helper.plist
Files that reference these IDs:
Sources/DNSCryptGUI/Resources/Info.plistβCFBundleIdentifier,SMPrivilegedExecutables(key + designated requirement)Sources/DNSCryptGUIHelper/HelperInfo.plistβCFBundleIdentifier,SMAuthorizedClientsrequirementSources/DNSCryptGUIHelper/<helper-id>.plistβLabel,MachServiceskey, file nameSources/DNSCryptGUIHelper/HelperTool.swiftβ fallback bundle ID,os.LoggersubsystemSources/DNSCryptGUIShared/HelperProtocol.swiftβkHelperMachServiceName,kHelperPlistName
A grep -r "com.hlincore" Sources/ will surface them all.
After building, verify both binaries (DNSCryptGUI and DNSCryptGUIHelper) have the same signing ID or the app will be blocked by Gatekeeper. You can sign using a local certificate to just run locally, or if you want to release your own version, you'll need to sign up for a Developer ID through Apple.
codesign -dvvv "DNSCryptGUI.app" 2>&1 | grep -E "Authority|TeamIdentifier"
codesign -dvvv "DNSCryptGUI.app/Contents/Library/LaunchDaemons/DNSCryptGUIHelper" 2>&1 | grep -E "Authority|TeamIdentifier"When the user launches from a fresh install, AppState.bootstrap() calls HelperClient.install() which invokes SMAppService.daemon(plistName:).register(). Three outcomes:
- First time, never approved: status becomes
.requiresApproval, the app opens System Settings β General β Login Items & Extensions, and the user flips the toggle for DNSCryptGUI. No password prompt. - Already approved on this Mac: status is
.enabled, no UI. - Previously denied: the user has to flip the toggle in Settings. The Settings tab in DNSCryptGUI has an "Open Login Items" button that deep-links there.
Once approved, all privileged operations go over XPC silently β no further prompts.
- macOS 13+ (Apple Silicon or Intel).
MenuBarExtraandSMAppService.daemonrequire this. - Homebrew installed at
/opt/homebrew(arm64) or/usr/local(x86_64). dnscrypt-proxyinstalled via Homebrew. The app can install it for you on first launch if it's missing.
- Add resolver latency probing (loop over
127.0.0.1:53or::1with each configured server exclusively enabled and time A queries). - Replace the handrolled TOML reader in
ProxyConfig/ConfigServicewith a real TOML library (e.g. TOMLKit) so mutations are lossless across nested tables. - Add per-interface DNS management (Wi-Fi vs Ethernet) instead of "active service only".
MIT - do whatever you want.
