@use-voltra/ios-client 2.0.0 → 2.1.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 (100) hide show
  1. package/README.md +20 -1
  2. package/build/commonjs/index.js +8 -0
  3. package/build/commonjs/index.js.map +1 -1
  4. package/build/commonjs/utils/enableWidgetHotReload.js +39 -0
  5. package/build/commonjs/utils/enableWidgetHotReload.js.map +1 -0
  6. package/build/module/index.js +1 -0
  7. package/build/module/index.js.map +1 -1
  8. package/build/module/utils/enableWidgetHotReload.js +35 -0
  9. package/build/module/utils/enableWidgetHotReload.js.map +1 -0
  10. package/build/typescript/commonjs/index.d.ts +1 -0
  11. package/build/typescript/commonjs/index.d.ts.map +1 -1
  12. package/build/typescript/commonjs/utils/enableWidgetHotReload.d.ts +23 -0
  13. package/build/typescript/commonjs/utils/enableWidgetHotReload.d.ts.map +1 -0
  14. package/build/typescript/module/index.d.ts +1 -0
  15. package/build/typescript/module/index.d.ts.map +1 -1
  16. package/build/typescript/module/utils/enableWidgetHotReload.d.ts +23 -0
  17. package/build/typescript/module/utils/enableWidgetHotReload.d.ts.map +1 -0
  18. package/expo-plugin/build/cjs/index.js +2 -1
  19. package/expo-plugin/build/cjs/index.js.map +1 -1
  20. package/expo-plugin/build/cjs/ios-widget/clientRendered.js +82 -0
  21. package/expo-plugin/build/cjs/ios-widget/clientRendered.js.map +1 -0
  22. package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js +118 -0
  23. package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js.map +1 -0
  24. package/expo-plugin/build/cjs/ios-widget/files/index.js +6 -0
  25. package/expo-plugin/build/cjs/ios-widget/files/index.js.map +1 -1
  26. package/expo-plugin/build/cjs/ios-widget/files/manifest.js +67 -0
  27. package/expo-plugin/build/cjs/ios-widget/files/manifest.js.map +1 -0
  28. package/expo-plugin/build/cjs/ios-widget/files/swift.js +149 -12
  29. package/expo-plugin/build/cjs/ios-widget/files/swift.js.map +1 -1
  30. package/expo-plugin/build/cjs/ios-widget/index.js +1 -1
  31. package/expo-plugin/build/cjs/ios-widget/index.js.map +1 -1
  32. package/expo-plugin/build/cjs/ios-widget/widgetPlist.js +22 -0
  33. package/expo-plugin/build/cjs/ios-widget/widgetPlist.js.map +1 -1
  34. package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js +88 -0
  35. package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js.map +1 -1
  36. package/expo-plugin/build/cjs/ios-widget/xcode/index.js +12 -1
  37. package/expo-plugin/build/cjs/ios-widget/xcode/index.js.map +1 -1
  38. package/expo-plugin/build/cjs/validation.js +7 -4
  39. package/expo-plugin/build/cjs/validation.js.map +1 -1
  40. package/expo-plugin/build/esm/index.js +2 -1
  41. package/expo-plugin/build/esm/index.js.map +1 -1
  42. package/expo-plugin/build/esm/ios-widget/clientRendered.js +46 -0
  43. package/expo-plugin/build/esm/ios-widget/clientRendered.js.map +1 -0
  44. package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js +82 -0
  45. package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js.map +1 -0
  46. package/expo-plugin/build/esm/ios-widget/files/index.js +6 -0
  47. package/expo-plugin/build/esm/ios-widget/files/index.js.map +1 -1
  48. package/expo-plugin/build/esm/ios-widget/files/manifest.js +30 -0
  49. package/expo-plugin/build/esm/ios-widget/files/manifest.js.map +1 -0
  50. package/expo-plugin/build/esm/ios-widget/files/swift.js +149 -12
  51. package/expo-plugin/build/esm/ios-widget/files/swift.js.map +1 -1
  52. package/expo-plugin/build/esm/ios-widget/index.js +1 -1
  53. package/expo-plugin/build/esm/ios-widget/index.js.map +1 -1
  54. package/expo-plugin/build/esm/ios-widget/widgetPlist.js +22 -0
  55. package/expo-plugin/build/esm/ios-widget/widgetPlist.js.map +1 -1
  56. package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js +87 -0
  57. package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js.map +1 -1
  58. package/expo-plugin/build/esm/ios-widget/xcode/index.js +13 -2
  59. package/expo-plugin/build/esm/ios-widget/xcode/index.js.map +1 -1
  60. package/expo-plugin/build/esm/validation.js +8 -5
  61. package/expo-plugin/build/esm/validation.js.map +1 -1
  62. package/expo-plugin/build/types/index.d.ts.map +1 -1
  63. package/expo-plugin/build/types/ios-widget/clientRendered.d.ts +24 -0
  64. package/expo-plugin/build/types/ios-widget/clientRendered.d.ts.map +1 -0
  65. package/expo-plugin/build/types/ios-widget/clientRenderedPrerender.d.ts +9 -0
  66. package/expo-plugin/build/types/ios-widget/clientRenderedPrerender.d.ts.map +1 -0
  67. package/expo-plugin/build/types/ios-widget/files/index.d.ts.map +1 -1
  68. package/expo-plugin/build/types/ios-widget/files/manifest.d.ts +9 -0
  69. package/expo-plugin/build/types/ios-widget/files/manifest.d.ts.map +1 -0
  70. package/expo-plugin/build/types/ios-widget/files/swift.d.ts +6 -0
  71. package/expo-plugin/build/types/ios-widget/files/swift.d.ts.map +1 -1
  72. package/expo-plugin/build/types/ios-widget/widgetPlist.d.ts.map +1 -1
  73. package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts +5 -0
  74. package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts.map +1 -1
  75. package/expo-plugin/build/types/ios-widget/xcode/index.d.ts +2 -0
  76. package/expo-plugin/build/types/ios-widget/xcode/index.d.ts.map +1 -1
  77. package/expo-plugin/build/types/types.d.ts +30 -2
  78. package/expo-plugin/build/types/types.d.ts.map +1 -1
  79. package/expo-plugin/build/types/validation.d.ts +2 -2
  80. package/expo-plugin/build/types/validation.d.ts.map +1 -1
  81. package/expo-plugin/src/index.ts +3 -2
  82. package/expo-plugin/src/ios-widget/clientRendered.ts +88 -0
  83. package/expo-plugin/src/ios-widget/clientRenderedPrerender.ts +103 -0
  84. package/expo-plugin/src/ios-widget/files/index.ts +7 -0
  85. package/expo-plugin/src/ios-widget/files/manifest.ts +47 -0
  86. package/expo-plugin/src/ios-widget/files/swift.ts +166 -15
  87. package/expo-plugin/src/ios-widget/index.ts +1 -1
  88. package/expo-plugin/src/ios-widget/widgetPlist.ts +22 -0
  89. package/expo-plugin/src/ios-widget/xcode/buildPhases.ts +93 -0
  90. package/expo-plugin/src/ios-widget/xcode/index.ts +19 -2
  91. package/expo-plugin/src/types.ts +32 -2
  92. package/expo-plugin/src/validation.ts +14 -5
  93. package/ios/app/VoltraModuleImpl.swift +26 -1
  94. package/ios/shared/VoltraConstants.swift +4 -0
  95. package/ios/shared/VoltraJSRenderer.swift +201 -0
  96. package/ios/shared/VoltraWidgetDefaults.swift +14 -0
  97. package/ios/target/VoltraClientWidgetRuntime.swift +379 -0
  98. package/package.json +5 -16
  99. package/src/index.ts +1 -0
  100. package/src/utils/enableWidgetHotReload.ts +39 -0
