|
| 1 | +# App bundle and DriverKit extension — the remaining phase |
| 2 | + |
| 3 | +Phase 1 (the enabling cleanup) landed in 0.7.0: the System Extension subsystem was |
| 4 | +removed, ~25,800 lines with it, and the release stopped publishing an extension bundle |
| 5 | +nothing could install. This document is what is left, and it is **gated entirely on |
| 6 | +Apple granting the DriverKit capability**. Nothing here is worth starting before that. |
| 7 | + |
| 8 | +## Why an app bundle at all |
| 9 | + |
| 10 | +Devices whose interfaces macOS holds cannot be served. Measured on this project's |
| 11 | +hardware: |
| 12 | + |
| 13 | +| Device class | Result | |
| 14 | +|---|---| |
| 15 | +| HID (Keychron K3) | `AppleUserHIDDevice` holds it — refused | |
| 16 | +| Audio (dock) | `AppleUSBAudioControlNub` — refused | |
| 17 | +| FTDI, CP210x | `IOUserSerial` attaches but takes no exclusive access — **served today** | |
| 18 | +| **CDC-ACM** | data interface opens; **control interface refuses** | |
| 19 | + |
| 20 | +The CDC-ACM split is the case that matters. Measured on a Raspberry Pi Debug Probe with |
| 21 | +`Scripts/validate-usb-entitlements.sh --only 2e8a:000c`: |
| 22 | + |
| 23 | +``` |
| 24 | +iface 0 (class 255, CMSIS-DAP) no driver → kIOReturnSuccess |
| 25 | +iface 1 (class 2, ACM control) AppleUSBACMControl → kIOReturnExclusiveAccess |
| 26 | +iface 2 (class 10, ACM data) AppleUSBACMData → kIOReturnSuccess |
| 27 | +``` |
| 28 | + |
| 29 | +Control carries `SET_LINE_CODING` and `SET_CONTROL_LINE_STATE` — baud rate, DTR, RTS. A |
| 30 | +client can be handed every byte and still never set the line up, so CDC-ACM cannot be |
| 31 | +served usefully. That is Arduino, STM32 virtual COM ports, the Pico's UART: the class |
| 32 | +most dev boards with native USB present, and the one measured case where a DriverKit |
| 33 | +entitlement would change the answer rather than being unnecessary. |
| 34 | + |
| 35 | +Taking that interface needs a dext, and a dext can only be activated by a process inside |
| 36 | +an `.app` in `/Applications`. A Homebrew-installed Mach-O is not that. |
| 37 | + |
| 38 | +## Chosen shape: an opt-in activator app |
| 39 | + |
| 40 | +Decided deliberately, because it is the only shape that is **not breaking**. |
| 41 | + |
| 42 | +- The Homebrew **formula stays exactly as it is** — `brew install usbip`, |
| 43 | + `sudo brew services start usbip`, `usbipd …`, `~/.usbipd/`, TCP 3240 all unchanged. |
| 44 | +- A new **cask** ships a small `.app` whose only job is hosting and activating the |
| 45 | + extension. Users who do not need driver-bound devices never install it. |
| 46 | +- The daemon stays a Homebrew binary and reaches the dext by opening an `IOUserClient`. |
| 47 | + |
| 48 | +Rejected, and why: |
| 49 | + |
| 50 | +| Shape | Why it breaks | |
| 51 | +|---|---| |
| 52 | +| App hosts the daemon; formula becomes a thin CLI | `sudo brew services start usbip` stops being how the daemon runs | |
| 53 | +| Cask replaces the formula | `brew upgrade` does **not** migrate formula → cask. Users silently stall, told they are up to date, until they manually uninstall and reinstall | |
| 54 | + |
| 55 | +## Two things to settle before writing any code |
| 56 | + |
| 57 | +**1. Can a daemon outside the app open an `IOUserClient` on the dext?** |
| 58 | +This is load-bearing. The archived entitlements already request |
| 59 | +`com.apple.developer.driverkit.allow-any-userclient-access`, and the Apple submission |
| 60 | +argues same-team access should not even need it — but it is not proven. If it is false, |
| 61 | +the daemon must move into the app and the migration becomes breaking after all. Prove it |
| 62 | +before committing. |
| 63 | + |
| 64 | +**2. Which bundle identifier?** |
| 65 | +There is a conflict worth resolving deliberately rather than by habit: |
| 66 | + |
| 67 | +- `com.github.usbipd-mac.systemextension` is what has historically been signed. |
| 68 | +- `com.usbipd.mac.system-extension` is the App ID that capability request `26F53XCAGY` |
| 69 | + is filed against. |
| 70 | + |
| 71 | +A Developer ID signature needs no registered App ID, but the **provisioning profile** — |
| 72 | +which is what makes the managed DriverKit capabilities take effect — does. So the |
| 73 | +identifier must match whatever App ID the profile is issued for. Apple additionally |
| 74 | +requires the extension's ID to be prefixed by the host app's, which fixes the whole |
| 75 | +namespace once chosen: |
| 76 | + |
| 77 | +``` |
| 78 | +com.usbipd.mac ← the activator .app |
| 79 | +com.usbipd.mac.system-extension ← the dext inside it |
| 80 | +``` |
| 81 | + |
| 82 | +## What the app contains |
| 83 | + |
| 84 | +Nothing but activation UI. No USB/IP logic, no daemon, no protocol code. |
| 85 | + |
| 86 | +``` |
| 87 | +/Applications/<name>.app/ |
| 88 | + Contents/ |
| 89 | + Info.plist CFBundleIdentifier = <app id> |
| 90 | + MacOS/<activator> minimal app: Activate / Deactivate / Status |
| 91 | + Library/SystemExtensions/ |
| 92 | + <app id>.<ext>.dext/ |
| 93 | + Info.plist IOKitPersonalities, IOUserClass, IOProbeScore |
| 94 | + MacOS/<dext> |
| 95 | + embedded.provisionprofile non-empty this time |
| 96 | + embedded.provisionprofile |
| 97 | +``` |
| 98 | + |
| 99 | +**The dext is a rewrite, not a port.** The deleted `Sources/SystemExtension/` was a plain |
| 100 | +Swift executable using Foundation, Dispatch and `RunLoop.main`; a dext is C++ against the |
| 101 | +DriverKit SDK with none of those. Zero lines carry over, and SwiftPM cannot build either |
| 102 | +the app or the dext — Phase 2 adds an Xcode project or `xcodebuild` alongside |
| 103 | +`Package.swift`. The archived `Info.plist` and entitlements under |
| 104 | +`system-extension-archive/` are useful only as a record of the identifier and entitlement |
| 105 | +decisions. |
| 106 | + |
| 107 | +## Signing, notarization, distribution |
| 108 | + |
| 109 | +- **Notarization becomes mandatory.** SIP is enabled on the target machine and |
| 110 | + `systemextensionsctl developer` refuses to run while it is, so there is no |
| 111 | + developer-mode bypass. Tailscale and OBS do exactly this: Developer ID + notarized |
| 112 | + `.app` in `/Applications`. |
| 113 | +- The release workflow already has working `notarytool` machinery and all four |
| 114 | + credentials (`DEVELOPER_ID_CERTIFICATE(_PASSWORD)`, `NOTARIZATION_USERNAME/PASSWORD`). |
| 115 | + It needs a `DRIVERKIT_PROVISIONING_PROFILE` secret — referenced by the old workflow but |
| 116 | + never configured, and removed in 0.7.0. |
| 117 | +- Sign inside-out: dext first, then the app, `--options runtime --timestamp`. Notarize |
| 118 | + the whole `.app` as one submission and staple it. Stapling works on an `.app`, unlike a |
| 119 | + bare Mach-O — which is why `usbipd` is signed but unnotarized, and can stay that way. |
| 120 | +- `com.apple.developer.system-extension.install` goes on **the app**, never on `usbipd`. |
| 121 | + It is restricted: AMFI kills a binary claiming it without an authorising profile, |
| 122 | + measured as exit 137 on launch. That is why 0.7.0 deleted `usbipd.entitlements`. |
| 123 | +- Cask in the existing tap, shipping only the `.app` — no `binary` stanza, so it cannot |
| 124 | + collide with the formula's `usbipd`. |
| 125 | + |
| 126 | +## The seam Phase 1 left |
| 127 | + |
| 128 | +`DeviceClaimManager` (`Sources/Common/DeviceClaimProtocol.swift`) survives precisely so |
| 129 | +this has somewhere to land. `UserspaceDeviceClaimManager` satisfies it today by tracking |
| 130 | +intent; a `DextDeviceClaimManager` would open the user client and issue claim/release |
| 131 | +over `IOConnectCallStructMethod`. `RequestProcessor`, `USBRequestHandler` and |
| 132 | +`USBDeviceCommunicatorImplementation` already talk to the protocol and need no change. |
| 133 | +Selecting between the two is one `if` in `main.swift`, keyed on whether the dext's |
| 134 | +service is present. |
| 135 | + |
| 136 | +## Must be re-measured before believing any of this |
| 137 | + |
| 138 | +That a dext with a higher `IOProbeScore` **actually displaces `AppleUSBACMControl`** on a |
| 139 | +CDC-ACM device. That is the entire justification, and it has been inferred, not |
| 140 | +demonstrated. The previous subsystem's claiming strategy was also plausible on paper and |
| 141 | +was measured not to unbind anything. |
| 142 | + |
| 143 | +## Version skew |
| 144 | + |
| 145 | +An independently installed cask and formula can drift apart. Mitigate with a version |
| 146 | +handshake over the user client and a clear error, rather than assuming lockstep. |
0 commit comments