macOS menu bar app that monitors and blocks AWDL (Apple Wireless Direct Link) to reduce WiFi latency spikes during cloud gaming. Swift/SwiftUI, macOS 13+.
| Component | Purpose |
|---|---|
PingWarden (main app) |
SwiftUI menu bar app, preferences, UI |
PingWardenHelper |
Privileged SMAppService daemon, manages AWDL via ifconfig |
PingWardenWidget |
macOS Control Center widget |
| XPC | App↔helper communication |
Bundle IDs: com.amesvt.pingwarden (app), com.amesvt.pingwarden.widget, com.amesvt.pingwarden.helper
Team ID: PV3W52NDZ3
App Group: PV3W52NDZ3.com.amesvt.pingwarden (Team-ID-prefixed so Developer ID builds work without an embedded provisioning profile)
PingWarden/
├── PingWarden/ # Main app (Swift/SwiftUI)
│ ├── Core/ # PingStatistics, TCPProbe, XPCReconnectPolicy
│ ├── PingWardenApp.swift # Entry point, menu bar setup
│ ├── PingWardenMonitor.swift # AWDL blocking logic, stateLock
│ ├── PingMonitor.swift # Real-time TCP latency monitoring
│ ├── DashboardView.swift # Live ping dashboard
│ ├── MonitoringStateStore.swift # Persisted monitoring state
│ ├── DiagnosticsExporter.swift # Diagnostic data export
│ ├── QuarantineHelper.swift # macOS quarantine attribute handling
│ ├── ControlCenterSupport.swift # Control Center widget integration
│ ├── PingWardenPreferences.swift # UserDefaults wrapper
│ ├── notarize.sh # Notarization + stapling
│ └── release.sh # DMG creation, appcast signing, GitHub release
├── PingWardenHelper/ # Privileged daemon (Obj-C)
│ ├── main.m # Daemon entry point
│ └── PingWardenMonitor.h/.m # AF_ROUTE socket listener, ifconfig calls
├── PingWardenWidget/ # macOS Control Center widget (toggle intent + preferences)
├── Common/ # Shared XPC protocol (HelperProtocol.h)
├── create-dmg.sh # DMG packaging with background image
└── PingWarden.xcodeproj
cd PingWarden
xcodebuild -project PingWarden.xcodeproj -scheme PingWarden -configuration ReleaseThe Foundation-only core helpers are covered by a SwiftPM test target driven
by Package.swift at the repo root. The Xcode app build is unaffected — the
package's PingWardenCore target points at PingWarden/PingWarden/Core/ via
an explicit path: so the same Swift sources back both build systems.
The package is cross-platform: TCPProbe and the test suite compile on
Linux (#if canImport(Darwin)/Glibc shims, poll(2) instead of select), and
CI runs swift test on both the macOS 26 runner and an ubuntu-latest Swift
container. Keep new Core code Foundation/POSIX-only.
swift test # canonical command
./scripts/run-smoke-tests.sh # thin wrapper, runs `swift test`Coverage: PingStatistics.calculate() edge cases (empty, healthy, lossy,
even-count median, fair band, min/max, all-failures, single-sample jitter),
XPCReconnectPolicy.delayForAttempt (backoff curve, 30 s cap, monotonicity),
TCPProbe failure and success paths (invalid hostname, closed loopback port,
open loopback port), StateObserverRegistry add/remove/snapshot lifecycle,
VersionPromptPolicy (incl. single-component fail-closed), custom-target
validation boundaries, and HelperBundleValidator failure modes (missing
binary, missing plist, non-executable binary, valid bundle).
notarize.sh pre-validates notarytool credentials automatically (it calls
xcrun notarytool history --keychain-profile and aborts with a setup hint
before doing any work). If the profile ever expires, run:
xcrun notarytool store-credentials "notarytool-profile"Do NOT use xcodebuild -exportArchive — broken in Xcode 26 (IDEDistributionMethodManagerErrorDomain Code=2). Archive unsigned and let notarize.sh apply the real Developer ID signature (fully headless, no Xcode UI needed):
xcodebuild archive -project PingWarden.xcodeproj -scheme PingWarden -configuration Release -archivePath /tmp/PingWarden-X.Y.Z.xcarchive -destination 'generic/platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO. TheCODE_SIGNING_ALLOWED=NOis required so the archive is deterministic and does not depend on Xcode's provisioning UI. The app uses a Team-ID-prefixed macOS App Group that does not require a profile.notarize.shre-signs every component withcodesign --entitlements(Developer ID, no profile). The archive's signing is throwaway. The archive yields both the.appanddSYMs/(the latter is needed byrelease.shfor Sentry).rsync -a "/tmp/PingWarden-X.Y.Z.xcarchive/Products/Applications/Ping Warden.app/" "PingWarden/build/Ping Warden.app/"cd PingWarden/PingWarden && bash notarize.sh X.Y.Z(optional — standalone notarize test;release.shruns it internally)bash release.sh X.Y.Z ../../RELEASE_NOTES.md— runsnotarize.sh(re-sign + notarize app + DMG + staple), signs the Sparkle update, creates the GitHub release, uploads dSYMs to Sentry, and pushes the gh-pagesappcast.xmlautomatically.release.shleavesappcast.xmlmodified onmainafter its gh-pages push; commit the main-side copy too:git add appcast.xml && git commit -m "Update appcast for vX.Y.Z" && git push.
Beta releases: prefix step 4 with BETA_CHANNEL=1 (same env-flag pattern as the existing CRITICAL_UPDATE=1). The script writes to appcast-beta.xml on gh-pages instead of appcast.xml, uses distinct channel metadata, and writes a different commit message. Same EdDSA signing, same DMG packaging, same GitHub release flow; beta is a Sparkle-layer concept only. Users opted into the beta channel via Settings → Advanced → Updates get routed there by SPUUpdaterDelegate.feedURLString(for:).
BETA_CHANNEL=1 bash release.sh 3.1.0-beta.1 ../../RELEASE_NOTES.mdThe canonical release path is now one command from a clean, pushed commit:
cd PingWarden/PingWarden && bash release.sh X.Y.Z ../../RELEASE_NOTES.md.
It creates and validates the unsigned archive and dSYMs itself before running
the signing, notarization, DMG, appcast, GitHub, Sentry, and gh-pages steps.
The numbered commands above remain useful only for a standalone notarization
test.
Sparkle EdDSA key is in keychain account "ed25519". Notarytool profile: "notarytool-profile".
- Sparkle auto-update framework with EdDSA signing (keychain account
"ed25519") appcast.xmlongh-pagesbranch — update after each release- DMGs built via
create-dmg.sh(PingWarden directory) - Developer ID signed + Apple notarized
| Script | Location | Purpose |
|---|---|---|
notarize.sh |
PingWarden/PingWarden/ |
Notarize, poll, staple |
release.sh |
PingWarden/PingWarden/ |
DMG + Sparkle appcast entry + GitHub release |
create-dmg.sh |
PingWarden/ |
DMG packaging with background image |
core_logic_smoke.swift |
scripts/ |
Standalone smoke tests |
- Mixed Swift/Obj-C: main app is Swift, helper daemon is Obj-C (requires root for
ifconfig). XPC protocol shared viaCommon/HelperProtocol.hand bridging header stateLock: NSLockinPingWardenMonitor— always usedefer { stateLock.unlock() }afterlock()PingWardenPreferences.defaultsis non-optional (UserDefaults.standardfallback ininit)- App Group UserDefaults persists across app deletion — always reset in
performUninstall() applicationShouldHandleReopenis the lockout recovery path (opens Settings if no windows visible)- User-facing strings use "Ping Protection" not "AWDL monitoring"
xcodebuild -exportArchiveis broken in Xcode 26 — use the rsync workaround in the Release Process section above.- SMAppService requires
/Applications: The daemon registration (SMAppService.daemon(plistName:)) refuses to register when the app runs from Xcode's DerivedData. To test the full helper registration flow, build in Release, copy to/Applications, and launch from there. Running from Xcode is fine for non-helper UI work. - Appcast publishing is automated:
release.shsnapshots the signed appcast, publishes it togh-pages, and restores the original branch. It leaves the signed appcast modified onmain, so commit and push that main-side copy after the release succeeds.