@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,874 +1,647 @@
|
|
|
1
|
-
# Background Transfers and
|
|
1
|
+
# Background Transfers and WebSockets
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
---
|
|
3
|
+
Two things that are often confused. A background URLSession moves HTTP and
|
|
4
|
+
HTTPS uploads and downloads while the app is not running. A WebSocket is a
|
|
5
|
+
live foreground connection. Neither does the other's job.
|
|
7
6
|
|
|
8
7
|
## Contents
|
|
9
8
|
|
|
10
|
-
- [
|
|
11
|
-
- [
|
|
12
|
-
- [
|
|
13
|
-
- [
|
|
9
|
+
- [Setting up a background session](#setting-up-a-background-session)
|
|
10
|
+
- [Downloads that outlive the app](#downloads-that-outlive-the-app)
|
|
11
|
+
- [Relaunch and session event delivery](#relaunch-and-session-event-delivery)
|
|
12
|
+
- [Uploads from a file on disk](#uploads-from-a-file-on-disk)
|
|
14
13
|
- [URLSessionWebSocketTask](#urlsessionwebsockettask)
|
|
15
|
-
- [
|
|
16
|
-
- [
|
|
17
|
-
- [
|
|
18
|
-
- [
|
|
14
|
+
- [Reconnecting a dropped socket](#reconnecting-a-dropped-socket)
|
|
15
|
+
- [Typed messages over a socket](#typed-messages-over-a-socket)
|
|
16
|
+
- [Pitfalls of background sessions](#pitfalls-of-background-sessions)
|
|
17
|
+
- [Showing download progress in SwiftUI](#showing-download-progress-in-swiftui)
|
|
19
18
|
- [WebSocket Authentication](#websocket-authentication)
|
|
20
|
-
- [
|
|
21
|
-
|
|
22
|
-
## Background URLSession Configuration
|
|
23
|
-
|
|
24
|
-
Background sessions allow HTTP/HTTPS upload and download transfers to continue
|
|
25
|
-
when the app is suspended or terminated by the system. The system manages the
|
|
26
|
-
transfer in a separate process and wakes the app on completion.
|
|
19
|
+
- [Choosing a subprotocol](#choosing-a-subprotocol)
|
|
27
20
|
|
|
28
|
-
|
|
21
|
+
## Setting up a background session
|
|
29
22
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
23
|
+
A background session hands its transfers to a system process. They carry on
|
|
24
|
+
while the app is suspended, after the system terminates it, and across a
|
|
25
|
+
device restart; the system also retries network failures by itself. When a
|
|
26
|
+
transfer finishes, the app is woken or relaunched to hear about it. Use one
|
|
27
|
+
for anything the user expects to complete after leaving the app: syncing a
|
|
28
|
+
folder, downloading an episode, uploading a video.
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
There is one hard limit: a swipe-away in the app switcher. That user
|
|
31
|
+
force-quit cancels every pending background transfer, and the system keeps
|
|
32
|
+
the app closed, with no wake-ups for those transfers, until the person
|
|
33
|
+
launches it by hand.
|
|
38
34
|
|
|
39
35
|
### Configuration
|
|
40
36
|
|
|
41
37
|
```swift
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
static let
|
|
38
|
+
final class BackgroundTransferService: NSObject, @unchecked Sendable {
|
|
39
|
+
static let shared = BackgroundTransferService()
|
|
40
|
+
static let identifier = "com.example.reader.transfers"
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
/// The system uses this to reconnect to the session after relaunch.
|
|
48
|
-
private let sessionID = "com.example.app.background-downloads"
|
|
42
|
+
@MainActor var systemCompletionHandler: (() -> Void)?
|
|
49
43
|
|
|
50
|
-
/// Lazy-initialized background session. Must use a delegate, not async/await,
|
|
51
|
-
/// because the system delivers events through the delegate after app relaunch.
|
|
52
44
|
lazy var session: URLSession = {
|
|
53
|
-
let config = URLSessionConfiguration.background(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
config.isDiscretionary = false // Start immediately (true = system-scheduled)
|
|
57
|
-
config.sessionSendsLaunchEvents = true // Wake app on completion
|
|
45
|
+
let config = URLSessionConfiguration.background(withIdentifier: Self.identifier)
|
|
46
|
+
config.isDiscretionary = false
|
|
47
|
+
config.sessionSendsLaunchEvents = true
|
|
58
48
|
config.allowsExpensiveNetworkAccess = true
|
|
59
|
-
config.allowsConstrainedNetworkAccess = false
|
|
60
|
-
config.timeoutIntervalForResource = 24 * 60 * 60
|
|
61
|
-
|
|
62
|
-
return URLSession(
|
|
63
|
-
configuration: config,
|
|
64
|
-
delegate: self,
|
|
65
|
-
delegateQueue: nil // Use a system-managed serial queue
|
|
66
|
-
)
|
|
49
|
+
config.allowsConstrainedNetworkAccess = false
|
|
50
|
+
config.timeoutIntervalForResource = 24 * 60 * 60
|
|
51
|
+
let callbackQueue: OperationQueue? = nil
|
|
52
|
+
return URLSession(configuration: config, delegate: self, delegateQueue: callbackQueue)
|
|
67
53
|
}()
|
|
68
|
-
|
|
69
|
-
/// Store completionHandler from AppDelegate for system callback
|
|
70
|
-
nonisolated(unsafe) var backgroundCompletionHandler: (() -> Void)?
|
|
71
54
|
}
|
|
72
55
|
```
|
|
73
56
|
|
|
74
|
-
|
|
57
|
+
- The identifier is unique per app and prefixed with the bundle ID. After a
|
|
58
|
+
relaunch, a new session built from that identifier picks up the transfers
|
|
59
|
+
that were already running.
|
|
60
|
+
- The session needs a delegate. Its events arrive through delegate callbacks,
|
|
61
|
+
possibly in a process that was just relaunched, so async/await calls on it
|
|
62
|
+
are not an option.
|
|
63
|
+
- `delegateQueue: nil` gives the session its own serial operation queue.
|
|
64
|
+
- `@unchecked Sendable` is acceptable here because `URLSession` is
|
|
65
|
+
thread-safe, the lazy session is first touched on the main thread, and the
|
|
66
|
+
completion handler is main-actor isolated.
|
|
67
|
+
- Background sessions are available since iOS 7; the async APIs used around
|
|
68
|
+
them need iOS 15.
|
|
75
69
|
|
|
76
|
-
|
|
77
|
-
|---|---|
|
|
78
|
-
| `isDiscretionary` | `true` = system schedules for optimal battery/network. Use for non-urgent sync. `false` = start immediately. |
|
|
79
|
-
| `sessionSendsLaunchEvents` | Relaunches the app when transfers complete. Required for completion handling. |
|
|
80
|
-
| `allowsConstrainedNetworkAccess` | `false` = honor Low Data Mode. Good for optional downloads. |
|
|
81
|
-
| `allowsExpensiveNetworkAccess` | `false` = Wi-Fi only. Use for large transfers. |
|
|
82
|
-
| `timeoutIntervalForResource` | Maximum time for the entire transfer. Default is 7 days. |
|
|
70
|
+
### Key Configuration Options
|
|
83
71
|
|
|
84
|
-
|
|
72
|
+
| Property | Effect | Typical choice |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `isDiscretionary` | `true` lets the system wait for power and good network | `true` for optional sync, `false` to start now |
|
|
75
|
+
| `sessionSendsLaunchEvents` | Relaunch the app when transfers finish | `true`; completion handling needs it |
|
|
76
|
+
| `allowsConstrainedNetworkAccess` | `false` respects Low Data Mode | `false` for optional downloads |
|
|
77
|
+
| `allowsExpensiveNetworkAccess` | `false` keeps transfers off cellular and hotspots | `false` for very large files |
|
|
78
|
+
| `timeoutIntervalForResource` | Maximum lifetime of a whole transfer | Default is 7 days |
|
|
85
79
|
|
|
86
|
-
##
|
|
80
|
+
## Downloads that outlive the app
|
|
87
81
|
|
|
88
|
-
Background downloads
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
Background downloads are `downloadTask(with:)` tasks. The async overloads
|
|
83
|
+
such as `data(for:)` and `download(for:)` are not supported on a background
|
|
84
|
+
session.
|
|
91
85
|
|
|
92
86
|
```swift
|
|
93
|
-
extension
|
|
94
|
-
func
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return task
|
|
87
|
+
extension BackgroundTransferService {
|
|
88
|
+
func enqueueDownload(of source: URL) {
|
|
89
|
+
let job = session.downloadTask(with: source)
|
|
90
|
+
job.earliestBeginDate = .now
|
|
91
|
+
job.countOfBytesClientExpectsToSend = 0
|
|
92
|
+
job.countOfBytesClientExpectsToReceive = 80 * 1_048_576
|
|
93
|
+
job.resume()
|
|
101
94
|
}
|
|
102
95
|
|
|
103
|
-
func
|
|
104
|
-
|
|
105
|
-
task.resume()
|
|
106
|
-
return task
|
|
96
|
+
func resumeDownload(with resumeData: Data) {
|
|
97
|
+
session.downloadTask(withResumeData: resumeData).resume()
|
|
107
98
|
}
|
|
108
99
|
}
|
|
109
100
|
```
|
|
110
101
|
|
|
102
|
+
The byte estimates help the scheduler decide when to run the task.
|
|
103
|
+
|
|
111
104
|
### Download Delegate
|
|
112
105
|
|
|
113
106
|
```swift
|
|
114
|
-
extension
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// CRITICAL: Move or open the file before this method returns.
|
|
121
|
-
// The temporary file is only available until the delegate returns.
|
|
122
|
-
let destinationDir = FileManager.default.urls(
|
|
123
|
-
for: .documentDirectory,
|
|
124
|
-
in: .userDomainMask
|
|
125
|
-
).first!
|
|
126
|
-
|
|
127
|
-
let filename = downloadTask.originalRequest?.url?.lastPathComponent ?? UUID().uuidString
|
|
128
|
-
let destination = destinationDir.appendingPathComponent(filename)
|
|
129
|
-
|
|
107
|
+
extension BackgroundTransferService: URLSessionDownloadDelegate {
|
|
108
|
+
func urlSession(_: URLSession, downloadTask job: URLSessionDownloadTask,
|
|
109
|
+
didFinishDownloadingTo location: URL) {
|
|
110
|
+
let fileName = job.originalRequest?.url?.lastPathComponent
|
|
111
|
+
let destination = URL.documentsDirectory.appending(path: fileName ?? UUID().uuidString)
|
|
112
|
+
let files = FileManager.default
|
|
130
113
|
do {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
try FileManager.default.removeItem(at: destination)
|
|
134
|
-
}
|
|
135
|
-
try FileManager.default.moveItem(at: location, to: destination)
|
|
136
|
-
// Notify the app (post notification, update state, etc.)
|
|
114
|
+
if files.fileExists(atPath: destination.path()) { try files.removeItem(at: destination) }
|
|
115
|
+
try files.moveItem(at: location, to: destination)
|
|
137
116
|
} catch {
|
|
138
|
-
|
|
117
|
+
Logger.transfers.error("Could not keep download: \(error.localizedDescription)")
|
|
139
118
|
}
|
|
140
119
|
}
|
|
141
120
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
totalBytesExpectedToWrite:
|
|
148
|
-
) {
|
|
149
|
-
guard totalBytesExpectedToWrite > 0 else { return }
|
|
150
|
-
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
151
|
-
// Update progress UI (dispatch to main if needed)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
nonisolated func urlSession(
|
|
155
|
-
_ session: URLSession,
|
|
156
|
-
task: URLSessionTask,
|
|
157
|
-
didCompleteWithError error: (any Error)?
|
|
158
|
-
) {
|
|
159
|
-
guard let error else { return } // Success handled in didFinishDownloadingTo
|
|
160
|
-
|
|
161
|
-
// Check for resume data on failure
|
|
162
|
-
let nsError = error as NSError
|
|
163
|
-
if let resumeData = nsError.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
|
|
164
|
-
// Store resumeData for retry
|
|
165
|
-
saveResumeData(resumeData, for: task)
|
|
166
|
-
}
|
|
121
|
+
func urlSession(_: URLSession, downloadTask job: URLSessionDownloadTask,
|
|
122
|
+
didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
|
|
123
|
+
totalBytesExpectedToWrite: Int64) {
|
|
124
|
+
guard totalBytesExpectedToWrite > 0,
|
|
125
|
+
let url = job.originalRequest?.url else { return }
|
|
126
|
+
reportProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite), for: url)
|
|
167
127
|
}
|
|
168
128
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
let
|
|
172
|
-
let
|
|
173
|
-
.
|
|
174
|
-
|
|
129
|
+
func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError failure: (any Error)?) {
|
|
130
|
+
guard let error = failure else { return }
|
|
131
|
+
let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data
|
|
132
|
+
if let resumeData, let url = task.originalRequest?.url {
|
|
133
|
+
let file = URL.temporaryDirectory.appending(path: "resume-\(url.absoluteString.hashValue)")
|
|
134
|
+
try? resumeData.write(to: file)
|
|
135
|
+
}
|
|
175
136
|
}
|
|
176
137
|
}
|
|
177
138
|
```
|
|
178
139
|
|
|
179
|
-
|
|
140
|
+
- `location` exists only until `didFinishDownloadingTo` returns, so the move
|
|
141
|
+
happens inside it.
|
|
142
|
+
- Progress arrives on the session queue; UI updates must hop to the main
|
|
143
|
+
actor (see the tracker below).
|
|
144
|
+
- `didCompleteWithError` with a `nil` error is the success case, already
|
|
145
|
+
handled when the file was moved.
|
|
146
|
+
- On failure, the resume data is in the error's `userInfo` under
|
|
147
|
+
`NSURLSessionDownloadTaskResumeData`. Persist it and pass it to
|
|
148
|
+
`downloadTask(withResumeData:)` later. `hashValue` is seeded per launch, so
|
|
149
|
+
a real app should key the file by a stable ID it stores itself.
|
|
180
150
|
|
|
181
|
-
##
|
|
151
|
+
## Relaunch and session event delivery
|
|
182
152
|
|
|
183
|
-
When
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
processing all events.
|
|
187
|
-
|
|
188
|
-
### UIKit App Delegate
|
|
153
|
+
When a transfer finishes while the app is not running, the system relaunches
|
|
154
|
+
it in the background and calls the app delegate with a completion handler.
|
|
155
|
+
Call that handler once every pending event has been delivered.
|
|
189
156
|
|
|
190
157
|
```swift
|
|
191
|
-
class AppDelegate:
|
|
192
|
-
func application(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// call it after processing all pending events.
|
|
199
|
-
BackgroundDownloadManager.shared.backgroundCompletionHandler = completionHandler
|
|
200
|
-
|
|
201
|
-
// Accessing .session triggers lazy initialization, which reconnects
|
|
202
|
-
// to the background session and starts delivering delegate events.
|
|
203
|
-
_ = BackgroundDownloadManager.shared.session
|
|
158
|
+
final class AppDelegate: NSObject, UIApplicationDelegate {
|
|
159
|
+
func application(_ application: UIApplication,
|
|
160
|
+
handleEventsForBackgroundURLSession identifier: String,
|
|
161
|
+
completionHandler: @escaping () -> Void) {
|
|
162
|
+
guard identifier == BackgroundTransferService.identifier else { return completionHandler() }
|
|
163
|
+
BackgroundTransferService.shared.systemCompletionHandler = completionHandler
|
|
164
|
+
_ = BackgroundTransferService.shared.session
|
|
204
165
|
}
|
|
205
166
|
}
|
|
206
|
-
```
|
|
207
167
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
```swift
|
|
211
|
-
extension BackgroundDownloadManager: URLSessionDelegate {
|
|
212
|
-
nonisolated func urlSessionDidFinishEvents(
|
|
213
|
-
forBackgroundURLSession session: URLSession
|
|
214
|
-
) {
|
|
215
|
-
// Called after ALL pending delegate events have been delivered.
|
|
216
|
-
// Call the stored completion handler on the main thread.
|
|
168
|
+
extension BackgroundTransferService {
|
|
169
|
+
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
|
217
170
|
Task { @MainActor in
|
|
218
|
-
|
|
219
|
-
|
|
171
|
+
self.systemCompletionHandler?()
|
|
172
|
+
self.systemCompletionHandler = nil
|
|
220
173
|
}
|
|
221
174
|
}
|
|
222
175
|
}
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
### SwiftUI App with AppDelegate Adapter
|
|
226
176
|
|
|
227
|
-
```swift
|
|
228
177
|
@main
|
|
229
|
-
struct
|
|
230
|
-
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|
231
|
-
|
|
178
|
+
struct ReaderApp: App {
|
|
179
|
+
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
232
180
|
var body: some Scene {
|
|
233
|
-
WindowGroup {
|
|
234
|
-
ContentView()
|
|
235
|
-
}
|
|
181
|
+
WindowGroup { ShelfView() }
|
|
236
182
|
}
|
|
237
183
|
}
|
|
238
184
|
```
|
|
239
185
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
186
|
+
- Touching `session` recreates the background session, which is what makes
|
|
187
|
+
the queued delegate events flow.
|
|
188
|
+
- `urlSessionDidFinishEvents(forBackgroundURLSession:)` fires after the last
|
|
189
|
+
pending event.
|
|
190
|
+
- Call the stored handler exactly once, on the main thread. It tells the
|
|
191
|
+
system to take the app-switcher snapshot and suspend the app; skipping it
|
|
192
|
+
leaves a stale snapshot and wastes background time.
|
|
193
|
+
- SwiftUI apps reach the app delegate through `@UIApplicationDelegateAdaptor`.
|
|
244
194
|
|
|
245
|
-
|
|
195
|
+
## Uploads from a file on disk
|
|
246
196
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
Background uploads require data from a file, not from memory.
|
|
197
|
+
A background upload must come from a file. Uploading `Data` or a stream is
|
|
198
|
+
not supported on a background session.
|
|
250
199
|
|
|
251
200
|
```swift
|
|
252
|
-
extension
|
|
253
|
-
func
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
var request = URLRequest(url: url)
|
|
260
|
-
request.httpMethod = method
|
|
261
|
-
for (key, value) in headers {
|
|
262
|
-
request.setValue(value, forHTTPHeaderField: key)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
let task = session.uploadTask(with: request, fromFile: fileURL)
|
|
266
|
-
task.resume()
|
|
267
|
-
return task
|
|
201
|
+
extension BackgroundTransferService {
|
|
202
|
+
func enqueueUpload(of stagedFile: URL, to endpoint: URL,
|
|
203
|
+
verb: String = "POST", extraHeaders: [String: String] = [:]) {
|
|
204
|
+
var put = URLRequest(url: endpoint)
|
|
205
|
+
put.httpMethod = verb
|
|
206
|
+
put.allHTTPHeaderFields = extraHeaders
|
|
207
|
+
session.uploadTask(with: put, fromFile: stagedFile).resume()
|
|
268
208
|
}
|
|
269
|
-
}
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
### Upload Delegate Methods
|
|
273
209
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
task: URLSessionTask,
|
|
279
|
-
didSendBodyData bytesSent: Int64,
|
|
280
|
-
totalBytesSent: Int64,
|
|
281
|
-
totalBytesExpectedToSend: Int64
|
|
282
|
-
) {
|
|
283
|
-
guard totalBytesExpectedToSend > 0 else { return }
|
|
284
|
-
let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
|
|
285
|
-
// Update progress UI
|
|
210
|
+
func urlSession(_: URLSession, task: URLSessionTask, didSendBodyData _: Int64,
|
|
211
|
+
totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
|
|
212
|
+
guard totalBytesExpectedToSend > 0, let url = task.originalRequest?.url else { return }
|
|
213
|
+
reportProgress(Double(totalBytesSent) / Double(totalBytesExpectedToSend), for: url)
|
|
286
214
|
}
|
|
287
215
|
}
|
|
288
216
|
```
|
|
289
217
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
- Write multipart form data to a temporary file first, then upload.
|
|
294
|
-
|
|
295
|
-
---
|
|
218
|
+
`uploadTask(with:from:)` with in-memory data fails on a background session.
|
|
219
|
+
For a multipart upload, write the encoded body to a temporary file first and
|
|
220
|
+
upload that file.
|
|
296
221
|
|
|
297
222
|
## URLSessionWebSocketTask
|
|
298
223
|
|
|
299
|
-
`URLSessionWebSocketTask`
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
suspension.
|
|
224
|
+
`URLSessionWebSocketTask` (iOS 13 and later) is the built-in WebSocket
|
|
225
|
+
client. It connects to `ws:` or `wss:` URLs. It is a foreground, default
|
|
226
|
+
session tool: a background configuration does not keep a socket alive once
|
|
227
|
+
the app is suspended.
|
|
304
228
|
|
|
305
229
|
### Basic Connection
|
|
306
230
|
|
|
307
231
|
```swift
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
private let task: URLSessionWebSocketTask
|
|
311
|
-
|
|
312
|
-
init(url: URL, session: URLSession = .shared) {
|
|
313
|
-
self.task = session.webSocketTask(with: url)
|
|
314
|
-
}
|
|
232
|
+
final class SocketLink: Sendable {
|
|
233
|
+
private let socket: URLSessionWebSocketTask
|
|
315
234
|
|
|
316
|
-
|
|
317
|
-
|
|
235
|
+
init(address: URL, using session: URLSession) {
|
|
236
|
+
socket = session.webSocketTask(with: address)
|
|
318
237
|
}
|
|
319
238
|
|
|
320
|
-
func
|
|
321
|
-
task.cancel(with: .normalClosure, reason: reason?.data(using: .utf8))
|
|
322
|
-
}
|
|
239
|
+
func open() { socket.resume() }
|
|
323
240
|
|
|
324
|
-
func
|
|
325
|
-
|
|
241
|
+
func close(why: String? = nil) {
|
|
242
|
+
let note = why.map { Data($0.utf8) }
|
|
243
|
+
socket.cancel(with: .normalClosure, reason: note)
|
|
326
244
|
}
|
|
327
245
|
|
|
328
|
-
func
|
|
329
|
-
try await
|
|
246
|
+
func write(_ line: String) async throws {
|
|
247
|
+
try await socket.send(.string(line))
|
|
330
248
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
try await task.send(.data(data))
|
|
249
|
+
func write(_ blob: Data) async throws {
|
|
250
|
+
try await socket.send(.data(blob))
|
|
334
251
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
252
|
+
func read() async throws -> URLSessionWebSocketTask.Message {
|
|
253
|
+
let incoming = try await socket.receive()
|
|
254
|
+
return incoming
|
|
338
255
|
}
|
|
339
256
|
}
|
|
340
257
|
```
|
|
341
258
|
|
|
342
259
|
### WebSocket with Structured Concurrency
|
|
343
260
|
|
|
344
|
-
|
|
345
|
-
messages through an `AsyncStream`. This integrates naturally with
|
|
346
|
-
structured concurrency.
|
|
261
|
+
An actor owns the socket and a receive loop that feeds an `AsyncStream`.
|
|
347
262
|
|
|
348
263
|
```swift
|
|
349
|
-
|
|
350
|
-
actor WebSocketManager {
|
|
351
|
-
private var task: URLSessionWebSocketTask?
|
|
352
|
-
private var receiveTask: Task<Void, Never>?
|
|
353
|
-
private let session: URLSession
|
|
354
|
-
private let url: URL
|
|
355
|
-
|
|
264
|
+
actor SocketChannel {
|
|
356
265
|
enum Event: Sendable {
|
|
357
|
-
case
|
|
266
|
+
case opened
|
|
358
267
|
case text(String)
|
|
359
|
-
case
|
|
360
|
-
case
|
|
361
|
-
case
|
|
268
|
+
case binary(Data)
|
|
269
|
+
case closed(URLSessionWebSocketTask.CloseCode, Data?)
|
|
270
|
+
case failed(any Error)
|
|
362
271
|
}
|
|
363
272
|
|
|
364
|
-
|
|
365
|
-
|
|
273
|
+
private let url: URL
|
|
274
|
+
private let session: URLSession
|
|
275
|
+
private var socket: URLSessionWebSocketTask?
|
|
276
|
+
private var receiver: Task<Void, Never>?
|
|
277
|
+
|
|
278
|
+
init(address: URL, using session: URLSession = .shared) {
|
|
279
|
+
url = address
|
|
366
280
|
self.session = session
|
|
367
281
|
}
|
|
368
282
|
|
|
369
|
-
/// Returns a stream of WebSocket events. Call `connect()` to start.
|
|
370
283
|
func events() -> AsyncStream<Event> {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
private func receiveLoop(continuation: AsyncStream<Event>.Continuation) async {
|
|
392
|
-
guard let task else { return }
|
|
393
|
-
|
|
394
|
-
while !Task.isCancelled {
|
|
395
|
-
do {
|
|
396
|
-
let message = try await task.receive()
|
|
397
|
-
switch message {
|
|
398
|
-
case .string(let text):
|
|
399
|
-
continuation.yield(.text(text))
|
|
400
|
-
case .data(let data):
|
|
401
|
-
continuation.yield(.data(data))
|
|
402
|
-
@unknown default:
|
|
284
|
+
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
|
|
285
|
+
let socket = session.webSocketTask(with: url)
|
|
286
|
+
self.socket = socket
|
|
287
|
+
socket.resume()
|
|
288
|
+
continuation.yield(.opened)
|
|
289
|
+
receiver = Task {
|
|
290
|
+
while !Task.isCancelled {
|
|
291
|
+
do {
|
|
292
|
+
switch try await socket.receive() {
|
|
293
|
+
case .string(let words): continuation.yield(.text(words))
|
|
294
|
+
case .data(let blob): continuation.yield(.binary(blob))
|
|
295
|
+
@unknown default: break
|
|
296
|
+
}
|
|
297
|
+
} catch {
|
|
298
|
+
if socket.closeCode == .invalid {
|
|
299
|
+
continuation.yield(.failed(error))
|
|
300
|
+
} else {
|
|
301
|
+
continuation.yield(.closed(socket.closeCode, socket.closeReason))
|
|
302
|
+
}
|
|
403
303
|
break
|
|
404
304
|
}
|
|
405
|
-
} catch {
|
|
406
|
-
// The receive threw -- connection closed or failed
|
|
407
|
-
let closeCode = task.closeCode
|
|
408
|
-
let closeReason = task.closeReason
|
|
409
|
-
if closeCode == .invalid {
|
|
410
|
-
// Unexpected disconnection
|
|
411
|
-
continuation.yield(.error(error))
|
|
412
|
-
} else {
|
|
413
|
-
continuation.yield(.disconnected(closeCode, closeReason))
|
|
414
|
-
}
|
|
415
|
-
continuation.finish()
|
|
416
|
-
return
|
|
417
305
|
}
|
|
306
|
+
continuation.finish()
|
|
418
307
|
}
|
|
308
|
+
continuation.onTermination = { [weak self] _ in
|
|
309
|
+
Task { await self?.close() }
|
|
310
|
+
}
|
|
311
|
+
return stream
|
|
419
312
|
}
|
|
420
313
|
|
|
421
|
-
func send(text: String) async throws {
|
|
422
|
-
try await
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
func send(data: Data) async throws {
|
|
426
|
-
try await task?.send(.data(data))
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
func disconnect() {
|
|
430
|
-
receiveTask?.cancel()
|
|
431
|
-
receiveTask = nil
|
|
432
|
-
task?.cancel(with: .normalClosure, reason: nil)
|
|
433
|
-
task = nil
|
|
314
|
+
func send(_ text: String) async throws {
|
|
315
|
+
try await socket?.send(.string(text))
|
|
434
316
|
}
|
|
435
317
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
guard let self else { return }
|
|
442
|
-
await self.ping()
|
|
443
|
-
}
|
|
444
|
-
}
|
|
318
|
+
func close() {
|
|
319
|
+
receiver?.cancel()
|
|
320
|
+
socket?.cancel(with: .normalClosure, reason: nil)
|
|
321
|
+
receiver = nil
|
|
322
|
+
socket = nil
|
|
445
323
|
}
|
|
446
324
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
325
|
+
func keepAlive(every interval: Duration = .seconds(30)) async {
|
|
326
|
+
while !Task.isCancelled {
|
|
327
|
+
do { try await Task.sleep(for: interval) } catch { return }
|
|
328
|
+
socket?.sendPing { error in
|
|
329
|
+
if error != nil {
|
|
330
|
+
Logger.transfers.notice("Ping failed; the connection is probably gone")
|
|
331
|
+
}
|
|
452
332
|
}
|
|
453
333
|
}
|
|
454
334
|
}
|
|
455
335
|
}
|
|
456
336
|
```
|
|
457
337
|
|
|
338
|
+
- A receive error with `closeCode == .invalid` means the peer never sent a
|
|
339
|
+
close frame: treat it as an unexpected failure. Any other close code is an
|
|
340
|
+
orderly close.
|
|
341
|
+
- The receive loop captures only the socket and the continuation, never the
|
|
342
|
+
actor, and the termination handler holds the actor weakly, so neither keeps
|
|
343
|
+
the channel alive.
|
|
344
|
+
- `keepAlive` sends a ping on an interval; a failed ping usually means the
|
|
345
|
+
connection is dead and a reconnect is due.
|
|
346
|
+
|
|
458
347
|
### Usage in SwiftUI
|
|
459
348
|
|
|
460
349
|
```swift
|
|
461
|
-
@MainActor
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
var connectionState: ConnectionState = .disconnected
|
|
350
|
+
@MainActor @Observable
|
|
351
|
+
final class RoomModel {
|
|
352
|
+
enum LinkState { case offline, connecting, online }
|
|
465
353
|
|
|
466
|
-
|
|
354
|
+
struct Line: Identifiable { let id = UUID(); let text: String; let isMine: Bool }
|
|
467
355
|
|
|
468
|
-
private
|
|
469
|
-
private var
|
|
356
|
+
private(set) var lines: [Line] = []
|
|
357
|
+
private(set) var state: LinkState = .offline
|
|
358
|
+
private let channel: SocketChannel
|
|
359
|
+
private var listener: Task<Void, Never>?
|
|
470
360
|
|
|
471
|
-
init(
|
|
472
|
-
self.wsManager = WebSocketManager(url: url)
|
|
473
|
-
}
|
|
361
|
+
init(channel: SocketChannel) { self.channel = channel }
|
|
474
362
|
|
|
475
363
|
func connect() async {
|
|
476
|
-
|
|
477
|
-
let stream = await
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
for await event in stream {
|
|
481
|
-
await handleEvent(event)
|
|
482
|
-
}
|
|
364
|
+
state = .connecting
|
|
365
|
+
let stream = await channel.events()
|
|
366
|
+
listener = Task {
|
|
367
|
+
for await event in stream { handle(event) }
|
|
483
368
|
}
|
|
484
369
|
}
|
|
485
370
|
|
|
486
|
-
func
|
|
371
|
+
func post(_ text: String) async {
|
|
487
372
|
do {
|
|
488
|
-
try await
|
|
489
|
-
|
|
373
|
+
try await channel.send(text)
|
|
374
|
+
lines.append(Line(text: text, isMine: true))
|
|
490
375
|
} catch {
|
|
491
|
-
|
|
376
|
+
state = .offline
|
|
492
377
|
}
|
|
493
378
|
}
|
|
494
379
|
|
|
495
380
|
func disconnect() async {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
connectionState = .disconnected
|
|
381
|
+
listener?.cancel()
|
|
382
|
+
await channel.close()
|
|
383
|
+
state = .offline
|
|
500
384
|
}
|
|
501
385
|
|
|
502
|
-
private func
|
|
386
|
+
private func handle(_ event: SocketChannel.Event) {
|
|
503
387
|
switch event {
|
|
504
|
-
case .
|
|
505
|
-
|
|
506
|
-
case .
|
|
507
|
-
|
|
508
|
-
case .
|
|
509
|
-
if let text = String(data: data, encoding: .utf8) {
|
|
510
|
-
messages.append(ChatMessage(text: text, isOutgoing: false))
|
|
511
|
-
}
|
|
512
|
-
case .disconnected:
|
|
513
|
-
connectionState = .disconnected
|
|
514
|
-
case .error:
|
|
515
|
-
connectionState = .disconnected
|
|
516
|
-
// Optionally trigger reconnection
|
|
388
|
+
case .opened: state = .online
|
|
389
|
+
case .text(let text): lines.append(Line(text: text, isMine: false))
|
|
390
|
+
case .binary(let blob):
|
|
391
|
+
lines.append(Line(text: String(decoding: blob, as: UTF8.self), isMine: false))
|
|
392
|
+
case .closed, .failed: state = .offline
|
|
517
393
|
}
|
|
518
394
|
}
|
|
519
395
|
}
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
```swift
|
|
523
|
-
struct ChatView: View {
|
|
524
|
-
@State var store: ChatStore
|
|
525
396
|
|
|
397
|
+
struct RoomView: View {
|
|
398
|
+
let model: RoomModel
|
|
526
399
|
var body: some View {
|
|
527
|
-
List(
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
.task { await store.connect() }
|
|
531
|
-
.onDisappear { Task { await store.disconnect() } }
|
|
400
|
+
List(model.lines) { Text($0.text) }
|
|
401
|
+
.task { await model.connect() }
|
|
402
|
+
.onDisappear { Task { await model.disconnect() } }
|
|
532
403
|
}
|
|
533
404
|
}
|
|
534
405
|
```
|
|
535
406
|
|
|
536
|
-
|
|
407
|
+
A sent message is appended only after `send` succeeds. On `.closed` or
|
|
408
|
+
`.failed` the model goes offline; reconnecting is the next section.
|
|
537
409
|
|
|
538
|
-
##
|
|
410
|
+
## Reconnecting a dropped socket
|
|
539
411
|
|
|
540
|
-
|
|
541
|
-
|
|
412
|
+
Sockets drop. A durable client reconnects on its own with exponential
|
|
413
|
+
backoff.
|
|
542
414
|
|
|
543
415
|
```swift
|
|
544
|
-
|
|
545
|
-
actor ReconnectingWebSocket {
|
|
416
|
+
actor ResilientChannel {
|
|
546
417
|
private let url: URL
|
|
547
|
-
private let
|
|
548
|
-
private let
|
|
549
|
-
private let
|
|
550
|
-
private
|
|
551
|
-
|
|
552
|
-
private var
|
|
553
|
-
private var reconnectAttempts = 0
|
|
554
|
-
private var isIntentionalDisconnect = false
|
|
555
|
-
|
|
556
|
-
init(
|
|
557
|
-
url: URL,
|
|
558
|
-
session: URLSession = .shared,
|
|
559
|
-
maxReconnectAttempts: Int = 10,
|
|
560
|
-
initialDelay: Duration = .seconds(1),
|
|
561
|
-
maxDelay: Duration = .seconds(60)
|
|
562
|
-
) {
|
|
563
|
-
self.url = url
|
|
564
|
-
self.session = session
|
|
565
|
-
self.maxReconnectAttempts = maxReconnectAttempts
|
|
566
|
-
self.initialDelay = initialDelay
|
|
567
|
-
self.maxDelay = maxDelay
|
|
568
|
-
}
|
|
418
|
+
private let maxAttempts = 10
|
|
419
|
+
private let firstDelay: Duration = .seconds(1)
|
|
420
|
+
private let ceiling: Duration = .seconds(60)
|
|
421
|
+
private var attempts = 0
|
|
422
|
+
private var stoppedOnPurpose = false
|
|
423
|
+
private var current: SocketChannel?
|
|
569
424
|
|
|
570
|
-
|
|
571
|
-
func events() -> AsyncStream<WebSocketManager.Event> {
|
|
572
|
-
AsyncStream { continuation in
|
|
573
|
-
Task {
|
|
574
|
-
await connectWithReconnection(continuation: continuation)
|
|
575
|
-
}
|
|
576
|
-
continuation.onTermination = { _ in
|
|
577
|
-
Task { [weak self] in
|
|
578
|
-
await self?.intentionalDisconnect()
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
}
|
|
425
|
+
init(url: URL) { self.url = url }
|
|
583
426
|
|
|
584
|
-
|
|
585
|
-
continuation
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
case .connected:
|
|
597
|
-
reconnectAttempts = 0 // Reset on successful connection
|
|
598
|
-
continuation.yield(event)
|
|
599
|
-
case .error, .disconnected:
|
|
600
|
-
continuation.yield(event)
|
|
601
|
-
default:
|
|
602
|
-
continuation.yield(event)
|
|
427
|
+
func events() -> AsyncStream<SocketChannel.Event> {
|
|
428
|
+
AsyncStream { continuation in
|
|
429
|
+
let loop = Task {
|
|
430
|
+
while shouldContinue() {
|
|
431
|
+
let channel = fresh()
|
|
432
|
+
for await event in await channel.events() {
|
|
433
|
+
if case .opened = event { resetAttempts() }
|
|
434
|
+
continuation.yield(event)
|
|
435
|
+
}
|
|
436
|
+
if Task.isCancelled || isStopped() { break }
|
|
437
|
+
let wait = nextDelay()
|
|
438
|
+
if (try? await Task.sleep(for: wait)) == nil { break }
|
|
603
439
|
}
|
|
440
|
+
continuation.finish()
|
|
604
441
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
reconnectAttempts += 1
|
|
610
|
-
let delay = calculateBackoff()
|
|
611
|
-
do {
|
|
612
|
-
try await Task.sleep(for: delay)
|
|
613
|
-
} catch {
|
|
614
|
-
break // Cancelled during sleep
|
|
442
|
+
continuation.onTermination = { _ in
|
|
443
|
+
loop.cancel()
|
|
444
|
+
Task { await self.stop() }
|
|
615
445
|
}
|
|
616
446
|
}
|
|
617
|
-
|
|
618
|
-
continuation.finish()
|
|
619
447
|
}
|
|
620
448
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
let jitter = Double.random(in: 0...(capped * 0.25))
|
|
625
|
-
return .seconds(capped + jitter)
|
|
449
|
+
func stop() async {
|
|
450
|
+
stoppedOnPurpose = true
|
|
451
|
+
await current?.close()
|
|
626
452
|
}
|
|
627
453
|
|
|
628
|
-
func
|
|
629
|
-
|
|
630
|
-
}
|
|
454
|
+
private func shouldContinue() -> Bool { !stoppedOnPurpose && attempts < maxAttempts }
|
|
455
|
+
private func isStopped() -> Bool { stoppedOnPurpose }
|
|
456
|
+
private func resetAttempts() { attempts = 0 }
|
|
631
457
|
|
|
632
|
-
func
|
|
633
|
-
|
|
458
|
+
private func fresh() -> SocketChannel {
|
|
459
|
+
let channel = SocketChannel(address: url)
|
|
460
|
+
current = channel
|
|
461
|
+
return channel
|
|
634
462
|
}
|
|
635
463
|
|
|
636
|
-
private func
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
464
|
+
private func nextDelay() -> Duration {
|
|
465
|
+
attempts += 1
|
|
466
|
+
let grown = firstDelay * Int(pow(2.0, Double(attempts - 1)))
|
|
467
|
+
let capped = min(grown, ceiling)
|
|
468
|
+
return capped + capped * Double.random(in: 0...0.25)
|
|
641
469
|
}
|
|
642
470
|
}
|
|
643
471
|
```
|
|
644
472
|
|
|
645
|
-
|
|
473
|
+
- Each attempt uses a new channel, and every event is passed through.
|
|
474
|
+
- The `Task` made inside `events()` inherits the actor's isolation, so the
|
|
475
|
+
actor's own helpers are called without `await`; only the other actor
|
|
476
|
+
(`channel.events()`) needs one.
|
|
477
|
+
- A successful open resets the attempt counter.
|
|
478
|
+
- After a stream ends: stop if the disconnect was intentional or the task
|
|
479
|
+
was cancelled; otherwise count the attempt and sleep. A cancelled sleep ends
|
|
480
|
+
the loop.
|
|
481
|
+
- Delay is `firstDelay * 2^(attempt - 1)`, capped at 60 seconds, plus up to
|
|
482
|
+
25% jitter; at most 10 attempts.
|
|
483
|
+
- Ending the outer stream marks the stop as intentional and closes the live
|
|
484
|
+
channel.
|
|
646
485
|
|
|
647
|
-
##
|
|
486
|
+
## Typed messages over a socket
|
|
648
487
|
|
|
649
|
-
|
|
650
|
-
|
|
488
|
+
Wrap typed messages in an envelope that names the type, so one socket can
|
|
489
|
+
carry many message kinds.
|
|
651
490
|
|
|
652
491
|
```swift
|
|
653
|
-
protocol
|
|
654
|
-
static var
|
|
492
|
+
protocol SocketMessage: Codable, Sendable {
|
|
493
|
+
static var kind: String { get }
|
|
655
494
|
}
|
|
656
495
|
|
|
657
|
-
struct
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
496
|
+
struct Envelope: Codable, Sendable {
|
|
497
|
+
let kind: String
|
|
498
|
+
let payload: Data
|
|
499
|
+
}
|
|
661
500
|
|
|
662
|
-
|
|
663
|
-
|
|
501
|
+
struct TypedChannel: Sendable {
|
|
502
|
+
enum Incoming: Sendable {
|
|
503
|
+
case opened
|
|
504
|
+
case message(kind: String, payload: Data)
|
|
505
|
+
case closed(URLSessionWebSocketTask.CloseCode)
|
|
506
|
+
case failed(any Error)
|
|
664
507
|
}
|
|
665
508
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
)
|
|
671
|
-
|
|
672
|
-
try await manager.send(data: data)
|
|
509
|
+
let channel: SocketChannel
|
|
510
|
+
|
|
511
|
+
func send<M: SocketMessage>(_ message: M) async throws {
|
|
512
|
+
let envelope = Envelope(kind: M.kind, payload: try JSONEncoder().encode(message))
|
|
513
|
+
let frame = try JSONEncoder().encode(envelope)
|
|
514
|
+
try await channel.send(String(decoding: frame, as: UTF8.self))
|
|
673
515
|
}
|
|
674
516
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
let rawEvents = await manager.events()
|
|
517
|
+
func incoming() async -> AsyncStream<Incoming> {
|
|
518
|
+
let raw = await channel.events()
|
|
678
519
|
return AsyncStream { continuation in
|
|
679
|
-
Task {
|
|
680
|
-
for await event in
|
|
520
|
+
let pump = Task {
|
|
521
|
+
for await event in raw {
|
|
681
522
|
switch event {
|
|
682
|
-
case .
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
case .
|
|
687
|
-
if let data = text.data(using: .utf8),
|
|
688
|
-
let envelope = try? decoder.decode(MessageEnvelope.self, from: data) {
|
|
689
|
-
continuation.yield(.message(type: envelope.type, payload: envelope.payload))
|
|
690
|
-
}
|
|
691
|
-
case .connected:
|
|
692
|
-
continuation.yield(.connected)
|
|
693
|
-
case .disconnected(let code, _):
|
|
694
|
-
continuation.yield(.disconnected(code))
|
|
695
|
-
case .error(let error):
|
|
696
|
-
continuation.yield(.error(error))
|
|
523
|
+
case .opened: continuation.yield(.opened)
|
|
524
|
+
case .text(let text): yieldEnvelope(Data(text.utf8), to: continuation)
|
|
525
|
+
case .binary(let data): yieldEnvelope(data, to: continuation)
|
|
526
|
+
case .closed(let code, _): continuation.yield(.closed(code))
|
|
527
|
+
case .failed(let problem): continuation.yield(.failed(problem))
|
|
697
528
|
}
|
|
698
529
|
}
|
|
699
530
|
continuation.finish()
|
|
700
531
|
}
|
|
532
|
+
continuation.onTermination = { _ in pump.cancel() }
|
|
701
533
|
}
|
|
702
534
|
}
|
|
703
535
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
case disconnected(URLSessionWebSocketTask.CloseCode)
|
|
708
|
-
case error(Error)
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
private struct MessageEnvelope: Codable, Sendable {
|
|
712
|
-
let type: String
|
|
713
|
-
let payload: Data
|
|
536
|
+
private func yieldEnvelope(_ data: Data, to continuation: AsyncStream<Incoming>.Continuation) {
|
|
537
|
+
guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data) else { return }
|
|
538
|
+
continuation.yield(.message(kind: envelope.kind, payload: envelope.payload))
|
|
714
539
|
}
|
|
715
540
|
}
|
|
716
541
|
```
|
|
717
542
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
`application(_:handleEventsForBackgroundURLSession:completionHandler:)`
|
|
749
|
-
and invoke it in `urlSessionDidFinishEvents(forBackgroundURLSession:)`
|
|
750
|
-
on the main thread.
|
|
751
|
-
|
|
752
|
-
### Test on a real device
|
|
753
|
-
Background session behavior differs significantly between the Simulator
|
|
754
|
-
and real devices. Always test background transfers on hardware.
|
|
755
|
-
|
|
756
|
-
---
|
|
757
|
-
|
|
758
|
-
## Combining Background Downloads with SwiftUI Progress
|
|
759
|
-
|
|
760
|
-
Bridge the delegate-based background download to an `@Observable` model
|
|
761
|
-
for live UI updates.
|
|
543
|
+
Frames that are not valid envelopes are dropped. The receiver switches on
|
|
544
|
+
`kind` and decodes `payload` into the matching `SocketMessage` type. The
|
|
545
|
+
channel above sends text frames; add a `send(data:)` to it if the server
|
|
546
|
+
expects binary envelopes.
|
|
547
|
+
|
|
548
|
+
## Pitfalls of background sessions
|
|
549
|
+
|
|
550
|
+
- Session identifiers must be unique within the app. Two sessions sharing one
|
|
551
|
+
can deliver events to the wrong delegate. Prefix with the bundle ID.
|
|
552
|
+
- `data(for:)` and `download(for:)` are not available on a background
|
|
553
|
+
session.
|
|
554
|
+
- Only download and upload tasks run there. Data tasks do not; turn a data
|
|
555
|
+
request into a download if it must survive.
|
|
556
|
+
- WebSocket tasks are not background transfers. Reconnect when the app
|
|
557
|
+
becomes active again.
|
|
558
|
+
- Write task identifiers and planned destinations to disk. Memory does not
|
|
559
|
+
survive a background relaunch.
|
|
560
|
+
- A user force-quit is not a system termination: transfers are cancelled and
|
|
561
|
+
the app is not relaunched for them.
|
|
562
|
+
- Move or open the `location` file inside `didFinishDownloadingTo`.
|
|
563
|
+
- The app delegate's background-session callback hands over a completion
|
|
564
|
+
closure. Hold on to it, then invoke it a single time on the main thread
|
|
565
|
+
when `urlSessionDidFinishEvents(forBackgroundURLSession:)` arrives.
|
|
566
|
+
- Background scheduling in Simulator does not match a device. Test on
|
|
567
|
+
hardware.
|
|
568
|
+
|
|
569
|
+
## Showing download progress in SwiftUI
|
|
570
|
+
|
|
571
|
+
Delegate callbacks run on the session queue. Bridge them into an observable
|
|
572
|
+
model on the main actor:
|
|
762
573
|
|
|
763
574
|
```swift
|
|
764
|
-
@MainActor
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
var
|
|
770
|
-
var state: State = .downloading
|
|
771
|
-
|
|
772
|
-
enum State: Sendable { case downloading, completed, failed }
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
func updateProgress(for url: URL, fraction: Double) {
|
|
776
|
-
downloads[url, default: DownloadProgress()].fractionCompleted = fraction
|
|
575
|
+
@MainActor @Observable
|
|
576
|
+
final class TransferBoard {
|
|
577
|
+
struct Entry {
|
|
578
|
+
enum Phase { case running, done, failed }
|
|
579
|
+
var fraction: Double = 0
|
|
580
|
+
var phase: Phase = .running
|
|
777
581
|
}
|
|
778
582
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
downloads[url]?.fractionCompleted = 1.0
|
|
782
|
-
}
|
|
583
|
+
static let shared = TransferBoard()
|
|
584
|
+
private(set) var entries: [URL: Entry] = [:]
|
|
783
585
|
|
|
784
|
-
func
|
|
785
|
-
|
|
786
|
-
}
|
|
586
|
+
func update(_ url: URL, fraction: Double) { entries[url, default: Entry()].fraction = fraction }
|
|
587
|
+
func finish(_ url: URL) { entries[url] = Entry(fraction: 1, phase: .done) }
|
|
588
|
+
func fail(_ url: URL) { entries[url, default: Entry()].phase = .failed }
|
|
787
589
|
}
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
Wire the delegate to the tracker:
|
|
791
590
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
func reportProgress(for url: URL, fraction: Double) {
|
|
796
|
-
Task { @MainActor in
|
|
797
|
-
downloadTracker.updateProgress(for: url, fraction: fraction)
|
|
798
|
-
}
|
|
591
|
+
extension BackgroundTransferService {
|
|
592
|
+
func reportProgress(_ fraction: Double, for url: URL) {
|
|
593
|
+
Task { @MainActor in TransferBoard.shared.update(url, fraction: fraction) }
|
|
799
594
|
}
|
|
800
595
|
}
|
|
801
596
|
```
|
|
802
597
|
|
|
803
|
-
---
|
|
804
|
-
|
|
805
598
|
## WebSocket Authentication
|
|
806
599
|
|
|
807
|
-
|
|
808
|
-
|
|
600
|
+
Authenticate during the opening handshake, with either a query parameter or
|
|
601
|
+
a header:
|
|
809
602
|
|
|
810
603
|
```swift
|
|
811
|
-
func
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else {
|
|
817
|
-
preconditionFailure("Invalid URL components for: \(baseURL)")
|
|
818
|
-
}
|
|
819
|
-
components.queryItems = [URLQueryItem(name: "token", value: token)]
|
|
820
|
-
guard let authenticatedURL = components.url else {
|
|
821
|
-
preconditionFailure("Failed to construct URL from components")
|
|
822
|
-
}
|
|
823
|
-
let task = URLSession.shared.webSocketTask(with: authenticatedURL)
|
|
824
|
-
|
|
825
|
-
// Option 2: Token as custom header (use URLRequest)
|
|
826
|
-
var request = URLRequest(url: baseURL)
|
|
827
|
-
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
828
|
-
let taskWithHeader = URLSession.shared.webSocketTask(with: request)
|
|
829
|
-
|
|
830
|
-
return taskWithHeader
|
|
604
|
+
func socketUsingQuery(_ base: URL, token: String, session: URLSession) -> URLSessionWebSocketTask? {
|
|
605
|
+
guard var parts = URLComponents(url: base, resolvingAgainstBaseURL: false) else { return nil }
|
|
606
|
+
parts.queryItems = [URLQueryItem(name: "access_token", value: token)]
|
|
607
|
+
guard let url = parts.url else { return nil }
|
|
608
|
+
return session.webSocketTask(with: url)
|
|
831
609
|
}
|
|
832
|
-
```
|
|
833
610
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
611
|
+
func socketUsingHeader(_ url: URL, token: String, session: URLSession) -> URLSessionWebSocketTask {
|
|
612
|
+
var handshake = URLRequest(url: url)
|
|
613
|
+
handshake.allHTTPHeaderFields = ["Authorization": "Bearer " + token]
|
|
614
|
+
return session.webSocketTask(with: handshake)
|
|
615
|
+
}
|
|
616
|
+
```
|
|
837
617
|
|
|
838
|
-
|
|
618
|
+
Prefer the header. Query strings tend to end up in server and proxy access
|
|
619
|
+
logs.
|
|
839
620
|
|
|
840
|
-
##
|
|
621
|
+
## Choosing a subprotocol
|
|
841
622
|
|
|
842
623
|
```swift
|
|
843
|
-
|
|
844
|
-
let task = URLSession.shared.webSocketTask(
|
|
845
|
-
with: url,
|
|
846
|
-
protocols: ["graphql-transport-ws"]
|
|
847
|
-
)
|
|
848
|
-
task.resume()
|
|
849
|
-
|
|
850
|
-
// After connection, verify the negotiated protocol
|
|
851
|
-
// via the URLSessionWebSocketDelegate
|
|
852
|
-
```
|
|
624
|
+
let socket = session.webSocketTask(with: endpoint, protocols: ["graphql-transport-ws"])
|
|
853
625
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
nonisolated func urlSession(
|
|
865
|
-
_ session: URLSession,
|
|
866
|
-
webSocketTask: URLSessionWebSocketTask,
|
|
867
|
-
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
|
868
|
-
reason: Data?
|
|
869
|
-
) {
|
|
870
|
-
let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) }
|
|
871
|
-
print("Closed: \(closeCode) - \(reasonString ?? "no reason")")
|
|
626
|
+
final class SocketObserver: NSObject, URLSessionWebSocketDelegate {
|
|
627
|
+
func urlSession(_: URLSession, webSocketTask socket: URLSessionWebSocketTask,
|
|
628
|
+
didOpenWithProtocol chosen: String?) {
|
|
629
|
+
if chosen != "graphql-transport-ws" { socket.cancel(with: .protocolError, reason: nil) }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
func urlSession(_: URLSession, webSocketTask _: URLSessionWebSocketTask,
|
|
633
|
+
didCloseWith code: URLSessionWebSocketTask.CloseCode, reason _: Data?) {
|
|
634
|
+
Logger.transfers.info("Socket closed with code \(code.rawValue)")
|
|
872
635
|
}
|
|
873
636
|
}
|
|
874
637
|
```
|
|
638
|
+
|
|
639
|
+
Check the protocol the server actually chose in `didOpenWithProtocol`. Close
|
|
640
|
+
events report a `CloseCode` and optional reason data.
|
|
641
|
+
|
|
642
|
+
The snippets on this page log through a shared logger:
|
|
643
|
+
|
|
644
|
+
```swift
|
|
645
|
+
import OSLog
|
|
646
|
+
extension Logger { static let transfers = Logger(subsystem: "app.networking", category: "transfers") }
|
|
647
|
+
```
|