Skip to content

M5-04 ClientKit and Notification Service Extension

No Date Ruling Reason
D-M5-04-0 2026-10-25 Built on main after M3-22, M4-31 and M5-02 landed; the changelog entry and these rulings are fragments. Differences to the brief's names, following the merged code: the preview request carries kind besides nid and message_id, because getPushPreview requires it (13 6.4 check 1), so PreviewClient.fetch takes the kind; the Swift tests use Swift Testing like DeviceKeyCore (D-M4-10-6), so they run with the command line tools of a Mac without Xcode, not XCTest; the strings generator is tools/catalog/apple_strings.py next to the other generators of make codegen-check (the brief named tools/l10n/); the NSE logic sits in ClientKit as NotificationProcessor and ClientKitNotificationService, and each NSE target's NotificationService subclasses it without code of its own; the Pigeon Dart output is lib/src/generated/push_apple_api.g.dart. D-W-100.
D-M5-04-1 2026-10-25 ClientKit contents and the shared container: SharedKeychain (generic passwords of the service clientkit in the shared keychain group, account <sha256 of the instance ID>.<name> for preview_token, registration_id and server_url, AfterFirstUnlockThisDeviceOnly, never synchronizable), SharedState (the App Group's UserDefaults with only preview_mode, the instance ID, client.push.fetch_timeout in milliseconds, the last nonce per server and the display counters), DPoPProof, NonceCache, PreviewClient, PreviewRenderer, DisplayCounters and the NSE logic. The app and the extensions read the group names from the Info.plist keys ClientKitAppGroup and DeviceKeyAccessGroup. App Groups and keychain sharing need a team signature, so the groups exist only in a signed build: on iOS with development_team of the branding (group.<bundle_id>, <team>.<bundle_id>.shared, the app's own <team>.<bundle_id> listed first so its own secrets stay out of the shared group), on macOS in the Release configuration (<team>.<bundle_id>, <team>.<bundle_id>.shared); an unsigned build (the neutral build, macOS Debug and Profile) has empty values, push reports itself unavailable and the device key stays in the app's default group (the plugin now treats an empty DeviceKeyAccessGroup like a missing one). 15 9; brief Design note 1; an access group the binary is not entitled to fails every keychain call, which would break the login of unsigned builds.
D-M5-04-2 2026-10-25 DPoP in Swift equals the Dart DpopProofFactory: header typ, alg, jwk (crv, kty, x, y), payload jti (UUIDv7), htm, htu (normalized like normalizeHtu), iat, ath, nonce, compact JSON with Dart's escaping; the contract vector apps/client/ios/ClientKit/Tests/ClientKitTests/Fixtures/dpop_vector.json (the P-256 generator point as the key, fixed clock and jti) is checked by the Swift and the Dart test, and the Swift signature verifies with the public key. The NSE only opens an existing dpop key, never creates one. NonceCache keeps the last DPoP-Nonce per server origin (hashed) in the App Group; a 401 use_dpop_nonce with a new nonce is repeated once. 08 7.3; brief Design note 2.
D-M5-04-3 2026-10-25 The extension has no AppConfig: the app writes client.push.fetch_timeout in milliseconds into the App Group at start, with the instance and the preview mode; the NSE uses it as the request timeout and as its own deadline, after which it delivers the placeholder. Without a written value the NSE fetches nothing. Only https server addresses are fetched, and redirects are refused, so the preview token and the proof never reach another URL. brief Design note 3; D-W-115.
D-M5-04-4 2026-10-25 NSE content: b.v must be 1 and the kind one with a preview (security, admin and anything else stay as they came); thread-id, badge and sound stay those of aps; with notify.preview_mode = none, no mode written yet, no credentials or no device key for b.i, the placeholder is delivered at once without a request. Title and body follow the desktop toast (01 10.2), 404 shows "Message not available", a failure keeps the placeholder; the extension delivers exactly once and counts duration and placeholder. With none the NSE never shows a channel name: it cannot read the encrypted database (15 6.3), and a list of names in the shared container would be content outside it; 01 10.2 says so. The image of image_url is not attached: an attachment needs a second fetch inside the same time budget; follow-up. 13 5.1, 6.1, 6.4; brief Design note 4.
D-M5-04-5 2026-10-25 Permission timing: never before the login; the app asks once per run when the person opens a thread (the first moment the request is understandable) and registers when the permission is or was given; with the permission given earlier, every start registers. Without it nothing registers; the system settings can grant it later, and the next start registers. The token goes to registerPush only when it differs from the token last registered or no registration is stored; the environment comes from the signing profile (D-M5-04-10). The extension tests run in the test process of swift test (Swift Testing), which calls didReceive of the extension's class with the payload of 13 6.1 against a recording fake server: an unsigned simulator build has neither App Group nor keychain group, so an extension started by xcrun simctl push could only keep the placeholder; delivery on devices is M5-11. 13 13; 15 10; brief Design note 5 and Tests.
D-M5-04-6 2026-10-25 The preview token lives only in the shared keychain: the runner writes the record of registerPush at once; Dart keeps only the APNs token last registered (in the instance's secrets, wiped with the account). Logout calls unregisterPush best effort before the server logout; every end of the account (logout, 4012, revocation, a wipe) deletes the keychain items through the wiper. 13 6.4; 15 9; brief Design note 5.
D-M5-04-7 2026-10-25 Native strings: tools/catalog/apple_strings.py writes apps/client/ios/Localizations/<lang>.lproj/Localizable.strings from the ARB files (PUSH_TITLE, PUSH_BODY_<KIND>, PUSH_MESSAGE_NOT_AVAILABLE, PUSH_PREVIEW_TITLE, PUSH_PREVIEW_TEXT); PUSH_BODY_MESSAGE, _SECURITY and _ADMIN reuse the texts of the desktop notifications; placeholders become %1$@, % becomes %%; the iOS and macOS runners and both extensions include the same files, and make codegen-check compares them with a fresh run. 13 6.1; 01 9; brief Design note 6.
D-M5-04-8 2026-10-25 Link check: tools/ci/check_extension_links.py reads every .appex of the iOS simulator and macOS app bundles built by make apps-platform and runs otool -L on its executable; a load command of Flutter.framework, FlutterMacOS.framework or App.framework, or a missing NotificationService.appex, fails; without Xcode it prints a SKIP line. It runs last in the macos entry of apps/client/platform-tests.json, after the ClientKit tests at its start. ADR 0056 "Confirmation"; brief Design note 7.
D-M5-04-9 2026-10-25 One delegate of the notification center for the whole app: NotificationCenterRouter of ClientKit becomes the delegate at launch and again after the plugins registered, keeps the delegate it replaces (on macOS flutter_local_notifications, which sets itself when it registers; on iOS, where no plugin sets one, the Flutter app delegate that hands the callbacks to its plugins) and handles only notifications whose userInfo carries the push object b; every other notification, its click and its foreground presentation go to the kept delegate unchanged, so the local notifications of M4-30 still open their thread. Review and red team on 6c56baea7: replacing the delegate dropped the clicks of local notifications on macOS.
D-M5-04-10 2026-10-25 The APNs environment follows the signing profile, not the build configuration: the iOS entitlements (written with a team) and the macOS Release entitlements say development, as Xcode writes them for development signing; the export for TestFlight, the App Store, ad hoc or Developer ID re-signs with the distribution profile's production. The app registers the environment of its embedded profile (embedded.mobileprovision, Contents/embedded.provisionprofile): development gives sandbox, anything else and no profile (an App Store install) production (ApsEnvironment of ClientKit). Red team on 6c56baea7: production in the Release and Profile entitlements does not match the development profile those configurations are signed with, and a fixed value would register TestFlight builds in the wrong environment.