@@ -0,0 +1,379 @@
1
+ import Foundation
2
+ import SwiftUI
3
+ import WidgetKit
4
+
5
+ // Shared runtime for Dynamic Widgets.
6
+ //
7
+ // This file is compiled into the VoltraWidget framework alongside the existing
8
+ // VoltraHomeWidget.swift, so widget extension code generated by the plugin can
9
+ // reference VoltraClientWidget* types without any per-widget glue duplicated.
10
+ //
11
+ // Pipeline:
12
+ //
13
+ // 1. Provider runs on a WidgetKit timeline tick.
14
+ // - Fetches the JS bundle (dev = Metro HTTP, prod = baked asset stub).
15
+ // - Evaluates it once in the shared JSContext via VoltraJSRenderer.
16
+ // - Emits a VoltraClientWidgetEntry with `bundleReady = true` (or errorMessage on failure).
17
+ //
18
+ // 2. ContentView body runs per (family, scheme, renderingMode) combo.
19
+ // - Reads SwiftUI @Environment values (which are ONLY accessible inside a View body).
20
+ // - Builds envJSON matching WidgetEnvironment in packages/core/src/widget-environment.ts.
21
+ // - Calls VoltraJSRenderer.render(widgetId, propsJSON, envJSON) → resolved JSON string.
22
+ // - Parses the resolved JSON into a VoltraNode and hands a VoltraHomeWidgetEntry to
23
+ // the existing VoltraHomeWidgetView so Dynamic Widgets reach the same UI
24
+ // renderer as server-rendered ones.
25
+ //
26
+ // On bundle-load failure the View falls back to the prerendered initial state.
27
+
28
+ // MARK: - Entry
29
+
30
+ public struct VoltraClientWidgetEntry: TimelineEntry {
31
+ public let date: Date
32
+ public let widgetId: String
33
+ public let bundleReady: Bool
34
+ public let errorMessage: String?
35
+ /// User-configured AppIntent parameters → `env.configuration`. Empty for widgets without an
36
+ /// AppIntent configuration; populated by the generated AppIntentTimelineProvider from the
37
+ /// configured intent.
38
+ public let configuration: [String: String]
39
+ /// The evaluated widget's JS bundle. Carried on the entry (not just left in the provider's
40
+ /// JSContext) because WidgetKit archives entries and re-renders the View in a *fresh* extension
41
+ /// process, where the provider's process-static JSContext is empty. The View re-evaluates from
42
+ /// this source so `render()` always has the widget's function available in its own process.
43
+ public let bundleSource: String?
44
+
45
+ public init(
46
+ date: Date,
47
+ widgetId: String,
48
+ bundleReady: Bool,
49
+ errorMessage: String? = nil,
50
+ configuration: [String: String] = [:],
51
+ bundleSource: String? = nil
52
+ ) {
53
+ self.date = date
54
+ self.widgetId = widgetId
55
+ self.bundleReady = bundleReady
56
+ self.errorMessage = errorMessage
57
+ self.configuration = configuration
58
+ self.bundleSource = bundleSource
59
+ }
60
+ }
61
+
62
+ // MARK: - Provider
63
+
64
+ //
65
+ // Refresh model:
66
+ //
67
+ // - DEBUG builds: Provider fetches `<id>.bundle` from Metro on every
68
+ // `getTimeline`/`getSnapshot`, evaluates it in the shared JSContext, and emits
69
+ // `bundleReady = true`. The ContentView then runs the freshly evaluated
70
+ // `render(props, env)` and feeds the result to `VoltraHomeWidgetView`.
71
+ //
72
+ // - Release builds: Provider attempts to load a baked-in bundle (release-path loader is
73
+ // a future addition); on failure the ContentView renders the prerendered initial state.
74
+ //
75
+ // - Timeline policy is ALWAYS `.never`. The widget refreshes only when something
76
+ // explicitly calls `WidgetCenter.shared.reloadAllTimelines()` or when WidgetKit
77
+ // naturally re-invokes the Provider on its own lifecycle events (e.g., host app
78
+ // foregrounding). iOS rate-limits timeline-policy-driven refresh aggressively
79
+ // (~5-minute floor even in the simulator), so explicit reloads or natural
80
+ // lifecycle re-invocations are the only mechanisms that deliver fresh content.
81
+
82
+ public struct VoltraClientWidgetProvider: TimelineProvider {
83
+ public let widgetId: String
84
+ /// Prerendered initial state JSON from `VoltraWidgetInitialStates.getInitialState(for:)`.
85
+ /// Used as the placeholder, the Loading fallback, and the steady-state view if a bundle
86
+ /// load fails.
87
+ public let initialState: Data?
88
+
89
+ public init(widgetId: String, initialState: Data? = nil) {
90
+ self.widgetId = widgetId
91
+ self.initialState = initialState
92
+ }
93
+
94
+ public func placeholder(in _: Context) -> VoltraClientWidgetEntry {
95
+ VoltraClientWidgetEntry(date: Date(), widgetId: widgetId, bundleReady: false)
96
+ }
97
+
98
+ public func getSnapshot(in _: Context, completion: @escaping (VoltraClientWidgetEntry) -> Void) {
99
+ Task { completion(await loadBundleEntry()) }
100
+ }
101
+
102
+ public func getTimeline(in _: Context, completion: @escaping (Timeline<VoltraClientWidgetEntry>) -> Void) {
103
+ Task {
104
+ let entry = await loadBundleEntry()
105
+ completion(Timeline(entries: [entry], policy: .never))
106
+ }
107
+ }
108
+
109
+ private func loadBundleEntry() async -> VoltraClientWidgetEntry {
110
+ await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: [:])
111
+ }
112
+
113
+ /// Fetch + evaluate the widget bundle and build an entry. Shared by this `TimelineProvider` and
114
+ /// the plugin-generated `AppIntentTimelineProvider`s (which pass the user-configured params as
115
+ /// `configuration`).
116
+ public static func loadEntry(widgetId: String, configuration: [String: String]) async -> VoltraClientWidgetEntry {
117
+ let date = Date()
118
+
119
+ let source: String
120
+ do {
121
+ source = try await VoltraClientWidgetBundleSource.load(widgetId: widgetId)
122
+ } catch {
123
+ return VoltraClientWidgetEntry(
124
+ date: date,
125
+ widgetId: widgetId,
126
+ bundleReady: false,
127
+ errorMessage: error.localizedDescription,
128
+ configuration: configuration
129
+ )
130
+ }
131
+
132
+ let okEval = VoltraJSRenderer.evaluateBundle(source: source, widgetId: widgetId)
133
+ if !okEval {
134
+ return VoltraClientWidgetEntry(
135
+ date: date,
136
+ widgetId: widgetId,
137
+ bundleReady: false,
138
+ errorMessage: "Bundle eval failed (see logs)",
139
+ configuration: configuration
140
+ )
141
+ }
142
+ return VoltraClientWidgetEntry(
143
+ date: date,
144
+ widgetId: widgetId,
145
+ bundleReady: true,
146
+ configuration: configuration,
147
+ bundleSource: source
148
+ )
149
+ }
150
+ }
151
+
152
+ // MARK: - Bundle source (dev vs prod)
153
+
154
+ //
155
+ // Dual-path bundle loader. Dev reads from Metro localhost; prod reads from a baked asset
156
+ // in the .app bundle. The build-time bundle writer that produces the baked asset is a
157
+ // future addition; until then prod throws a clear error.
158
+
159
+ public enum VoltraClientWidgetBundleSource {
160
+ public enum LoadError: LocalizedError {
161
+ case metroHTTP(Int)
162
+ case nonUTF8
163
+ case bakedBundleNotFound(widgetId: String)
164
+
165
+ public var errorDescription: String? {
166
+ switch self {
167
+ case let .metroHTTP(code):
168
+ return "Metro HTTP \(code) — is the dev server running?"
169
+ case .nonUTF8:
170
+ return "Bundle response was not UTF-8 text"
171
+ case let .bakedBundleNotFound(id):
172
+ return "Production bundle missing for widgetId=\(id) (release path not yet implemented)"
173
+ }
174
+ }
175
+ }
176
+
177
+ public static func load(widgetId: String) async throws -> String {
178
+ #if DEBUG
179
+ return try await loadFromMetro(widgetId: widgetId)
180
+ #else
181
+ return try loadFromBakedAsset(widgetId: widgetId)
182
+ #endif
183
+ }
184
+
185
+ private static func loadFromMetro(widgetId: String) async throws -> String {
186
+ // Dev mode = always-refetch. URLSession default cache policy is fine —
187
+ // Metro's bundle responses are not cacheable, so each request hits the server.
188
+ //
189
+ // The base URL is relayed from the app via the app group (the app resolves it with
190
+ // RCTBundleURLProvider; this extension is React-free). Falls back to localhost:8081 when the
191
+ // app hasn't written it yet (e.g. first render before the host app has run).
192
+ let base = VoltraWidgetDefaults.devServerURL() ?? "http://localhost:8081"
193
+ let urlString = "\(base)/voltra/widgets/\(widgetId).bundle?platform=ios&dev=true"
194
+ guard let url = URL(string: urlString) else {
195
+ throw LoadError.metroHTTP(-1)
196
+ }
197
+ let (data, response) = try await URLSession.shared.data(from: url)
198
+ if let httpResponse = response as? HTTPURLResponse, !(200 ... 299).contains(httpResponse.statusCode) {
199
+ throw LoadError.metroHTTP(httpResponse.statusCode)
200
+ }
201
+ guard let text = String(data: data, encoding: .utf8) else {
202
+ throw LoadError.nonUTF8
203
+ }
204
+ return text
205
+ }
206
+
207
+ /// Release-path stub — currently always throws. The plan is to emit the baked bundle
208
+ /// at `expo prebuild` (config plugin step) into the extension target's resources, then
209
+ /// read it here with Bundle.main.url(forResource:withExtension:).
210
+ private static func loadFromBakedAsset(widgetId: String) throws -> String {
211
+ if let url = Bundle.main.url(forResource: "voltra-widget-\(widgetId)", withExtension: "bundle"),
212
+ let text = try? String(contentsOf: url, encoding: .utf8)
213
+ {
214
+ return text
215
+ }
216
+ throw LoadError.bakedBundleNotFound(widgetId: widgetId)
217
+ }
218
+ }
219
+
220
+ // MARK: - Env capture + JSON marshaling
221
+
222
+ //
223
+ // SwiftUI @Environment values are read inside the View body (see ContentView below) and
224
+ // passed here as plain values. This helper emits a JSON string matching the
225
+ // WidgetEnvironment type from packages/core/src/widget-environment.ts. We hand-roll the
226
+ // JSON instead of going through JSONSerialization so the shape is locked to that type and
227
+ // any drift produces a Swift compile error rather than a runtime parse failure.
228
+
229
+ public enum VoltraClientWidgetEnvBuilder {
230
+ public static func build(
231
+ date: Date,
232
+ widgetFamily: WidgetFamily,
233
+ colorScheme: ColorScheme?,
234
+ widgetRenderingMode: WidgetRenderingMode,
235
+ showsWidgetContainerBackground: Bool,
236
+ locale: Locale,
237
+ configuration: [String: String]
238
+ ) -> String {
239
+ let timestampMs = Int(date.timeIntervalSince1970 * 1000)
240
+ let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
241
+
242
+ #if DEBUG
243
+ let isDev = true
244
+ let metroUrl: String? = VoltraWidgetDefaults.devServerURL() ?? "http://localhost:8081"
245
+ #else
246
+ let isDev = false
247
+ let metroUrl: String? = nil
248
+ #endif
249
+
250
+ let metroUrlLiteral = metroUrl.map(jsonString) ?? "null"
251
+ let buildJSON = """
252
+ {
253
+ "isDev": \(isDev),
254
+ "metroUrl": \(metroUrlLiteral),
255
+ "appVersion": \(jsonString(appVersion)),
256
+ "voltraVersion": \(jsonString("1.4.1"))
257
+ }
258
+ """
259
+
260
+ let configurationJSON: String
261
+ if configuration.isEmpty {
262
+ configurationJSON = "{}"
263
+ } else {
264
+ let entries = configuration
265
+ .map { "\(jsonString($0.key)): \(jsonString($0.value))" }
266
+ .joined(separator: ", ")
267
+ configurationJSON = "{ \(entries) }"
268
+ }
269
+
270
+ return """
271
+ {
272
+ "date": \(timestampMs),
273
+ "widgetFamily": \(jsonString(familyString(widgetFamily))),
274
+ "colorScheme": \(jsonString(schemeString(colorScheme))),
275
+ "locale": \(jsonString(locale.identifier)),
276
+ "widgetRenderingMode": \(jsonString(renderingModeString(widgetRenderingMode))),
277
+ "showsWidgetContainerBackground": \(showsWidgetContainerBackground),
278
+ "configuration": \(configurationJSON),
279
+ "build": \(buildJSON)
280
+ }
281
+ """
282
+ }
283
+
284
+ /// All three SwiftUI enums have String(describing:) representations that match their
285
+ /// case names exactly (e.g. "systemMedium", "fullColor", "dark"). Using String(describing:)
286
+ /// keeps these helpers forward-compatible when Apple adds new cases in a future SDK.
287
+ private static func familyString(_ family: WidgetFamily) -> String {
288
+ String(describing: family)
289
+ }
290
+
291
+ private static func renderingModeString(_ mode: WidgetRenderingMode) -> String {
292
+ String(describing: mode)
293
+ }
294
+
295
+ private static func schemeString(_ scheme: ColorScheme?) -> String {
296
+ guard let scheme else { return "light" }
297
+ return String(describing: scheme)
298
+ }
299
+
300
+ private static func jsonString(_ value: String) -> String {
301
+ let escaped = value
302
+ .replacingOccurrences(of: "\\", with: "\\\\")
303
+ .replacingOccurrences(of: "\"", with: "\\\"")
304
+ .replacingOccurrences(of: "\n", with: "\\n")
305
+ .replacingOccurrences(of: "\r", with: "\\r")
306
+ return "\"\(escaped)\""
307
+ }
308
+ }
309
+
310
+ // MARK: - Content view
311
+
312
+ //
313
+ // Per Q5 grilling: the resolved JSON is parsed into a VoltraNode and rendered via the
314
+ // existing VoltraHomeWidgetView so Dynamic Widgets are visually indistinguishable
315
+ // from server-rendered ones at the UI layer.
316
+
317
+ public struct VoltraClientWidgetContentView: View {
318
+ public let entry: VoltraClientWidgetEntry
319
+ public let initialState: Data?
320
+
321
+ @Environment(\.widgetFamily) private var widgetFamily
322
+ @Environment(\.colorScheme) private var colorScheme
323
+ @Environment(\.widgetRenderingMode) private var widgetRenderingMode
324
+ @Environment(\.showsWidgetContainerBackground) private var showsWidgetContainerBackground
325
+ @Environment(\.locale) private var locale
326
+
327
+ public init(entry: VoltraClientWidgetEntry, initialState: Data?) {
328
+ self.entry = entry
329
+ self.initialState = initialState
330
+ }
331
+
332
+ public var body: some View {
333
+ let homeEntry = makeHomeEntry()
334
+ return VoltraHomeWidgetView(entry: homeEntry)
335
+ }
336
+
337
+ private func makeHomeEntry() -> VoltraHomeWidgetEntry {
338
+ if entry.bundleReady {
339
+ // WidgetKit may render this archived entry in a fresh extension process where the provider's
340
+ // bundle evaluation didn't happen. Re-evaluate from the entry's carried source (no-op if this
341
+ // process already has it) so render() finds the widget's function.
342
+ if let source = entry.bundleSource {
343
+ _ = VoltraJSRenderer.ensureEvaluated(widgetId: entry.widgetId, source: source)
344
+ }
345
+ let envJSON = VoltraClientWidgetEnvBuilder.build(
346
+ date: entry.date,
347
+ widgetFamily: widgetFamily,
348
+ colorScheme: colorScheme,
349
+ widgetRenderingMode: widgetRenderingMode,
350
+ showsWidgetContainerBackground: showsWidgetContainerBackground,
351
+ locale: locale,
352
+ configuration: entry.configuration
353
+ )
354
+ if let resolved = VoltraJSRenderer.render(
355
+ widgetId: entry.widgetId,
356
+ propsJSON: "{}",
357
+ envJSON: envJSON
358
+ ), let node = parseResolvedNode(jsonString: resolved) {
359
+ return VoltraHomeWidgetEntry(date: entry.date, rootNode: node, widgetId: entry.widgetId)
360
+ }
361
+ // render() or VoltraNode.parse failed — fall through to the prerendered initial state
362
+ // so the widget still shows real UI instead of a blank tile.
363
+ }
364
+ let fallbackNode = initialState.flatMap(parseResolvedNode(jsonData:))
365
+ return VoltraHomeWidgetEntry(date: entry.date, rootNode: fallbackNode, widgetId: entry.widgetId)
366
+ }
367
+
368
+ private func parseResolvedNode(jsonString: String) -> VoltraNode? {
369
+ guard let json = try? JSONValue.parse(from: jsonString) else { return nil }
370
+ let node = VoltraNode.parse(from: json)
371
+ if case .empty = node { return nil }
372
+ return node
373
+ }
374
+
375
+ private func parseResolvedNode(jsonData: Data) -> VoltraNode? {
376
+ guard let text = String(data: jsonData, encoding: .utf8) else { return nil }
377
+ return parseResolvedNode(jsonString: text)
378
+ }
379
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@use-voltra/ios-client",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Client-only Voltra APIs for iOS",
5
5
  "main": "./build/commonjs/index.js",
6
6
  "module": "./build/module/index.js",
@@ -28,19 +28,7 @@
28
28
  "expo-plugin/app.plugin.js",
29
29
  "expo-plugin/build",
30
30
  "expo-plugin/src",
31
- "ios/Package.swift",
32
- "ios/VoltraWidget.podspec",
33
- "ios/app",
34
- "ios/shared",
35
- "ios/target",
36
- "ios/ui/Extensions",
37
- "ios/ui/Generated/Parameters/*",
38
- "ios/ui/Helpers",
39
- "ios/ui/Layout",
40
- "ios/ui/Protocols",
41
- "ios/ui/Style",
42
- "ios/ui/Views",
43
- "ios/ui/Voltra.swift",
31
+ "ios",
44
32
  "src",
45
33
  "*.podspec",
46
34
  "README.md"
@@ -51,8 +39,9 @@
51
39
  "@expo/plist": "^0.3.5",
52
40
  "dedent": "^1.7.1",
53
41
  "xcode": "^3.0.1",
54
- "@use-voltra/expo-plugin": "^2.0.0",
55
- "@use-voltra/ios": "^2.0.0"
42
+ "@use-voltra/compiler": "^2.1.0",
43
+ "@use-voltra/ios": "^2.1.0",
44
+ "@use-voltra/expo-plugin": "^2.1.0"
56
45
  },
