@use-voltra/ios-client 2.2.0 → 2.3.1

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,34 @@
1
+ import Foundation
2
+
3
+ /// Collapses a burst of timeline requests for one scope into a single fetch.
4
+ ///
5
+ /// WidgetKit asks for a timeline once per widget instance per reload, and tapping the refresh
6
+ /// button produces two reloads for one tap (the intent's own reload plus the automatic
7
+ /// post-intent one). Without this, a widget placed twice in two sizes would fetch four times for
8
+ /// what the user experienced as one refresh — and the reload budget is shared across every widget
9
+ /// in the app.
10
+ ///
11
+ /// The payload engine already coalesces this way; this is the same window, keyed by scope so it
12
+ /// keeps working when instances arrive.
13
+ public actor DynamicWidgetServerFetchCoordinator {
14
+ public static let defaultCoalesceInterval: TimeInterval = 3
15
+
16
+ public static let shared = DynamicWidgetServerFetchCoordinator()
17
+
18
+ private var lastRun: [WidgetScope: Date] = [:]
19
+ private let coalesceInterval: TimeInterval
20
+
21
+ public init(coalesceInterval: TimeInterval = DynamicWidgetServerFetchCoordinator.defaultCoalesceInterval) {
22
+ self.coalesceInterval = coalesceInterval
23
+ }
24
+
25
+ /// Whether this request should fetch, or ride on one that just happened.
26
+ public func shouldFetch(_ scope: WidgetScope, now: Date = Date()) -> Bool {
27
+ if let last = lastRun[scope], now.timeIntervalSince(last) < coalesceInterval {
28
+ return false
29
+ }
30
+
31
+ lastRun[scope] = now
32
+ return true
33
+ }
34
+ }
@@ -0,0 +1,55 @@
1
+ import Foundation
2
+
3
+ /// What a `200` body turned out to be.
4
+ public enum DynamicWidgetPropsParseResult: Equatable {
5
+ case props(String)
6
+ case invalid(reason: String)
7
+ }
8
+
9
+ /// Reads a server response as Dynamic Widget props.
10
+ ///
11
+ /// The whole point of ADR 0002 is that the server returns data, not UI, so the only thing accepted
12
+ /// here is a JSON object. The one shape called out specially is a Voltra payload: `serverUpdate`
13
+ /// is the same config key for both engines, so pointing a widget with an `entry` at a payload
14
+ /// endpoint is the easy mistake to make, and it has to fail loudly rather than look like unusable
15
+ /// props.
16
+ public enum DynamicWidgetServerProps {
17
+ public static func parse(_ body: Data) -> DynamicWidgetPropsParseResult {
18
+ guard !body.isEmpty else {
19
+ return .invalid(reason: "response body was empty")
20
+ }
21
+
22
+ guard let parsed = try? JSONSerialization.jsonObject(with: body) else {
23
+ return .invalid(reason: "response body is not JSON; a Dynamic Widget's props must be a JSON object")
24
+ }
25
+
26
+ guard let object = parsed as? [String: Any] else {
27
+ return .invalid(reason: "response body is not a JSON object; a Dynamic Widget's props must be a JSON object")
28
+ }
29
+
30
+ if looksLikeVoltraPayload(object) {
31
+ return .invalid(reason: """
32
+ response body looks like a Voltra payload (top-level 'v' with 'variants' or 'e'). This widget \
33
+ has an entry, so it renders on the device: return the props it should render, not a rendered \
34
+ payload.
35
+ """)
36
+ }
37
+
38
+ guard let normalized = try? JSONSerialization.data(withJSONObject: object),
39
+ let json = String(data: normalized, encoding: .utf8)
40
+ else {
41
+ return .invalid(reason: "response body could not be re-encoded as props")
42
+ }
43
+
44
+ return .props(json)
45
+ }
46
+
47
+ /// A Voltra payload always carries a version under `v` alongside either the size variants a
48
+ /// widget renders or the shared element table. Props that happen to have a `v` key are not
49
+ /// mistaken for one.
50
+ private static func looksLikeVoltraPayload(_ body: [String: Any]) -> Bool {
51
+ guard body["v"] is Int else { return false }
52
+
53
+ return body["variants"] is [String: Any] || body["e"] is [Any]
54
+ }
55
+ }
@@ -0,0 +1,146 @@
1
+ import Foundation
2
+
3
+ /// What the widget is told about the server side of its props, as `env.serverUpdate`.
4
+ ///
5
+ /// Deliberately not the props themselves: fetched props are committed to the Dynamic Widget's
6
+ /// existing props slot, so the render path cannot tell whether they came from a fetch or from
7
+ /// `updateDynamicWidget`. This record is only the story around them.
8
+ public struct DynamicWidgetServerStatus: Equatable, Codable {
9
+ public static let fresh = "fresh"
10
+ public static let stale = "stale"
11
+ public static let never = "never"
12
+ public static let disabled = "disabled"
13
+
14
+ public static let errorNetwork = "network"
15
+ public static let errorHttp = "http"
16
+ public static let errorUnauthorized = "unauthorized"
17
+ public static let errorParse = "parse"
18
+ public static let errorRender = "render"
19
+
20
+ public let status: String
21
+ public let fetchedAt: Int?
22
+ public let error: String?
23
+ public let httpStatus: Int?
24
+
25
+ public init(status: String, fetchedAt: Int? = nil, error: String? = nil, httpStatus: Int? = nil) {
26
+ self.status = status
27
+ self.fetchedAt = fetchedAt
28
+ self.error = error
29
+ self.httpStatus = httpStatus
30
+ }
31
+
32
+ /// What a widget sees before any fetch has succeeded.
33
+ public static let neverFetched = DynamicWidgetServerStatus(status: never)
34
+
35
+ public func toJSON() -> String {
36
+ var fields = ["\"status\": \(jsonString(status))"]
37
+
38
+ fetchedAt.map { fields.append("\"fetchedAt\": \($0)") }
39
+ error.map { fields.append("\"error\": \(jsonString($0))") }
40
+ httpStatus.map { fields.append("\"httpStatus\": \($0)") }
41
+
42
+ return "{ \(fields.joined(separator: ", ")) }"
43
+ }
44
+
45
+ private func jsonString(_ value: String) -> String {
46
+ let escaped = value
47
+ .replacingOccurrences(of: "\\", with: "\\\\")
48
+ .replacingOccurrences(of: "\"", with: "\\\"")
49
+
50
+ return "\"\(escaped)\""
51
+ }
52
+ }
53
+
54
+ /// Per-scope record of how the last fetch went, in the App Group so the app and the widget
55
+ /// extension agree on it.
56
+ ///
57
+ /// Kept apart from the props themselves so that clearing a widget's props — logout,
58
+ /// `clearWidget` — and clearing its fetch history stay separate decisions.
59
+ public struct DynamicWidgetServerPropsStore {
60
+ private let defaults: UserDefaults?
61
+
62
+ public init(defaults: UserDefaults? = VoltraConfig.groupIdentifier().flatMap { UserDefaults(suiteName: $0) }) {
63
+ self.defaults = defaults
64
+ }
65
+
66
+ public func status(for scope: WidgetScope) -> DynamicWidgetServerStatus {
67
+ guard let data = defaults?.data(forKey: key(scope)),
68
+ let status = try? JSONDecoder().decode(DynamicWidgetServerStatus.self, from: data)
69
+ else {
70
+ return .neverFetched
71
+ }
72
+
73
+ return status
74
+ }
75
+
76
+ public func put(_ status: DynamicWidgetServerStatus, for scope: WidgetScope) {
77
+ guard let data = try? JSONEncoder().encode(status) else { return }
78
+
79
+ defaults?.set(data, forKey: key(scope))
80
+ }
81
+
82
+ /// Records a failure without losing the fact that a fetch once worked. `stale` is only
83
+ /// meaningful next to the `fetchedAt` of the last success, so that is carried forward.
84
+ public func recordFailure(_ error: String, httpStatus: Int? = nil, for scope: WidgetScope) {
85
+ let previous = status(for: scope)
86
+
87
+ put(
88
+ DynamicWidgetServerStatus(
89
+ status: previous.fetchedAt == nil ? DynamicWidgetServerStatus.never : DynamicWidgetServerStatus.stale,
90
+ fetchedAt: previous.fetchedAt,
91
+ error: error,
92
+ httpStatus: httpStatus
93
+ ),
94
+ for: scope
95
+ )
96
+ }
97
+
98
+ public func recordSuccess(fetchedAt: Int, httpStatus: Int, for scope: WidgetScope) {
99
+ put(
100
+ DynamicWidgetServerStatus(status: DynamicWidgetServerStatus.fresh, fetchedAt: fetchedAt, httpStatus: httpStatus),
101
+ for: scope
102
+ )
103
+ }
104
+
105
+ /// Reports `disabled` once, keeping the last `fetchedAt` so a widget that comes back under app
106
+ /// control can still say when the server last spoke.
107
+ public func markDisabledIfNeeded(enabled: Bool, for scope: WidgetScope) {
108
+ guard !enabled else { return }
109
+
110
+ let previous = status(for: scope)
111
+
112
+ guard previous.status != DynamicWidgetServerStatus.disabled else { return }
113
+
114
+ put(
115
+ DynamicWidgetServerStatus(status: DynamicWidgetServerStatus.disabled, fetchedAt: previous.fetchedAt),
116
+ for: scope
117
+ )
118
+ }
119
+
120
+ /// Records that the app just wrote props itself, so the reload that write triggers renders them
121
+ /// instead of racing a fetch that would immediately overwrite them.
122
+ public func noteAppWrite(for scope: WidgetScope, at date: Date = Date()) {
123
+ defaults?.set(date.timeIntervalSince1970, forKey: appWriteKey(scope))
124
+ }
125
+
126
+ public func appWriteAt(for scope: WidgetScope) -> Date? {
127
+ guard let seconds = defaults?.object(forKey: appWriteKey(scope)) as? TimeInterval else {
128
+ return nil
129
+ }
130
+
131
+ return Date(timeIntervalSince1970: seconds)
132
+ }
133
+
134
+ public func clear(_ scope: WidgetScope) {
135
+ defaults?.removeObject(forKey: key(scope))
136
+ defaults?.removeObject(forKey: appWriteKey(scope))
137
+ }
138
+
139
+ private func key(_ scope: WidgetScope) -> String {
140
+ "Voltra_DynamicWidgetServer_v1_\(scope.storageKey)"
141
+ }
142
+
143
+ private func appWriteKey(_ scope: WidgetScope) -> String {
144
+ "Voltra_DynamicWidgetServer_AppWrite_v1_\(scope.storageKey)"
145
+ }
146
+ }
@@ -0,0 +1,193 @@
1
+ import Foundation
2
+
3
+ /// What one server-update run decided, before it becomes a timeline.
4
+ public enum DynamicWidgetServerUpdateOutcome: Equatable {
5
+ /// Props were committed, or the server said `304` and what we have is still current.
6
+ case committed
7
+ /// Nothing to do: the widget has no URL, the app turned fetching off, or another request in the
8
+ /// same burst just fetched.
9
+ case skipped
10
+ /// The fetch failed in a way that waiting could fix. Previous props stay on screen.
11
+ case retry
12
+ /// The fetch or the response failed in a way waiting will not fix. Previous props stay.
13
+ case failed
14
+ /// The settings the response was built from are no longer current, so it was dropped.
15
+ case dropped
16
+ }
17
+
18
+ /// The outcome plus what the server asked about the next fetch.
19
+ public struct DynamicWidgetServerUpdateResult: Equatable {
20
+ public let outcome: DynamicWidgetServerUpdateOutcome
21
+ /// `Cache-Control: max-age` on a success, `Retry-After` on a `429` or `503`, in minutes and
22
+ /// already clamped to what WidgetKit can honour. Nil when the server said nothing and the
23
+ /// widget's own interval stands.
24
+ public let nextIntervalMinutes: Int?
25
+
26
+ public init(_ outcome: DynamicWidgetServerUpdateOutcome, nextIntervalMinutes: Int? = nil) {
27
+ self.outcome = outcome
28
+ self.nextIntervalMinutes = nextIntervalMinutes
29
+ }
30
+ }
31
+
32
+ /// Fetch, parse, trial-render, commit — the four steps ADR 0002 requires of every server-driven
33
+ /// widget, with every collaborator injected so the failure table can be tested without a network,
34
+ /// a JS runtime, or WidgetKit.
35
+ ///
36
+ /// The rule the ordering exists for: props that do not render are never committed. A server that
37
+ /// starts returning a shape the widget throws on leaves the last good props on screen instead of
38
+ /// replacing them with a blank tile.
39
+ public struct DynamicWidgetServerUpdateRunner {
40
+ public typealias Fetch = (WidgetScope, ResolvedWidgetServerSettings, String?) async -> WidgetServerFetchResult
41
+
42
+ private let resolveSettings: (WidgetScope) -> ResolvedWidgetServerSettings
43
+ private let currentRevision: (WidgetScope) -> Int
44
+ private let readEtag: (WidgetScope, String?) -> String?
45
+ private let fetch: Fetch
46
+ private let writeEtag: (WidgetScope, String, String?) -> Void
47
+ private let trialRender: (WidgetScope, String) -> Bool
48
+ private let commitProps: (WidgetScope, String) throws -> Void
49
+ private let recordSuccess: (WidgetScope, Int, Int) -> Void
50
+ private let recordFailure: (WidgetScope, String, Int?) -> Void
51
+ private let markDisabled: (WidgetScope, Bool) -> Void
52
+ private let now: () -> Date
53
+
54
+ public init(
55
+ resolveSettings: @escaping (WidgetScope) -> ResolvedWidgetServerSettings,
56
+ currentRevision: @escaping (WidgetScope) -> Int,
57
+ readEtag: @escaping (WidgetScope, String?) -> String?,
58
+ fetch: @escaping Fetch,
59
+ writeEtag: @escaping (WidgetScope, String, String?) -> Void,
60
+ trialRender: @escaping (WidgetScope, String) -> Bool,
61
+ commitProps: @escaping (WidgetScope, String) throws -> Void,
62
+ recordSuccess: @escaping (WidgetScope, Int, Int) -> Void,
63
+ recordFailure: @escaping (WidgetScope, String, Int?) -> Void,
64
+ markDisabled: @escaping (WidgetScope, Bool) -> Void,
65
+ now: @escaping () -> Date = Date.init
66
+ ) {
67
+ self.resolveSettings = resolveSettings
68
+ self.currentRevision = currentRevision
69
+ self.readEtag = readEtag
70
+ self.fetch = fetch
71
+ self.writeEtag = writeEtag
72
+ self.trialRender = trialRender
73
+ self.commitProps = commitProps
74
+ self.recordSuccess = recordSuccess
75
+ self.recordFailure = recordFailure
76
+ self.markDisabled = markDisabled
77
+ self.now = now
78
+ }
79
+
80
+ public func run(_ scope: WidgetScope) async -> DynamicWidgetServerUpdateResult {
81
+ let settings = resolveSettings(scope)
82
+
83
+ guard settings.shouldFetch, let url = settings.url else {
84
+ markDisabled(scope, settings.enabled)
85
+ return DynamicWidgetServerUpdateResult(.skipped)
86
+ }
87
+
88
+ let revision = currentRevision(scope)
89
+ let result = await fetch(scope, settings, readEtag(scope, url))
90
+
91
+ // Settings that moved while we were on the network make this response answer a question
92
+ // nobody is asking any more.
93
+ guard currentRevision(scope) == revision else {
94
+ VoltraLogger.widget.debug("Dropping server update for '\(scope.widgetId, privacy: .public)': settings changed mid-fetch")
95
+ return DynamicWidgetServerUpdateResult(.dropped)
96
+ }
97
+
98
+ switch result {
99
+ case let .notModified(nextIntervalMinutes):
100
+ recordSuccess(scope, epochMs(), 304)
101
+ return DynamicWidgetServerUpdateResult(.committed, nextIntervalMinutes: clamped(nextIntervalMinutes))
102
+
103
+ case let .networkFailure(message):
104
+ VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' failed: \(message, privacy: .public)")
105
+ recordFailure(scope, DynamicWidgetServerStatus.errorNetwork, nil)
106
+ return DynamicWidgetServerUpdateResult(.retry)
107
+
108
+ case let .tooLarge(httpStatus):
109
+ // The server answered, with a body the extension will not hold. Asking again returns the
110
+ // same one, so this is a parse failure rather than something to back off from.
111
+ VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' returned a body that is too large")
112
+ recordFailure(scope, DynamicWidgetServerStatus.errorParse, httpStatus)
113
+ return DynamicWidgetServerUpdateResult(.failed)
114
+
115
+ case let .httpFailure(httpStatus, retryAfterMinutes):
116
+ let error = result.isUnauthorized ? DynamicWidgetServerStatus.errorUnauthorized : DynamicWidgetServerStatus.errorHttp
117
+
118
+ VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' got HTTP \(httpStatus, privacy: .public)")
119
+ recordFailure(scope, error, httpStatus)
120
+
121
+ // A 401 will keep being a 401 until the app sets a fresh token, and setting one reloads the
122
+ // widget. Retrying sooner would only spend the reload budget.
123
+ guard result.isTransient else {
124
+ return DynamicWidgetServerUpdateResult(.failed)
125
+ }
126
+
127
+ return DynamicWidgetServerUpdateResult(.retry, nextIntervalMinutes: clamped(retryAfterMinutes))
128
+
129
+ case let .success(body, etag, httpStatus, nextIntervalMinutes):
130
+ return commit(
131
+ scope: scope,
132
+ url: url,
133
+ body: body,
134
+ etag: etag,
135
+ httpStatus: httpStatus,
136
+ nextIntervalMinutes: clamped(nextIntervalMinutes)
137
+ )
138
+ }
139
+ }
140
+
141
+ /// What the server asked for, held to what WidgetKit can honour: never sooner than 15 minutes,
142
+ /// never further out than a day.
143
+ private func clamped(_ minutes: Int?) -> Int? {
144
+ minutes.map { WidgetServerUpdateDefaults.clampIntervalMinutes($0) }
145
+ }
146
+
147
+ private func commit(
148
+ scope: WidgetScope,
149
+ url: String,
150
+ body: Data,
151
+ etag: String?,
152
+ httpStatus: Int,
153
+ nextIntervalMinutes: Int?
154
+ ) -> DynamicWidgetServerUpdateResult {
155
+ switch DynamicWidgetServerProps.parse(body) {
156
+ case let .invalid(reason):
157
+ VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' rejected: \(reason, privacy: .public)")
158
+ recordFailure(scope, DynamicWidgetServerStatus.errorParse, httpStatus)
159
+ // Asking again returns the same body, so this is not something to retry.
160
+ return DynamicWidgetServerUpdateResult(.failed)
161
+
162
+ case let .props(json):
163
+ guard trialRender(scope, json) else {
164
+ VoltraLogger.widget.error(
165
+ "Server update for '\(scope.widgetId, privacy: .public)' did not render; keeping the previous props"
166
+ )
167
+ recordFailure(scope, DynamicWidgetServerStatus.errorRender, httpStatus)
168
+ return DynamicWidgetServerUpdateResult(.failed)
169
+ }
170
+
171
+ do {
172
+ try commitProps(scope, json)
173
+ } catch {
174
+ // Storage is not something the server can fix, and there is no error kind for it, so it is
175
+ // reported as a render failure: the props arrived and could not be put on screen.
176
+ VoltraLogger.widget.error(
177
+ "Could not store fetched props for '\(scope.widgetId, privacy: .public)': \(error.localizedDescription, privacy: .public)"
178
+ )
179
+ recordFailure(scope, DynamicWidgetServerStatus.errorRender, httpStatus)
180
+ return DynamicWidgetServerUpdateResult(.failed)
181
+ }
182
+
183
+ writeEtag(scope, url, etag)
184
+ recordSuccess(scope, epochMs(), httpStatus)
185
+
186
+ return DynamicWidgetServerUpdateResult(.committed, nextIntervalMinutes: nextIntervalMinutes)
187
+ }
188
+ }
189
+
190
+ private func epochMs() -> Int {
191
+ Int(now().timeIntervalSince1970 * 1000)
192
+ }
193
+ }
@@ -0,0 +1,60 @@
1
+ import Foundation
2
+
3
+ /// Decides which server response a server-driven widget renders for one timeline request.
4
+ ///
5
+ /// WidgetKit requests a timeline once per widget instance per reload, and an interactive refresh
6
+ /// button produces two reloads for a single tap (the intent's own reload plus the automatic
7
+ /// post-intent reload). The resolver collapses such bursts into one network request and answers
8
+ /// every request in the burst with the same response. When a fetch fails it hands back the last
9
+ /// successful response instead, so the widget never regresses to older or initial content while
10
+ /// the server has already provided something newer.
11
+ struct ServerWidgetContentResolver {
12
+ enum Outcome {
13
+ /// Fetched now, or fetched moments ago by another request in the same burst.
14
+ case current(Data)
15
+ /// The fetch failed; this is the most recent successful response.
16
+ case lastKnown(Data, Error)
17
+ /// The fetch failed and no successful response exists in this process.
18
+ case unavailable(Error)
19
+ }
20
+
21
+ typealias Fetch = (_ widgetId: String) async throws -> Data
22
+
23
+ static let defaultCoalesceInterval: TimeInterval = 3
24
+
25
+ private let responseStore: ServerWidgetResponseStore
26
+ private let fetch: Fetch
27
+ private let coalesceInterval: TimeInterval
28
+ private let now: () -> Date
29
+
30
+ init(
31
+ responseStore: ServerWidgetResponseStore,
32
+ fetch: @escaping Fetch,
33
+ coalesceInterval: TimeInterval = ServerWidgetContentResolver.defaultCoalesceInterval,
34
+ now: @escaping () -> Date = Date.init
35
+ ) {
36
+ self.responseStore = responseStore
37
+ self.fetch = fetch
38
+ self.coalesceInterval = coalesceInterval
39
+ self.now = now
40
+ }
41
+
42
+ func resolve(widgetId: String) async -> Outcome {
43
+ if let recent = await responseStore.response(for: widgetId),
44
+ now().timeIntervalSince(recent.fetchedAt) < coalesceInterval
45
+ {
46
+ return .current(recent.data)
47
+ }
48
+
49
+ do {
50
+ let data = try await fetch(widgetId)
51
+ await responseStore.store(data, for: widgetId, fetchedAt: now())
52
+ return .current(data)
53
+ } catch {
54
+ if let lastKnown = await responseStore.response(for: widgetId) {
55
+ return .lastKnown(lastKnown.data, error)
56
+ }
57
+ return .unavailable(error)
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,25 @@
1
+ import Foundation
2
+
3
+ /// In-memory record of the last successful server response for each server-driven widget.
4
+ ///
5
+ /// Lives for the lifetime of the widget extension process. It is the source of truth for
6
+ /// "the last content the server actually sent", independent of whether an App Group is
7
+ /// configured for on-disk persistence.
8
+ actor ServerWidgetResponseStore {
9
+ struct Response: Equatable {
10
+ let data: Data
11
+ let fetchedAt: Date
12
+ }
13
+
14
+ private var responses: [String: Response] = [:]
15
+
16
+ init() {}
17
+
18
+ func response(for widgetId: String) -> Response? {
19
+ responses[widgetId]
20
+ }
21
+
22
+ func store(_ data: Data, for widgetId: String, fetchedAt: Date) {
23
+ responses[widgetId] = Response(data: data, fetchedAt: fetchedAt)
24
+ }
25
+ }
@@ -77,6 +77,7 @@ public enum ShortNames {
77
77
  "g": "gap",
78
78
  "gs": "gaugeStyle",
79
79
  "ge": "glassEffect",
80
+ "gc": "gradientColors",
80
81
  "h": "height",
81
82
  "halig": "horizontalAlignment",
82
83
  "hp": "horizontalPadding",
@@ -92,6 +93,7 @@ public enum ShortNames {
92
93
  "l": "left",
93
94
  "lgv": "legendVisibility",
94
95
  "ls": "letterSpacing",
96
+ "lc": "lineCap",
95
97
  "lh": "lineHeight",
96
98
  "ll": "lineLimit",
97
99
  "lsp": "lineSpacing",
@@ -149,12 +151,15 @@ public enum ShortNames {
149
151
  "smc": "smallCaps",
150
152
  "src": "source",
151
153
  "sp": "spacing",
154
+ "sa": "startAngle",
152
155
  "start": "startAtMs",
153
156
  "sic": "startIcon",
154
157
  "stp": "startPoint",
155
158
  "sts": "stops",
156
159
  "st": "strikethrough",
160
+ "sw": "strokeWidth",
157
161
  "s": "style",
162
+ "swa": "sweepAngle",
158
163
  "si": "systemImage",
159
164
  "txt": "text",
160
165
  "ta": "textAlign",
@@ -184,6 +189,7 @@ public enum ShortNames {
184
189
  "xav": "xAxisVisibility",
185
190
  "ygs": "yAxisGridStyle",
186
191
  "yav": "yAxisVisibility",
192
+ "ysc": "yScale",
187
193
  "zi": "zIndex",
188
194
  ]
189
195
 
@@ -15,4 +15,45 @@ public enum VoltraConfig {
15
15
  public static func keychainGroup() -> String? {
16
16
  Bundle.main.object(forInfoDictionaryKey: VoltraStorageKeys.keychainGroup) as? String
17
17
  }
18
+
19
+ /// Reported when `Voltra_Version` is missing or unusable, mirroring the `appVersion` fallback.
20
+ public static let unknownVoltraVersion = "unknown"
21
+
22
+ /// Get the installed Voltra client package version from Info.plist
23
+ /// Written at prebuild (`expo prebuild`) or apply (`voltra apply`) time from the installed
24
+ /// `@use-voltra/{ios,android}-client` manifest, and surfaced to widgets as `env.build.voltraVersion`.
25
+ public static func voltraVersion(bundle: Bundle = .main) -> String {
26
+ normalizeVoltraVersion(bundle.object(forInfoDictionaryKey: VoltraStorageKeys.voltraVersion))
27
+ }
28
+
29
+ /// Value handling for `voltraVersion(bundle:)`, split out so every malformed shape the plist can
30
+ /// hold is covered without building a bundle per case.
31
+ static func normalizeVoltraVersion(_ rawValue: Any?) -> String {
32
+ guard let version = rawValue as? String, !version.isEmpty else {
33
+ return unknownVoltraVersion
34
+ }
35
+
36
+ return version
37
+ }
38
+
39
+ /// Get the `{ widgetId: kind }` map of widgets that pin a custom WidgetKit kind from Info.plist
40
+ /// Written at prebuild (`expo prebuild`) or apply (`voltra apply`) time from the `kind` option.
41
+ /// Absent when every widget keeps the default `Voltra_Widget_<id>` kind.
42
+ public static func widgetKinds(bundle: Bundle = .main) -> [String: String] {
43
+ normalizeWidgetKinds(bundle.object(forInfoDictionaryKey: VoltraStorageKeys.widgetKinds))
44
+ }
45
+
46
+ /// Value handling for `widgetKinds(bundle:)`, split out so every malformed shape the plist can
47
+ /// hold is covered without building a bundle per case. Entries that are not non-empty strings
48
+ /// are dropped, leaving those widgets on the default kind.
49
+ static func normalizeWidgetKinds(_ rawValue: Any?) -> [String: String] {
50
+ guard let rawMap = rawValue as? [String: Any] else {
51
+ return [:]
52
+ }
53
+
54
+ return rawMap.compactMapValues { value in
55
+ guard let kind = value as? String, !kind.isEmpty else { return nil }
56
+ return kind
57
+ }
58
+ }
18
59
  }
@@ -32,6 +32,18 @@ public enum VoltraStorageKeys {
32
32
  "Voltra_DynamicWidget_Props_v1_\(dynamicWidgetId)"
33
33
  }
34
34
 
35
+ /// One placement's own props slot (ADR 0007), keyed by `WidgetScope.Instance.storageKey`
36
+ /// (`<widgetId>#<key>`).
37
+ public static func dynamicWidgetInstancePropsV1(_ storageKey: String) -> String {
38
+ "Voltra_DynamicWidget_Props_v1_\(storageKey)"
39
+ }
40
+
41
+ /// The set of instance keys `dynamicWidgetId` has ever committed props into, so a widget-level
42
+ /// clear (or logout) can drop every instance slot without enumerating placements.
43
+ public static func dynamicWidgetPropsInstanceIndexV1(_ dynamicWidgetId: String) -> String {
44
+ "Voltra_DynamicWidget_Props_Instances_v1_\(dynamicWidgetId)"
45
+ }
46
+
35
47
  // MARK: - Prefixes / kind identifiers
36
48
 
37
49
  public static let widgetKindPrefix = "Voltra_Widget_"
@@ -43,6 +55,8 @@ public enum VoltraStorageKeys {
43
55
  // MARK: - Info.plist keys
44
56
 
45
57
  public static let widgetIds = "Voltra_WidgetIds"
58
+ /// `{ widgetId: kind }` for widgets that pin a custom WidgetKit kind (plugin `kind` option)
59
+ public static let widgetKinds = "Voltra_WidgetKinds"
46
60
  public static let enablePushNotifications = "Voltra_EnablePushNotifications"
47
61
  public static let widgetServerUrls = "Voltra_WidgetServerUrls"
48
62
  public static let widgetServerIntervals = "Voltra_WidgetServerIntervals"
@@ -50,4 +64,5 @@ public enum VoltraStorageKeys {
50
64
  public static let appGroupIdentifier = "Voltra_AppGroupIdentifier"
51
65
  public static let legacyAppGroupIdentifier = "AppGroupIdentifier"
52
66
  public static let keychainGroup = "Voltra_KeychainGroup"
67
+ public static let voltraVersion = "Voltra_Version"
53
68
  }