@use-voltra/ios-client 2.2.0 → 2.3.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 (289) hide show
  1. package/Voltra.podspec +1 -0
  2. package/build/commonjs/events.js +23 -0
  3. package/build/commonjs/events.js.map +1 -1
  4. package/build/commonjs/index.js +59 -0
  5. package/build/commonjs/index.js.map +1 -1
  6. package/build/commonjs/live-activity/api.js +3 -1
  7. package/build/commonjs/live-activity/api.js.map +1 -1
  8. package/build/commonjs/live-activity/dynamic-api.js +173 -0
  9. package/build/commonjs/live-activity/dynamic-api.js.map +1 -0
  10. package/build/commonjs/native/NativeVoltra.js.map +1 -1
  11. package/build/commonjs/utils/enableDynamicLiveActivityHotReload.js +47 -0
  12. package/build/commonjs/utils/enableDynamicLiveActivityHotReload.js.map +1 -0
  13. package/build/commonjs/utils/enableWidgetHotReload.js +8 -11
  14. package/build/commonjs/utils/enableWidgetHotReload.js.map +1 -1
  15. package/build/commonjs/utils/useUpdateOnHMR.js +3 -7
  16. package/build/commonjs/utils/useUpdateOnHMR.js.map +1 -1
  17. package/build/commonjs/widgets/server-credentials.js +9 -0
  18. package/build/commonjs/widgets/server-credentials.js.map +1 -1
  19. package/build/commonjs/widgets/server-update.js +78 -0
  20. package/build/commonjs/widgets/server-update.js.map +1 -0
  21. package/build/module/events.js +23 -0
  22. package/build/module/events.js.map +1 -1
  23. package/build/module/index.js +3 -0
  24. package/build/module/index.js.map +1 -1
  25. package/build/module/live-activity/api.js +2 -2
  26. package/build/module/live-activity/api.js.map +1 -1
  27. package/build/module/live-activity/dynamic-api.js +167 -0
  28. package/build/module/live-activity/dynamic-api.js.map +1 -0
  29. package/build/module/native/NativeVoltra.js.map +1 -1
  30. package/build/module/utils/enableDynamicLiveActivityHotReload.js +43 -0
  31. package/build/module/utils/enableDynamicLiveActivityHotReload.js.map +1 -0
  32. package/build/module/utils/enableWidgetHotReload.js +9 -11
  33. package/build/module/utils/enableWidgetHotReload.js.map +1 -1
  34. package/build/module/utils/useUpdateOnHMR.js +3 -7
  35. package/build/module/utils/useUpdateOnHMR.js.map +1 -1
  36. package/build/module/widgets/server-credentials.js +9 -0
  37. package/build/module/widgets/server-credentials.js.map +1 -1
  38. package/build/module/widgets/server-update.js +72 -0
  39. package/build/module/widgets/server-update.js.map +1 -0
  40. package/build/typescript/commonjs/events.d.ts +7 -0
  41. package/build/typescript/commonjs/events.d.ts.map +1 -1
  42. package/build/typescript/commonjs/index.d.ts +3 -0
  43. package/build/typescript/commonjs/index.d.ts.map +1 -1
  44. package/build/typescript/commonjs/live-activity/api.d.ts +7 -0
  45. package/build/typescript/commonjs/live-activity/api.d.ts.map +1 -1
  46. package/build/typescript/commonjs/live-activity/dynamic-api.d.ts +54 -0
  47. package/build/typescript/commonjs/live-activity/dynamic-api.d.ts.map +1 -0
  48. package/build/typescript/commonjs/native/NativeVoltra.d.ts +20 -0
  49. package/build/typescript/commonjs/native/NativeVoltra.d.ts.map +1 -1
  50. package/build/typescript/commonjs/types.d.ts +1 -1
  51. package/build/typescript/commonjs/types.d.ts.map +1 -1
  52. package/build/typescript/commonjs/utils/enableDynamicLiveActivityHotReload.d.ts +15 -0
  53. package/build/typescript/commonjs/utils/enableDynamicLiveActivityHotReload.d.ts.map +1 -0
  54. package/build/typescript/commonjs/utils/enableWidgetHotReload.d.ts +5 -7
  55. package/build/typescript/commonjs/utils/enableWidgetHotReload.d.ts.map +1 -1
  56. package/build/typescript/commonjs/utils/useUpdateOnHMR.d.ts +0 -3
  57. package/build/typescript/commonjs/utils/useUpdateOnHMR.d.ts.map +1 -1
  58. package/build/typescript/commonjs/widgets/server-credentials.d.ts +8 -0
  59. package/build/typescript/commonjs/widgets/server-credentials.d.ts.map +1 -1
  60. package/build/typescript/commonjs/widgets/server-update.d.ts +64 -0
  61. package/build/typescript/commonjs/widgets/server-update.d.ts.map +1 -0
  62. package/build/typescript/module/events.d.ts +7 -0
  63. package/build/typescript/module/events.d.ts.map +1 -1
  64. package/build/typescript/module/index.d.ts +3 -0
  65. package/build/typescript/module/index.d.ts.map +1 -1
  66. package/build/typescript/module/live-activity/api.d.ts +7 -0
  67. package/build/typescript/module/live-activity/api.d.ts.map +1 -1
  68. package/build/typescript/module/live-activity/dynamic-api.d.ts +54 -0
  69. package/build/typescript/module/live-activity/dynamic-api.d.ts.map +1 -0
  70. package/build/typescript/module/native/NativeVoltra.d.ts +20 -0
  71. package/build/typescript/module/native/NativeVoltra.d.ts.map +1 -1
  72. package/build/typescript/module/types.d.ts +1 -1
  73. package/build/typescript/module/types.d.ts.map +1 -1
  74. package/build/typescript/module/utils/enableDynamicLiveActivityHotReload.d.ts +15 -0
  75. package/build/typescript/module/utils/enableDynamicLiveActivityHotReload.d.ts.map +1 -0
  76. package/build/typescript/module/utils/enableWidgetHotReload.d.ts +5 -7
  77. package/build/typescript/module/utils/enableWidgetHotReload.d.ts.map +1 -1
  78. package/build/typescript/module/utils/useUpdateOnHMR.d.ts +0 -3
  79. package/build/typescript/module/utils/useUpdateOnHMR.d.ts.map +1 -1
  80. package/build/typescript/module/widgets/server-credentials.d.ts +8 -0
  81. package/build/typescript/module/widgets/server-credentials.d.ts.map +1 -1
  82. package/build/typescript/module/widgets/server-update.d.ts +64 -0
  83. package/build/typescript/module/widgets/server-update.d.ts.map +1 -0
  84. package/expo-plugin/build/cjs/constants.js +20 -1
  85. package/expo-plugin/build/cjs/constants.js.map +1 -1
  86. package/expo-plugin/build/cjs/index.js +6 -0
  87. package/expo-plugin/build/cjs/index.js.map +1 -1
  88. package/expo-plugin/build/cjs/ios/index.js +1 -0
  89. package/expo-plugin/build/cjs/ios/index.js.map +1 -1
  90. package/expo-plugin/build/cjs/ios/infoPlist.js +28 -5
  91. package/expo-plugin/build/cjs/ios/infoPlist.js.map +1 -1
  92. package/expo-plugin/build/cjs/ios/serverUpdate.js +25 -0
  93. package/expo-plugin/build/cjs/ios/serverUpdate.js.map +1 -0
  94. package/expo-plugin/build/cjs/ios-widget/clientRendered.js +4 -4
  95. package/expo-plugin/build/cjs/ios-widget/clientRendered.js.map +1 -1
  96. package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js +5 -5
  97. package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js.map +1 -1
  98. package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js +130 -0
  99. package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js.map +1 -0
  100. package/expo-plugin/build/cjs/ios-widget/files/index.js +9 -2
  101. package/expo-plugin/build/cjs/ios-widget/files/index.js.map +1 -1
  102. package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js +6 -3
  103. package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js.map +1 -1
  104. package/expo-plugin/build/cjs/ios-widget/files/manifest.js +24 -0
  105. package/expo-plugin/build/cjs/ios-widget/files/manifest.js.map +1 -1
  106. package/expo-plugin/build/cjs/ios-widget/files/swift-utils.js +7 -0
  107. package/expo-plugin/build/cjs/ios-widget/files/swift-utils.js.map +1 -0
  108. package/expo-plugin/build/cjs/ios-widget/files/swift.js +56 -26
  109. package/expo-plugin/build/cjs/ios-widget/files/swift.js.map +1 -1
  110. package/expo-plugin/build/cjs/ios-widget/index.js +9 -3
  111. package/expo-plugin/build/cjs/ios-widget/index.js.map +1 -1
  112. package/expo-plugin/build/cjs/ios-widget/widgetPlist.js +34 -22
  113. package/expo-plugin/build/cjs/ios-widget/widgetPlist.js.map +1 -1
  114. package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js +51 -11
  115. package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js.map +1 -1
  116. package/expo-plugin/build/cjs/ios-widget/xcode/index.js +21 -5
  117. package/expo-plugin/build/cjs/ios-widget/xcode/index.js.map +1 -1
  118. package/expo-plugin/build/cjs/utils/urlScheme.js +36 -14
  119. package/expo-plugin/build/cjs/utils/urlScheme.js.map +1 -1
  120. package/expo-plugin/build/cjs/validation.js +54 -0
  121. package/expo-plugin/build/cjs/validation.js.map +1 -1
  122. package/expo-plugin/build/esm/constants.js +17 -0
  123. package/expo-plugin/build/esm/constants.js.map +1 -1
  124. package/expo-plugin/build/esm/index.js +6 -0
  125. package/expo-plugin/build/esm/index.js.map +1 -1
  126. package/expo-plugin/build/esm/ios/index.js +1 -0
  127. package/expo-plugin/build/esm/ios/index.js.map +1 -1
  128. package/expo-plugin/build/esm/ios/infoPlist.js +28 -5
  129. package/expo-plugin/build/esm/ios/infoPlist.js.map +1 -1
  130. package/expo-plugin/build/esm/ios/serverUpdate.js +21 -0
  131. package/expo-plugin/build/esm/ios/serverUpdate.js.map +1 -0
  132. package/expo-plugin/build/esm/ios-widget/clientRendered.js +5 -5
  133. package/expo-plugin/build/esm/ios-widget/clientRendered.js.map +1 -1
  134. package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js +6 -6
  135. package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js.map +1 -1
  136. package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js +122 -0
  137. package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js.map +1 -0
  138. package/expo-plugin/build/esm/ios-widget/files/index.js +10 -3
  139. package/expo-plugin/build/esm/ios-widget/files/index.js.map +1 -1
  140. package/expo-plugin/build/esm/ios-widget/files/infoPlist.js +6 -3
  141. package/expo-plugin/build/esm/ios-widget/files/infoPlist.js.map +1 -1
  142. package/expo-plugin/build/esm/ios-widget/files/manifest.js +23 -1
  143. package/expo-plugin/build/esm/ios-widget/files/manifest.js.map +1 -1
  144. package/expo-plugin/build/esm/ios-widget/files/swift-utils.js +4 -0
  145. package/expo-plugin/build/esm/ios-widget/files/swift-utils.js.map +1 -0
  146. package/expo-plugin/build/esm/ios-widget/files/swift.js +50 -20
  147. package/expo-plugin/build/esm/ios-widget/files/swift.js.map +1 -1
  148. package/expo-plugin/build/esm/ios-widget/index.js +9 -3
  149. package/expo-plugin/build/esm/ios-widget/index.js.map +1 -1
  150. package/expo-plugin/build/esm/ios-widget/widgetPlist.js +34 -22
  151. package/expo-plugin/build/esm/ios-widget/widgetPlist.js.map +1 -1
  152. package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js +51 -11
  153. package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js.map +1 -1
  154. package/expo-plugin/build/esm/ios-widget/xcode/index.js +21 -5
  155. package/expo-plugin/build/esm/ios-widget/xcode/index.js.map +1 -1
  156. package/expo-plugin/build/esm/utils/urlScheme.js +34 -14
  157. package/expo-plugin/build/esm/utils/urlScheme.js.map +1 -1
  158. package/expo-plugin/build/esm/validation.js +54 -1
  159. package/expo-plugin/build/esm/validation.js.map +1 -1
  160. package/expo-plugin/build/types/constants.d.ts +10 -1
  161. package/expo-plugin/build/types/constants.d.ts.map +1 -1
  162. package/expo-plugin/build/types/index.d.ts +1 -1
  163. package/expo-plugin/build/types/index.d.ts.map +1 -1
  164. package/expo-plugin/build/types/ios/index.d.ts +2 -0
  165. package/expo-plugin/build/types/ios/index.d.ts.map +1 -1
  166. package/expo-plugin/build/types/ios/infoPlist.d.ts +2 -0
  167. package/expo-plugin/build/types/ios/infoPlist.d.ts.map +1 -1
  168. package/expo-plugin/build/types/ios/serverUpdate.d.ts +11 -0
  169. package/expo-plugin/build/types/ios/serverUpdate.d.ts.map +1 -0
  170. package/expo-plugin/build/types/ios-widget/clientRendered.d.ts.map +1 -1
  171. package/expo-plugin/build/types/ios-widget/clientRenderedPrerender.d.ts.map +1 -1
  172. package/expo-plugin/build/types/ios-widget/dynamic-live-activity/swift.d.ts +7 -0
  173. package/expo-plugin/build/types/ios-widget/dynamic-live-activity/swift.d.ts.map +1 -0
  174. package/expo-plugin/build/types/ios-widget/files/index.d.ts +3 -1
  175. package/expo-plugin/build/types/ios-widget/files/index.d.ts.map +1 -1
  176. package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts +2 -1
  177. package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts.map +1 -1
  178. package/expo-plugin/build/types/ios-widget/files/manifest.d.ts +9 -2
  179. package/expo-plugin/build/types/ios-widget/files/manifest.d.ts.map +1 -1
  180. package/expo-plugin/build/types/ios-widget/files/swift-utils.d.ts +2 -0
  181. package/expo-plugin/build/types/ios-widget/files/swift-utils.d.ts.map +1 -0
  182. package/expo-plugin/build/types/ios-widget/files/swift.d.ts +6 -2
  183. package/expo-plugin/build/types/ios-widget/files/swift.d.ts.map +1 -1
  184. package/expo-plugin/build/types/ios-widget/index.d.ts +3 -1
  185. package/expo-plugin/build/types/ios-widget/index.d.ts.map +1 -1
  186. package/expo-plugin/build/types/ios-widget/widgetPlist.d.ts.map +1 -1
  187. package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts +3 -1
  188. package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts.map +1 -1
  189. package/expo-plugin/build/types/ios-widget/xcode/index.d.ts +2 -1
  190. package/expo-plugin/build/types/ios-widget/xcode/index.d.ts.map +1 -1
  191. package/expo-plugin/build/types/types.d.ts +23 -4
  192. package/expo-plugin/build/types/types.d.ts.map +1 -1
  193. package/expo-plugin/build/types/utils/urlScheme.d.ts +16 -0
  194. package/expo-plugin/build/types/utils/urlScheme.d.ts.map +1 -1
  195. package/expo-plugin/build/types/validation.d.ts +2 -1
  196. package/expo-plugin/build/types/validation.d.ts.map +1 -1
  197. package/expo-plugin/src/constants.ts +24 -1
  198. package/expo-plugin/src/index.ts +9 -0
  199. package/expo-plugin/src/ios/index.ts +3 -0
  200. package/expo-plugin/src/ios/infoPlist.ts +33 -5
  201. package/expo-plugin/src/ios/serverUpdate.ts +29 -0
  202. package/expo-plugin/src/ios-widget/clientRendered.ts +9 -5
  203. package/expo-plugin/src/ios-widget/clientRenderedPrerender.ts +11 -6
  204. package/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts +138 -0
  205. package/expo-plugin/src/ios-widget/files/index.ts +15 -4
  206. package/expo-plugin/src/ios-widget/files/infoPlist.ts +17 -3
  207. package/expo-plugin/src/ios-widget/files/manifest.ts +42 -2
  208. package/expo-plugin/src/ios-widget/files/swift-utils.ts +3 -0
  209. package/expo-plugin/src/ios-widget/files/swift.ts +69 -22
  210. package/expo-plugin/src/ios-widget/index.ts +13 -3
  211. package/expo-plugin/src/ios-widget/widgetPlist.ts +37 -23
  212. package/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap +34 -28
  213. package/expo-plugin/src/ios-widget/xcode/buildPhases.ts +59 -12
  214. package/expo-plugin/src/ios-widget/xcode/index.ts +23 -6
  215. package/expo-plugin/src/types.ts +28 -4
  216. package/expo-plugin/src/utils/urlScheme.ts +47 -17
  217. package/expo-plugin/src/validation.ts +76 -1
  218. package/ios/Package.swift +32 -3
  219. package/ios/VoltraWidget.podspec +1 -0
  220. package/ios/app/NativeVoltra.mm +107 -7
  221. package/ios/app/VoltraLiveActivityManager.swift +30 -0
  222. package/ios/app/VoltraLiveActivityService.swift +77 -5
  223. package/ios/app/VoltraModule.swift +99 -1
  224. package/ios/app/VoltraModuleImpl.swift +100 -2
  225. package/ios/app/VoltraViewComponentView.mm +3 -3
  226. package/ios/app/VoltraWidgetService.swift +147 -12
  227. package/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift +97 -0
  228. package/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift +68 -0
  229. package/ios/shared/DynamicWidgetPropsStore.swift +73 -6
  230. package/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerFetchResolver.swift +34 -0
  231. package/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerProps.swift +55 -0
  232. package/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerPropsStore.swift +146 -0
  233. package/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerUpdateRunner.swift +193 -0
  234. package/ios/shared/ServerWidgetContentResolver.swift +60 -0
  235. package/ios/shared/ServerWidgetResponseStore.swift +25 -0
  236. package/ios/shared/ShortNames.swift +6 -0
  237. package/ios/shared/VoltraConfig.swift +41 -0
  238. package/ios/shared/VoltraConstants.swift +15 -0
  239. package/ios/shared/VoltraElement.swift +127 -43
  240. package/ios/shared/VoltraEventBus.swift +52 -7
  241. package/ios/shared/VoltraJSRenderer.swift +90 -22
  242. package/ios/shared/VoltraNode.swift +1 -109
  243. package/ios/shared/VoltraRefreshIntent.swift +1 -1
  244. package/ios/shared/VoltraWidgetKind.swift +41 -0
  245. package/ios/shared/VoltraWidgetServerFetcher.swift +52 -107
  246. package/ios/shared/WidgetServer/VoltraWidgetAppearance.swift +17 -0
  247. package/ios/shared/WidgetServer/VoltraWidgetServer.swift +138 -0
  248. package/ios/shared/WidgetServer/WidgetCanonicalConfiguration.swift +77 -0
  249. package/ios/shared/WidgetServer/WidgetScope.swift +55 -0
  250. package/ios/shared/WidgetServer/WidgetServerEtagStore.swift +55 -0
  251. package/ios/shared/WidgetServer/WidgetServerFetcher.swift +179 -0
  252. package/ios/shared/WidgetServer/WidgetServerRequestBuilder.swift +114 -0
  253. package/ios/shared/WidgetServer/WidgetServerSettingsCodec.swift +47 -0
  254. package/ios/shared/WidgetServer/WidgetServerSettingsResolver.swift +129 -0
  255. package/ios/shared/WidgetServer/WidgetServerSettingsStore.swift +170 -0
  256. package/ios/shared/WidgetServer/WidgetServerSettingsValidator.swift +73 -0
  257. package/ios/shared/WidgetServer/WidgetServerUpdateSettings.swift +123 -0
  258. package/ios/shared/WidgetServer/WidgetServerUpdateSettingsJson.swift +115 -0
  259. package/ios/shared/dynamic-live-activity/DynamicLiveActivityPropsCodec.swift +13 -0
  260. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift +156 -0
  261. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift +97 -0
  262. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift +105 -0
  263. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift +55 -0
  264. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift +143 -0
  265. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift +158 -0
  266. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift +120 -0
  267. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift +370 -0
  268. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift +87 -0
  269. package/ios/shared/dynamic-live-activity/VoltraLiveActivityOrder.swift +19 -0
  270. package/ios/target/VoltraClientWidgetRuntime.swift +44 -6
  271. package/ios/target/VoltraDynamicWidgetServerUpdateProvider.swift +204 -0
  272. package/ios/target/VoltraHomeWidget.swift +85 -90
  273. package/ios/ui/Extensions/VoltraNode+View.swift +113 -0
  274. package/ios/ui/Generated/Parameters/ChartParameters.swift +3 -0
  275. package/ios/ui/Helpers/VoltraDeepLinkResolver.swift +2 -2
  276. package/ios/ui/Views/VoltraChart.swift +108 -0
  277. package/package.json +4 -4
  278. package/src/events.ts +34 -0
  279. package/src/index.ts +22 -0
  280. package/src/live-activity/api.ts +2 -2
  281. package/src/live-activity/dynamic-api.ts +240 -0
  282. package/src/native/NativeVoltra.ts +21 -0
  283. package/src/types.ts +4 -0
  284. package/src/utils/enableDynamicLiveActivityHotReload.ts +50 -0
  285. package/src/utils/enableWidgetHotReload.ts +9 -16
  286. package/src/utils/useUpdateOnHMR.ts +3 -12
  287. package/src/widgets/server-credentials.ts +8 -0
  288. package/src/widgets/server-update.ts +84 -0
  289. /package/ios/shared/{VoltraKeychainHelper.swift → WidgetServer/VoltraKeychainHelper.swift} +0 -0
