@use-voltra/ios-client 2.1.1 → 2.2.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 (131) hide show
  1. package/README.md +61 -8
  2. package/build/commonjs/dynamic-widget/api.js +15 -0
  3. package/build/commonjs/dynamic-widget/api.js.map +1 -0
  4. package/build/commonjs/index.js +15 -7
  5. package/build/commonjs/index.js.map +1 -1
  6. package/build/commonjs/native/NativeVoltra.js.map +1 -1
  7. package/build/module/dynamic-widget/api.js +10 -0
  8. package/build/module/dynamic-widget/api.js.map +1 -0
  9. package/build/module/index.js +1 -0
  10. package/build/module/index.js.map +1 -1
  11. package/build/module/native/NativeVoltra.js.map +1 -1
  12. package/build/typescript/commonjs/dynamic-widget/api.d.ts +8 -0
  13. package/build/typescript/commonjs/dynamic-widget/api.d.ts.map +1 -0
  14. package/build/typescript/commonjs/index.d.ts +1 -0
  15. package/build/typescript/commonjs/index.d.ts.map +1 -1
  16. package/build/typescript/commonjs/native/NativeVoltra.d.ts +1 -0
  17. package/build/typescript/commonjs/native/NativeVoltra.d.ts.map +1 -1
  18. package/build/typescript/module/dynamic-widget/api.d.ts +8 -0
  19. package/build/typescript/module/dynamic-widget/api.d.ts.map +1 -0
  20. package/build/typescript/module/index.d.ts +1 -0
  21. package/build/typescript/module/index.d.ts.map +1 -1
  22. package/build/typescript/module/native/NativeVoltra.d.ts +1 -0
  23. package/build/typescript/module/native/NativeVoltra.d.ts.map +1 -1
  24. package/expo-plugin/build/cjs/index.js +3 -1
  25. package/expo-plugin/build/cjs/index.js.map +1 -1
  26. package/expo-plugin/build/cjs/ios-widget/index.js +1 -1
  27. package/expo-plugin/build/cjs/ios-widget/index.js.map +1 -1
  28. package/expo-plugin/build/cjs/ios-widget/podfile.js +38 -12
  29. package/expo-plugin/build/cjs/ios-widget/podfile.js.map +1 -1
  30. package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js +143 -107
  31. package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js.map +1 -1
  32. package/expo-plugin/build/cjs/ios-widget/xcode/configurationList.js +30 -22
  33. package/expo-plugin/build/cjs/ios-widget/xcode/configurationList.js.map +1 -1
  34. package/expo-plugin/build/cjs/ios-widget/xcode/fileReferences.js +50 -0
  35. package/expo-plugin/build/cjs/ios-widget/xcode/fileReferences.js.map +1 -0
  36. package/expo-plugin/build/cjs/ios-widget/xcode/groups.js +45 -43
  37. package/expo-plugin/build/cjs/ios-widget/xcode/groups.js.map +1 -1
  38. package/expo-plugin/build/cjs/ios-widget/xcode/index.js +77 -91
  39. package/expo-plugin/build/cjs/ios-widget/xcode/index.js.map +1 -1
  40. package/expo-plugin/build/cjs/ios-widget/xcode/mainAppSettings.js +41 -39
  41. package/expo-plugin/build/cjs/ios-widget/xcode/mainAppSettings.js.map +1 -1
  42. package/expo-plugin/build/cjs/ios-widget/xcode/productFile.js +2 -2
  43. package/expo-plugin/build/cjs/ios-widget/xcode/productFile.js.map +1 -1
  44. package/expo-plugin/build/cjs/ios-widget/xcode/target.js +29 -63
  45. package/expo-plugin/build/cjs/ios-widget/xcode/target.js.map +1 -1
  46. package/expo-plugin/build/esm/index.js +3 -1
  47. package/expo-plugin/build/esm/index.js.map +1 -1
  48. package/expo-plugin/build/esm/ios-widget/index.js +1 -1
  49. package/expo-plugin/build/esm/ios-widget/index.js.map +1 -1
  50. package/expo-plugin/build/esm/ios-widget/podfile.js +36 -11
  51. package/expo-plugin/build/esm/ios-widget/podfile.js.map +1 -1
  52. package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js +143 -106
  53. package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js.map +1 -1
  54. package/expo-plugin/build/esm/ios-widget/xcode/configurationList.js +31 -22
  55. package/expo-plugin/build/esm/ios-widget/xcode/configurationList.js.map +1 -1
  56. package/expo-plugin/build/esm/ios-widget/xcode/fileReferences.js +45 -0
  57. package/expo-plugin/build/esm/ios-widget/xcode/fileReferences.js.map +1 -0
  58. package/expo-plugin/build/esm/ios-widget/xcode/groups.js +45 -42
  59. package/expo-plugin/build/esm/ios-widget/xcode/groups.js.map +1 -1
  60. package/expo-plugin/build/esm/ios-widget/xcode/index.js +81 -96
  61. package/expo-plugin/build/esm/ios-widget/xcode/index.js.map +1 -1
  62. package/expo-plugin/build/esm/ios-widget/xcode/mainAppSettings.js +41 -39
  63. package/expo-plugin/build/esm/ios-widget/xcode/mainAppSettings.js.map +1 -1
  64. package/expo-plugin/build/esm/ios-widget/xcode/productFile.js +3 -2
  65. package/expo-plugin/build/esm/ios-widget/xcode/productFile.js.map +1 -1
  66. package/expo-plugin/build/esm/ios-widget/xcode/target.js +28 -62
  67. package/expo-plugin/build/esm/ios-widget/xcode/target.js.map +1 -1
  68. package/expo-plugin/build/types/index.d.ts.map +1 -1
  69. package/expo-plugin/build/types/ios-widget/podfile.d.ts +19 -1
  70. package/expo-plugin/build/types/ios-widget/podfile.d.ts.map +1 -1
  71. package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts +3 -7
  72. package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts.map +1 -1
  73. package/expo-plugin/build/types/ios-widget/xcode/configurationList.d.ts +15 -10
  74. package/expo-plugin/build/types/ios-widget/xcode/configurationList.d.ts.map +1 -1
  75. package/expo-plugin/build/types/ios-widget/xcode/fileReferences.d.ts +16 -0
  76. package/expo-plugin/build/types/ios-widget/xcode/fileReferences.d.ts.map +1 -0
  77. package/expo-plugin/build/types/ios-widget/xcode/groups.d.ts +0 -4
  78. package/expo-plugin/build/types/ios-widget/xcode/groups.d.ts.map +1 -1
  79. package/expo-plugin/build/types/ios-widget/xcode/index.d.ts +22 -3
  80. package/expo-plugin/build/types/ios-widget/xcode/index.d.ts.map +1 -1
  81. package/expo-plugin/build/types/ios-widget/xcode/mainAppSettings.d.ts +16 -4
  82. package/expo-plugin/build/types/ios-widget/xcode/mainAppSettings.d.ts.map +1 -1
  83. package/expo-plugin/build/types/ios-widget/xcode/productFile.d.ts +0 -4
  84. package/expo-plugin/build/types/ios-widget/xcode/productFile.d.ts.map +1 -1
  85. package/expo-plugin/build/types/ios-widget/xcode/target.d.ts +11 -26
  86. package/expo-plugin/build/types/ios-widget/xcode/target.d.ts.map +1 -1
  87. package/expo-plugin/src/index.ts +5 -1
  88. package/expo-plugin/src/ios-widget/index.ts +1 -1
  89. package/expo-plugin/src/ios-widget/podfile.ts +46 -13
  90. package/expo-plugin/src/ios-widget/xcode/__fixtures__/fresh.pbxproj +197 -0
  91. package/expo-plugin/src/ios-widget/xcode/__fixtures__/with-existing-extension.pbxproj +321 -0
  92. package/expo-plugin/src/ios-widget/xcode/__fixtures__/with-per-config-signing.pbxproj +203 -0
  93. package/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap +357 -0
  94. package/expo-plugin/src/ios-widget/xcode/__tests__/pbxConsistency.ts +130 -0
  95. package/expo-plugin/src/ios-widget/xcode/buildPhases.ts +151 -152
  96. package/expo-plugin/src/ios-widget/xcode/configurationList.ts +52 -35
  97. package/expo-plugin/src/ios-widget/xcode/fileReferences.ts +51 -0
  98. package/expo-plugin/src/ios-widget/xcode/groups.ts +52 -54
  99. package/expo-plugin/src/ios-widget/xcode/index.ts +107 -119
  100. package/expo-plugin/src/ios-widget/xcode/mainAppSettings.ts +59 -54
  101. package/expo-plugin/src/ios-widget/xcode/productFile.ts +3 -2
  102. package/expo-plugin/src/ios-widget/xcode/target.ts +31 -83
  103. package/ios/Package.swift +7 -1
  104. package/ios/app/NativeVoltra.mm +19 -0
  105. package/ios/app/VoltraModule.swift +18 -0
  106. package/ios/app/VoltraModuleImpl.swift +16 -0
  107. package/ios/shared/DynamicWidgetPropsCodec.swift +51 -0
  108. package/ios/shared/DynamicWidgetPropsStorage.swift +53 -0
  109. package/ios/shared/DynamicWidgetPropsStore.swift +50 -0
  110. package/ios/shared/DynamicWidgetRenderCoordinator.swift +31 -0
  111. package/ios/shared/DynamicWidgetUpdater.swift +30 -0
  112. package/ios/shared/VoltraConstants.swift +4 -0
  113. package/ios/shared/VoltraJSRenderer.swift +3 -1
  114. package/ios/shared/VoltraNode.swift +6 -2
  115. package/ios/shared/VoltraWidgetDefaults.swift +9 -1
  116. package/ios/target/VoltraClientWidgetRuntime.swift +17 -5
  117. package/ios/target/VoltraHomeWidget.swift +6 -2
  118. package/ios/target/VoltraWidget.swift +3 -1
  119. package/ios/ui/Helpers/VoltraDeepLinkResolver.swift +3 -1
  120. package/ios/ui/Helpers/VoltraProgressDriver.swift +6 -2
  121. package/ios/ui/Layout/VoltraFlexStackLayout.swift +9 -3
  122. package/ios/ui/Style/JSColorParser.swift +24 -8
  123. package/ios/ui/Style/JSGradientParser.swift +36 -12
  124. package/ios/ui/Style/JSStyleParser.swift +36 -12
  125. package/ios/ui/Style/LayoutStyle.swift +12 -4
  126. package/ios/ui/Views/VoltraChart.swift +52 -16
  127. package/ios/ui/Views/VoltraLinearGradient.swift +6 -2
  128. package/package.json +5 -5
  129. package/src/dynamic-widget/api.ts +25 -0
  130. package/src/index.ts +1 -0
  131. package/src/native/NativeVoltra.ts +1 -0
