@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
package/expo-plugin/src/types.ts
CHANGED
|
@@ -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 {
|
|
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.
|