@@ -0,0 +1,179 @@
1
+ import Foundation
2
+
3
+ /// What came back from the server, before either engine decides what it means.
4
+ ///
5
+ /// The request side is identical for a payload widget and a Dynamic Widget, so it lives here once.
6
+ /// Only the interpretation of `body` differs: one parses a Voltra payload, the other props.
7
+ public enum WidgetServerFetchResult: Sendable {
8
+ /// `200` with a body. `etag` is present when the response carried one.
9
+ case success(body: Data, etag: String?, httpStatus: Int, nextIntervalMinutes: Int?)
10
+ /// `304`: what is already committed is still current.
11
+ case notModified(nextIntervalMinutes: Int?)
12
+ /// The request never completed: no connectivity, DNS, TLS, or a timeout.
13
+ case networkFailure(message: String)
14
+ /// The server answered with a status we cannot use.
15
+ case httpFailure(httpStatus: Int, retryAfterMinutes: Int?)
16
+ /// A `2xx` whose body is over `WidgetServerUpdateDefaults.maxBodyBytes`. Kept apart from
17
+ /// `httpFailure` because the server did answer: this is a body the device refuses, so it is
18
+ /// reported as a parse failure and asking again is pointless.
19
+ case tooLarge(httpStatus: Int)
20
+
21
+ public var isUnauthorized: Bool {
22
+ if case let .httpFailure(status, _) = self {
23
+ return status == 401 || status == 403
24
+ }
25
+ return false
26
+ }
27
+
28
+ /// True when waiting and asking again could plausibly succeed.
29
+ public var isTransient: Bool {
30
+ switch self {
31
+ case .networkFailure:
32
+ return true
33
+ case let .httpFailure(status, _):
34
+ return status >= 500 || status == 429
35
+ default:
36
+ return false
37
+ }
38
+ }
39
+ }
40
+
41
+ /// Executes a request built by `WidgetServerRequestBuilder` and reports what happened, without
42
+ /// deciding what to do about it.
43
+ public enum WidgetServerFetcher {
44
+ /// Refuses to leave the host the app configured, so an `Authorization` header or a request body
45
+ /// cannot be replayed somewhere the app never agreed to send it.
46
+ private final class SameHostRedirectDelegate: NSObject, URLSessionTaskDelegate {
47
+ func urlSession(
48
+ _: URLSession,
49
+ task: URLSessionTask,
50
+ willPerformHTTPRedirection _: HTTPURLResponse,
51
+ newRequest request: URLRequest,
52
+ completionHandler: @escaping (URLRequest?) -> Void
53
+ ) {
54
+ guard let originalHost = task.originalRequest?.url?.host,
55
+ let originalScheme = task.originalRequest?.url?.scheme,
56
+ let nextHost = request.url?.host,
57
+ nextHost.caseInsensitiveCompare(originalHost) == .orderedSame,
58
+ request.url?.scheme == originalScheme
59
+ else {
60
+ VoltraLogger.widget.warning("Refusing cross-host redirect for a widget server request")
61
+ completionHandler(nil)
62
+ return
63
+ }
64
+
65
+ completionHandler(request)
66
+ }
67
+ }
68
+
69
+ private static let redirectDelegate = SameHostRedirectDelegate()
70
+
71
+ public static func fetch(
72
+ _ request: URLRequest,
73
+ session: URLSession = .shared
74
+ ) async -> WidgetServerFetchResult {
75
+ do {
76
+ // bytes(for:) rather than data(for:) so an oversized body is abandoned mid-stream. A widget
77
+ // extension has a 30 MB ceiling for the whole render, and buffering first would spend it
78
+ // before we ever got to check.
79
+ let (stream, response) = try await session.bytes(for: request, delegate: redirectDelegate)
80
+
81
+ guard let http = response as? HTTPURLResponse else {
82
+ return .networkFailure(message: "Response was not an HTTP response")
83
+ }
84
+
85
+ let nextIntervalMinutes = maxAgeMinutes(http.value(forHTTPHeaderField: "Cache-Control"))
86
+
87
+ if http.statusCode == 304 {
88
+ return .notModified(nextIntervalMinutes: nextIntervalMinutes)
89
+ }
90
+
91
+ guard (200 ... 299).contains(http.statusCode) else {
92
+ return .httpFailure(
93
+ httpStatus: http.statusCode,
94
+ retryAfterMinutes: retryAfterMinutes(http.value(forHTTPHeaderField: "Retry-After"))
95
+ )
96
+ }
97
+
98
+ guard let body = try await readBody(stream) else {
99
+ // Asking again returns the same oversized body, so this is a failure the app has to fix
100
+ // rather than one to back off from.
101
+ VoltraLogger.widget.error(
102
+ "Response is larger than \(WidgetServerUpdateDefaults.maxBodyBytes, privacy: .public) bytes"
103
+ )
104
+ return .tooLarge(httpStatus: http.statusCode)
105
+ }
106
+
107
+ return .success(
108
+ body: body,
109
+ etag: http.value(forHTTPHeaderField: "ETag"),
110
+ httpStatus: http.statusCode,
111
+ nextIntervalMinutes: nextIntervalMinutes
112
+ )
113
+ } catch {
114
+ return .networkFailure(message: error.localizedDescription)
115
+ }
116
+ }
117
+
118
+ /// Returns nil as soon as the body passes the cap, without holding the rest of it.
119
+ private static func readBody(_ stream: URLSession.AsyncBytes) async throws -> Data? {
120
+ var body = Data()
121
+ body.reserveCapacity(16 * 1024)
122
+
123
+ for try await byte in stream {
124
+ if body.count >= WidgetServerUpdateDefaults.maxBodyBytes {
125
+ return nil
126
+ }
127
+
128
+ body.append(byte)
129
+ }
130
+
131
+ return body
132
+ }
133
+
134
+ /// `Cache-Control: max-age=N`, in minutes, rounded down.
135
+ static func maxAgeMinutes(_ header: String?) -> Int? {
136
+ guard let header else { return nil }
137
+
138
+ let pattern = try? NSRegularExpression(pattern: "max-age\\s*=\\s*(\\d+)", options: .caseInsensitive)
139
+ let range = NSRange(header.startIndex ..< header.endIndex, in: header)
140
+
141
+ guard let match = pattern?.firstMatch(in: header, range: range),
142
+ let secondsRange = Range(match.range(at: 1), in: header),
143
+ let seconds = Int(header[secondsRange])
144
+ else {
145
+ return nil
146
+ }
147
+
148
+ return seconds / 60
149
+ }
150
+
151
+ /// `Retry-After`, in minutes, rounded up so we never retry early.
152
+ ///
153
+ /// The header is delta-seconds or an HTTP date; both are in the wild, so both are read.
154
+ static func retryAfterMinutes(_ header: String?, now: Date = Date()) -> Int? {
155
+ guard let value = header?.trimmingCharacters(in: .whitespaces), !value.isEmpty else {
156
+ return nil
157
+ }
158
+
159
+ if let seconds = Int(value) {
160
+ return seconds > 0 ? (seconds + 59) / 60 : nil
161
+ }
162
+
163
+ guard let date = httpDateFormatter.date(from: value) else {
164
+ return nil
165
+ }
166
+
167
+ let seconds = Int(date.timeIntervalSince(now))
168
+
169
+ return seconds > 0 ? (seconds + 59) / 60 : nil
170
+ }
171
+
172
+ private static let httpDateFormatter: DateFormatter = {
173
+ let formatter = DateFormatter()
174
+ formatter.locale = Locale(identifier: "en_US_POSIX")
175
+ formatter.timeZone = TimeZone(identifier: "GMT")
176
+ formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
177
+ return formatter
178
+ }()
179
+ }
@@ -0,0 +1,114 @@
1
+ import Foundation
2
+
3
+ /// The device state a request carries that is not part of the settings: the theme and locale the
4
+ /// widget is being drawn in, and who is asking.
5
+ ///
6
+ /// Passed in rather than read here so the request contract can be tested without UIKit, and so a
7
+ /// timeline request built for one appearance cannot accidentally report another.
8
+ public struct WidgetServerRequestContext: Equatable, Sendable {
9
+ public let theme: String
10
+ public let locale: String
11
+ public let userAgent: String
12
+ /// Only payload widgets send `family`: one Dynamic Widget fetch serves every size, so its props
13
+ /// must be size-agnostic and the entry picks its layout from `env.widgetFamily`.
14
+ public let family: String?
15
+
16
+ public init(theme: String, locale: String, userAgent: String, family: String? = nil) {
17
+ self.theme = theme
18
+ self.locale = locale
19
+ self.userAgent = userAgent
20
+ self.family = family
21
+ }
22
+ }
23
+
24
+ /// Turns resolved settings plus Voltra's own request parameters into the request the device sends.
25
+ ///
26
+ /// Both engines build their requests here, so a payload widget and a Dynamic Widget send the same
27
+ /// shape and the app's runtime overrides apply to both. Only the response and what the device does
28
+ /// with it differ.
29
+ public enum WidgetServerRequestBuilder {
30
+ /// Widgets have limited execution time, so a request that has not answered in this long is not
31
+ /// going to be useful even if it eventually does.
32
+ public static let timeoutSeconds: TimeInterval = 15
33
+
34
+ /// - Parameter etag: from the last `200`, sent as `If-None-Match`. Callers pass nil when the
35
+ /// stored ETag belongs to a different URL than the one being fetched now.
36
+ /// - Returns: nil when there is nothing to fetch — no URL, fetching is off, or the URL will not
37
+ /// parse.
38
+ public static func build(
39
+ scope: WidgetScope,
40
+ settings: ResolvedWidgetServerSettings,
41
+ context: WidgetServerRequestContext,
42
+ etag: String? = nil,
43
+ // The placement's merged configuration (ADR 0007). Empty for a widget with no configuration
44
+ // parameters, or when the caller only knows the scope's key — the caller is responsible for
45
+ // passing the same map that produced `scope`'s key, since the key alone cannot be reversed
46
+ // back into the configuration that produced it.
47
+ configuration: [String: String] = [:]
48
+ ) -> URLRequest? {
49
+ guard settings.shouldFetch, let url = settings.url, var components = URLComponents(string: url) else {
50
+ return nil
51
+ }
52
+
53
+ var queryItems = components.queryItems ?? []
54
+ queryItems.append(URLQueryItem(name: "widgetId", value: scope.widgetId))
55
+ queryItems.append(URLQueryItem(name: "platform", value: "ios"))
56
+
57
+ if let family = context.family {
58
+ queryItems.append(URLQueryItem(name: "family", value: family))
59
+ }
60
+
61
+ queryItems.append(URLQueryItem(name: "theme", value: context.theme))
62
+ queryItems.append(URLQueryItem(name: "locale", value: context.locale))
63
+
64
+ // Present for every method, absent when the widget has no configuration parameters (ADR
65
+ // 0007). `instance` is the hash of the canonical `configuration`, sent so a backend can cache
66
+ // or log per instance without recomputing it.
67
+ if case let .instance(_, key) = scope, let canonicalConfiguration = WidgetCanonicalConfiguration.canonicalize(configuration) {
68
+ queryItems.append(URLQueryItem(name: "instance", value: key))
69
+ queryItems.append(URLQueryItem(name: "configuration", value: canonicalConfiguration))
70
+ }
71
+
72
+ // Voltra's own keys are appended first and the app's keys are rejected at call time if they
73
+ // collide, so nothing here can shadow what the server relies on.
74
+ for key in settings.query.keys.sorted() {
75
+ queryItems.append(URLQueryItem(name: key, value: settings.query[key]))
76
+ }
77
+
78
+ components.queryItems = queryItems
79
+
80
+ guard let resolvedUrl = components.url else { return nil }
81
+
82
+ var request = URLRequest(url: resolvedUrl)
83
+ let method = settings.method.uppercased()
84
+
85
+ request.httpMethod = method
86
+ request.timeoutInterval = timeoutSeconds
87
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
88
+ request.setValue(context.userAgent, forHTTPHeaderField: "User-Agent")
89
+
90
+ for (key, value) in settings.headers {
91
+ request.setValue(value, forHTTPHeaderField: key)
92
+ }
93
+
94
+ if let etag {
95
+ request.setValue(etag, forHTTPHeaderField: "If-None-Match")
96
+ }
97
+
98
+ if let body = settings.body {
99
+ if WidgetServerUpdateDefaults.bodylessMethods.contains(method) {
100
+ // URLSession sends a Content-Length and drops the body on GET, so the server would see a
101
+ // request the app did not mean to send. Dropping it here is the lesser surprise, and it is
102
+ // documented.
103
+ VoltraLogger.widget.warning(
104
+ "Dropping request body for widget '\(scope.widgetId, privacy: .public)': \(method, privacy: .public) cannot carry one"
105
+ )
106
+ } else {
107
+ request.httpBody = Data(body.utf8)
108
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
109
+ }
110
+ }
111
+
112
+ return request
113
+ }
114
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+
3
+ /// Serializes one settings layer for storage. Written as a small versioned envelope so a future
4
+ /// shape change can be recognised rather than guessed at, the same way Dynamic Widget props are
5
+ /// stored.
6
+ public enum WidgetServerSettingsCodec {
7
+ private static let versionKey = "widgetServerSettingsVersion"
8
+ private static let settingsKey = "widgetServerSettings"
9
+ private static let version = 1
10
+
11
+ public static func encode(_ settings: WidgetServerUpdateSettings) -> Data? {
12
+ var payload: [String: Any] = [:]
13
+
14
+ settings.url.map { payload["url"] = $0 }
15
+ settings.intervalMinutes.map { payload["intervalMinutes"] = $0 }
16
+ settings.enabled.map { payload["enabled"] = $0 }
17
+ settings.method.map { payload["method"] = $0 }
18
+ settings.query.map { payload["query"] = $0 }
19
+ settings.headers.map { payload["headers"] = $0 }
20
+ settings.body.map { payload["body"] = $0 }
21
+
22
+ let envelope: [String: Any] = [versionKey: version, settingsKey: payload]
23
+
24
+ return try? JSONSerialization.data(withJSONObject: envelope)
25
+ }
26
+
27
+ /// Returns nil for anything this version cannot read, so a bad record reads as "no opinion".
28
+ public static func decode(_ data: Data?) -> WidgetServerUpdateSettings? {
29
+ guard let data,
30
+ let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
31
+ envelope[versionKey] as? Int == version,
32
+ let payload = envelope[settingsKey] as? [String: Any]
33
+ else {
34
+ return nil
35
+ }
36
+
37
+ return WidgetServerUpdateSettings(
38
+ url: payload["url"] as? String,
39
+ intervalMinutes: payload["intervalMinutes"] as? Int,
40
+ enabled: payload["enabled"] as? Bool,
41
+ method: payload["method"] as? String,
42
+ query: payload["query"] as? [String: String],
43
+ headers: payload["headers"] as? [String: String],
44
+ body: payload["body"] as? String
45
+ )
46
+ }
47
+ }
@@ -0,0 +1,129 @@
1
+ import Foundation
2
+
3
+ /// One source of server-update settings. Implementations return a partial
4
+ /// `WidgetServerUpdateSettings`, or nil when they have nothing to say about the scope.
5
+ ///
6
+ /// Layers are stacked in a fixed order by `WidgetServerSettingsResolver` and never consulted
7
+ /// directly: nothing outside this folder reads Info.plist, the Keychain, or anything else for
8
+ /// server-update purposes.
9
+ public protocol WidgetServerSettingsLayer: Sendable {
10
+ /// A short name used in logs, so a surprising resolved value can be traced to its source.
11
+ var name: String { get }
12
+
13
+ func settings(for scope: WidgetScope) -> WidgetServerUpdateSettings?
14
+
15
+ /// Whether this layer knows the scope to be server-driven at all. Only the config layer can
16
+ /// answer this — a runtime layer setting a URL does not turn a locally-rendered widget into a
17
+ /// server-driven one, because the engine is chosen at generate time.
18
+ func isServerDriven(_ scope: WidgetScope) -> Bool
19
+ }
20
+
21
+ public extension WidgetServerSettingsLayer {
22
+ func isServerDriven(_: WidgetScope) -> Bool {
23
+ false
24
+ }
25
+ }
26
+
27
+ /// The only way to read server-update settings.
28
+ ///
29
+ /// Layers are walked lowest to highest and merged by the rule stated once here: `headers` and
30
+ /// `query` merge per key, everything else takes the value from the highest layer that sets it.
31
+ /// Adding a layer later — an instance layer above `widget`, say — is a new
32
+ /// `WidgetServerSettingsLayer` plus one entry in `layers`; this API and every caller stay as they
33
+ /// are.
34
+ public struct WidgetServerSettingsResolver: Sendable {
35
+ private let layers: [any WidgetServerSettingsLayer]
36
+ private let revisionSource: @Sendable () -> Int
37
+
38
+ /// - Parameter layers: lowest priority first: config, credentials, global, widget.
39
+ public init(layers: [any WidgetServerSettingsLayer], revisionSource: @escaping @Sendable () -> Int) {
40
+ self.layers = layers
41
+ self.revisionSource = revisionSource
42
+ }
43
+
44
+ /// Flattens every layer for `scope`. Safe to call for any widget: a widget that is not
45
+ /// server-driven resolves to disabled with no URL, so a caller that fetches on `shouldFetch`
46
+ /// does nothing rather than guessing.
47
+ public func resolve(_ scope: WidgetScope) -> ResolvedWidgetServerSettings {
48
+ var merged = WidgetServerUpdateSettings.empty
49
+ var intervalFromConfig = false
50
+
51
+ for (index, layer) in layers.enumerated() {
52
+ guard let settings = layer.settings(for: scope) else { continue }
53
+
54
+ if settings.intervalMinutes != nil {
55
+ // Index 0 is the config layer. An interval that came from app.json was already validated
56
+ // against this platform's rules when the native project was generated, so clamping it
57
+ // again here would silently change an existing widget's schedule.
58
+ intervalFromConfig = index == 0
59
+ }
60
+
61
+ merged = Self.merge(lower: merged, higher: settings)
62
+ }
63
+
64
+ let serverDriven = isServerDriven(scope)
65
+ let url = serverDriven ? merged.url.flatMap { $0.isEmpty ? nil : $0 } : nil
66
+ let intervalMinutes = merged.intervalMinutes ?? WidgetServerUpdateDefaults.defaultIntervalMinutes
67
+
68
+ return ResolvedWidgetServerSettings(
69
+ url: url,
70
+ intervalMinutes: intervalFromConfig
71
+ ? intervalMinutes
72
+ : WidgetServerUpdateDefaults.clampIntervalMinutes(intervalMinutes),
73
+ enabled: serverDriven && (merged.enabled ?? true),
74
+ method: merged.method ?? WidgetServerUpdateDefaults.defaultMethod,
75
+ query: merged.query ?? [:],
76
+ headers: merged.headers ?? [:],
77
+ body: merged.body
78
+ )
79
+ }
80
+
81
+ /// True when app.json marked this widget server-driven. The engine is chosen at generate time,
82
+ /// so a runtime URL cannot make a widget server-driven and `setWidgetServerUpdate` rejects
83
+ /// settings for one that is not.
84
+ public func isServerDriven(_ scope: WidgetScope) -> Bool {
85
+ layers.contains { $0.isServerDriven(scope) }
86
+ }
87
+
88
+ /// Raw contents of the global layer: no defaulting, no merge — there is nothing to resolve
89
+ /// against without a widget scope. Nil when nothing has been set globally.
90
+ public func globalSettings() -> WidgetServerUpdateSettings? {
91
+ layers.lazy.compactMap { $0 as? GlobalWidgetServerSettingsLayer }.first?.raw()
92
+ }
93
+
94
+ /// Monotonic counter of settings changes. A fetcher records it before fetching and commits only
95
+ /// if it is still current, so settings changed mid-flight cannot commit a response built from
96
+ /// the old ones.
97
+ ///
98
+ /// It is one counter for the whole store rather than one per scope: a change to another widget
99
+ /// can make an in-flight fetch drop its result, and the reload that every `set` queues fetches
100
+ /// again, so the cost is one wasted request in a rare race.
101
+ public func revision(_: WidgetScope) -> Int {
102
+ revisionSource()
103
+ }
104
+
105
+ static func merge(
106
+ lower: WidgetServerUpdateSettings,
107
+ higher: WidgetServerUpdateSettings
108
+ ) -> WidgetServerUpdateSettings {
109
+ WidgetServerUpdateSettings(
110
+ url: higher.url ?? lower.url,
111
+ intervalMinutes: higher.intervalMinutes ?? lower.intervalMinutes,
112
+ enabled: higher.enabled ?? lower.enabled,
113
+ method: higher.method ?? lower.method,
114
+ query: mergePerKey(lower.query, higher.query),
115
+ headers: mergePerKey(lower.headers, higher.headers),
116
+ body: higher.body ?? lower.body
117
+ )
118
+ }
119
+
120
+ private static func mergePerKey(
121
+ _ lower: [String: String]?,
122
+ _ higher: [String: String]?
123
+ ) -> [String: String]? {
124
+ guard let lower else { return higher }
125
+ guard let higher else { return lower }
126
+
127
+ return lower.merging(higher) { _, fromHigher in fromHigher }
128
+ }
129
+ }
@@ -0,0 +1,170 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ /// The only way to write server-update settings, and the storage behind the three runtime layers.
5
+ ///
6
+ /// Records live in the same shared Keychain the widget credentials have always used, so nothing
7
+ /// migrates: the deprecated `setWidgetServerCredentials` keeps writing the accounts it always did,
8
+ /// and this store adds its own accounts alongside them. The Keychain is also the only store both
9
+ /// the app and the widget extension can reach without depending on an App Group being configured.
10
+ ///
11
+ /// Callers do not read through this class. They read through `WidgetServerSettingsResolver`, which
12
+ /// is what keeps the layer order and the merge rule in one place.
13
+ public enum WidgetServerSettingsStore {
14
+ private static let service = "voltra-widget-server-credentials"
15
+ private static let globalAccount = "server_update_global"
16
+ private static let widgetAccountPrefix = "server_update_widget_"
17
+ private static let revisionAccount = "server_update_revision"
18
+
19
+ // MARK: - Writes
20
+
21
+ /// Replaces the global layer, or one widget's layer when `scope` is given.
22
+ @discardableResult
23
+ public static func set(_ settings: WidgetServerUpdateSettings, scope: WidgetScope?) -> Bool {
24
+ guard let data = WidgetServerSettingsCodec.encode(settings) else { return false }
25
+
26
+ let saved = write(data, account: account(for: scope))
27
+ bumpRevision()
28
+
29
+ return saved
30
+ }
31
+
32
+ /// Empties the global layer, or one widget's layer when `scope` is given.
33
+ public static func clear(scope: WidgetScope?) {
34
+ delete(account: account(for: scope))
35
+ bumpRevision()
36
+ }
37
+
38
+ /// Bumps the revision without changing a layer. The credentials layer writes through the
39
+ /// deprecated credential API, which does not go through `set`, so it calls this to make sure an
40
+ /// in-flight fetch built with the old token does not commit.
41
+ public static func bumpRevision() {
42
+ let next = revision() &+ 1
43
+ write(Data("\(next)".utf8), account: revisionAccount)
44
+ }
45
+
46
+ public static func revision() -> Int {
47
+ guard let data = read(account: revisionAccount),
48
+ let text = String(data: data, encoding: .utf8),
49
+ let value = Int(text)
50
+ else {
51
+ return 0
52
+ }
53
+
54
+ return value
55
+ }
56
+
57
+ // MARK: - Reads
58
+
59
+ static func settings(scope: WidgetScope?) -> WidgetServerUpdateSettings? {
60
+ WidgetServerSettingsCodec.decode(read(account: account(for: scope)))
61
+ }
62
+
63
+ private static func account(for scope: WidgetScope?) -> String {
64
+ guard let scope else { return globalAccount }
65
+
66
+ return "\(widgetAccountPrefix)\(scope.storageKey)"
67
+ }
68
+
69
+ // MARK: - Keychain
70
+
71
+ private static func baseQuery(account: String) -> [String: Any] {
72
+ var query: [String: Any] = [
73
+ kSecClass as String: kSecClassGenericPassword,
74
+ kSecAttrService as String: service,
75
+ kSecAttrAccount as String: account,
76
+ ]
77
+
78
+ if let group = VoltraConfig.keychainGroup() {
79
+ query[kSecAttrAccessGroup as String] = group
80
+ }
81
+
82
+ return query
83
+ }
84
+
85
+ @discardableResult
86
+ private static func write(_ data: Data, account: String) -> Bool {
87
+ delete(account: account)
88
+
89
+ var query = baseQuery(account: account)
90
+ query[kSecValueData as String] = data
91
+ query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
92
+
93
+ let status = SecItemAdd(query as CFDictionary, nil)
94
+
95
+ if status != errSecSuccess {
96
+ VoltraLogger.keychain.error("Failed to save widget server settings: \(status, privacy: .public)")
97
+ }
98
+
99
+ return status == errSecSuccess
100
+ }
101
+
102
+ private static func read(account: String) -> Data? {
103
+ var query = baseQuery(account: account)
104
+ query[kSecReturnData as String] = true
105
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
106
+
107
+ var item: CFTypeRef?
108
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
109
+
110
+ guard status == errSecSuccess, let data = item as? Data else {
111
+ if status != errSecItemNotFound {
112
+ VoltraLogger.keychain.error("Failed to read widget server settings: \(status, privacy: .public)")
113
+ }
114
+ return nil
115
+ }
116
+
117
+ return data
118
+ }
119
+
120
+ private static func delete(account: String) {
121
+ SecItemDelete(baseQuery(account: account) as CFDictionary)
122
+ }
123
+ }
124
+
125
+ /// Settings the app set for every server-driven widget.
126
+ struct GlobalWidgetServerSettingsLayer: WidgetServerSettingsLayer {
127
+ let name = "global"
128
+
129
+ func settings(for _: WidgetScope) -> WidgetServerUpdateSettings? {
130
+ WidgetServerSettingsStore.settings(scope: nil)
131
+ }
132
+
133
+ /// Raw contents of this layer, for reading back what was set — no scope to resolve against.
134
+ func raw() -> WidgetServerUpdateSettings? {
135
+ WidgetServerSettingsStore.settings(scope: nil)
136
+ }
137
+ }
138
+
139
+ /// Settings the app set for one widget. Highest layer until instance scopes arrive.
140
+ struct WidgetWidgetServerSettingsLayer: WidgetServerSettingsLayer {
141
+ let name = "widget"
142
+
143
+ func settings(for scope: WidgetScope) -> WidgetServerUpdateSettings? {
144
+ WidgetServerSettingsStore.settings(scope: scope)
145
+ }
146
+ }
147
+
148
+ /// The deprecated `setWidgetServerCredentials` API, expressed as a settings layer.
149
+ ///
150
+ /// It reads the same token and header records it always has, which is why nothing migrates. It
151
+ /// sits below the global layer so an app that has moved to
152
+ /// `setWidgetServerUpdate({ headers: { Authorization: ... } })` overrides whatever an older call
153
+ /// left behind, rather than the other way round.
154
+ struct CredentialsWidgetServerSettingsLayer: WidgetServerSettingsLayer {
155
+ let name = "credentials"
156
+
157
+ func settings(for _: WidgetScope) -> WidgetServerUpdateSettings? {
158
+ var headers: [String: String] = [:]
159
+
160
+ if let token = VoltraKeychainHelper.readToken(), !token.isEmpty {
161
+ headers["Authorization"] = "Bearer \(token)"
162
+ }
163
+
164
+ if let custom = VoltraKeychainHelper.readHeaders() {
165
+ headers.merge(custom) { _, fromCustom in fromCustom }
166
+ }
167
+
168
+ return headers.isEmpty ? nil : WidgetServerUpdateSettings(headers: headers)
169
+ }
170
+ }