@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.
Files changed (264) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +0 -10
  3. package/docs/facts.json +1 -1
  4. package/manifest.json +266 -267
  5. package/package.json +2 -2
  6. package/pipeline/scripts/_notices.mjs +1 -1
  7. package/pipeline/skills/.skill-manifest.json +68 -68
  8. package/pipeline/skills/shared/README.md +70 -70
  9. package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
  10. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
  11. package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
  12. package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
  13. package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
  14. package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
  15. package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
  16. package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
  17. package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
  18. package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
  19. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
  20. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +339 -277
  21. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
  22. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +105 -122
  23. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +143 -166
  24. package/pipeline/skills/shared/external/app-store-review/SKILL.md +307 -326
  25. package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
  26. package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
  27. package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
  28. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +333 -360
  29. package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
  30. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
  31. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
  32. package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
  33. package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
  34. package/pipeline/skills/shared/external/authentication/SKILL.md +265 -381
  35. package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
  36. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +133 -178
  37. package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
  38. package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
  39. package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
  40. package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
  41. package/pipeline/skills/shared/external/background-processing/SKILL.md +270 -382
  42. package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
  43. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +169 -317
  44. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
  45. package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
  46. package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
  47. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
  48. package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
  49. package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
  50. package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
  51. package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
  52. package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
  53. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +226 -376
  54. package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
  55. package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
  56. package/pipeline/skills/shared/external/core-data/SKILL.md +292 -368
  57. package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
  58. package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
  59. package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
  60. package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
  61. package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
  62. package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
  63. package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
  64. package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
  65. package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
  66. package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
  67. package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
  68. package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
  69. package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
  70. package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
  71. package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
  72. package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
  73. package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
  74. package/pipeline/skills/shared/external/device-integrity/SKILL.md +230 -353
  75. package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
  76. package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
  77. package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
  78. package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
  79. package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
  80. package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
  81. package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
  82. package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
  83. package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
  84. package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
  85. package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
  86. package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
  87. package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
  88. package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
  89. package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
  90. package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
  91. package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
  92. package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
  93. package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
  94. package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
  95. package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
  96. package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
  97. package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
  98. package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
  99. package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
  100. package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
  101. package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
  102. package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
  103. package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
  104. package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
  105. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
  106. package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
  107. package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
  108. package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
  109. package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
  110. package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
  111. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +295 -267
  112. package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
  113. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
  114. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
  115. package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
  116. package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
  117. package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
  118. package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
  119. package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
  120. package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
  121. package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
  122. package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
  123. package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
  124. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
  125. package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
  126. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
  127. package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
  128. package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
  129. package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
  130. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
  131. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
  132. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
  133. package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
  134. package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
  135. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
  136. package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
  137. package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
  138. package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
  139. package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
  140. package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
  141. package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
  142. package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
  143. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
  144. package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
  145. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
  146. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
  147. package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
  148. package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
  149. package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
  150. package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
  151. package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
  152. package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
  153. package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
  154. package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
  155. package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
  156. package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
  157. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +298 -242
  158. package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
  159. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
  160. package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
  161. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
  162. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
  163. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
  164. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
  165. package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
  166. package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
  167. package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
  168. package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
  169. package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
  170. package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
  171. package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
  172. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +303 -351
  173. package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
  174. package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
  175. package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
  176. package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
  177. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
  178. package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
  179. package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
  180. package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
  181. package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
  182. package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
  183. package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
  184. package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
  185. package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
  186. package/pipeline/skills/shared/external/swift-security/SKILL.md +180 -161
  187. package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
  188. package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
  189. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +408 -476
  190. package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
  191. package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
  192. package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
  193. package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
  194. package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
  195. package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
  196. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +352 -472
  197. package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
  198. package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
  199. package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
  200. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +396 -457
  201. package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
  202. package/pipeline/skills/shared/external/swift-testing/SKILL.md +188 -175
  203. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
  204. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +80 -84
  205. package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
  206. package/pipeline/skills/shared/external/swiftdata/SKILL.md +392 -256
  207. package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
  208. package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
  209. package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
  210. package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
  211. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
  212. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
  213. package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
  214. package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
  215. package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
  216. package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
  217. package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
  218. package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
  219. package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
  220. package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
  221. package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
  222. package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
  223. package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
  224. package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
  225. package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
  226. package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
  227. package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
  228. package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
  229. package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
  230. package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
  231. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +193 -168
  232. package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
  233. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +132 -133
  234. package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
  235. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +106 -140
  236. package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
  237. package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
  238. package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
  239. package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
  240. package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
  241. package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
  242. package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
  243. package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
  244. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
  245. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
  246. package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
  247. package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
  248. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
  249. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
  250. package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
  251. package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
  252. package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
  253. package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
  254. package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
  255. package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
  256. package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
  257. package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
  258. package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
  259. package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
  260. package/pipeline/skills/shared/external/weatherkit/SKILL.md +152 -310
  261. package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
  262. package/pipeline/skills/shared/external/widgetkit/SKILL.md +216 -288
  263. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +414 -719
  264. package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