57
46
  "peerDependencies": {
58
47
  "expo": "*",
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  reloadLiveActivities,
32
32
  } from './preload.js'
33
33
  export { assertRunningOnApple } from './utils/assertRunningOnApple.js'
34
+ export { enableWidgetHotReload } from './utils/enableWidgetHotReload.js'
34
35
  export { useUpdateOnHMR } from './utils/useUpdateOnHMR.js'
35
36
  export * from './utils/helpers.js'
36
37
  export type { VoltraElementJson, VoltraNodeJson } from './types.js'
@@ -0,0 +1,39 @@
1
+ import { reloadWidgets } from '../widgets/widget-api.js'
2
+
3
+ declare global {
4
+ var __accept: (...args: unknown[]) => void
5
+ }
6
+
7
+ /**
8
+ * Trigger `reloadWidgets()` on every Metro Fast Refresh patch (DEV only).
9
+ *
10
+ * Hooks the global `__accept` callback Metro fires when a Fast Refresh patch
11
+ * lands in the host app's JS runtime. When fired, calls
12
+ * `WidgetCenter.shared.reloadAllTimelines()` so WidgetKit re-invokes each widget
13
+ * Provider, which re-fetches the freshest bundle from Metro and renders the
14
+ * updated UI.
15
+ *
16
+ * Only effective while the host app's JS thread is alive — iOS suspends the
17
+ * RN runtime within ~5 seconds of backgrounding, so the "edit while staring at
18
+ * the home screen, never touch the host app" case is not covered. For that
19
+ * workflow the dev still relies on WidgetKit's natural lifecycle refresh on
20
+ * app foreground.
21
+ *
22
+ * Call once at app startup. Returns `dispose()` to restore the prior
23
+ * `__accept` (rarely needed in practice). No-op in release builds.
24
+ */
25
+ export function enableWidgetHotReload(): () => void {
26
+ if (!__DEV__) {
27
+ return () => {}
28
+ }
29
+
30
+ const oldAccept = global['__accept']
31
+ global['__accept'] = (...args) => {
32
+ void reloadWidgets()
33
+ oldAccept?.(...args)
34
+ }
35
+
36
+ return () => {
37
+ global['__accept'] = oldAccept
38
+ }
39
+ }