@mmerterden/multi-agent-pipeline 20.7.0 → 20.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +0 -10
- package/docs/facts.json +1 -1
- package/manifest.json +266 -267
- package/package.json +2 -2
- package/pipeline/scripts/_notices.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +68 -68
- package/pipeline/skills/shared/README.md +70 -70
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
- package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
- package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
- package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
- package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
- package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
- package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
- package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
- package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
- package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +339 -277
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
- package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +105 -122
- package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +143 -166
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +307 -326
- package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
- package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +333 -360
- package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
- package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
- package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
- package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
- package/pipeline/skills/shared/external/authentication/SKILL.md +265 -381
- package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
- package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +133 -178
- package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
- package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
- package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
- package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
- package/pipeline/skills/shared/external/background-processing/SKILL.md +270 -382
- package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
- package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +169 -317
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
- package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
- package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
- package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
- package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
- package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
- package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
- package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +226 -376
- package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
- package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
- package/pipeline/skills/shared/external/core-data/SKILL.md +292 -368
- package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
- package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
- package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
- package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
- package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
- package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
- package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
- package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
- package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
- package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
- package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
- package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
- package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
- package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
- package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
- package/pipeline/skills/shared/external/device-integrity/SKILL.md +230 -353
- package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
- package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
- package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
- package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
- package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
- package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
- package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
- package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
- package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
- package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
- package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
- package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
- package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
- package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
- package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
- package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
- package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
- package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
- package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
- package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
- package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
- package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
- package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
- package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
- package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
- package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
- package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
- package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
- package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
- package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
- package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
- package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
- package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +295 -267
- package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
- package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
- package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
- package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
- package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
- package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
- package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
- package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
- package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
- package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
- package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
- package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
- package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
- package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
- package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
- package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
- package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
- package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
- package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
- package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
- package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
- package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
- package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
- package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
- package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
- package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
- package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
- package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
- package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
- package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
- package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
- package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +298 -242
- package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
- package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
- package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
- package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
- package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
- package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
- package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
- package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
- package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +303 -351
- package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
- package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
- package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
- package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
- package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
- package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
- package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
- package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
- package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
- package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
- package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
- package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
- package/pipeline/skills/shared/external/swift-security/SKILL.md +180 -161
- package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
- package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
- package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +408 -476
- package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
- package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
- package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
- package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
- package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
- package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
- package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +352 -472
- package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
- package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
- package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
- package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +396 -457
- package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +188 -175
- package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
- package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +80 -84
- package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +392 -256
- package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
- package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
- package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
- package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
- package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
- package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
- package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
- package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
- package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
- package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
- package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
- package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
- package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
- package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
- package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
- package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
- package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
- package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
- package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
- package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
- package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
- package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
- package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
- package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +193 -168
- package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
- package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +132 -133
- package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
- package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +106 -140
- package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
- package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
- package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
- package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
- package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
- package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
- package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
- package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
- package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
- package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
- package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
- package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
- package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
- package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
- package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
- package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
- package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
- package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
- package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
- package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
- package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
- package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
- package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
- package/pipeline/skills/shared/external/weatherkit/SKILL.md +152 -310
- package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +216 -288
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +414 -719
- package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
|
@@ -1,262 +1,236 @@
|
|
|
1
1
|
# Rich Notifications
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Two app extensions shape how a notification looks:
|
|
4
|
+
|
|
5
|
+
- **Notification Service Extension**: edits the content of a remote
|
|
6
|
+
notification before the user sees it.
|
|
7
|
+
- **Notification Content Extension**: draws a custom interface when the user
|
|
8
|
+
expands the notification.
|
|
4
9
|
|
|
5
10
|
## Contents
|
|
6
11
|
|
|
7
|
-
- [
|
|
8
|
-
- [
|
|
9
|
-
- [
|
|
12
|
+
- [Service Extension](#service-extension)
|
|
13
|
+
- [Attachments](#attachments)
|
|
14
|
+
- [Content Extension](#content-extension)
|
|
10
15
|
- [Communication Notifications](#communication-notifications)
|
|
11
|
-
- [
|
|
12
|
-
- [Complete Service Extension
|
|
16
|
+
- [Troubleshooting Extensions](#troubleshooting-extensions)
|
|
17
|
+
- [A Complete Service Extension](#a-complete-service-extension)
|
|
18
|
+
|
|
19
|
+
## Service Extension
|
|
20
|
+
|
|
21
|
+
The extension is launched only for a remote notification that alerts the user
|
|
22
|
+
(its `alert` has a title, subtitle or body) and carries `mutable-content: 1`.
|
|
23
|
+
It then has about 30 seconds.
|
|
13
24
|
|
|
14
|
-
|
|
25
|
+
When a design gets this wrong, correct these four points:
|
|
26
|
+
1. A silent push never launches the extension.
|
|
27
|
+
2. Attachments are supported files on disk; the system validates them and
|
|
28
|
+
moves them into its own storage.
|
|
29
|
+
3. A communication notification needs the capability, an
|
|
30
|
+
`NSUserActivityTypes` entry, a donated `INInteraction`, and a call to
|
|
31
|
+
`content.updating(from:)`.
|
|
32
|
+
4. The content handler runs exactly once on every path: success, partial
|
|
33
|
+
failure, bad payload and `serviceExtensionTimeWillExpire()`.
|
|
15
34
|
|
|
16
|
-
|
|
35
|
+
If the handler is never called in time, the user sees the original,
|
|
36
|
+
unmodified notification.
|
|
17
37
|
|
|
18
|
-
|
|
38
|
+
### Creating it
|
|
19
39
|
|
|
20
|
-
|
|
40
|
+
Add a target in Xcode (File > New > Target) and pick the Notification Service
|
|
41
|
+
Extension template. It adds a `NotificationService` class that subclasses
|
|
42
|
+
`UNNotificationServiceExtension`.
|
|
21
43
|
|
|
22
|
-
|
|
44
|
+
The template's shape has a trap: the async work and the timeout callback can
|
|
45
|
+
both try to deliver. Guard delivery so only the first caller wins:
|
|
23
46
|
|
|
24
47
|
```swift
|
|
25
48
|
import UserNotifications
|
|
26
49
|
|
|
27
|
-
class NotificationService: UNNotificationServiceExtension {
|
|
28
|
-
private
|
|
29
|
-
private var
|
|
30
|
-
private var
|
|
50
|
+
final class NotificationService: UNNotificationServiceExtension, @unchecked Sendable {
|
|
51
|
+
private let lock = NSLock()
|
|
52
|
+
private var handler: ((UNNotificationContent) -> Void)?
|
|
53
|
+
private var draft: UNMutableNotificationContent?
|
|
31
54
|
|
|
32
55
|
override func didReceive(
|
|
33
56
|
_ request: UNNotificationRequest,
|
|
34
57
|
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
|
35
58
|
) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
guard let content = bestAttemptContent else {
|
|
40
|
-
contentHandler(request.content)
|
|
59
|
+
let original = request.content
|
|
60
|
+
guard let copy = original.mutableCopy() as? UNMutableNotificationContent else {
|
|
61
|
+
contentHandler(original)
|
|
41
62
|
return
|
|
42
63
|
}
|
|
64
|
+
lock.withLock {
|
|
65
|
+
handler = contentHandler
|
|
66
|
+
draft = copy
|
|
67
|
+
}
|
|
68
|
+
let headline = request.content.userInfo["headline"] as? String
|
|
43
69
|
|
|
44
|
-
// Modify the notification content here
|
|
45
70
|
Task {
|
|
46
|
-
await
|
|
47
|
-
|
|
71
|
+
let cleaned = await HeadlineFormatter.clean(headline)
|
|
72
|
+
self.deliver { content in
|
|
73
|
+
if let cleaned { content.title = cleaned }
|
|
74
|
+
}
|
|
48
75
|
}
|
|
49
76
|
}
|
|
50
77
|
|
|
51
|
-
/// Called if the extension is about to be terminated (ran out of time).
|
|
52
|
-
/// Deliver the best attempt -- even a partially modified notification
|
|
53
|
-
/// is better than nothing.
|
|
54
78
|
override func serviceExtensionTimeWillExpire() {
|
|
55
|
-
|
|
56
|
-
handler(content)
|
|
57
|
-
}
|
|
79
|
+
deliver() // hand over whatever the draft holds now
|
|
58
80
|
}
|
|
59
81
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if let encrypted = content.userInfo["encryptedBody"] as? String {
|
|
68
|
-
content.body = decrypt(encrypted)
|
|
82
|
+
/// Calls the content handler at most once, whichever path gets here first.
|
|
83
|
+
private func deliver(_ edit: (UNMutableNotificationContent) -> Void = { _ in }) {
|
|
84
|
+
let pending: (((UNNotificationContent) -> Void), UNMutableNotificationContent)? = lock.withLock {
|
|
85
|
+
guard let handler, let draft else { return nil }
|
|
86
|
+
self.handler = nil
|
|
87
|
+
self.draft = nil
|
|
88
|
+
return (handler, draft)
|
|
69
89
|
}
|
|
90
|
+
guard let (deliverTo, content) = pending else { return }
|
|
91
|
+
edit(content)
|
|
92
|
+
deliverTo(content)
|
|
70
93
|
}
|
|
71
94
|
}
|
|
72
95
|
```
|
|
73
96
|
|
|
74
|
-
### Downloading
|
|
75
|
-
|
|
76
|
-
Download media from a URL and attach it to the notification. The attachment must be written to disk in the extension's temporary directory.
|
|
97
|
+
### Downloading media
|
|
77
98
|
|
|
78
99
|
```swift
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
enum MediaFetcher {
|
|
101
|
+
static func attachment(from remote: URL) async -> UNNotificationAttachment? {
|
|
102
|
+
let session = URLSession.shared
|
|
103
|
+
guard let (bytes, reply) = try? await session.data(from: remote) else {
|
|
104
|
+
return nil // show the notification without media
|
|
105
|
+
}
|
|
106
|
+
let suffix = fileExtension(for: reply.mimeType)
|
|
107
|
+
let local = FileManager.default.temporaryDirectory
|
|
108
|
+
.appendingPathComponent(UUID().uuidString)
|
|
109
|
+
.appendingPathExtension(suffix)
|
|
86
110
|
do {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// Determine file extension from MIME type
|
|
90
|
-
let ext = fileExtension(for: response.mimeType)
|
|
91
|
-
let tempDir = FileManager.default.temporaryDirectory
|
|
92
|
-
let fileURL = tempDir.appendingPathComponent(UUID().uuidString + ext)
|
|
93
|
-
|
|
94
|
-
try data.write(to: fileURL)
|
|
95
|
-
|
|
96
|
-
let attachment = try UNNotificationAttachment(
|
|
97
|
-
identifier: "image",
|
|
98
|
-
url: fileURL,
|
|
99
|
-
options: nil
|
|
100
|
-
)
|
|
101
|
-
content.attachments = [attachment]
|
|
111
|
+
try bytes.write(to: local)
|
|
112
|
+
return try UNNotificationAttachment(identifier: "media", url: local, options: nil)
|
|
102
113
|
} catch {
|
|
103
|
-
|
|
104
|
-
// Notification displays without the image -- graceful degradation
|
|
114
|
+
return nil
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
|
|
108
|
-
|
|
109
|
-
switch
|
|
110
|
-
case "image/jpeg":
|
|
111
|
-
case "image/png":
|
|
112
|
-
case "image/gif":
|
|
113
|
-
case "video/mp4", "video/mpeg4":
|
|
114
|
-
case "audio/mpeg", "audio/mp3":
|
|
115
|
-
case "audio/wav":
|
|
116
|
-
default:
|
|
118
|
+
static func fileExtension(for mime: String?) -> String {
|
|
119
|
+
switch mime {
|
|
120
|
+
case "image/jpeg": "jpg"
|
|
121
|
+
case "image/png": "png"
|
|
122
|
+
case "image/gif": "gif"
|
|
123
|
+
case "video/mp4", "video/mpeg4": "mp4"
|
|
124
|
+
case "audio/mpeg", "audio/mp3": "mp3"
|
|
125
|
+
case "audio/wav", "audio/x-wav": "wav"
|
|
126
|
+
default: "jpg"
|
|
117
127
|
}
|
|
118
128
|
}
|
|
119
129
|
}
|
|
120
130
|
```
|
|
121
131
|
|
|
122
|
-
|
|
132
|
+
A failed download is not an error to the user: deliver the text without media.
|
|
133
|
+
|
|
134
|
+
### Decrypting the payload
|
|
123
135
|
|
|
124
|
-
|
|
136
|
+
For end-to-end encrypted messages the server sends a base64 ciphertext and the
|
|
137
|
+
extension decrypts it before display. If decoding or decryption fails, show
|
|
138
|
+
neutral text such as "New message" rather than nothing.
|
|
125
139
|
|
|
126
140
|
```swift
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// by the app and extension, not in UserDefaults or the APNs payload.
|
|
135
|
-
do {
|
|
136
|
-
let decrypted = try EncryptionService.shared.decrypt(data)
|
|
137
|
-
return String(data: decrypted, encoding: .utf8) ?? "New notification"
|
|
138
|
-
} catch {
|
|
139
|
-
return "New notification"
|
|
140
|
-
}
|
|
141
|
-
}
|
|
141
|
+
if let sealed = info["sealed"] as? String,
|
|
142
|
+
let bytes = Data(base64Encoded: sealed),
|
|
143
|
+
let key = try? SharedKeychain.messageKey(),
|
|
144
|
+
let plain = try? MessageCrypto.open(bytes, using: key) {
|
|
145
|
+
content.body = plain
|
|
146
|
+
} else {
|
|
147
|
+
content.body = "New message"
|
|
142
148
|
}
|
|
143
149
|
```
|
|
144
150
|
|
|
145
|
-
|
|
151
|
+
The key belongs in a Keychain access group that the app and the extension both
|
|
152
|
+
use. Never put it in `UserDefaults`, and never in the push payload.
|
|
146
153
|
|
|
147
|
-
|
|
154
|
+
### Sharing data with the app
|
|
148
155
|
|
|
149
|
-
|
|
150
|
-
2. Use the same group identifier (e.g., `group.com.example.myapp`).
|
|
151
|
-
3. Enable "Keychain Sharing" on both targets for shared keychain items. The `kSecAttrAccessGroup` value must be one of the target's keychain access groups, not the App Group container identifier.
|
|
156
|
+
The extension is a separate process.
|
|
152
157
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// Shared Keychain: set kSecAttrAccessGroup to a Keychain Sharing access group
|
|
163
|
-
// that both targets include in their entitlements.
|
|
164
|
-
```
|
|
158
|
+
- **App Groups**: turn on the capability for both targets with the same
|
|
159
|
+
identifier (for example `group.com.example.garden`). Use
|
|
160
|
+
`UserDefaults(suiteName:)` for small values and
|
|
161
|
+
`FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)` for
|
|
162
|
+
files.
|
|
163
|
+
- **Keychain Sharing**: turn it on for both targets. The
|
|
164
|
+
`kSecAttrAccessGroup` value has to appear in the target's list of keychain
|
|
165
|
+
access groups; the App Group identifier does not work there.
|
|
165
166
|
|
|
166
|
-
|
|
167
|
+
Service extensions get little memory. Keep dependencies small; if the system
|
|
168
|
+
kills the extension, the original notification is shown.
|
|
167
169
|
|
|
168
|
-
##
|
|
170
|
+
## Attachments
|
|
169
171
|
|
|
170
|
-
`UNNotificationAttachment`
|
|
172
|
+
`UNNotificationAttachment` handles pictures (GIFs included), sound and movies. The collapsed
|
|
173
|
+
notification shows a thumbnail; the expanded one shows the media.
|
|
171
174
|
|
|
172
|
-
|
|
175
|
+
| Kind | Size limit | Formats |
|
|
176
|
+
|------|------------|---------|
|
|
177
|
+
| Image | 10 MB | JPEG, GIF, PNG |
|
|
178
|
+
| Audio | 5 MB | WAV, AIFF, M4A, MP3 |
|
|
179
|
+
| Video | 50 MB | MPEG-4, MPEG-2, MPEG, AVI |
|
|
173
180
|
|
|
174
|
-
|
|
175
|
-
|------|---------|----------|
|
|
176
|
-
| Image | JPEG, PNG, GIF | 10 MB |
|
|
177
|
-
| Audio | AIFF, WAV, MP3, M4A | 5 MB |
|
|
178
|
-
| Video | MPEG, MPEG-2, MPEG-4, AVI | 50 MB |
|
|
179
|
-
|
|
180
|
-
### Creating Attachments
|
|
181
|
+
Thumbnail options:
|
|
181
182
|
|
|
182
183
|
```swift
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
let attachment = try UNNotificationAttachment(
|
|
192
|
-
identifier: "photo",
|
|
193
|
-
url: localFileURL,
|
|
194
|
-
options: [
|
|
195
|
-
UNNotificationAttachmentOptionsThumbnailClippingRectKey:
|
|
196
|
-
CGRect(x: 0, y: 0, width: 1, height: 0.5).dictionaryRepresentation,
|
|
197
|
-
UNNotificationAttachmentOptionsThumbnailTimeKey: 0 // for video: thumbnail at 0 seconds
|
|
198
|
-
]
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
// Attach to content
|
|
202
|
-
content.attachments = [attachment]
|
|
184
|
+
let options: [AnyHashable: Any] = [
|
|
185
|
+
// Normalized rect: show the centre half of the image as the thumbnail.
|
|
186
|
+
UNNotificationAttachmentOptionsThumbnailClippingRectKey:
|
|
187
|
+
CGRect(origin: CGPoint(x: 0.25, y: 0.25), size: CGSize(width: 0.5, height: 0.5))
|
|
188
|
+
.dictionaryRepresentation,
|
|
189
|
+
// For video: the frame at 3 seconds.
|
|
190
|
+
UNNotificationAttachmentOptionsThumbnailTimeKey: 3
|
|
191
|
+
]
|
|
203
192
|
```
|
|
204
193
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
```
|
|
194
|
+
Rules:
|
|
195
|
+
- The URL points to a supported file on disk. A remote URL or an unsupported
|
|
196
|
+
type fails.
|
|
197
|
+
- For a local notification, the file must be readable when you schedule it.
|
|
198
|
+
- The system validates the file and moves it into its own data store. Files
|
|
199
|
+
inside the app bundle are copied instead.
|
|
200
|
+
- Several attachments are allowed; the first one is the thumbnail.
|
|
201
|
+
- GIFs can animate in the expanded view; check the real presentation on a
|
|
202
|
+
device.
|
|
215
203
|
|
|
216
|
-
|
|
204
|
+
## Content Extension
|
|
217
205
|
|
|
218
|
-
|
|
206
|
+
A content extension supplies a view controller that appears when the user
|
|
207
|
+
long-presses or expands the notification. Use it when an attachment is not
|
|
208
|
+
enough.
|
|
219
209
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
let attachment = try UNNotificationAttachment(
|
|
224
|
-
identifier: "animation",
|
|
225
|
-
url: gifURL,
|
|
226
|
-
options: nil
|
|
227
|
-
)
|
|
228
|
-
```
|
|
210
|
+
Add it the same way (File > New > Target), choosing the Notification Content
|
|
211
|
+
Extension template; Xcode adds
|
|
212
|
+
a storyboard and a `NotificationViewController`.
|
|
229
213
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
A Notification Content Extension provides a custom view controller that displays when the user long-presses (or expands) a notification. Use it for richer UI than attachments alone can provide.
|
|
233
|
-
|
|
234
|
-
### Creating the Extension
|
|
235
|
-
|
|
236
|
-
In Xcode: File > New > Target > Notification Content Extension. This creates a new target with a storyboard and a `NotificationViewController`.
|
|
237
|
-
|
|
238
|
-
### Configuration (Info.plist)
|
|
239
|
-
|
|
240
|
-
The extension's `Info.plist` must declare which notification categories it handles:
|
|
214
|
+
### Info.plist
|
|
241
215
|
|
|
242
216
|
```xml
|
|
243
217
|
<key>NSExtension</key>
|
|
244
218
|
<dict>
|
|
245
219
|
<key>NSExtensionAttributes</key>
|
|
246
220
|
<dict>
|
|
221
|
+
<!-- One category as a string, or several as an array -->
|
|
247
222
|
<key>UNNotificationExtensionCategory</key>
|
|
248
|
-
<!-- Use a string for one category, or an array for multiple. -->
|
|
249
223
|
<array>
|
|
250
|
-
<string>
|
|
251
|
-
<string>
|
|
224
|
+
<string>ORDER_UPDATE</string>
|
|
225
|
+
<string>DELIVERY_PHOTO</string>
|
|
252
226
|
</array>
|
|
253
|
-
<!--
|
|
227
|
+
<!-- Height as a fraction of width; 1.0 when omitted -->
|
|
254
228
|
<key>UNNotificationExtensionInitialContentSizeRatio</key>
|
|
255
|
-
<real>0.
|
|
256
|
-
<!--
|
|
229
|
+
<real>0.6</real>
|
|
230
|
+
<!-- Hide the standard title/body under the custom view -->
|
|
257
231
|
<key>UNNotificationExtensionDefaultContentHidden</key>
|
|
258
232
|
<true/>
|
|
259
|
-
<!--
|
|
233
|
+
<!-- Let the custom view receive touches -->
|
|
260
234
|
<key>UNNotificationExtensionUserInteractionEnabled</key>
|
|
261
235
|
<true/>
|
|
262
236
|
</dict>
|
|
@@ -267,498 +241,291 @@ The extension's `Info.plist` must declare which notification categories it handl
|
|
|
267
241
|
</dict>
|
|
268
242
|
```
|
|
269
243
|
|
|
270
|
-
###
|
|
244
|
+
### The view controller
|
|
271
245
|
|
|
272
246
|
```swift
|
|
273
247
|
import UIKit
|
|
274
248
|
import UserNotifications
|
|
275
249
|
import UserNotificationsUI
|
|
276
250
|
|
|
277
|
-
class NotificationViewController: UIViewController, UNNotificationContentExtension {
|
|
278
|
-
@IBOutlet
|
|
279
|
-
@IBOutlet
|
|
280
|
-
@IBOutlet weak var imageView: UIImageView!
|
|
251
|
+
final class NotificationViewController: UIViewController, @preconcurrency UNNotificationContentExtension {
|
|
252
|
+
@IBOutlet private var statusLabel: UILabel!
|
|
253
|
+
@IBOutlet private var photoView: UIImageView!
|
|
281
254
|
|
|
282
255
|
func didReceive(_ notification: UNNotification) {
|
|
283
256
|
let content = notification.request.content
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
attachment.url.startAccessingSecurityScopedResource() {
|
|
290
|
-
defer { attachment.url.stopAccessingSecurityScopedResource() }
|
|
291
|
-
if let data = try? Data(contentsOf: attachment.url) {
|
|
292
|
-
imageView.image = UIImage(data: data)
|
|
293
|
-
}
|
|
257
|
+
statusLabel.text = content.body
|
|
258
|
+
|
|
259
|
+
if let file = content.attachments.first?.url, file.startAccessingSecurityScopedResource() {
|
|
260
|
+
defer { file.stopAccessingSecurityScopedResource() }
|
|
261
|
+
photoView.image = UIImage(contentsOfFile: file.path)
|
|
294
262
|
}
|
|
295
263
|
}
|
|
296
264
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
func didReceive(
|
|
300
|
-
_ response: UNNotificationResponse,
|
|
301
|
-
completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void
|
|
302
|
-
) {
|
|
265
|
+
func didReceive(_ response: UNNotificationResponse,
|
|
266
|
+
completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void) {
|
|
303
267
|
switch response.actionIdentifier {
|
|
304
|
-
case "
|
|
305
|
-
|
|
306
|
-
animateLikeConfirmation()
|
|
307
|
-
// Dismiss after a short delay
|
|
268
|
+
case "CONFIRM_DELIVERY":
|
|
269
|
+
statusLabel.text = "Thanks, noted."
|
|
308
270
|
Task {
|
|
309
|
-
try? await Task.sleep(for: .
|
|
271
|
+
try? await Task.sleep(for: .milliseconds(900))
|
|
310
272
|
completion(.dismiss)
|
|
311
273
|
}
|
|
312
|
-
|
|
313
|
-
case "REPLY_ACTION":
|
|
314
|
-
if let textResponse = response as? UNTextInputNotificationResponse {
|
|
315
|
-
// Handle reply inline without opening the app
|
|
316
|
-
showReplySentConfirmation(text: textResponse.userText)
|
|
317
|
-
Task {
|
|
318
|
-
try? await Task.sleep(for: .seconds(1.0))
|
|
319
|
-
completion(.dismiss)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
274
|
default:
|
|
324
|
-
// Forward to the app's notification delegate
|
|
325
275
|
completion(.dismissAndForwardAction)
|
|
326
276
|
}
|
|
327
277
|
}
|
|
328
278
|
}
|
|
329
279
|
```
|
|
330
280
|
|
|
331
|
-
|
|
281
|
+
The conformance is marked `@preconcurrency` because the protocol has no
|
|
282
|
+
concurrency annotations while the view controller is main-actor isolated; the
|
|
283
|
+
system drives a content extension on the main thread.
|
|
332
284
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
| `.dismiss` | Dismiss the notification. The action is handled entirely in the extension. |
|
|
337
|
-
| `.dismissAndForwardAction` | Dismiss and forward the action to `UNUserNotificationCenterDelegate.didReceive`. Use when the app needs to handle the action. |
|
|
285
|
+
Attachment files are security scoped inside the extension: wrap access in
|
|
286
|
+
`startAccessingSecurityScopedResource()` and
|
|
287
|
+
`stopAccessingSecurityScopedResource()`.
|
|
338
288
|
|
|
339
|
-
###
|
|
289
|
+
### Response options
|
|
340
290
|
|
|
341
|
-
|
|
291
|
+
| Option | Result |
|
|
292
|
+
|--------|--------|
|
|
293
|
+
| `.doNotDismiss` | Stays on screen; for multi-step interactions |
|
|
294
|
+
| `.dismiss` | Closes; the extension handled the action |
|
|
295
|
+
| `.dismissAndForwardAction` | Closes and passes the action to the app's `didReceive` |
|
|
342
296
|
|
|
343
|
-
|
|
344
|
-
class MediaNotificationViewController: UIViewController, UNNotificationContentExtension {
|
|
345
|
-
var player: AVPlayer?
|
|
297
|
+
### Media playback
|
|
346
298
|
|
|
347
|
-
|
|
348
|
-
return .overlay // .none, .default, .overlay
|
|
349
|
-
}
|
|
299
|
+
For audio or video, ask the system for its play/pause button and respond to it.
|
|
350
300
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
301
|
+
```swift
|
|
302
|
+
import AVFoundation
|
|
354
303
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
304
|
+
final class ClipViewController: UIViewController, @preconcurrency UNNotificationContentExtension {
|
|
305
|
+
private var player: AVPlayer?
|
|
358
306
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
307
|
+
var mediaPlayPauseButtonType: UNNotificationContentExtensionMediaPlayPauseButtonType { .overlay }
|
|
308
|
+
var mediaPlayPauseButtonFrame: CGRect { CGRect(x: 16, y: 16, width: 44, height: 44) }
|
|
362
309
|
|
|
363
310
|
func didReceive(_ notification: UNNotification) {
|
|
364
|
-
guard let
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
let playerLayer = AVPlayerLayer(player: player)
|
|
371
|
-
playerLayer.frame = view.bounds
|
|
372
|
-
view.layer.addSublayer(playerLayer)
|
|
311
|
+
guard let url = notification.request.content.attachments.first?.url else { return }
|
|
312
|
+
let player = AVPlayer(url: url)
|
|
313
|
+
let layer = AVPlayerLayer(player: player)
|
|
314
|
+
layer.frame = view.bounds
|
|
315
|
+
view.layer.addSublayer(layer)
|
|
316
|
+
self.player = player
|
|
373
317
|
}
|
|
318
|
+
|
|
319
|
+
func mediaPlay() { player?.play() }
|
|
320
|
+
func mediaPause() { player?.pause() }
|
|
374
321
|
}
|
|
375
322
|
```
|
|
376
323
|
|
|
377
|
-
|
|
324
|
+
`mediaPlayPauseButtonType` can be `.none`, `.default` or `.overlay`.
|
|
378
325
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
```swift
|
|
382
|
-
class InteractiveNotificationViewController: UIViewController, UNNotificationContentExtension {
|
|
383
|
-
private var ratingStars: [UIButton] = []
|
|
384
|
-
private var selectedRating = 0
|
|
326
|
+
### Interactive views
|
|
385
327
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
setupRatingUI()
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
func didReceive(_ notification: UNNotification) {
|
|
392
|
-
// Configure with notification content
|
|
393
|
-
}
|
|
328
|
+
With `UNNotificationExtensionUserInteractionEnabled` set, buttons and gesture
|
|
329
|
+
recognizers in the custom view work like anywhere else in UIKit:
|
|
394
330
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
for i in 1...5 {
|
|
403
|
-
let button = UIButton(type: .system)
|
|
404
|
-
button.setImage(UIImage(systemName: "star"), for: .normal)
|
|
405
|
-
button.tag = i
|
|
406
|
-
button.addTarget(self, action: #selector(starTapped(_:)), for: .touchUpInside)
|
|
407
|
-
stack.addArrangedSubview(button)
|
|
408
|
-
ratingStars.append(button)
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
NSLayoutConstraint.activate([
|
|
412
|
-
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
413
|
-
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
414
|
-
])
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
@objc private func starTapped(_ sender: UIButton) {
|
|
418
|
-
selectedRating = sender.tag
|
|
419
|
-
for (index, button) in ratingStars.enumerated() {
|
|
420
|
-
let imageName = index < selectedRating ? "star.fill" : "star"
|
|
421
|
-
button.setImage(UIImage(systemName: imageName), for: .normal)
|
|
422
|
-
}
|
|
423
|
-
// Send rating to server
|
|
424
|
-
Task {
|
|
425
|
-
await submitRating(selectedRating)
|
|
426
|
-
}
|
|
331
|
+
```swift
|
|
332
|
+
private func makeReactionBar(for orderID: String) -> UIStackView {
|
|
333
|
+
let reactions = ["Great", "Okay", "Late"].map { title in
|
|
334
|
+
UIButton(configuration: .tinted(), primaryAction: UIAction(title: title) { _ in
|
|
335
|
+
Task { await FeedbackAPI.submit(orderID: orderID, reaction: title) }
|
|
336
|
+
})
|
|
427
337
|
}
|
|
338
|
+
let bar = UIStackView(arrangedSubviews: reactions)
|
|
339
|
+
bar.axis = .horizontal
|
|
340
|
+
bar.distribution = .fillEqually
|
|
341
|
+
bar.spacing = 8
|
|
342
|
+
return bar
|
|
428
343
|
}
|
|
429
344
|
```
|
|
430
345
|
|
|
431
346
|
## Communication Notifications
|
|
432
347
|
|
|
433
|
-
|
|
348
|
+
These show the sender's name and picture and are built on the SiriKit intents
|
|
349
|
+
`INSendMessageIntent` and `INStartCallIntent`. They change how Focus and the
|
|
350
|
+
notification summary treat the app, so use them only for messages or calls
|
|
351
|
+
between real people.
|
|
434
352
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
3.
|
|
440
|
-
4.
|
|
353
|
+
Setup:
|
|
354
|
+
1. Add the Communication Notifications capability to the app target.
|
|
355
|
+
2. List the intent classes (for example `INSendMessageIntent`) under
|
|
356
|
+
`NSUserActivityTypes` in the app's Info.plist.
|
|
357
|
+
3. Link the `Intents` framework into the service extension.
|
|
358
|
+
4. In the extension: build the intent, wrap it in
|
|
359
|
+
`INInteraction(intent:response:)`, set `direction = .incoming`, call
|
|
360
|
+
`donate()`, then pass `content.updating(from:)` to the handler.
|
|
441
361
|
|
|
442
362
|
```swift
|
|
443
363
|
import Intents
|
|
444
|
-
import UserNotifications
|
|
445
|
-
|
|
446
|
-
extension NotificationService {
|
|
447
|
-
func configureCommunicationNotification(
|
|
448
|
-
content: UNMutableNotificationContent,
|
|
449
|
-
senderName: String,
|
|
450
|
-
senderImageURL: String?,
|
|
451
|
-
conversationId: String
|
|
452
|
-
) async -> UNNotificationContent? {
|
|
453
|
-
// Create the sender identity
|
|
454
|
-
let handle = INPersonHandle(value: conversationId, type: .unknown)
|
|
455
|
-
var avatar: INImage? = nil
|
|
456
|
-
|
|
457
|
-
// Download sender avatar
|
|
458
|
-
if let urlString = senderImageURL,
|
|
459
|
-
let url = URL(string: urlString) {
|
|
460
|
-
if let (data, _) = try? await URLSession.shared.data(from: url) {
|
|
461
|
-
avatar = INImage(imageData: data)
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
let nameComponents = PersonNameComponentsFormatter()
|
|
466
|
-
.personNameComponents(from: senderName)
|
|
467
364
|
|
|
365
|
+
enum SenderStyling {
|
|
366
|
+
static func apply(to content: UNNotificationContent,
|
|
367
|
+
senderID: String,
|
|
368
|
+
senderName: String,
|
|
369
|
+
avatar: Data?,
|
|
370
|
+
conversationID: String) async -> UNNotificationContent {
|
|
468
371
|
let sender = INPerson(
|
|
469
|
-
personHandle:
|
|
470
|
-
nameComponents:
|
|
372
|
+
personHandle: INPersonHandle(value: senderID, type: .unknown),
|
|
373
|
+
nameComponents: PersonNameComponentsFormatter().personNameComponents(from: senderName),
|
|
471
374
|
displayName: senderName,
|
|
472
|
-
image: avatar,
|
|
375
|
+
image: avatar.map { INImage(imageData: $0) },
|
|
473
376
|
contactIdentifier: nil,
|
|
474
|
-
customIdentifier:
|
|
377
|
+
customIdentifier: senderID
|
|
475
378
|
)
|
|
476
|
-
|
|
477
|
-
// Create the messaging intent
|
|
478
379
|
let intent = INSendMessageIntent(
|
|
479
380
|
recipients: nil,
|
|
480
381
|
outgoingMessageType: .outgoingMessageText,
|
|
481
382
|
content: content.body,
|
|
482
383
|
speakableGroupName: nil,
|
|
483
|
-
conversationIdentifier:
|
|
384
|
+
conversationIdentifier: conversationID,
|
|
484
385
|
serviceName: nil,
|
|
485
386
|
sender: sender,
|
|
486
387
|
attachments: nil
|
|
487
388
|
)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
interaction.direction = .incoming
|
|
492
|
-
try? await interaction.donate()
|
|
389
|
+
let donation = INInteraction(intent: intent, response: nil)
|
|
390
|
+
donation.direction = .incoming
|
|
391
|
+
try? await donation.donate()
|
|
493
392
|
|
|
494
393
|
do {
|
|
495
|
-
|
|
394
|
+
let styled = try content.updating(from: intent)
|
|
395
|
+
return styled
|
|
496
396
|
} catch {
|
|
497
|
-
|
|
498
|
-
return nil
|
|
397
|
+
return content // fall back to the plain notification
|
|
499
398
|
}
|
|
500
399
|
}
|
|
501
400
|
}
|
|
502
401
|
```
|
|
503
402
|
|
|
504
|
-
###
|
|
403
|
+
### In the service extension
|
|
505
404
|
|
|
506
|
-
|
|
405
|
+
Attach media first. Then, if the payload names a sender, run the styling
|
|
406
|
+
above and deliver its result; if not, deliver the plain content. The
|
|
407
|
+
[complete example](#a-complete-service-extension) shows the whole flow.
|
|
507
408
|
|
|
508
|
-
|
|
509
|
-
override func didReceive(
|
|
510
|
-
_ request: UNNotificationRequest,
|
|
511
|
-
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
|
512
|
-
) {
|
|
513
|
-
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
|
514
|
-
contentHandler(request.content)
|
|
515
|
-
return
|
|
516
|
-
}
|
|
409
|
+
## Troubleshooting Extensions
|
|
517
410
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
411
|
+
**Service extension never runs**
|
|
412
|
+
- The payload includes an alert and sets `mutable-content: 1`.
|
|
413
|
+
- The extension is embedded in the app and has a valid bundle ID and profile.
|
|
414
|
+
- Both targets carry the shared App Group and Keychain entitlements they use.
|
|
523
415
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
let handle = INPersonHandle(value: conversationId, type: .unknown)
|
|
530
|
-
var avatar: INImage? = nil
|
|
531
|
-
if let urlString = senderImage,
|
|
532
|
-
let url = URL(string: urlString),
|
|
533
|
-
let (data, _) = try? await URLSession.shared.data(from: url) {
|
|
534
|
-
avatar = INImage(imageData: data)
|
|
535
|
-
}
|
|
416
|
+
**Content extension never appears**
|
|
417
|
+
- `UNNotificationExtensionCategory` matches the notification's
|
|
418
|
+
`categoryIdentifier` exactly.
|
|
419
|
+
- The storyboard or view is actually set up, and the size ratio is sensible.
|
|
536
420
|
|
|
537
|
-
|
|
538
|
-
|
|
421
|
+
**Memory**
|
|
422
|
+
- The system kills extensions that use too much memory. Keep dependencies
|
|
423
|
+
small and use `URLSession` directly rather than a large networking library.
|
|
424
|
+
A service extension has no UI, so a UI framework buys nothing there; a
|
|
425
|
+
content extension may host SwiftUI through `UIHostingController`, but keep
|
|
426
|
+
it light.
|
|
539
427
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
contactIdentifier: nil,
|
|
546
|
-
customIdentifier: conversationId
|
|
547
|
-
)
|
|
428
|
+
**Fallbacks**
|
|
429
|
+
- The handler runs exactly once. If a download, decryption, donation or
|
|
430
|
+
`updating(from:)` fails, deliver the original or the best attempt so far.
|
|
431
|
+
- In `serviceExtensionTimeWillExpire()`, stop waiting and deliver the best
|
|
432
|
+
content right away.
|
|
548
433
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
speakableGroupName: nil,
|
|
554
|
-
conversationIdentifier: conversationId,
|
|
555
|
-
serviceName: nil,
|
|
556
|
-
sender: sender,
|
|
557
|
-
attachments: nil
|
|
558
|
-
)
|
|
434
|
+
**Network**
|
|
435
|
+
- Extensions may use the network; a `URLSession` with the `.default`
|
|
436
|
+
configuration is fine. Stay well inside the roughly 30-second budget and
|
|
437
|
+
fall back to the payload text on failure.
|
|
559
438
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
439
|
+
**Debugging**
|
|
440
|
+
- Run the extension's scheme, or attach from the Debug menu (Attach to
|
|
441
|
+
Process by PID or Name) using the extension's name.
|
|
442
|
+
- Log with `os_log` or `Logger` and filter Console.app by the extension's
|
|
443
|
+
bundle ID.
|
|
444
|
+
- Set breakpoints, then send a notification to trigger the extension.
|
|
563
445
|
|
|
564
|
-
|
|
565
|
-
contentHandler(updatedContent)
|
|
566
|
-
return
|
|
567
|
-
}
|
|
568
|
-
}
|
|
446
|
+
## A Complete Service Extension
|
|
569
447
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
```
|
|
574
|
-
|
|
575
|
-
## Extension Gotchas
|
|
576
|
-
|
|
577
|
-
**Service extension not running:**
|
|
578
|
-
- Verify `mutable-content: 1` is set in the APNs payload.
|
|
579
|
-
- The notification must have an alert (title or body). Silent pushes do not trigger the service extension.
|
|
580
|
-
- Confirm the service extension target is embedded in the containing app and its bundle identifier/provisioning profile are valid.
|
|
581
|
-
- Confirm both targets include the same App Group or Keychain Sharing entitlements if they share data.
|
|
582
|
-
|
|
583
|
-
**Content extension not showing:**
|
|
584
|
-
- Verify the `UNNotificationExtensionCategory` in Info.plist matches the `categoryIdentifier` in the notification.
|
|
585
|
-
- Check that the storyboard or programmatic UI is properly configured.
|
|
586
|
-
- Ensure `UNNotificationExtensionInitialContentSizeRatio` is set to a reasonable value.
|
|
587
|
-
|
|
588
|
-
**Memory pressure:**
|
|
589
|
-
- Extensions are memory-constrained and can be terminated under pressure.
|
|
590
|
-
- Avoid loading large frameworks (no SwiftUI, no heavy networking libraries).
|
|
591
|
-
- Use `URLSession` directly for network requests in extensions.
|
|
592
|
-
|
|
593
|
-
**Fallback handling:**
|
|
594
|
-
- Always call the content handler exactly once on every path.
|
|
595
|
-
- On download, decryption, donation, or `content.updating(from:)` failure, return the original or best-attempt content rather than dropping the notification.
|
|
596
|
-
- In `serviceExtensionTimeWillExpire()`, stop waiting for in-flight work and call the content handler immediately with the best content available.
|
|
597
|
-
|
|
598
|
-
**Network access in extensions:**
|
|
599
|
-
- Extensions can make network requests. Use `URLSession` with the `.default` configuration.
|
|
600
|
-
- Keep requests fast -- the service extension has approximately 30 seconds total.
|
|
601
|
-
- Handle network failures gracefully; fall back to showing what the original payload contains.
|
|
602
|
-
|
|
603
|
-
**Debugging extensions:**
|
|
604
|
-
- In Xcode, select the extension scheme and attach to the extension process.
|
|
605
|
-
- Use `Debug > Attach to Process by PID or Name` with the extension's process name.
|
|
606
|
-
- Use `os_log` or `print` statements and view them in Console.app filtered by the extension's bundle identifier.
|
|
607
|
-
- Set breakpoints in the extension target and trigger a notification to hit them.
|
|
608
|
-
|
|
609
|
-
## Complete Service Extension Example
|
|
610
|
-
|
|
611
|
-
A production-ready service extension that handles image download, body decryption, and communication notifications:
|
|
448
|
+
The steps run in order: decrypt the body with a key from the shared Keychain,
|
|
449
|
+
attach media, style as a communication notification, then deliver once. The
|
|
450
|
+
timeout path delivers whatever is ready.
|
|
612
451
|
|
|
613
452
|
```swift
|
|
614
|
-
import UserNotifications
|
|
615
453
|
import Intents
|
|
454
|
+
import UserNotifications
|
|
616
455
|
|
|
617
|
-
class NotificationService: UNNotificationServiceExtension {
|
|
618
|
-
private
|
|
619
|
-
private var
|
|
456
|
+
final class NotificationService: UNNotificationServiceExtension, @unchecked Sendable {
|
|
457
|
+
private let lock = NSLock()
|
|
458
|
+
private var handler: ((UNNotificationContent) -> Void)?
|
|
459
|
+
private var bestAttempt: UNNotificationContent?
|
|
620
460
|
|
|
621
461
|
override func didReceive(
|
|
622
462
|
_ request: UNNotificationRequest,
|
|
623
463
|
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
|
624
464
|
) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
guard let content = bestAttemptContent else {
|
|
629
|
-
contentHandler(request.content)
|
|
630
|
-
return
|
|
465
|
+
lock.withLock {
|
|
466
|
+
handler = contentHandler
|
|
467
|
+
bestAttempt = request.content
|
|
631
468
|
}
|
|
469
|
+
let payload = IncomingPayload(request.content.userInfo)
|
|
632
470
|
|
|
633
471
|
Task {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
content.body = decrypted
|
|
472
|
+
guard let draft = self.snapshot()?.mutableCopy() as? UNMutableNotificationContent else {
|
|
473
|
+
self.finish(with: nil)
|
|
474
|
+
return
|
|
638
475
|
}
|
|
639
476
|
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
477
|
+
// 1. Decrypt, falling back to neutral text.
|
|
478
|
+
draft.body = payload.decryptedBody() ?? "New message"
|
|
479
|
+
self.remember(draft)
|
|
644
480
|
|
|
645
|
-
//
|
|
646
|
-
if let
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
content: content,
|
|
650
|
-
senderName: senderName,
|
|
651
|
-
senderImageURL: content.userInfo["senderImage"] as? String,
|
|
652
|
-
conversationId: convId
|
|
653
|
-
) {
|
|
654
|
-
finish(with: updated)
|
|
655
|
-
return
|
|
656
|
-
}
|
|
481
|
+
// 2. Media, skipped silently on failure.
|
|
482
|
+
if let url = payload.mediaURL, let media = await MediaFetcher.attachment(from: url) {
|
|
483
|
+
draft.attachments = [media]
|
|
484
|
+
self.remember(draft)
|
|
657
485
|
}
|
|
658
486
|
|
|
659
|
-
|
|
487
|
+
// 3. Sender styling, only when the payload names a sender.
|
|
488
|
+
guard let sender = payload.sender else {
|
|
489
|
+
self.finish(with: draft)
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
let styled = await SenderStyling.apply(
|
|
493
|
+
to: draft,
|
|
494
|
+
senderID: sender.id,
|
|
495
|
+
senderName: sender.name,
|
|
496
|
+
avatar: await payload.avatarData(),
|
|
497
|
+
conversationID: sender.conversationID
|
|
498
|
+
)
|
|
499
|
+
self.finish(with: styled)
|
|
660
500
|
}
|
|
661
501
|
}
|
|
662
502
|
|
|
663
503
|
override func serviceExtensionTimeWillExpire() {
|
|
664
|
-
|
|
665
|
-
finish(with: content)
|
|
666
|
-
}
|
|
504
|
+
finish(with: nil) // best attempt so far
|
|
667
505
|
}
|
|
668
506
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
private func finish(with content: UNNotificationContent) {
|
|
672
|
-
guard !didComplete, let handler = contentHandler else { return }
|
|
673
|
-
didComplete = true
|
|
674
|
-
handler(content)
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
private func decryptBody(_ base64: String) -> String? {
|
|
678
|
-
guard let data = Data(base64Encoded: base64) else { return nil }
|
|
679
|
-
guard let keyData = SharedKeychain.loadData(
|
|
680
|
-
account: "notificationEncryptionKey"
|
|
681
|
-
) else { return nil }
|
|
682
|
-
// Decrypt using CryptoKit or similar
|
|
683
|
-
return try? Decryptor.decrypt(data, key: keyData)
|
|
507
|
+
private func snapshot() -> UNNotificationContent? {
|
|
508
|
+
lock.withLock { bestAttempt }
|
|
684
509
|
}
|
|
685
510
|
|
|
686
|
-
private func
|
|
687
|
-
|
|
688
|
-
to content: UNMutableNotificationContent
|
|
689
|
-
) async {
|
|
690
|
-
guard let url = URL(string: urlString) else { return }
|
|
691
|
-
do {
|
|
692
|
-
let (data, response) = try await URLSession.shared.data(from: url)
|
|
693
|
-
let ext = fileExtension(for: response.mimeType)
|
|
694
|
-
let fileURL = FileManager.default.temporaryDirectory
|
|
695
|
-
.appendingPathComponent(UUID().uuidString + ext)
|
|
696
|
-
try data.write(to: fileURL)
|
|
697
|
-
let attachment = try UNNotificationAttachment(
|
|
698
|
-
identifier: UUID().uuidString,
|
|
699
|
-
url: fileURL,
|
|
700
|
-
options: nil
|
|
701
|
-
)
|
|
702
|
-
content.attachments = [attachment]
|
|
703
|
-
} catch {
|
|
704
|
-
// Image download failed -- notification displays without media
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
private func configureAsCommunication(
|
|
709
|
-
content: UNMutableNotificationContent,
|
|
710
|
-
senderName: String,
|
|
711
|
-
senderImageURL: String?,
|
|
712
|
-
conversationId: String
|
|
713
|
-
) async -> UNNotificationContent? {
|
|
714
|
-
let handle = INPersonHandle(value: conversationId, type: .unknown)
|
|
715
|
-
var avatar: INImage? = nil
|
|
716
|
-
|
|
717
|
-
if let urlStr = senderImageURL,
|
|
718
|
-
let url = URL(string: urlStr),
|
|
719
|
-
let (data, _) = try? await URLSession.shared.data(from: url) {
|
|
720
|
-
avatar = INImage(imageData: data)
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
let nameComponents = PersonNameComponentsFormatter()
|
|
724
|
-
.personNameComponents(from: senderName)
|
|
725
|
-
|
|
726
|
-
let sender = INPerson(
|
|
727
|
-
personHandle: handle,
|
|
728
|
-
nameComponents: nameComponents,
|
|
729
|
-
displayName: senderName,
|
|
730
|
-
image: avatar,
|
|
731
|
-
contactIdentifier: nil,
|
|
732
|
-
customIdentifier: conversationId
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
let intent = INSendMessageIntent(
|
|
736
|
-
recipients: nil,
|
|
737
|
-
outgoingMessageType: .outgoingMessageText,
|
|
738
|
-
content: content.body,
|
|
739
|
-
speakableGroupName: nil,
|
|
740
|
-
conversationIdentifier: conversationId,
|
|
741
|
-
serviceName: nil,
|
|
742
|
-
sender: sender,
|
|
743
|
-
attachments: nil
|
|
744
|
-
)
|
|
745
|
-
|
|
746
|
-
let interaction = INInteraction(intent: intent, response: nil)
|
|
747
|
-
interaction.direction = .incoming
|
|
748
|
-
try? await interaction.donate()
|
|
749
|
-
|
|
750
|
-
return try? content.updating(from: intent)
|
|
511
|
+
private func remember(_ content: UNNotificationContent) {
|
|
512
|
+
lock.withLock { if handler != nil { bestAttempt = content.copy() as? UNNotificationContent } }
|
|
751
513
|
}
|
|
752
514
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
case "audio/mpeg": return ".mp3"
|
|
760
|
-
default: return ".jpg"
|
|
515
|
+
/// Single delivery point. `nil` means "use the best attempt".
|
|
516
|
+
private func finish(with ready: UNNotificationContent?) {
|
|
517
|
+
let delivery: (((UNNotificationContent) -> Void), UNNotificationContent)? = lock.withLock {
|
|
518
|
+
guard let handler, let fallback = bestAttempt else { return nil }
|
|
519
|
+
self.handler = nil
|
|
520
|
+
return (handler, ready ?? fallback)
|
|
761
521
|
}
|
|
522
|
+
guard let (deliverTo, result) = delivery else { return }
|
|
523
|
+
deliverTo(result)
|
|
762
524
|
}
|
|
763
525
|
}
|
|
764
526
|
```
|
|
527
|
+
|
|
528
|
+
`IncomingPayload` is the app's own parser over `userInfo`: it reads the sealed
|
|
529
|
+
body and decrypts it with the shared-Keychain key, and exposes `mediaURL`, the
|
|
530
|
+
sender fields and an avatar download. Keep it free of UI and large
|
|
531
|
+
dependencies.
|