@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.
- package/README.md +20 -1
- package/build/commonjs/index.js +8 -0
- package/build/commonjs/index.js.map +1 -1
- package/build/commonjs/utils/enableWidgetHotReload.js +39 -0
- package/build/commonjs/utils/enableWidgetHotReload.js.map +1 -0
- package/build/module/index.js +1 -0
- package/build/module/index.js.map +1 -1
- package/build/module/utils/enableWidgetHotReload.js +35 -0
- package/build/module/utils/enableWidgetHotReload.js.map +1 -0
- package/build/typescript/commonjs/index.d.ts +1 -0
- package/build/typescript/commonjs/index.d.ts.map +1 -1
- package/build/typescript/commonjs/utils/enableWidgetHotReload.d.ts +23 -0
- package/build/typescript/commonjs/utils/enableWidgetHotReload.d.ts.map +1 -0
- package/build/typescript/module/index.d.ts +1 -0
- package/build/typescript/module/index.d.ts.map +1 -1
- package/build/typescript/module/utils/enableWidgetHotReload.d.ts +23 -0
- package/build/typescript/module/utils/enableWidgetHotReload.d.ts.map +1 -0
- package/expo-plugin/build/cjs/index.js +2 -1
- package/expo-plugin/build/cjs/index.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/clientRendered.js +82 -0
- package/expo-plugin/build/cjs/ios-widget/clientRendered.js.map +1 -0
- package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js +118 -0
- package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js.map +1 -0
- package/expo-plugin/build/cjs/ios-widget/files/index.js +6 -0
- package/expo-plugin/build/cjs/ios-widget/files/index.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/files/manifest.js +67 -0
- package/expo-plugin/build/cjs/ios-widget/files/manifest.js.map +1 -0
- package/expo-plugin/build/cjs/ios-widget/files/swift.js +149 -12
- package/expo-plugin/build/cjs/ios-widget/files/swift.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/index.js +1 -1
- package/expo-plugin/build/cjs/ios-widget/index.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/widgetPlist.js +22 -0
- package/expo-plugin/build/cjs/ios-widget/widgetPlist.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js +88 -0
- package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/index.js +12 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/index.js.map +1 -1
- package/expo-plugin/build/cjs/validation.js +7 -4
- package/expo-plugin/build/cjs/validation.js.map +1 -1
- package/expo-plugin/build/esm/index.js +2 -1
- package/expo-plugin/build/esm/index.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/clientRendered.js +46 -0
- package/expo-plugin/build/esm/ios-widget/clientRendered.js.map +1 -0
- package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js +82 -0
- package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js.map +1 -0
- package/expo-plugin/build/esm/ios-widget/files/index.js +6 -0
- package/expo-plugin/build/esm/ios-widget/files/index.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/files/manifest.js +30 -0
- package/expo-plugin/build/esm/ios-widget/files/manifest.js.map +1 -0
- package/expo-plugin/build/esm/ios-widget/files/swift.js +149 -12
- package/expo-plugin/build/esm/ios-widget/files/swift.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/index.js +1 -1
- package/expo-plugin/build/esm/ios-widget/index.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/widgetPlist.js +22 -0
- package/expo-plugin/build/esm/ios-widget/widgetPlist.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js +87 -0
- package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/index.js +13 -2
- package/expo-plugin/build/esm/ios-widget/xcode/index.js.map +1 -1
- package/expo-plugin/build/esm/validation.js +8 -5
- package/expo-plugin/build/esm/validation.js.map +1 -1
- package/expo-plugin/build/types/index.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/clientRendered.d.ts +24 -0
- package/expo-plugin/build/types/ios-widget/clientRendered.d.ts.map +1 -0
- package/expo-plugin/build/types/ios-widget/clientRenderedPrerender.d.ts +9 -0
- package/expo-plugin/build/types/ios-widget/clientRenderedPrerender.d.ts.map +1 -0
- package/expo-plugin/build/types/ios-widget/files/index.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/files/manifest.d.ts +9 -0
- package/expo-plugin/build/types/ios-widget/files/manifest.d.ts.map +1 -0
- package/expo-plugin/build/types/ios-widget/files/swift.d.ts +6 -0
- package/expo-plugin/build/types/ios-widget/files/swift.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/widgetPlist.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts +5 -0
- package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/index.d.ts +2 -0
- package/expo-plugin/build/types/ios-widget/xcode/index.d.ts.map +1 -1
- package/expo-plugin/build/types/types.d.ts +30 -2
- package/expo-plugin/build/types/types.d.ts.map +1 -1
- package/expo-plugin/build/types/validation.d.ts +2 -2
- package/expo-plugin/build/types/validation.d.ts.map +1 -1
- package/expo-plugin/src/index.ts +3 -2
- package/expo-plugin/src/ios-widget/clientRendered.ts +88 -0
- package/expo-plugin/src/ios-widget/clientRenderedPrerender.ts +103 -0
- package/expo-plugin/src/ios-widget/files/index.ts +7 -0
- package/expo-plugin/src/ios-widget/files/manifest.ts +47 -0
- package/expo-plugin/src/ios-widget/files/swift.ts +166 -15
- package/expo-plugin/src/ios-widget/index.ts +1 -1
- package/expo-plugin/src/ios-widget/widgetPlist.ts +22 -0
- package/expo-plugin/src/ios-widget/xcode/buildPhases.ts +93 -0
- package/expo-plugin/src/ios-widget/xcode/index.ts +19 -2
- package/expo-plugin/src/types.ts +32 -2
- package/expo-plugin/src/validation.ts +14 -5
- package/ios/app/VoltraModuleImpl.swift +26 -1
- package/ios/shared/VoltraConstants.swift +4 -0
- package/ios/shared/VoltraJSRenderer.swift +201 -0
- package/ios/shared/VoltraWidgetDefaults.swift +14 -0
- package/ios/target/VoltraClientWidgetRuntime.swift +379 -0
- package/package.json +5 -16
- package/src/index.ts +1 -0
- 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.
|
|
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
|
|
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/
|
|
55
|
-
"@use-voltra/ios": "^2.
|
|
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
|
+
}
|