iOS 26.5 AccessoryNotifications + AccessoryTransportExtension demo with a
Mac acting as a custom Bluetooth LE accessory. Goal: iPhone notification
transport bytes to Mac receiver over BLE.
iPhone (iOS 26.5+) Mac (macOS 26+)
------------------- ----------------
NotifBridge app
- ASK picker/status
- forwarding request
|
| pairs with advertised BLE service
v
AccessoryNotifications
|
+--> DataProviderExtension
| - add/update/remove callbacks
| - serializes title/body/source/id
| - session.send(AccessoryMessage)
|
+--> TransportSecurityExtension
| - generates XWing public/private key
| - receives encapsulated key from system
| - writes ShareKeyEvent JSON
| |
| | BLE GATT write:
| | D5E1...8C46 keySharing
| v
+--> TransportAppExtension
- receives encrypted TransportMessage
- wraps ciphertext + sessionID
- writes NotificationEnvelope JSON
|
| BLE GATT write:
| D5E1...8C47 notification
v
CBPeripheralManager
- advertises D5E1...8C44
- name contains "NotifBdg"
- reassembles chunks
|
v
HPKEDecryptor
- derives AES-GCM key
- decrypts payload
|
v
ReceiverModel / SwiftUI
- lists notifications
- posts local Mac banner
- iPhone Apple Account region = EU.
AccessoryNotificationCentercan throwunsupportedPlatformoutside the EU. This is Apple/DMA scoped. - Real iPhone on iOS 26.5+. Simulator cannot exercise Bluetooth or AccessorySetupKit pairing.
- No active Apple Watch notification target while testing, or Watch notifications must be disabled. iOS enforces one notification target at a time.
- Mac with Bluetooth running macOS 26.0+.
- Apple Developer Program membership with the gated split entitlements:
com.apple.developer.accessory-data-providercom.apple.developer.accessory-transport-securitycom.apple.developer.accessory-transport-extension
- Correct extension plist metadata:
- all three extensions declare
NSAccessorySetupKitSupports = ["Bluetooth"] - all three declare
NSAccessorySetupBluetoothServices - all three declare
NSAccessorySetupBluetoothNames TransportAppExtensionexports a UTI conforming topublic.data-access-protocol
- all three extensions declare
~/dev/NotifBridge/
├── README.md ← you are here
├── docs/
│ └── FINDINGS.md ← detailed deep-dive incl. HPKE strategy
├── shared/
│ └── DemoGATT.swift ← GATT UUIDs (iOS + Mac)
├── ios/
│ ├── project.yml ← xcodegen for iOS app + 3 extensions
│ ├── NotifBridge-iOS.xcodeproj ← generated
│ ├── AccessoryBLEWriter.swift ← extension-side CBCentral writer (iOS-only)
│ ├── NotifBridge/ ← companion SwiftUI app
│ ├── DataProviderExtension/ ← receives notifications, serializes
│ ├── TransportSecurityExtension/ ← XWing key exchange relay
│ └── TransportAppExtension/ ← encrypted payload BLE relay
├── macos/
│ ├── project.yml ← xcodegen for Mac app
│ ├── NotifBridge-macOS.xcodeproj ← generated
│ └── NotifBridge/ ← SwiftUI + CBPeripheralManager + HPKE decrypt
└── scripts/
└── test-e2e.sh ← automated end-to-end regression
# iOS side
cd ~/dev/NotifBridge/ios
xcodegen generate
open NotifBridge-iOS.xcodeproj
# Mac side
cd ~/dev/NotifBridge/macos
xcodegen generate
open NotifBridge-macOS.xcodeprojTeam ID N8YZB43954 is wired into both project.yml files. Change
DEVELOPMENT_TEAM if using a different account.
- Mac: build and run
NotifBridge(mac target). The window should show that it is advertising asNotifBdg. Approve any Bluetooth permission prompt. - iPhone: build and run
NotifBridgeon the real iOS 26.5 device. - iPhone: tap Pair accessory…. In the ASK picker, select NotifBridge Mac. After bonding, the button changes to Accessory paired.
- iPhone: tap Request notification forwarding and allow the apps you want to forward. On later launches, the app refreshes stored forwarding status automatically after ASK activation.
- iPhone: trigger a notification (or use the test harness — see below).
- Mac: the decrypted title + body appear in the receiver window. Logs show
decrypted NB from MB sess=...followed byHPKE-PLAINTEXT ascii=....
./scripts/test-e2e.sh # random body
./scripts/test-e2e.sh hpke-probe-005 # known body for greppingThe script launches the iPhone app with --send-test-notif --test-notif-body=<body>,
waits 15s, then greps the Mac receiver log. ✅ when the unique body string is
found in the decrypted plaintext. The harness auto-retries once on a no-decrypt
miss (see Known Issues below).
Always run this regression after touching any Swift source, Info.plist,
entitlements, or project.yml — a green build does not prove the pipeline still
works.
bluetoothd intermittently flags the Transport extension as a non-extension.
At XPC check-in time, bluetoothd sometimes records the TransportApp extension
as isExtension false instead of isExtension true. When that happens, the
session is sent to TCC's bundle-list path with appAuthorizationHasBeenChecked: 0,
bluetoothd never raises the central state to On for that session, and the
extension's CBCentralManager stays at state=4 (poweredOff) — attemptConnect
guards out and no BLE writes happen. Most common on the first launch after a
reinstall. Mitigations:
- Run
scripts/test-e2e.sh; it auto-retries once on no-decrypt. - Manually re-launch the iPhone app and trigger another notification; the next process is almost always classified correctly.
- Reinstalls reset TCC + per-app forwarding caches and make the misclassification more likely on the first attempt.
Pre-warming CBCentralManager eagerly in TransportEventHandler.init does NOT
help — it shifts the failure to state=2 (unsupported) because DeviceAccess
hasn't granted Bluetooth yet at that point.
Useful iPhone log lines:
deviceaccessd: Issuing sandbox extension mach: Bluetooth
TransportSecurityExtension: direct BLE wrote ShareKeyEvent (...B)
TransportAppExtension: messageReceived ...B
TransportAppExtension: direct BLE wrote ...B
DataProviderExtension: addNotification ...
Mac receiver log lines:
keySharing reassembled NB → ShareKeyEvent
keys installed: id=… cipher=XWing v=Version1 …
notification reassembled NB → NotificationEnvelope
decrypted NB from MB sess=…
HPKE-PLAINTEXT ascii=<title><body><source>…
| Outcome | Meaning |
|---|---|
| Pair picker does not show Mac | NSAccessorySetupBluetoothServices UUID mismatch, Mac not advertising, or NotifBdg name filter mismatch. |
Pair picker errors with ASErrorDomain 700 |
User cancelled. Retry pairing. |
requestForwarding throws unsupportedPlatform |
Apple Account / platform is outside Apple's supported region. |
Forwarding status shows — briefly on launch |
ASK has not emitted .activated yet. The app now refreshes status automatically once the paired accessory appears. |
| Forwarding is allowed but extensions never launch | Check deviceaccessd for entitlement, provisioning, or plist errors before looking at app logs. |
Sandbox deny for com.apple.server.bluetooth.le.att.xpc |
CoreBluetooth was created too early or outside the DeviceAccess-granted extension context. Keep the writer lazy inside Security/Transport handlers. |
| Mac receives encrypted bytes but cannot decrypt | HPKE ciphersuite, key material, or info context mismatch. The demo currently uses .xWing. |
| Decrypted but garbled body | Wire format mismatch between the iOS encoder and Mac receiver parser. |
The active path is direct CoreBluetooth from the Security and Transport extensions. The host app does not broker extension data.
Important details:
TransportEventHandlersetssession.transport = .bluetooth.- Security and Transport handlers store
AccessoryBLEWriteras alazy var. This delaysCBCentralManagercreation until the system has invoked the extension for a paired accessory. AccessoryBLEWritercreatesCBCentralManagerwithCBCentralManagerOptionDeviceAccessForMedia: true.- The writer retrieves the bonded peripheral from
ASAccessorySessionand writes framed chunks (--START--, payload chunks,--END--) to the target characteristic. - App Group files, App Group UserDefaults, keychain IPC, and loopback networking
were denied from the extension sandbox. They remain documented in
docs/FINDINGS.mdas negative evidence.
Ciphersuite: XWingMLKEM768X25519_SHA256_AES_GCM_256. Apple does not document
the info value used for AccessoryTransportSession payload encryption. Found
empirically:
let protocolInfo = "\(cipherStr)-\(version)-\(identifier)" // "XWing-Version1-<accessory-uuid>"
let exportContext = "\(protocolInfo)-HostToAccessory-\(sessionID)"
let recipient = try HPKE.Recipient(
privateKey: privateKey, ciphersuite: .XWingMLKEM768X25519_SHA256_AES_GCM_256,
info: Data(protocolInfo.utf8), encapsulatedKey: encapsulatedKey
)
let secret = try recipient.exportSecret(context: Data(exportContext.utf8), outputByteCount: 32)
let plaintext = try AES.GCM.open(AES.GCM.SealedBox(combined: ciphertext),
using: SymmetricKey(data: secret))No AAD. Ciphertext is the AES-GCM SealedBox.combined form (nonce ‖ ciphertext ‖
tag). Implemented in macos/NotifBridge/HPKEDecryptor.swift. See
docs/FINDINGS.md for the full discovery context.
cd ~/dev/NotifBridge/ios && xcodebuild -project NotifBridge-iOS.xcodeproj \
-scheme NotifBridge -sdk iphoneos -destination 'generic/platform=iOS' \
CODE_SIGNING_ALLOWED=NO build
cd ~/dev/NotifBridge/macos && xcodebuild -project NotifBridge-macOS.xcodeproj \
-scheme NotifBridge CODE_SIGNING_ALLOWED=NO buildBoth should print ** BUILD SUCCEEDED **.
# Mac receiver
log stream --predicate 'subsystem CONTAINS "NotifBridge"' --info
# iPhone via Console.app or idevicesyslog
log stream --predicate 'subsystem CONTAINS "NotifBridge" OR
subsystem == "com.apple.deviceaccessd" OR
subsystem == "com.apple.CoreBluetooth"' --infoThe first sign of provisioning, plist, or entitlement trouble usually lands in
com.apple.deviceaccessd before either of our subsystems logs anything.
To repair if pairing gets stuck:
- iPhone: Settings → Privacy & Security → Accessories → remove NotifBridge Mac.
- Mac: relaunch
NotifBridgefor a fresh advertisement.