@@ -1,874 +1,647 @@
1
- # Background Transfers and WebSocket
1
+ # Background Transfers and WebSockets
2
2
 
3
- Patterns for background URLSession downloads/uploads and
4
- URLSessionWebSocketTask with structured concurrency.
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
- - [Background URLSession Configuration](#background-urlsession-configuration)
11
- - [Background Download Tasks](#background-download-tasks)
12
- - [Handling Background Session Events](#handling-background-session-events)
13
- - [Background Upload Tasks](#background-upload-tasks)
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
- - [WebSocket Reconnection Strategy](#websocket-reconnection-strategy)
16
- - [WebSocket with Codable Messages](#websocket-with-codable-messages)
17
- - [Background Session Gotchas](#background-session-gotchas)
18
- - [Combining Background Downloads with SwiftUI Progress](#combining-background-downloads-with-swiftui-progress)
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
- - [WebSocket Subprotocol Negotiation](#websocket-subprotocol-negotiation)
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
- ### Why Background Sessions
21
+ ## Setting up a background session
29
22
 
30
- - Downloads/uploads survive app suspension, system termination, and device restarts.
31
- - The system handles retries for network failures automatically.
32
- - Required for any transfer the user expects to complete even if they
33
- switch away from the app (e.g., file sync, media downloads).
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
- If the user force-quits the app from the multitasking screen, iOS cancels the
36
- background transfers and does not relaunch the app until the user opens it
37
- again.
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
- @available(iOS 15.0, *)
43
- final class BackgroundDownloadManager: NSObject, Sendable {
44
- static let shared = BackgroundDownloadManager()
38
+ final class BackgroundTransferService: NSObject, @unchecked Sendable {
39
+ static let shared = BackgroundTransferService()
40
+ static let identifier = "com.example.reader.transfers"
45
41
 
46
- /// Use a unique identifier tied to your app's bundle ID.
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
- withIdentifier: sessionID
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 // Respect Low Data Mode
60
- config.timeoutIntervalForResource = 24 * 60 * 60 // 24 hours
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
- ### Key Configuration Options
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
- | Property | Effect |
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
- ## Background Download Tasks
80
+ ## Downloads that outlive the app
87
81
 
88
- Background downloads must use `downloadTask(with:)`, not `data(for:)`.
89
- The async/await overloads are not supported for background sessions --
90
- you must use the delegate pattern.
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 BackgroundDownloadManager {
94
- func startDownload(from url: URL) -> URLSessionDownloadTask {
95
- let task = session.downloadTask(with: url)
96
- task.earliestBeginDate = Date() // Start now
97
- task.countOfBytesClientExpectsToSend = 0
98
- task.countOfBytesClientExpectsToReceive = 50 * 1024 * 1024 // Estimated size
99
- task.resume()
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 startDownload(from url: URL, resumeData: Data) -> URLSessionDownloadTask {
104
- let task = session.downloadTask(withResumeData: resumeData)
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 BackgroundDownloadManager: URLSessionDownloadDelegate {
115
- nonisolated func urlSession(
116
- _ session: URLSession,
117
- downloadTask: URLSessionDownloadTask,
118
- didFinishDownloadingTo location: URL
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
- // Remove existing file if present
132
- if FileManager.default.fileExists(atPath: destination.path) {
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
- // Handle file move failure
117
+ Logger.transfers.error("Could not keep download: \(error.localizedDescription)")
139
118
  }
140
119
  }
141
120
 
142
- nonisolated func urlSession(
143
- _ session: URLSession,
144
- downloadTask: URLSessionDownloadTask,
145
- didWriteData bytesWritten: Int64,
146
- totalBytesWritten: Int64,
147
- totalBytesExpectedToWrite: Int64
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
- private func saveResumeData(_ data: Data, for task: URLSessionTask) {
170
- // Persist resume data to disk for later retry
171
- let key = task.originalRequest?.url?.absoluteString ?? ""
172
- let path = FileManager.default.temporaryDirectory
173
- .appendingPathComponent("resume-\(key.hashValue)")
174
- try? data.write(to: path)
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
- ## Handling Background Session Events
151
+ ## Relaunch and session event delivery
182
152
 
183
- When the system completes a background transfer and the app is not
184
- running, it relaunches the app and calls the `AppDelegate` method. If you use
185
- the completion-handler overload, call the system's completion handler after
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: UIResponder, UIApplicationDelegate {
192
- func application(
193
- _ application: UIApplication,
194
- handleEventsForBackgroundURLSession identifier: String,
195
- completionHandler: @escaping () -> Void
196
- ) {
197
- // Store the completion handler. The BackgroundDownloadManager will
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
- ### Session-Level Delegate
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
- backgroundCompletionHandler?()
219
- backgroundCompletionHandler = nil
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 MyApp: App {
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
- **Important:** For the handler-based `UIApplicationDelegate` overload, the
241
- completion handler must be called exactly once and on the main thread. Failing
242
- to call it causes the system to take a snapshot of the app in the wrong state
243
- and may waste background runtime.
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
- ## Background Upload Tasks
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 BackgroundDownloadManager {
253
- func startUpload(
254
- to url: URL,
255
- fileURL: URL,
256
- method: String = "POST",
257
- headers: [String: String] = [:]
258
- ) -> URLSessionUploadTask {
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
- ```swift
275
- extension BackgroundDownloadManager {
276
- nonisolated func urlSession(
277
- _ session: URLSession,
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
- **Constraints of background uploads:**
291
- - Data must come from a file (`uploadTask(with:fromFile:)`).
292
- - `uploadTask(with:from: Data)` is not supported in background sessions.
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` provides native WebSocket support without
300
- third-party libraries. Available since iOS 13. WebSockets use `ws:` or `wss:`
301
- URLs and are foreground/default-session realtime networking; background
302
- URLSession configuration does not make a WebSocket connection durable after
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
- @available(iOS 15.0, *)
309
- final class WebSocketConnection: Sendable {
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
- func connect() {
317
- task.resume()
235
+ init(address: URL, using session: URLSession) {
236
+ socket = session.webSocketTask(with: address)
318
237
  }
319
238
 
320
- func disconnect(reason: String? = nil) {
321
- task.cancel(with: .normalClosure, reason: reason?.data(using: .utf8))
322
- }
239
+ func open() { socket.resume() }
323
240
 
324
- func send(_ message: URLSessionWebSocketTask.Message) async throws {
325
- try await task.send(message)
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 send(text: String) async throws {
329
- try await task.send(.string(text))
246
+ func write(_ line: String) async throws {
247
+ try await socket.send(.string(line))
330
248
  }
331
-
332
- func send(data: Data) async throws {
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
- func receive() async throws -> URLSessionWebSocketTask.Message {
337
- try await task.receive()
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
- The key pattern: run a receive loop as an async task that yields
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
- @available(iOS 15.0, *)
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 connected
266
+ case opened
358
267
  case text(String)
359
- case data(Data)
360
- case disconnected(URLSessionWebSocketTask.CloseCode, Data?)
361
- case error(Error)
268
+ case binary(Data)
269
+ case closed(URLSessionWebSocketTask.CloseCode, Data?)
270
+ case failed(any Error)
362
271
  }
363
272
 
364
- init(url: URL, session: URLSession = .shared) {
365
- self.url = url
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
- AsyncStream { continuation in
372
- let wsTask = session.webSocketTask(with: url)
373
- self.task = wsTask
374
-
375
- wsTask.resume()
376
- continuation.yield(.connected)
377
-
378
- // Start the receive loop
379
- self.receiveTask = Task { [weak self] in
380
- await self?.receiveLoop(continuation: continuation)
381
- }
382
-
383
- continuation.onTermination = { _ in
384
- Task { [weak self] in
385
- await self?.disconnect()
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 task?.send(.string(text))
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
- /// Send periodic pings to keep the connection alive
437
- func startPinging(interval: Duration = .seconds(30)) {
438
- Task { [weak self] in
439
- while !Task.isCancelled {
440
- try? await Task.sleep(for: interval)
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
- private func ping() {
448
- task?.sendPing { error in
449
- if let error {
450
- // Connection may be dead
451
- print("Ping failed: \(error)")
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
- @Observable final class ChatStore {
463
- var messages: [ChatMessage] = []
464
- var connectionState: ConnectionState = .disconnected
350
+ @MainActor @Observable
351
+ final class RoomModel {
352
+ enum LinkState { case offline, connecting, online }
465
353
 
466
- enum ConnectionState { case disconnected, connecting, connected }
354
+ struct Line: Identifiable { let id = UUID(); let text: String; let isMine: Bool }
467
355
 
468
- private let wsManager: WebSocketManager
469
- private var eventTask: Task<Void, Never>?
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(url: URL) {
472
- self.wsManager = WebSocketManager(url: url)
473
- }
361
+ init(channel: SocketChannel) { self.channel = channel }
474
362
 
475
363
  func connect() async {
476
- connectionState = .connecting
477
- let stream = await wsManager.events()
478
-
479
- eventTask = Task {
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 sendMessage(_ text: String) async {
371
+ func post(_ text: String) async {
487
372
  do {
488
- try await wsManager.send(text: text)
489
- messages.append(ChatMessage(text: text, isOutgoing: true))
373
+ try await channel.send(text)
374
+ lines.append(Line(text: text, isMine: true))
490
375
  } catch {
491
- // Handle send failure
376
+ state = .offline
492
377
  }
493
378
  }
494
379
 
495
380
  func disconnect() async {
496
- eventTask?.cancel()
497
- eventTask = nil
498
- await wsManager.disconnect()
499
- connectionState = .disconnected
381
+ listener?.cancel()
382
+ await channel.close()
383
+ state = .offline
500
384
  }
501
385
 
502
- private func handleEvent(_ event: WebSocketManager.Event) async {
386
+ private func handle(_ event: SocketChannel.Event) {
503
387
  switch event {
504
- case .connected:
505
- connectionState = .connected
506
- case .text(let text):
507
- messages.append(ChatMessage(text: text, isOutgoing: false))
508
- case .data(let data):
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(store.messages) { message in
528
- ChatBubble(message: message)
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
- ## WebSocket Reconnection Strategy
410
+ ## Reconnecting a dropped socket
539
411
 
540
- Network drops happen. A robust WebSocket client must reconnect
541
- automatically with exponential backoff.
412
+ Sockets drop. A durable client reconnects on its own with exponential
413
+ backoff.
542
414
 
543
415
  ```swift
544
- @available(iOS 15.0, *)
545
- actor ReconnectingWebSocket {
416
+ actor ResilientChannel {
546
417
  private let url: URL
547
- private let session: URLSession
548
- private let maxReconnectAttempts: Int
549
- private let initialDelay: Duration
550
- private let maxDelay: Duration
551
-
552
- private var currentManager: WebSocketManager?
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
- /// Returns a stream that automatically reconnects on disconnection.
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
- private func connectWithReconnection(
585
- continuation: AsyncStream<WebSocketManager.Event>.Continuation
586
- ) async {
587
- while !isIntentionalDisconnect && reconnectAttempts < maxReconnectAttempts {
588
- guard !Task.isCancelled else { break }
589
-
590
- let manager = WebSocketManager(url: url, session: session)
591
- currentManager = manager
592
- let stream = await manager.events()
593
-
594
- for await event in stream {
595
- switch event {
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
- // Stream ended -- attempt reconnection unless intentional
607
- guard !isIntentionalDisconnect, !Task.isCancelled else { break }
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
- private func calculateBackoff() -> Duration {
622
- let base = Double(initialDelay.components.seconds) * pow(2.0, Double(reconnectAttempts - 1))
623
- let capped = min(base, Double(maxDelay.components.seconds))
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 send(text: String) async throws {
629
- try await currentManager?.send(text: text)
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 send(data: Data) async throws {
633
- try await currentManager?.send(data: data)
458
+ private func fresh() -> SocketChannel {
459
+ let channel = SocketChannel(address: url)
460
+ current = channel
461
+ return channel
634
462
  }
635
463
 
636
- private func intentionalDisconnect() {
637
- isIntentionalDisconnect = true
638
- Task {
639
- await currentManager?.disconnect()
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
- ## WebSocket with Codable Messages
486
+ ## Typed messages over a socket
648
487
 
649
- For typed message protocols (common in chat, gaming, real-time apps),
650
- decode/encode messages automatically.
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 WebSocketMessage: Codable, Sendable {
654
- static var messageType: String { get }
492
+ protocol SocketMessage: Codable, Sendable {
493
+ static var kind: String { get }
655
494
  }
656
495
 
657
- struct TypedWebSocketTransport {
658
- private let manager: WebSocketManager
659
- private let encoder = JSONEncoder()
660
- private let decoder = JSONDecoder()
496
+ struct Envelope: Codable, Sendable {
497
+ let kind: String
498
+ let payload: Data
499
+ }
661
500
 
662
- init(manager: WebSocketManager) {
663
- self.manager = manager
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
- func send<T: WebSocketMessage>(_ message: T) async throws {
667
- let envelope = MessageEnvelope(
668
- type: T.messageType,
669
- payload: try encoder.encode(message)
670
- )
671
- let data = try encoder.encode(envelope)
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
- /// Typed event stream that decodes known message types
676
- func typedEvents() async -> AsyncStream<DecodedEvent> {
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 rawEvents {
520
+ let pump = Task {
521
+ for await event in raw {
681
522
  switch event {
682
- case .data(let data):
683
- if let envelope = try? decoder.decode(MessageEnvelope.self, from: data) {
684
- continuation.yield(.message(type: envelope.type, payload: envelope.payload))
685
- }
686
- case .text(let text):
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
- enum DecodedEvent: Sendable {
705
- case connected
706
- case message(type: String, payload: Data)
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
- ## Background Session Gotchas
721
-
722
- ### The session identifier must be unique per app
723
- If two sessions share the same identifier, events may be delivered to
724
- the wrong delegate. Use your bundle identifier as a prefix.
725
-
726
- ### Background sessions do not support async/await overloads
727
- The `data(for:)` and `download(for:)` async methods are not available
728
- on background sessions. Use `downloadTask(with:)` and the delegate.
729
-
730
- ### Only download and upload tasks are supported
731
- Data tasks (`dataTask`) are not supported in background sessions. Convert
732
- data requests to download tasks if needed for background execution.
733
- WebSocket tasks are not background transfer tasks; reconnect them when the app
734
- is active again.
735
-
736
- ### The app may be terminated and relaunched
737
- Store any state you need (task identifiers, file destinations) to disk.
738
- Do not rely on in-memory state surviving a background relaunch.
739
- User force-quit is different from system termination: iOS cancels outstanding
740
- background transfers and will not relaunch the app automatically.
741
-
742
- ### File must be moved or opened in didFinishDownloadingTo
743
- The temporary file at `location` is available until the delegate method
744
- returns. Move it to preserve it, or open it for reading before returning.
745
-
746
- ### Call the system completion handler exactly once
747
- Store the completion handler from
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
- @Observable final class DownloadTracker {
766
- var downloads: [URL: DownloadProgress] = [:]
767
-
768
- struct DownloadProgress: Sendable {
769
- var fractionCompleted: Double = 0
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
- func markCompleted(for url: URL) {
780
- downloads[url]?.state = .completed
781
- downloads[url]?.fractionCompleted = 1.0
782
- }
583
+ static let shared = TransferBoard()
584
+ private(set) var entries: [URL: Entry] = [:]
783
585
 
784
- func markFailed(for url: URL) {
785
- downloads[url]?.state = .failed
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
- ```swift
793
- extension BackgroundDownloadManager {
794
- // Called from delegate methods; dispatches to MainActor
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
- WebSocket connections often require authentication via a token in the
808
- initial handshake (either as a query parameter or a custom header).
600
+ Authenticate during the opening handshake, with either a query parameter or
601
+ a header:
809
602
 
810
603
  ```swift
811
- func authenticatedWebSocket(
812
- baseURL: URL,
813
- token: String
814
- ) -> URLSessionWebSocketTask {
815
- // Option 1: Token as query parameter
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
- **Prefer the header approach** when the server supports it. Query
835
- parameters may appear in server access logs, which is a security
836
- concern for tokens.
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
- ## WebSocket Subprotocol Negotiation
621
+ ## Choosing a subprotocol
841
622
 
842
623
  ```swift
843
- // Request a specific subprotocol (e.g., graphql-ws)
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
- ```swift
855
- extension WebSocketConnection: URLSessionWebSocketDelegate {
856
- nonisolated func urlSession(
857
- _ session: URLSession,
858
- webSocketTask: URLSessionWebSocketTask,
859
- didOpenWithProtocol protocol: String?
860
- ) {
861
- print("Connected with protocol: \(`protocol` ?? "none")")
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
+ ```