@@ -6,18 +6,9 @@ import { IOS } from '../../constants'
6
6
  // Types
7
7
  // ============================================================================
8
8
 
9
- export interface ConfigureTargetOptions {
9
+ export interface CreateTargetSkeletonOptions {
10
10
  targetName: string
11
11
  targetUuid: string
12
- productFile: { fileRef: string }
13
- xCConfigurationList: { uuid: string }
14
- }
15
-
16
- interface AddNativeTargetOptions {
17
- targetName: string
18
- targetUuid: string
19
- productFile: { fileRef: string }
20
- xCConfigurationList: { uuid: string }
21
12
  }
22
13
 
23
14
  // ============================================================================
@@ -25,19 +16,39 @@ interface AddNativeTargetOptions {
25
16
  // ============================================================================
26
17
 
27
18
  /**
28
- * Configures the widget extension target in the Xcode project.
19
+ * Creates the minimal widget-extension native target and registers it with the project.
20
+ *
21
+ * A target cannot be "ensured" into existence, so this is the one create-only step in
22
+ * {@link applyXcodeChanges}: it adds an empty `PBXNativeTarget` (no product reference, configuration
23
+ * list or build phases yet) to the `PBXNativeTarget` and `PBXProject` sections. The shared ensure
24
+ * pipeline then fills in the configuration list, product file, group, build phases, attributes and
25
+ * dependency — the same code that reconciles an already-existing target.
29
26
  *
30
- * This:
31
- * - Adds the target to PBXNativeTarget section
32
- * - Adds the target to PBXProject section
33
- * - Adds a dependency from the main app to the widget extension
27
+ * @returns The freshly created native-target object (the value stored in the `PBXNativeTarget`
28
+ * section under `targetUuid`).
34
29
  */
35
- export function configureTarget(xcodeProject: XcodeProject, options: ConfigureTargetOptions) {
36
- const target = addToPbxNativeTargetSection(xcodeProject, options)
37
- addToPbxProjectSection(xcodeProject, target)
38
- addTargetDependency(xcodeProject, target)
30
+ export function createTargetSkeleton(xcodeProject: XcodeProject, options: CreateTargetSkeletonOptions) {
31
+ const { targetName, targetUuid } = options
32
+
33
+ const target = {
34
+ uuid: targetUuid,
35
+ pbxNativeTarget: {
36
+ isa: 'PBXNativeTarget',
37
+ name: targetName,
38
+ productName: targetName,
39
+ productType: `"com.apple.product-type.app-extension"`,
40
+ buildConfigurationList: '',
41
+ productReference: '',
42
+ buildPhases: [],
43
+ buildRules: [],
44
+ dependencies: [],
45
+ },
46
+ }
39
47
 
40
- return target
48
+ xcodeProject.addToPbxNativeTargetSection(target)
49
+ xcodeProject.addToPbxProjectSection(target)
50
+
51
+ return xcodeProject.pbxNativeTargetSection()[targetUuid]
41
52
  }
42
53
 
43
54
  /**
@@ -83,66 +94,3 @@ export function ensureTargetDependency(xcodeProject: XcodeProject, targetUuid: s
83
94
  xcodeProject.addTargetDependency(mainTargetUuid, [targetUuid])
84
95
  }
85
96
  }
86
-
87
- // ============================================================================
88
- // Helper Functions
89
- // ============================================================================
90
-
91
- /**
92
- * Adds the widget extension target to the PBXNativeTarget section.
93
- */
94
- function addToPbxNativeTargetSection(xcodeProject: XcodeProject, options: AddNativeTargetOptions) {
95
- const { targetName, targetUuid, productFile, xCConfigurationList } = options
96
-
97
- const target = {
98
- uuid: targetUuid,
99
- pbxNativeTarget: {
100
- isa: 'PBXNativeTarget',
101
- name: targetName,
102
- productName: targetName,
103
- productReference: productFile.fileRef,
104
- productType: `"com.apple.product-type.app-extension"`,
105
- buildConfigurationList: xCConfigurationList.uuid,
106
- buildPhases: [],
107
- buildRules: [],
108
- dependencies: [],
109
- },
110
- }
111
-
112
- xcodeProject.addToPbxNativeTargetSection(target)
113
-
114
- return target
115
- }
116
-
117
- /**
118
- * Adds the target to the PBXProject section.
119
- */
120
- function addToPbxProjectSection(xcodeProject: XcodeProject, target: { uuid: string }): void {
121
- xcodeProject.addToPbxProjectSection(target)
122
-
123
- // Add target attributes to project section
124
- const projectSection = xcodeProject.pbxProjectSection()
125
- const firstProject = xcodeProject.getFirstProject()
126
-
127
- if (!projectSection[firstProject.uuid].attributes.TargetAttributes) {
128
- projectSection[firstProject.uuid].attributes.TargetAttributes = {}
129
- }
130
-
131
- projectSection[firstProject.uuid].attributes.TargetAttributes[target.uuid] = {
132
- LastSwiftMigration: IOS.LAST_SWIFT_MIGRATION,
133
- }
134
- }
135
-
136
- /**
137
- * Adds a target dependency so the main app depends on the widget extension.
138
- */
139
- function addTargetDependency(xcodeProject: XcodeProject, target: { uuid: string }): void {
140
- if (!xcodeProject.hash.project.objects['PBXTargetDependency']) {
141
- xcodeProject.hash.project.objects['PBXTargetDependency'] = {}
142
- }
143
- if (!xcodeProject.hash.project.objects['PBXContainerItemProxy']) {
144
- xcodeProject.hash.project.objects['PBXContainerItemProxy'] = {}
145
- }
146
-
147
- xcodeProject.addTargetDependency(xcodeProject.getFirstTarget().uuid, [target.uuid])
148
- }
package/ios/Package.swift CHANGED
@@ -27,7 +27,6 @@ let package = Package(
27
27
  "Date+toTimerInterval.swift",
28
28
  "ShortNames.swift",
29
29
  "VoltraAttributes.swift",
30
- "VoltraConfig.swift",
31
30
  "VoltraElement.swift",
32
31
  "VoltraEvent.swift",
33
32
  "VoltraEventBus.swift",
@@ -38,7 +37,14 @@ let package = Package(
38
37
  "VoltraPersistentEventQueue.swift",
39
38
  ],
40
39
  sources: [
40
+ "DynamicWidgetPropsCodec.swift",
41
+ "DynamicWidgetPropsStorage.swift",
42
+ "DynamicWidgetPropsStore.swift",
43
+ "DynamicWidgetRenderCoordinator.swift",
44
+ "DynamicWidgetUpdater.swift",
41
45
  "JSONValue.swift",
46
+ "VoltraConfig.swift",
47
+ "VoltraConstants.swift",
42
48
  "VoltraPayloadMigrator.swift",
43
49
  "VoltraRegion.swift",
44
50
  "ComponentTypeID.swift",
@@ -289,6 +289,25 @@
289
289
  }];
290
290
  }
291
291
 
292
+ - (void)updateDynamicWidget:(NSString *)dynamicWidgetId
293
+ dynamicWidgetPropsJson:(NSString *)dynamicWidgetPropsJson
294
+ resolve:(RCTPromiseResolveBlock)resolve
295
+ reject:(RCTPromiseRejectBlock)reject
296
+ {
297
+ [self.module updateDynamicWidget:dynamicWidgetId
298
+ dynamicWidgetPropsJson:dynamicWidgetPropsJson
299
+ completion:^(NSError *error) {
300
+ if (error) {
301
+ NSString *message = [NSString stringWithFormat:@"Failed to update Dynamic Widget '%@': %@",
302
+ dynamicWidgetId,
303
+ error.localizedDescription];
304
+ reject(@"updateDynamicWidget", message, error);
305
+ } else {
306
+ resolve(nil);
307
+ }
308
+ }];
309
+ }
310
+
292
311
  - (void)scheduleWidget:(NSString *)widgetId
293
312
  timelineJson:(NSString *)timelineJson
294
313
  resolve:(RCTPromiseResolveBlock)resolve
@@ -145,6 +145,24 @@ public enum VoltraErrors: Error {
145
145
 
146
146
  // MARK: - Home Screen Widgets
147
147
 
148
+ @objc public func updateDynamicWidget(
149
+ _ dynamicWidgetId: String,
150
+ dynamicWidgetPropsJson: String,
151
+ completion: @escaping (Error?) -> Void
152
+ ) {
153
+ Task {
154
+ do {
155
+ try await impl.updateDynamicWidget(
156
+ dynamicWidgetId: dynamicWidgetId,
157
+ dynamicWidgetPropsJson: dynamicWidgetPropsJson
158
+ )
159
+ completion(nil)
160
+ } catch {
161
+ completion(error)
162
+ }
163
+ }
164
+ }
165
+
148
166
  @objc public func updateWidget(
149
167
  _ widgetId: String,
150
168
  jsonString: String,
@@ -257,6 +257,22 @@ public class VoltraModuleImpl {
257
257
 
258
258
  // MARK: - Widgets
259
259
 
260
+ func updateDynamicWidget(
261
+ dynamicWidgetId: String,
262
+ dynamicWidgetPropsJson: String
263
+ ) async throws {
264
+ let dynamicWidgetUpdater = DynamicWidgetUpdater(
265
+ dynamicWidgetPropsPersistence: DynamicWidgetPropsStore(),
266
+ dynamicWidgetTimelineReload: { dynamicWidgetId in
267
+ VoltraWidgetService.reloadTimeline(for: dynamicWidgetId)
268
+ }
269
+ )
270
+ try await dynamicWidgetUpdater.updateDynamicWidget(
271
+ dynamicWidgetID: dynamicWidgetId,
272
+ dynamicWidgetPropsJSON: dynamicWidgetPropsJson
273
+ )
274
+ }
275
+
260
276
  func updateWidget(widgetId: String, jsonString: String, options: UpdateWidgetOptions?) async throws {
261
277
  try VoltraWidgetService.setWidgetData(widgetId: widgetId, jsonString: jsonString, deepLinkUrl: options?.deepLinkUrl)
262
278
 
@@ -0,0 +1,51 @@
1
+ import Foundation
2
+
3
+ enum DynamicWidgetPropsCodec {
4
+ static let emptyDynamicWidgetPropsJSON = "{}"
5
+
6
+ private static let currentStorageVersion = 1
7
+
8
+ static func encodeStorageEntry(dynamicWidgetPropsJSON: String) throws -> String {
9
+ let dynamicWidgetProps: JSONValue
10
+ do {
11
+ dynamicWidgetProps = try JSONValue.parse(from: dynamicWidgetPropsJSON)
12
+ } catch {
13
+ throw DynamicWidgetPropsStoreError.invalidJSON
14
+ }
15
+
16
+ guard case .object = dynamicWidgetProps else {
17
+ throw DynamicWidgetPropsStoreError.topLevelValueMustBeObject
18
+ }
19
+
20
+ let entry = DynamicWidgetPropsStorageEntry(
21
+ dynamicWidgetPropsStorageVersion: currentStorageVersion,
22
+ dynamicWidgetProps: dynamicWidgetProps
23
+ )
24
+ let data = try JSONEncoder().encode(entry)
25
+ guard let json = String(data: data, encoding: .utf8) else {
26
+ throw DynamicWidgetPropsStoreError.invalidJSON
27
+ }
28
+ return json
29
+ }
30
+
31
+ static func decodeDynamicWidgetProps(storageEntryJSON: String?) -> String {
32
+ guard
33
+ let storageEntryJSON,
34
+ let data = storageEntryJSON.data(using: .utf8),
35
+ let entry = try? JSONDecoder().decode(DynamicWidgetPropsStorageEntry.self, from: data),
36
+ entry.dynamicWidgetPropsStorageVersion == currentStorageVersion,
37
+ case .object = entry.dynamicWidgetProps,
38
+ let dynamicWidgetPropsData = try? JSONEncoder().encode(entry.dynamicWidgetProps),
39
+ let dynamicWidgetPropsJSON = String(data: dynamicWidgetPropsData, encoding: .utf8)
40
+ else {
41
+ return emptyDynamicWidgetPropsJSON
42
+ }
43
+
44
+ return dynamicWidgetPropsJSON
45
+ }
46
+ }
47
+
48
+ private struct DynamicWidgetPropsStorageEntry: Codable {
49
+ let dynamicWidgetPropsStorageVersion: Int
50
+ let dynamicWidgetProps: JSONValue
51
+ }
@@ -0,0 +1,53 @@
1
+ import Foundation
2
+
3
+ enum DynamicWidgetPropsStoreError: Error, Equatable, LocalizedError {
4
+ case appGroupNotConfigured
5
+ case userDefaultsUnavailable
6
+ case persistenceFailed
7
+ case invalidJSON
8
+ case topLevelValueMustBeObject
9
+
10
+ var errorDescription: String? {
11
+ switch self {
12
+ case .appGroupNotConfigured:
13
+ return "App Group not configured. Set 'groupIdentifier' in the Voltra config plugin to use Dynamic Widgets."
14
+ case .userDefaultsUnavailable:
15
+ return "Unable to access UserDefaults for the Dynamic Widget App Group."
16
+ case .persistenceFailed:
17
+ return "Failed to persist Dynamic Widget props to the App Group."
18
+ case .invalidJSON:
19
+ return "Dynamic Widget props must be valid JSON."
20
+ case .topLevelValueMustBeObject:
21
+ return "Dynamic Widget props must be a top-level JSON object."
22
+ }
23
+ }
24
+ }
25
+
26
+ protocol DynamicWidgetPropsStorage {
27
+ func string(forKey key: String) throws -> String?
28
+ func set(_ value: String, forKey key: String) throws
29
+ func removeObject(forKey key: String) throws
30
+ }
31
+
32
+ struct DynamicWidgetPropsUserDefaultsStorage: DynamicWidgetPropsStorage {
33
+ private let userDefaults: UserDefaults
34
+
35
+ init(userDefaults: UserDefaults) {
36
+ self.userDefaults = userDefaults
37
+ }
38
+
39
+ func string(forKey key: String) throws -> String? {
40
+ userDefaults.string(forKey: key)
41
+ }
42
+
43
+ func set(_ value: String, forKey key: String) throws {
44
+ userDefaults.set(value, forKey: key)
45
+ guard userDefaults.synchronize() else {
46
+ throw DynamicWidgetPropsStoreError.persistenceFailed
47
+ }
48
+ }
49
+
50
+ func removeObject(forKey key: String) throws {
51
+ userDefaults.removeObject(forKey: key)
52
+ }
53
+ }
@@ -0,0 +1,50 @@
1
+ import Foundation
2
+
3
+ /// Versioned, per-ID Dynamic Widget props shared by the app and widget extension processes.
4
+ struct DynamicWidgetPropsStore {
5
+ private let storage: Result<any DynamicWidgetPropsStorage, Error>
6
+
7
+ init() {
8
+ guard let groupIdentifier = VoltraConfig.groupIdentifier() else {
9
+ storage = .failure(DynamicWidgetPropsStoreError.appGroupNotConfigured)
10
+ return
11
+ }
12
+ guard let userDefaults = UserDefaults(suiteName: groupIdentifier) else {
13
+ storage = .failure(DynamicWidgetPropsStoreError.userDefaultsUnavailable)
14
+ return
15
+ }
16
+ storage = .success(DynamicWidgetPropsUserDefaultsStorage(userDefaults: userDefaults))
17
+ }
18
+
19
+ init(storage: any DynamicWidgetPropsStorage) {
20
+ self.storage = .success(storage)
21
+ }
22
+
23
+ func persistDynamicWidgetProps(_ dynamicWidgetPropsJSON: String, for dynamicWidgetID: String) throws {
24
+ let storageEntryJSON = try DynamicWidgetPropsCodec.encodeStorageEntry(
25
+ dynamicWidgetPropsJSON: dynamicWidgetPropsJSON
26
+ )
27
+ let resolvedStorage = try storage.get()
28
+ try resolvedStorage.set(storageEntryJSON, forKey: Self.storageKey(for: dynamicWidgetID))
29
+ }
30
+
31
+ func dynamicWidgetProps(for dynamicWidgetID: String) -> String {
32
+ guard
33
+ case let .success(resolvedStorage) = storage,
34
+ let storageEntryJSON = try? resolvedStorage.string(forKey: Self.storageKey(for: dynamicWidgetID))
35
+ else {
36
+ return DynamicWidgetPropsCodec.emptyDynamicWidgetPropsJSON
37
+ }
38
+
39
+ return DynamicWidgetPropsCodec.decodeDynamicWidgetProps(storageEntryJSON: storageEntryJSON)
40
+ }
41
+
42
+ func clearDynamicWidgetProps(for dynamicWidgetID: String) throws {
43
+ let resolvedStorage = try storage.get()
44
+ try resolvedStorage.removeObject(forKey: Self.storageKey(for: dynamicWidgetID))
45
+ }
46
+
47
+ static func storageKey(for dynamicWidgetID: String) -> String {
48
+ VoltraStorageKeys.dynamicWidgetPropsV1(dynamicWidgetID)
49
+ }
50
+ }
@@ -0,0 +1,31 @@
1
+ /// Routes a Dynamic Widget's current persisted props and environment to its runtime boundary.
2
+ struct DynamicWidgetRenderCoordinator {
3
+ typealias DynamicWidgetPropsProvider = (_ dynamicWidgetID: String) -> String
4
+ typealias DynamicWidgetRuntimeBoundary = (
5
+ _ dynamicWidgetID: String,
6
+ _ dynamicWidgetPropsJSON: String,
7
+ _ dynamicWidgetEnvironmentJSON: String
8
+ ) -> String?
9
+
10
+ private let dynamicWidgetPropsProvider: DynamicWidgetPropsProvider
11
+ private let dynamicWidgetRuntimeBoundary: DynamicWidgetRuntimeBoundary
12
+
13
+ init(
14
+ dynamicWidgetPropsProvider: @escaping DynamicWidgetPropsProvider,
15
+ dynamicWidgetRuntimeBoundary: @escaping DynamicWidgetRuntimeBoundary
16
+ ) {
17
+ self.dynamicWidgetPropsProvider = dynamicWidgetPropsProvider
18
+ self.dynamicWidgetRuntimeBoundary = dynamicWidgetRuntimeBoundary
19
+ }
20
+
21
+ func renderDynamicWidget(
22
+ dynamicWidgetID: String,
23
+ dynamicWidgetEnvironmentJSON: String
24
+ ) -> String? {
25
+ dynamicWidgetRuntimeBoundary(
26
+ dynamicWidgetID,
27
+ dynamicWidgetPropsProvider(dynamicWidgetID),
28
+ dynamicWidgetEnvironmentJSON
29
+ )
30
+ }
31
+ }
@@ -0,0 +1,30 @@
1
+ protocol DynamicWidgetPropsPersistence {
2
+ func persistDynamicWidgetProps(_ dynamicWidgetPropsJSON: String, for dynamicWidgetID: String) throws
3
+ }
4
+
5
+ extension DynamicWidgetPropsStore: DynamicWidgetPropsPersistence {}
6
+
7
+ /// Persists runtime props before requesting a targeted Dynamic Widget timeline reload.
8
+ struct DynamicWidgetUpdater {
9
+ private let dynamicWidgetPropsPersistence: any DynamicWidgetPropsPersistence
10
+ private let dynamicWidgetTimelineReload: (String) async throws -> Void
11
+
12
+ init(
13
+ dynamicWidgetPropsPersistence: any DynamicWidgetPropsPersistence,
14
+ dynamicWidgetTimelineReload: @escaping (String) async throws -> Void
15
+ ) {
16
+ self.dynamicWidgetPropsPersistence = dynamicWidgetPropsPersistence
17
+ self.dynamicWidgetTimelineReload = dynamicWidgetTimelineReload
18
+ }
19
+
20
+ func updateDynamicWidget(
21
+ dynamicWidgetID: String,
22
+ dynamicWidgetPropsJSON: String
23
+ ) async throws {
24
+ try dynamicWidgetPropsPersistence.persistDynamicWidgetProps(
25
+ dynamicWidgetPropsJSON,
26
+ for: dynamicWidgetID
27
+ )
28
+ try await dynamicWidgetTimelineReload(dynamicWidgetID)
29
+ }
30
+ }
@@ -28,6 +28,10 @@ public enum VoltraStorageKeys {
28
28
  "Voltra_Widget_ServerUrl_\(widgetId)"
29
29
  }
30
30
 
31
+ public static func dynamicWidgetPropsV1(_ dynamicWidgetId: String) -> String {
32
+ "Voltra_DynamicWidget_Props_v1_\(dynamicWidgetId)"
33
+ }
34
+
31
35
  // MARK: - Prefixes / kind identifiers
32
36
 
33
37
  public static let widgetKindPrefix = "Voltra_Widget_"
@@ -155,7 +155,9 @@ public enum VoltraJSRenderer {
155
155
  // MARK: - Lifecycle
156
156
 
157
157
  private static func context() -> JSContext? {
158
- if let existing = _context { return existing }
158
+ if let existing = _context {
159
+ return existing
160
+ }
159
161
  guard let ctx = JSContext() else {
160
162
  VoltraLogger.widget.error("[\(TAG)] Failed to create JSContext")
161
163
  return nil
@@ -55,7 +55,9 @@ public indirect enum VoltraNode: Hashable, View {
55
55
 
56
56
  /// Check if node represents nothing
57
57
  public var isEmpty: Bool {
58
- if case .empty = self { return true }
58
+ if case .empty = self {
59
+ return true
60
+ }
59
61
  return false
60
62
  }
61
63
 
@@ -72,7 +74,9 @@ public indirect enum VoltraNode: Hashable, View {
72
74
  // Extract stylesheet (key "s")
73
75
  if case let .array(stylesheetArray) = rootObject["s"] {
74
76
  stylesheet = stylesheetArray.compactMap { item in
75
- if case let .object(dict) = item { return dict }
77
+ if case let .object(dict) = item {
78
+ return dict
79
+ }
76
80
  return nil
77
81
  }
78
82
  }
@@ -86,13 +86,17 @@ public enum VoltraWidgetDefaults {
86
86
 
87
87
  // MARK: - Remove
88
88
 
89
- /// Removes all persisted data (json, deepLinkUrl, timeline) for a single widget.
89
+ /// Removes all persisted data (json, deepLinkUrl, timeline, Dynamic Widget props) for a single widget.
90
90
  /// This is the single source of truth for which keys exist per widget.
91
91
  public static func removeAllData(for widgetId: String) {
92
92
  guard let defaults = try? resolvedDefaults() else { return }
93
93
  defaults.removeObject(forKey: VoltraStorageKeys.widgetJson(widgetId))
94
94
  defaults.removeObject(forKey: VoltraStorageKeys.widgetDeepLinkUrl(widgetId))
95
95
  defaults.removeObject(forKey: VoltraStorageKeys.widgetTimeline(widgetId))
96
+ let dynamicWidgetPropsStore = DynamicWidgetPropsStore(
97
+ storage: DynamicWidgetPropsUserDefaultsStorage(userDefaults: defaults)
98
+ )
99
+ try? dynamicWidgetPropsStore.clearDynamicWidgetProps(for: widgetId)
96
100
  defaults.synchronize()
97
101
  }
98
102
 
@@ -105,11 +109,15 @@ public enum VoltraWidgetDefaults {
105
109
  /// Removes all persisted data for every widget listed in the app's Info.plist.
106
110
  public static func removeAllWidgets() {
107
111
  guard let defaults = try? resolvedDefaults() else { return }
112
+ let dynamicWidgetPropsStore = DynamicWidgetPropsStore(
113
+ storage: DynamicWidgetPropsUserDefaultsStorage(userDefaults: defaults)
114
+ )
108
115
  let widgetIds = Bundle.main.object(forInfoDictionaryKey: VoltraStorageKeys.widgetIds) as? [String] ?? []
109
116
  for widgetId in widgetIds {
110
117
  defaults.removeObject(forKey: VoltraStorageKeys.widgetJson(widgetId))
111
118
  defaults.removeObject(forKey: VoltraStorageKeys.widgetDeepLinkUrl(widgetId))
112
119
  defaults.removeObject(forKey: VoltraStorageKeys.widgetTimeline(widgetId))
120
+ try? dynamicWidgetPropsStore.clearDynamicWidgetProps(for: widgetId)
113
121
  }
114
122
  defaults.synchronize()
115
123
  }
@@ -351,10 +351,20 @@ public struct VoltraClientWidgetContentView: View {
351
351
  locale: locale,
352
352
  configuration: entry.configuration
353
353
  )
354
- if let resolved = VoltraJSRenderer.render(
355
- widgetId: entry.widgetId,
356
- propsJSON: "{}",
357
- envJSON: envJSON
354
+ let dynamicWidgetPropsStore = DynamicWidgetPropsStore()
355
+ let dynamicWidgetRenderCoordinator = DynamicWidgetRenderCoordinator(
356
+ dynamicWidgetPropsProvider: dynamicWidgetPropsStore.dynamicWidgetProps(for:),
357
+ dynamicWidgetRuntimeBoundary: { dynamicWidgetID, dynamicWidgetPropsJSON, dynamicWidgetEnvironmentJSON in
358
+ VoltraJSRenderer.render(
359
+ widgetId: dynamicWidgetID,
360
+ propsJSON: dynamicWidgetPropsJSON,
361
+ envJSON: dynamicWidgetEnvironmentJSON
362
+ )
363
+ }
364
+ )
365
+ if let resolved = dynamicWidgetRenderCoordinator.renderDynamicWidget(
366
+ dynamicWidgetID: entry.widgetId,
367
+ dynamicWidgetEnvironmentJSON: envJSON
358
368
  ), let node = parseResolvedNode(jsonString: resolved) {
359
369
  return VoltraHomeWidgetEntry(date: entry.date, rootNode: node, widgetId: entry.widgetId)
360
370
  }
@@ -368,7 +378,9 @@ public struct VoltraClientWidgetContentView: View {
368
378
  private func parseResolvedNode(jsonString: String) -> VoltraNode? {
369
379
  guard let json = try? JSONValue.parse(from: jsonString) else { return nil }
370
380
  let node = VoltraNode.parse(from: json)
371
- if case .empty = node { return nil }
381
+ if case .empty = node {
382
+ return nil
383
+ }
372
384
  return node
373
385
  }
374
386
 
@@ -461,7 +461,9 @@ private extension View {
461
461
  private func resolveDeepLinkURL(_ entry: VoltraHomeWidgetEntry) -> URL? {
462
462
  // Prefer the timeline entry's deep link URL if available
463
463
  if let entryUrl = entry.deepLinkUrl, !entryUrl.isEmpty {
464
- if entryUrl.contains("://"), let url = URL(string: entryUrl) { return url }
464
+ if entryUrl.contains("://"), let url = URL(string: entryUrl) {
465
+ return url
466
+ }
465
467
  if let scheme = VoltraDeepLinkResolver.deepLinkScheme() {
466
468
  let path = entryUrl.hasPrefix("/") ? entryUrl : "/\(entryUrl)"
467
469
  return URL(string: "\(scheme)://\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))")
@@ -470,7 +472,9 @@ private func resolveDeepLinkURL(_ entry: VoltraHomeWidgetEntry) -> URL? {
470
472
 
471
473
  // Fallback to static deep link URL from UserDefaults
472
474
  if let raw = VoltraHomeWidgetStore.readDeepLinkUrl(widgetId: entry.widgetId), !raw.isEmpty {
473
- if raw.contains("://"), let url = URL(string: raw) { return url }
475
+ if raw.contains("://"), let url = URL(string: raw) {
476
+ return url
477
+ }
474
478
  if let scheme = VoltraDeepLinkResolver.deepLinkScheme() {
475
479
  let path = raw.hasPrefix("/") ? raw : "/\(raw)"
476
480
  return URL(string: "\(scheme)://\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))")
@@ -9,7 +9,9 @@ public struct VoltraWidget: Widget {
9
9
  /// Convert an array of nodes to a single root node for rendering
10
10
  private func rootNode(for region: VoltraRegion, from state: VoltraAttributes.ContentState) -> VoltraNode {
11
11
  let nodes = state.regions[region] ?? []
12
- if nodes.isEmpty { return .empty }
12
+ if nodes.isEmpty {
13
+ return .empty
14
+ }
13
15
  return nodes.count == 1 ? nodes[0] : .array(nodes)
14
16
  }
15
17
 
@@ -30,7 +30,9 @@ enum VoltraDeepLinkResolver {
30
30
  guard !raw.isEmpty else { return nil }
31
31
 
32
32
  // If it's already an absolute URL, use it as-is
33
- if raw.contains("://"), let url = URL(string: raw) { return url }
33
+ if raw.contains("://"), let url = URL(string: raw) {
34
+ return url
35
+ }
34
36
 
35
37
  // Otherwise, prepend the app's URL scheme
36
38
  if let scheme = deepLinkScheme() {
@@ -43,10 +43,14 @@ enum VoltraProgressDriver {
43
43
  /// The progress value is clamped to `[0, 1]` unless `clamp` is `false`.
44
44
  static func progress(for range: ClosedRange<Date>, at date: Date = Date(), clamp: Bool = true) -> Double {
45
45
  let total = range.upperBound.timeIntervalSince(range.lowerBound)
46
- if total <= 0 { return clamp ? 1 : 0 }
46
+ if total <= 0 {
47
+ return clamp ? 1 : 0
48
+ }
47
49
  let elapsed = date.timeIntervalSince(range.lowerBound)
48
50
  let ratio = elapsed / total
49
- if !clamp { return ratio }
51
+ if !clamp {
52
+ return ratio
53
+ }
50
54
  return min(max(ratio, 0), 1)
51
55
  }
52
56
  }
@@ -250,7 +250,9 @@ struct VoltraFlexStackLayout: Layout {
250
250
  }
251
251
  }
252
252
  // If stretch and no explicit cross size, use full cross axis (only finite values)
253
- if let proposal = proposal, proposal.isFinite { return proposal }
253
+ if let proposal = proposal, proposal.isFinite {
254
+ return proposal
255
+ }
254
256
  // Otherwise, measure intrinsic
255
257
  let size = subview.sizeThatFits(ProposedViewSize(width: nil, height: nil))
256
258
  return crossAxis(size)
@@ -266,8 +268,12 @@ struct VoltraFlexStackLayout: Layout {
266
268
 
267
269
  private func clamp(_ value: CGFloat, min: CGFloat?, max: CGFloat?) -> CGFloat {
268
270
  var result = value
269
- if let min = min { result = Swift.max(result, min) }
270
- if let max = max { result = Swift.min(result, max) }
271
+ if let min = min {
272
+ result = Swift.max(result, min)
273
+ }
274
+ if let max = max {
275
+ result = Swift.min(result, max)
276
+ }
271
277
  return result
272
278
  }
273
279