Skip to content

Commit 1c2ab45

Browse files
beriberikixclaude
andcommitted
Record the app-bundle plan where it will be found
The Phase 2 plan lived only in a scratch plan file with a generated name, so it would survive a cleared session on disk but nothing would lead anyone back to it. It is now Documentation/development/app-bundle-phase-2.md, referenced from CLAUDE.md, next to the archive of why the previous attempt failed. It records what Phase 1 deliberately left open: the per-interface CDC-ACM measurement that justifies the work at all, the opt-in cask shape that keeps it non-breaking, and the two questions to settle before writing code — whether a daemon outside the app can open an IOUserClient on the dext, and which bundle identifier the provisioning profile will be issued for. Also corrects the note claiming this machine cannot run swift test. It can: Xcode is installed and the full suite runs locally in seconds. That claim was true when written and has been false for some time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rmyikdjWveP99ZUCDLY89
1 parent 10fb71d commit 1c2ab45

2 files changed

Lines changed: 156 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ implementation would slot into, satisfied by `UserspaceDeviceClaimManager`, whic
100100
intent and says so. See
101101
`Documentation/development/system-extension-archive/why-it-was-removed.md`.
102102

103+
Serving the devices macOS *does* hold — HID, mass storage, audio, cameras, and CDC-ACM
104+
control interfaces — needs a DriverKit extension, which needs an app bundle in
105+
`/Applications` to activate it, which needs a capability Apple has not granted. That
106+
work is planned but not started: see
107+
`Documentation/development/app-bundle-phase-2.md`, which records the measurements, the
108+
chosen non-breaking distribution shape, and the two questions to settle first.
109+
103110
## Architecture
104111

105112
The project is structured as a multi-target Swift package:
@@ -162,9 +169,9 @@ scripts that claimed to provide one filtered on target names that were never dec
162169
so they matched nothing and exited 0. They were removed in 2026-08 along with the CI
163170
step that called them. `swift test` is the whole story.
164171

165-
Note that `swift test` needs XCTest, which ships with Xcode. A machine with only the
166-
Command Line Tools cannot run it (`error: no such module 'XCTest'`); use CI, which runs
167-
on `macos-latest` with full Xcode.
172+
Note that `swift test` needs XCTest, which ships with Xcode rather than the Command Line
173+
Tools — a CLT-only machine fails with `error: no such module 'XCTest'` and has to use CI.
174+
The current development Mac has full Xcode, so the whole gate runs locally.
168175

169176
### Code Quality
170177
```bash
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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

Comments
 (0)