Before you build one, take a real one apart. Tailscale's extension is sitting on your disk — let's dissect its bundle, Info.plist, and entitlements.
NEProviderClasses map, and its entitlements — so the files you're about to write in Lesson 3 are already familiar.Recap. In Lesson 1 you saw three extensions running and chose to build a Network Extension content filter. A System Extension isn't a loose binary you install — it's an .systemextension bundle nested inside a host app. Today we read a real one so the structure is concrete.
When a host app activates an extension, macOS copies it out of the app and into a system-owned store, keyed by UUID. Find yours:
The plist tying UUIDs to bundle IDs and states is /Library/SystemExtensions/db.plist. This store is why a reboot isn't needed to swap extensions (unlike a kext's AuxKC) — and why your app being deleted doesn't immediately kill a running extension.
Inside the shipping app, the extension lives at a fixed path. This is the layout your build.sh will assemble in Lesson 4:
The Info.plist is where an extension declares what kind it is and which class the framework should instantiate. Dump Tailscale's:
| Key | What it does |
|---|---|
CFBundlePackageType = SYSX | Marks the bundle as a System Extension (an app is APPL, a kext is KEXT). |
NEProviderClasses | Maps a NetworkExtension extension-point identifier → your provider class name (Module.ClassName). Tailscale registers a packet-tunnel; your content filter will register com.apple.networkextension.filter-data. |
NEMachServiceName | A Mach service for app↔extension IPC. Needed by VPN/proxy types; a bare content-filter data provider can omit it. |
NSSystemExtensionUsageDescription | The sentence shown in the approval prompt. Required. |
Everything above is identical for any NetworkExtension; the type is chosen by the NEProviderClasses key. Tailscale uses packet-tunnel; you'll use filter-data. That single string is the difference between a VPN and a firewall.
Entitlements are signed capabilities baked into the binary. See Tailscale's:
Your content filter's entitlement array will read content-filter-provider-systemextension instead — same key, different value. The -systemextension suffix is what lets it ship outside the Mac App Store with Developer-ID signing. The extension is sandboxed (app-sandbox = 1), like every System Extension.
Your app drives activation through one API — OSSystemExtensionRequest — and gets called back as the OS works through these states:
OSSystemExtensionRequestDelegate; the OS calls these methods. We wrote exactly this in the demo's main.swift — next lesson.Before: nested in the host app at YourApp.app/Contents/Library/SystemExtensions/…systemextension. After activation: copied by the OS into /Library/SystemExtensions/<UUID>/ and tracked in db.plist.
NEProviderClasses — the extension-point identifier you map. com.apple.networkextension.packet-tunnel = VPN, com.apple.networkextension.filter-data = content filter.
CFBundlePackageType of a System Extension?SYSX. (App = APPL, kext = KEXT.)
requestNeedsUserApproval(_:). Success is request(_:didFinishWithResult:); rejection is request(_:didFailWithError:).
NEMachServiceName is for? Ask me. Otherwise say "Lesson 3" and we'll read the Swift you'll compile.