17 KiB
iOS Notification Service Extension (NSE) — Setup & Übergabe
Für wen: Eine spätere (kontextlose) Claude-Session und den Menschen, der auf einem Mac mit Xcode weiterbaut. Dieses Dokument ist die einzige Quelle der Wahrheit für die iOS-Push-Fertigstellung. Die Swift-/Dart-Dateien existieren bereits (auf Linux textuell erstellt, nie kompiliert). Was fehlt, ist die reine Xcode-Verdrahtung (Target anlegen, Capabilities, Signing) — das geht nur auf dem Mac.
1. Architektur-Kurzfassung
Nextcloud (cloud.marianum-fulda.de)
│ push-v2: E2E-verschlüsselte Notification (mit Device-Public-Key verschlüsselt)
▼
MarianumConnect Push-Proxy (connect.marianum-fulda.de/push-proxy/notifications)
│ verifiziert Signatur, leitet per FCM HTTP v1 weiter
▼
FCM → APNs → iOS-Gerät
│ alert-Push mit `mutable-content: 1`, `subject` + `signature` top-level im Payload
▼
NotificationServiceExtension (DIESES Target)
│ liest Device-Private-Key + Server-Public-Key aus dem geteilten Keychain
│ verifiziert Signatur → entschlüsselt subject → setzt Titel/Body/Category
▼
iOS zeigt die fertige Notification
- Verschlüsselung: OAEP-SHA1 (NC-32-Default) mit Fallback PKCS#1 v1.5.
- Signatur:
SHA512withRSAüber die base64-dekodierten (verschlüsselten) subject-Bytes, geprüft mit dem per-User-Server-Public-Key aus der Registrierung. - Dart-Referenz:
lib/push/— insb.push_decryptor.dart(Krypto-Spiegel),push_secure_storage.dart(Keychain-Optionen),push_keypair.dart(Keyformat),push_registration_store.dart(was group-scoped gespeichert wird),push_registration.dart(_persistNativeAuthContext). - Backend-Referenz: MarianumConnect-Service
marMobileApi, Endpunktepush-proxy/notifications(NC→Proxy) undPUT/DELETE me/push-device. - Talk-Actions (Antworten / Als gelesen markieren): werden nativ im
AppDelegateperURLSessionan die Talk-OCS-API geschickt, weil bei einer Notification-Action nicht garantiert ist, dass die Flutter-Engine läuft.
2. Dateiinventar
| Datei | Status | Anmerkung |
|---|---|---|
ios/NotificationServiceExtension/NotificationService.swift |
existiert | NSE-Hauptklasse (entschlüsseln + Content setzen) |
ios/NotificationServiceExtension/PEM.swift |
existiert | PEM/DER→SecKey (PKCS#8/SPKI-Stripping) |
ios/NotificationServiceExtension/Info.plist |
existiert | NSExtensionPointIdentifier = com.apple.usernotifications.service |
ios/NotificationServiceExtension/NotificationServiceExtension.entitlements |
existiert | App Group + keychain-access-groups |
ios/Runner/Runner.entitlements |
geändert | keychain-access-groups (widget-Group) ergänzt |
ios/Runner/AppDelegate.swift |
geändert | TALK_MESSAGE-Category + native Action-Behandlung |
lib/push/push_registration_store.dart |
geändert | schreibt nextcloud_username + nextcloud_base_url group-scoped |
lib/push/push_registration.dart |
geändert | _persistNativeAuthContext() bei register() |
| Xcode-Target „NotificationServiceExtension" | FEHLT | muss in Xcode angelegt werden (Abschnitt 3) |
ios/Runner.xcodeproj/project.pbxproj |
unverändert | bewusst NICHT von Hand editiert — Xcode legt das Target an |
Wichtig: Die vier Dateien unter
ios/NotificationServiceExtension/liegen schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene Platzhalter-Dateien — diese löschen und die vorhandenen Dateien zuordnen (siehe 3.2), sonst überschreibst du die fertige Implementierung.
3. Xcode-Checkliste (auf dem Mac)
3.1 Target anlegen
ios/Runner.xcworkspacein Xcode öffnen (nicht.xcodeproj).- File → New → Target… → iOS → Notification Service Extension.
- Product Name exakt:
NotificationServiceExtension(derNSExtensionPrincipalClassin der Info.plist ist$(PRODUCT_MODULE_NAME).NotificationService— bei abweichendem Namen zerbricht das). Language: Swift. „Embed in Application": Runner. - Beim Dialog „Activate scheme?" → Activate.
- Bundle Identifier:
eu.mhsl.marianum.mobile.client.NotificationServiceExtension(Muster wie bei den bestehenden Extensions). - Deployment Target = 15.0 (identisch zum Runner; im
post_installdes Podfiles wird ohnehin alles auf 15.0 gezwungen).
3.2 Generierte Dateien durch die vorhandenen ersetzen
- Xcode legt eine eigene
NotificationService.swiftundInfo.plistim Target-Ordner an. Beide aus dem Projekt entfernen („Move to Trash" für die frisch generierten) und stattdessen die bereits vorhandenen Dateien hinzufügen:NotificationService.swift,PEM.swift→ Target Membership: nur NSE.Info.plist→ in den Build Settings des NSE-Targets alsINFOPLIST_FILE = NotificationServiceExtension/Info.plistsetzen (bzw. die vorhandene Datei als Info.plist des Targets referenzieren).NotificationServiceExtension.entitlements→ in Build SettingsCODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements.
3.3 Capabilities (in beiden Targets: Runner und NSE)
- App Groups:
group.eu.mhsl.marianum.mobile.client.widgetaktivieren. (Beim Runner ist die Group bereits vorhanden; beim NSE neu hinzufügen.) - Keychain Sharing: Keychain-Group
group.eu.mhsl.marianum.mobile.client.widgethinzufügen. Die Entitlement-Dateien enthalten das schon alskeychain-access-groups; über die Capabilities-UI stellst du sicher, dass das Provisioning-Profil es abdeckt. - Prüfen, dass die Capabilities-UI keine doppelten/abweichenden Einträge
erzeugt hat (Xcode schreibt gern in die
.entitlements; danach Abschnitt 4 gegenchecken).
3.4 Signing
- Beide Targets: Team = dasselbe wie Runner. Automatic Signing an, oder die passenden Profile wählen. Der App-Identifier-Prefix (Team-ID) muss identisch sein, sonst greift die Keychain-Group nicht.
3.5 Pods / Podfile
- Die NSE ist komplett selbst-enthalten (
Foundation,Security,UserNotifications— alles System-Frameworks). Kein Pod-Target nötig. - Prüfen, ob irgendein Flutter-Plugin explizit ein NSE-Target im Podfile verlangt
(aktuell nein —
firebase_messagingbraucht keins). Falls doch mal nötig: im Podfile analog zurShare Extensioneinentarget 'NotificationServiceExtension' do inherit! :search_paths end-Block ergänzen, danachpod install. - Nach dem Anlegen einmal
cd ios && pod installlaufen lassen (aktualisiert die Workspace-Referenzen).
3.6 aps-environment (Production!)
ios/Runner/Runner.entitlementshat aktuellaps-environment = development. Für TestFlight/Release mussproductionaktiv sein. Das wurde hier bewusst nicht hart umgestellt, um den Debug-Flow nicht zu brechen.- Empfehlung: über die Capabilities → Push Notifications und die
Build-Konfiguration steuern (Xcode setzt beim Archive automatisch
production, wenn das Profil ein Distribution-Profil ist). Beim Archive-Export kontrollieren, dass in der finalen.ipaaps-environment = productionsteht (siehe 5.2).
4. Ermittelte Keychain-Details (verbindlich)
Die Dart-Seite schreibt mit
IOSOptions(groupId: 'group.eu.mhsl.marianum.mobile.client.widget', accessibility: first_unlock).
Aus dem Quellcode von flutter_secure_storage_darwin 0.3.2 (gepinnt in
pubspec.lock) ergibt sich die exakte Ablage im Keychain:
| Keychain-Attribut | Wert |
|---|---|
kSecClass |
kSecClassGenericPassword |
kSecAttrAccount |
der Dart-Key, wortwörtlich (kein Hash, kein Prefix) |
kSecAttrService |
nicht gesetzt (die IOSOptions setzen kein accountName) |
kSecAttrAccessGroup |
group.eu.mhsl.marianum.mobile.client.widget |
kSecAttrAccessible |
kSecAttrAccessibleAfterFirstUnlock (aus first_unlock) |
Wert (kSecValueData) |
rohe UTF-8-Bytes des Strings (PEM/Passwort im Klartext) |
Deshalb fragt die Swift-Seite (keychainString(...) in NSE und
AppDelegate) exakt so ab: GenericPassword + kSecAttrAccount = <key> +
kSecAttrAccessGroup + kSecMatchLimit=One + kSecReturnData=true, ohne
kSecAttrService. (Ein gesetztes kSecAttrService würde nicht matchen.)
Konkrete Einträge (Account-Namen)
Account (kSecAttrAccount) |
Inhalt | Geschrieben von | Genutzt von |
|---|---|---|---|
push_device_private_key_pem |
Device-Private-Key, PEM | push_keypair.dart |
NSE (entschlüsseln) |
push_device_public_key_pem |
Device-Public-Key, SPKI-PEM | push_keypair.dart |
— (nur Registrierung) |
push_server_public_key_pem |
Server-Public-Key (per User), SPKI-PEM | push_registration_store.dart |
NSE (Signatur prüfen) |
push_device_identifier |
NC Device-Identifier | push_registration_store.dart |
Dart |
push_registered_fcm_token |
FCM-Token der Registrierung | push_registration_store.dart |
Dart |
nextcloud_app_password |
NC App-Password | account_data.dart |
AppDelegate (Basic-Auth) |
nextcloud_username |
NC Username | push_registration_store.dart (neu) |
AppDelegate (Basic-Auth) |
nextcloud_base_url |
z.B. https://cloud.marianum-fulda.de |
push_registration_store.dart (neu) |
AppDelegate (OCS-URL) |
usernameundpassword(Realpasswort) liegen in AccountDatas Default-Storage ohnegroupId→ nur im primären Access-Group der App, für die NSE unsichtbar. Deshalb werdennextcloud_username+nextcloud_base_urlbei jederregister()-Runde zusätzlich group-scoped geschrieben. Das App-Password lag schon immer group-scoped.
Schlüsselformate (kritisch für das PEM-Parsing)
crypton (2.2.1) erzeugt die Keys:
- Private Key: Label
-----BEGIN RSA PRIVATE KEY-----, der DER-Body ist aber PKCS#8PrivateKeyInfo(SEQ{ INTEGER version, SEQ algId, OCTET STRING pkcs1 }) — nicht rohes PKCS#1!SecKeyCreateWithDatawill bei RSA aber rohes PKCS#1.PEM.pkcs1PrivateKey(fromPkcs8:)strippt den PKCS#8-Wrapper (extrahiert den OCTET-STRING-Inhalt). - Public Key (Device und der von NC gelieferte Server-Key): Label
-----BEGIN PUBLIC KEY-----, DER = SPKISubjectPublicKeyInfo.PEM.pkcs1PublicKey(fromSpki:)extrahiert den BIT-STRING-Inhalt und wirft das führende0x00-Byte (unused bits) weg → PKCS#1RSAPublicKey.
5. Verifikation auf dem Mac
5.1 Build
flutter clean && flutter pub getcd ios && pod install- In Xcode: beide Targets bauen. NSE-Target-Scheme separat bauen, um Swift- Fehler früh zu sehen.
- Auf echtem Gerät installieren (Push funktioniert nicht im Simulator).
5.2 aps-environment im Archive prüfen
- Product → Archive → Distribute (oder
.ipaexportieren) →.ipaentpacken →codesign -d --entitlements :- Payload/Runner.app→ mussaps-environment = productionzeigen.
5.3 Testbenachrichtigung (schneller Smoke-Test, ohne NC-Krypto)
- In den App-Einstellungen → Benachrichtigungen → Testbenachrichtigung
(Backend
POST me/push-device/test). Erwartung: Alert „Testbenachrichtigung / Push-Benachrichtigungen funktionieren! 🎉". Testet Registrierung + FCM + Rendering — die NSE ist hier nicht beteiligt (Connect-Push, keinmutable-content).
5.4 Echte Talk-Nachricht (End-to-End inkl. NSE)
- Gerät sperren. Von einem Zweitaccount eine Talk-Nachricht schicken.
- Erwartung: Banner mit echtem Absender + Nachrichtentext (nicht der Platzhalter „Neue Benachrichtigung / Tippen zum Öffnen"). Long-press → Actions „Antworten" + „Als gelesen markieren".
- „Antworten" → Text senden → Nachricht erscheint im Chat (nativ via URLSession), Chat wird als gelesen markiert.
- Alternativ auf der NC-Instanz:
occ notification:test-push --talk <user> -v.
5.5 Woran erkenne ich, ob die NSE lief?
- NSE lief & OK: echter Absender/Text im Banner.
- NSE lief nicht / Fehler: Platzhalter-Text bleibt stehen. Das ist der bewusste Fallback (nie leer, nie Crash).
- Logs: Über die macOS-Console.app das Gerät wählen und nach
[NSE]bzw.[Talk action]filtern (NSLog-Ausgaben). Oder in Xcode: Debug → Attach to Process →NotificationServiceExtension.
6. Bekannte Fehlerbilder + Fixes
| Symptom | Wahrscheinliche Ursache | Fix |
|---|---|---|
| NSE greift gar nicht (immer Platzhalter, auch bei Talk) | mutable-content fehlt / Signing des NSE-Targets kaputt / Bundle-ID falsch |
Backend-FcmMessageFactory setzt mutable-content:1 (prüfen). NSE-Target-Signing + Provisioning prüfen. Bundle-ID = …client.NotificationServiceExtension. |
| Banner zeigt Platzhalter trotz Talk | PEM-Parsing/Padding schlägt fehl | Console.app nach [NSE] SecKeyCreateWithData failed / could not decrypt. Prüfen, dass Private-Key wirklich als PKCS#8-in-„RSA PRIVATE KEY" ankommt (crypton). OAEP↔PKCS1-Fallback ist schon drin. |
[NSE] no device private key in keychain |
Keychain-Group greift nicht | In beiden Targets Keychain Sharing + App Group aktiv? Team-ID identisch? Access-Group-String exakt group.eu.mhsl.marianum.mobile.client.widget? |
| Signatur schlägt immer fehl | Falscher/kein Server-Public-Key | push_server_public_key_pem vorhanden? (wird bei register() gesetzt). Notfalls: ohne Server-Key überspringt die NSE die Prüfung — bleibt sie hängen, ist der Key da aber falsch geparst. |
| Reply-Action tut nichts | Credentials fehlen / Delegate-Konflikt (s.u.) | Console.app nach [Talk action]. nextcloud_username/_app_password/_base_url im Keychain? App einmal neu einloggen (schreibt sie via register()). |
| Reply wird doppelt gesendet | AppDelegate und ein Plugin behandeln dieselbe Action | Der AppDelegate returnt bei TALK_REPLY/TALK_MARK_READ ohne super-Aufruf, das Plugin sieht die Action also nicht. Falls doch doppelt: prüfen, ob flutter_local_notifications auf iOS separat als Delegate registriert ist. |
⚠️ Delegate-Ownership (wichtigster Verifikationspunkt)
FlutterAppDelegate konformiert (über FlutterAppLifeCycleProvider) zu
UNUserNotificationCenterDelegate und leitet userNotificationCenter(...) an
die registrierten Plugins weiter (firebase_messaging,
flutter_local_notifications). Der AppDelegate setzt sich in
didFinishLaunching als UNUserNotificationCenter.current().delegate = self,
überschreibt die beiden Delegate-Methoden und ruft für alles außer den
zwei Talk-Actions super auf — dadurch bleibt das Plugin-Forwarding intakt. Das
ist der fragilste Teil und muss auf dem Gerät verifiziert werden:
- Normale Push-Taps müssen weiterhin ins richtige Chat/Ziel navigieren
(läuft über
super→ Plugins). - Foreground-Darstellung von FCM darf nicht kaputtgehen (
willPresentwird hier bewusst nicht überschrieben, läuft komplett übersuper/Plugins). - Sollte ein Plugin den Delegate nach
didFinishLaunchingerneut übernehmen (und damit unsere Talk-Actions abfangen), das Setzen vonUNUserNotificationCenter.current().delegate = selfan einen späteren Punkt verschieben (z. B. nach dem ersten Dart-Frame). Ohne Gerät nicht abschließend testbar.
Compile-Hinweis:
overridean den beidenuserNotificationCenter-Methoden ist korrekt, weil die Methoden über das vonFlutterAppDelegateadoptierte ProtokollFlutterAppLifeCycleProvider : UNUserNotificationCenterDelegatesichtbar sind (verifiziert im Flutter-3.44-Engine-Header).super.userNotification…ist daher aufrufbar.
7. Offene TODOs / bewusste Einschränkungen
- Delete-Handling auf iOS (best effort). Delete-Pushes kommen als silent
background-Pushes (content-available, keinmutable-content) → die NSE läuft dafür nicht. Das Wegräumen erledigt der Flutter-Background-Handler (PushMessageHandler) bzw. ein Sweep beim App-Öffnen. Erreicht doch mal ein Delete-Subject die NSE, kann sie die Notification nicht unterdrücken (eine NSE muss immer irgendeinen Content liefern) — sie lässt den Platzhalter stehen und loggt. Kein Fix möglich, dokumentierte iOS-Limitierung. - Rich-Detail via OCS (spätere Erweiterung). v1 lädt nichts nach. Optional
könnte die NSE bei Talk
GET …/ocs/v2.php/apps/notifications/api/v2/notifications/{nid}mit dem App-Password abrufen, um Avatar/Rich-Subject anzuzeigen (Timeout < 25 s, innerhalb des NSE-Budgets). Nicht implementiert. aps-environment = productionist noch nicht hart gesetzt (Abschnitt 3.6) — vor dem Release erledigen und im Archive gegenchecken (5.2).- Keychain-Access-Group-Schreibweise. Die Entitlements listen die App-Group
ohne
$(AppIdentifierPrefix)alskeychain-access-groups. Das ist das vonflutter_secure_storageerwartete Verhalten (Access-Group == App-Group-ID). Sollte der Keychain-Zugriff wider Erwarten scheitern (Status-34018/errSecMissingEntitlement), in beiden Targets die Keychain-Sharing- Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.