@use-voltra/ios-client 2.0.0 → 2.1.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 (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
@@ -1,6 +1,6 @@
1
1
  import type { ConfigPlugin } from '@expo/config-plugins'
2
2
 
3
- import type { WidgetInitialStatePath, WidgetLabel } from '@use-voltra/expo-plugin'
3
+ import type { DynamicWidgetEntryConfig, WidgetInitialStatePath, WidgetLabel } from '@use-voltra/expo-plugin'
4
4
 
5
5
  /**
6
6
  * Supported iOS Home Screen widget size families.
@@ -14,10 +14,33 @@ export type IOSWidgetFamily =
14
14
  | 'accessoryRectangular'
15
15
  | 'accessoryInline'
16
16
 
17
+ /**
18
+ * A single user-configurable parameter exposed via AppIntent (the native "Edit Widget" sheet).
19
+ */
20
+ export interface AppIntentParameter {
21
+ /** Swift property name + the key under `env.configuration`. */
22
+ name: string
23
+ /** Label shown in the widget configuration sheet. */
24
+ title: string
25
+ /** Default value used before the user configures the widget (the "from code" default). */
26
+ default?: string
27
+ }
28
+
29
+ /**
30
+ * AppIntent configuration for a user-configurable widget (iOS 17+).
31
+ */
32
+ export interface IOSWidgetAppIntentConfig {
33
+ /** Parameters the user can edit via "Edit Widget"; surfaced as `env.configuration`. */
34
+ parameters: AppIntentParameter[]
35
+ }
36
+
17
37
  /**
18
38
  * Configuration for a single iOS home screen widget.
39
+ *
40
+ * `entry` is required only for Dynamic Widgets. Widgets without `entry` remain server-rendered /
41
+ * server-updated legacy widgets and are excluded from the Dynamic Widgets manifest.
19
42
  */
20
- export interface IOSWidgetConfig {
43
+ export interface IOSWidgetConfig extends DynamicWidgetEntryConfig {
21
44
  /**
22
45
  * Unique identifier for the widget (used as the widget kind and in JS API)
23
46
  */
@@ -28,6 +51,13 @@ export interface IOSWidgetConfig {
28
51
  supportedFamilies?: IOSWidgetFamily[]
29
52
  initialStatePath?: WidgetInitialStatePath
30
53
  serverUpdate?: IOSWidgetServerUpdateConfig
54
+ /**
55
+ * AppIntent configuration (iOS 17+). When set on a Dynamic Widget, the plugin generates
56
+ * an `AppIntentConfiguration` so users configure parameters via the native "Edit Widget" sheet;
57
+ * defaults come from `parameters[].default`, and the configured values are passed into the
58
+ * widget's `env.configuration` on each render.
59
+ */
60
+ appIntent?: IOSWidgetAppIntentConfig
31
61
  }
32
62
 
33
63
  /**
@@ -1,4 +1,9 @@
1
- import { validateHomeScreenWidgetId, validateInitialStatePath, validateWidgetLabel } from '@use-voltra/expo-plugin'
1
+ import {
2
+ validateHomeScreenWidgetId,
3
+ validateInitialStatePath,
4
+ validateWidgetEntry,
5
+ validateWidgetLabel,
6
+ } from '@use-voltra/expo-plugin'
2
7
 
3
8
  import type { IOSConfigPluginProps, IOSWidgetConfig, IOSWidgetFamily } from './types'
4
9
 
@@ -12,11 +17,15 @@ const VALID_FAMILIES: Set<IOSWidgetFamily> = new Set([
12
17
  'accessoryInline',
13
18
  ])
14
19
 
15
- export function validateIOSWidgetConfig(widget: IOSWidgetConfig): void {
20
+ export function validateIOSWidgetConfig(widget: IOSWidgetConfig, projectRoot?: string): void {
16
21
  validateHomeScreenWidgetId(widget.id)
17
22
  validateWidgetLabel(widget.displayName, widget.id, 'displayName')
18
23
  validateWidgetLabel(widget.description, widget.id, 'description')
19
- validateInitialStatePath(widget.initialStatePath, widget.id)
24
+ validateInitialStatePath(widget.initialStatePath, widget.id, projectRoot)
25
+
26
+ if (widget.entry !== undefined) {
27
+ validateWidgetEntry(widget.entry, widget.id, projectRoot)
28
+ }
20
29
 
21
30
  if (widget.supportedFamilies) {
22
31
  if (!Array.isArray(widget.supportedFamilies)) {
@@ -34,7 +43,7 @@ export function validateIOSWidgetConfig(widget: IOSWidgetConfig): void {
34
43
  }
35
44
  }
36
45
 
37
- export function validateIOSConfigPluginProps(props: IOSConfigPluginProps): void {
46
+ export function validateIOSConfigPluginProps(props: IOSConfigPluginProps, projectRoot?: string): void {
38
47
  if (props.groupIdentifier !== undefined) {
39
48
  if (typeof props.groupIdentifier !== 'string') {
40
49
  throw new Error('groupIdentifier must be a string')
@@ -52,7 +61,7 @@ export function validateIOSConfigPluginProps(props: IOSConfigPluginProps): void
52
61
 
53
62
  const seenIds = new Set<string>()
54
63
  for (const widget of props.widgets) {
55
- validateIOSWidgetConfig(widget)
64
+ validateIOSWidgetConfig(widget, projectRoot)
56
65
 
57
66
  if (seenIds.has(widget.id)) {
58
67
  throw new Error(`Duplicate widget ID: '${widget.id}'`)
@@ -2,6 +2,7 @@ import ActivityKit
2
2
  import Compression
3
3
  import Foundation
4
4
  import os
5
+ import React
5
6
  import UIKit
6
7
 
7
8
  @objc(VoltraHeadlessState)
@@ -59,7 +60,26 @@ public class VoltraModuleImpl {
59
60
  public init() {
60
61
  // Clean up data for widgets that are no longer installed
61
62
  VoltraWidgetService.cleanupOrphanedData()
62
- }
63
+ #if DEBUG
64
+ syncDevServerURL()
65
+ #endif
66
+ }
67
+
68
+ #if DEBUG
69
+ /// Resolve the Metro dev-server base URL via React Native's own provider and relay it to the
70
+ /// widget extension through the app group. The extension is React-free (can't call
71
+ /// RCTBundleURLProvider itself), so the app resolves it and the extension reads it — fixing the
72
+ /// case where Metro isn't on localhost:8081 (custom port, LAN dev server, physical device).
73
+ private func syncDevServerURL() {
74
+ guard
75
+ let url = RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index"),
76
+ let scheme = url.scheme,
77
+ let host = url.host
78
+ else { return }
79
+ let port = url.port.map { ":\($0)" } ?? ""
80
+ VoltraWidgetDefaults.setDevServerURL("\(scheme)://\(host)\(port)")
81
+ }
82
+ #endif
63
83
 
64
84
  func isHeadless() -> Bool {
65
85
  VoltraHeadlessState.shared.isHeadless()
@@ -255,6 +275,11 @@ public class VoltraModuleImpl {
255
275
  }
256
276
 
257
277
  func reloadWidgets(widgetIds: [String]?) async {
278
+ #if DEBUG
279
+ // Refresh the relayed dev-server URL before reloading (hot-reload path) so the extension
280
+ // fetches from the current Metro host.
281
+ syncDevServerURL()
282
+ #endif
258
283
  if let ids = widgetIds, !ids.isEmpty {
259
284
  for widgetId in ids {
260
285
  VoltraWidgetService.reloadTimeline(for: widgetId)
@@ -32,6 +32,10 @@ public enum VoltraStorageKeys {
32
32
 
33
33
  public static let widgetKindPrefix = "Voltra_Widget_"
34
34
 
35
+ /// Metro dev-server base URL, relayed app → widget extension (DEBUG only). The extension is
36
+ /// React-free, so it can't resolve the URL itself; the app writes it via RCTBundleURLProvider.
37
+ public static let devServerURL = "Voltra_DevServerURL"
38
+
35
39
  // MARK: - Info.plist keys
36
40
 
37
41
  public static let widgetIds = "Voltra_WidgetIds"
@@ -0,0 +1,201 @@
1
+ import Foundation
2
+ import JavaScriptCore
3
+
4
+ /// JavaScriptCore-backed runtime for Voltra **Dynamic Widgets**.
5
+ ///
6
+ /// Each widget ships its own Metro bundle that defines `module.exports.render(props, env)`
7
+ /// (see `example/metro/widgetRegistry.js`). When evaluated, the bundle ends with `__r(0)`
8
+ /// which executes its entry module; the source is wrapped with a small bootstrap that
9
+ /// captures those exports into `globalThis.__voltraWidgets[<widgetId>]` so `render` can be
10
+ /// called from native at any later moment.
11
+ ///
12
+ /// One shared `JSContext` per process. Each subsequent bundle evaluation overwrites Metro's
13
+ /// `__r`/`__d` globals (they're closure-scoped per bundle's IIFE), but the captured
14
+ /// `globalThis.__voltraWidgets[<widgetId>]` reference retains each widget's render function
15
+ /// indefinitely.
16
+ public enum VoltraJSRenderer {
17
+ private static var _context: JSContext?
18
+ private static let lock = NSLock()
19
+ private static let TAG = "VoltraJSRenderer"
20
+
21
+ // MARK: - Public API
22
+
23
+ /// Evaluate a Voltra widget bundle in the shared JSContext, capturing the bundle's
24
+ /// `render(props, env)` export under `globalThis.__voltraWidgets[<widgetId>]`.
25
+ ///
26
+ /// The bundle source is the raw output of Metro's
27
+ /// `/voltra/widgets/<widgetId>.bundle` endpoint. We append a small bootstrap line
28
+ /// that runs after the bundle's `__r(0)` to capture the entry module's exports.
29
+ ///
30
+ /// Idempotent: re-evaluating the same widget overwrites the captured exports — used
31
+ /// by dev-mode hot-reload (always-refetch policy).
32
+ public static func evaluateBundle(source: String, widgetId: String) -> Bool {
33
+ lock.lock()
34
+ defer { lock.unlock() }
35
+
36
+ guard let ctx = context() else {
37
+ VoltraLogger.widget.error("[\(TAG)] No JSContext available")
38
+ return false
39
+ }
40
+
41
+ let escapedId = jsStringLiteral(widgetId)
42
+ // Metro emits the bundle's entry invocation as `__r(<entryModuleId>);` near the
43
+ // end of the file (before the sourcemap/sourceURL comments). The entry id is NOT
44
+ // always 0 — when Metro serves multiple widget bundles from the same process, it
45
+ // shares its module-id registry across bundles so the second/third widget's entry
46
+ // gets a higher id (e.g. `__r(74);`). We extract whatever id Metro produced and
47
+ // re-invoke it; Metro's `__r` caches module exports, so the second invocation
48
+ // returns the same exports the bundle already evaluated.
49
+ let entryModuleId = extractEntryModuleId(from: source) ?? 0
50
+ let wrapped = """
51
+ \(source)
52
+ ;(function () {
53
+ if (!globalThis.__voltraWidgets) { globalThis.__voltraWidgets = {}; }
54
+ globalThis.__voltraWidgets[\(escapedId)] = __r(\(entryModuleId));
55
+ })();
56
+ """
57
+
58
+ ctx.exception = nil
59
+ ctx.evaluateScript(wrapped)
60
+ if let message = exceptionMessage(ctx) {
61
+ VoltraLogger.widget.error("[\(TAG)] Bundle eval failed for widgetId=\(widgetId): \(message)")
62
+ return false
63
+ }
64
+
65
+ // Verify the bootstrap captured the exports correctly
66
+ guard
67
+ let registry = ctx.objectForKeyedSubscript("__voltraWidgets"),
68
+ !registry.isUndefined,
69
+ let widget = registry.objectForKeyedSubscript(widgetId),
70
+ !widget.isUndefined,
71
+ let renderFn = widget.objectForKeyedSubscript("render"),
72
+ renderFn.isObject,
73
+ renderFn.objectForKeyedSubscript("call") != nil
74
+ else {
75
+ VoltraLogger.widget.error("[\(TAG)] Bundle evaluated but did not expose render() for widgetId=\(widgetId)")
76
+ return false
77
+ }
78
+ _ = renderFn
79
+
80
+ VoltraLogger.widget.info("[\(TAG)] Bundle evaluated for widgetId=\(widgetId) (\(source.count) chars)")
81
+ return true
82
+ }
83
+
84
+ /// Ensure the widget's bundle is evaluated in *this process's* JSContext, returning true if its
85
+ /// `render()` is available afterward.
86
+ ///
87
+ /// Cheap no-op when the widget is already captured in this process (the warm case: the provider
88
+ /// just evaluated it). Evaluates from `source` when the context is fresh — which is what happens
89
+ /// when WidgetKit re-renders an archived entry in a new extension process, where the provider's
90
+ /// evaluation never ran. The View calls this before `render()` so rendering never depends on
91
+ /// which process evaluated the bundle.
92
+ public static func ensureEvaluated(widgetId: String, source: String) -> Bool {
93
+ lock.lock()
94
+ let alreadyEvaluated =
95
+ _context?
96
+ .objectForKeyedSubscript("__voltraWidgets")?
97
+ .objectForKeyedSubscript(widgetId)?
98
+ .objectForKeyedSubscript("render")?
99
+ .isObject ?? false
100
+ lock.unlock()
101
+
102
+ if alreadyEvaluated {
103
+ return true
104
+ }
105
+ return evaluateBundle(source: source, widgetId: widgetId)
106
+ }
107
+
108
+ /// Invoke the previously-evaluated widget's `render(propsJSON, envJSON)` function and
109
+ /// return its resolved JSON string output.
110
+ ///
111
+ /// Returns `nil` if the widget hasn't been evaluated, the function throws, or the
112
+ /// result is not a string.
113
+ public static func render(
114
+ widgetId: String,
115
+ propsJSON: String,
116
+ envJSON: String
117
+ ) -> String? {
118
+ lock.lock()
119
+ defer { lock.unlock() }
120
+
121
+ guard let ctx = _context else {
122
+ VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)): no JSContext (call evaluateBundle first)")
123
+ return nil
124
+ }
125
+
126
+ guard
127
+ let registry = ctx.objectForKeyedSubscript("__voltraWidgets"),
128
+ !registry.isUndefined,
129
+ let widget = registry.objectForKeyedSubscript(widgetId),
130
+ !widget.isUndefined,
131
+ let renderFn = widget.objectForKeyedSubscript("render"),
132
+ renderFn.isObject
133
+ else {
134
+ VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)): no captured render() — bundle not evaluated?")
135
+ return nil
136
+ }
137
+
138
+ ctx.exception = nil
139
+ guard let result = renderFn.call(withArguments: [propsJSON, envJSON]) else {
140
+ VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)): call returned nil")
141
+ return nil
142
+ }
143
+ if let message = exceptionMessage(ctx) {
144
+ VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)) threw: \(message)")
145
+ return nil
146
+ }
147
+
148
+ guard result.isString else {
149
+ VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)) did not return a string")
150
+ return nil
151
+ }
152
+ return result.toString()
153
+ }
154
+
155
+ // MARK: - Lifecycle
156
+
157
+ private static func context() -> JSContext? {
158
+ if let existing = _context { return existing }
159
+ guard let ctx = JSContext() else {
160
+ VoltraLogger.widget.error("[\(TAG)] Failed to create JSContext")
161
+ return nil
162
+ }
163
+ ctx.exceptionHandler = { _, exception in
164
+ VoltraLogger.widget.error("[\(TAG)] JS exception: \(exception?.toString() ?? "unknown")")
165
+ }
166
+ _context = ctx
167
+ return ctx
168
+ }
169
+
170
+ // MARK: - Helpers
171
+
172
+ private static func exceptionMessage(_ ctx: JSContext) -> String? {
173
+ guard let exc = ctx.exception, !exc.isUndefined, !exc.isNull else { return nil }
174
+ ctx.exception = nil
175
+ return exc.toString()
176
+ }
177
+
178
+ private static func jsStringLiteral(_ value: String) -> String {
179
+ let escaped = value
180
+ .replacingOccurrences(of: "\\", with: "\\\\")
181
+ .replacingOccurrences(of: "\"", with: "\\\"")
182
+ .replacingOccurrences(of: "\n", with: "\\n")
183
+ .replacingOccurrences(of: "\r", with: "\\r")
184
+ return "\"\(escaped)\""
185
+ }
186
+
187
+ /// Find the entry module id Metro emitted in the bundle's trailing `__r(<id>);`.
188
+ /// Scans the LAST occurrence of `__r(<digits>);` in the source — every `__d(...)`
189
+ /// module declaration also contains internal `__r(...)` calls, so taking the last
190
+ /// match (the entrypoint invocation Metro appends at bundle's end) is what we want.
191
+ private static func extractEntryModuleId(from source: String) -> Int? {
192
+ guard let regex = try? NSRegularExpression(pattern: #"__r\((\d+)\);"#) else { return nil }
193
+ let nsRange = NSRange(source.startIndex ..< source.endIndex, in: source)
194
+ let matches = regex.matches(in: source, range: nsRange)
195
+ guard let last = matches.last,
196
+ let range = Range(last.range(at: 1), in: source),
197
+ let id = Int(source[range])
198
+ else { return nil }
199
+ return id
200
+ }
201
+ }
@@ -37,6 +37,12 @@ public enum VoltraWidgetDefaults {
37
37
  try? resolvedDefaults().string(forKey: VoltraStorageKeys.widgetTimeline(widgetId))
38
38
  }
39
39
 
40
+ /// Metro dev-server base URL relayed from the app (DEBUG). Read by the client-widget runtime;
41
+ /// nil falls back to localhost.
42
+ public static func devServerURL() -> String? {
43
+ try? resolvedDefaults().string(forKey: VoltraStorageKeys.devServerURL)
44
+ }
45
+
40
46
  // MARK: - Write
41
47
 
42
48
  public static func setWidgetJson(_ json: String, for widgetId: String, deepLinkUrl: String?) throws {
@@ -70,6 +76,14 @@ public enum VoltraWidgetDefaults {
70
76
  defaults.synchronize()
71
77
  }
72
78
 
79
+ /// Relay the Metro dev-server base URL to the widget extension (DEBUG). Best-effort: a missing
80
+ /// app group just leaves the extension on its localhost fallback.
81
+ public static func setDevServerURL(_ url: String) {
82
+ guard let defaults = try? resolvedDefaults() else { return }
83
+ defaults.set(url, forKey: VoltraStorageKeys.devServerURL)
84
+ defaults.synchronize()
85
+ }
86
+
73
87
  // MARK: - Remove
74
88
 
75
89
  /// Removes all persisted data (json, deepLinkUrl, timeline) for a single widget.