@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.
- 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,103 @@
|
|
|
1
|
+
import { evaluateWidgetModuleExports, logger, type PrerenderedWidgetStates } from '@use-voltra/expo-plugin'
|
|
2
|
+
|
|
3
|
+
import type { DetectedIOSWidget } from './clientRendered'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Initial-state prerender for Dynamic Widgets.
|
|
7
|
+
*
|
|
8
|
+
* For widgets with `initialStatePath`, the existing `prerenderWidgetState` in
|
|
9
|
+
* @use-voltra/expo-plugin loads the file and reads `exports.default` (a `WidgetVariants`
|
|
10
|
+
* object), then runs it through the multi-family `renderWidgetToString` to produce a
|
|
11
|
+
* per-family JSON payload that the runtime's `selectContentForFamily` will pick from.
|
|
12
|
+
*
|
|
13
|
+
* Dynamic Widgets work differently: the module referenced by `entry` default-exports a
|
|
14
|
+
* function or component, and the runtime calls it per-render with real env values. For the
|
|
15
|
+
* WidgetKit placeholder (`Provider.placeholder` and the widget gallery preview) ONE
|
|
16
|
+
* pre-rendered JSON entry is needed to display before any Metro fetch completes. That's
|
|
17
|
+
* generated here by calling the entry default export at prebuild with empty props + a
|
|
18
|
+
* minimal env, storing the compact `{t, c, p}` JSON in the existing `voltra_initial_states.json`.
|
|
19
|
+
*
|
|
20
|
+
* The placeholder's env values are fixed (`widgetFamily: 'systemMedium'`,
|
|
21
|
+
* `colorScheme: 'light'`, etc.) and may not match what the user actually sees the moment
|
|
22
|
+
* they add the widget — but the first real timeline tick replaces this entry within
|
|
23
|
+
* milliseconds, so the brief mismatch is invisible in practice.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Locale key used for non-localized prerendered states (mirrors prerender.ts behavior). */
|
|
27
|
+
const SINGLE_LOCALE_KEY = '__default'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Default env passed to Dynamic Widgets at prebuild for the placeholder render. Fields
|
|
31
|
+
* mirror `WidgetEnvironment` from packages/core/src/widget-environment.ts so the widget
|
|
32
|
+
* function sees the same shape it gets at runtime.
|
|
33
|
+
*/
|
|
34
|
+
function buildPlaceholderEnv(): Record<string, unknown> {
|
|
35
|
+
return {
|
|
36
|
+
date: Date.now(),
|
|
37
|
+
widgetFamily: 'systemMedium',
|
|
38
|
+
colorScheme: 'light',
|
|
39
|
+
locale: 'en-US',
|
|
40
|
+
widgetRenderingMode: 'fullColor',
|
|
41
|
+
showsWidgetContainerBackground: true,
|
|
42
|
+
configuration: undefined,
|
|
43
|
+
build: {
|
|
44
|
+
isDev: false,
|
|
45
|
+
metroUrl: null,
|
|
46
|
+
appVersion: 'unknown',
|
|
47
|
+
voltraVersion: '1.4.1',
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Prerender placeholder JSON for every Dynamic Widget. Returns a map shaped
|
|
54
|
+
* identically to `prerenderWidgetState`'s output so the two can be merged before
|
|
55
|
+
* generating `VoltraWidgetInitialStates.swift`.
|
|
56
|
+
*/
|
|
57
|
+
export async function prerenderClientRenderedWidgets(
|
|
58
|
+
widgets: DetectedIOSWidget[],
|
|
59
|
+
projectRoot: string
|
|
60
|
+
): Promise<PrerenderedWidgetStates> {
|
|
61
|
+
const results: PrerenderedWidgetStates = new Map()
|
|
62
|
+
|
|
63
|
+
const clientWidgets = widgets.filter(
|
|
64
|
+
(w): w is Extract<DetectedIOSWidget, { clientRendered: true }> => w.clientRendered
|
|
65
|
+
)
|
|
66
|
+
if (clientWidgets.length === 0) {
|
|
67
|
+
return results
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Lazy-loaded so server-only projects never pull in @use-voltra/ios. The renderer
|
|
71
|
+
// is iOS-specific by design — the Android counterpart will use the same shape via
|
|
72
|
+
// @use-voltra/android in a future phase.
|
|
73
|
+
const iosModuleId = '@use-voltra/ios'
|
|
74
|
+
const { renderVoltraVariantToJson } = (await import(iosModuleId)) as {
|
|
75
|
+
renderVoltraVariantToJson: (element: unknown) => unknown
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const placeholderEnv = buildPlaceholderEnv()
|
|
79
|
+
|
|
80
|
+
for (const widget of clientWidgets) {
|
|
81
|
+
try {
|
|
82
|
+
const widgetModule = evaluateWidgetModuleExports(projectRoot, widget.clientSourcePath)
|
|
83
|
+
const widgetFn = widgetModule?.default ?? widgetModule
|
|
84
|
+
if (typeof widgetFn !== 'function') {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Expected the entry module at ${widget.clientSourcePath} to default-export a function or component.`
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const element = widgetFn({}, placeholderEnv)
|
|
91
|
+
const json = renderVoltraVariantToJson(element)
|
|
92
|
+
const jsonString = JSON.stringify(json)
|
|
93
|
+
results.set(widget.id, new Map([[SINGLE_LOCALE_KEY, jsonString]]))
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Failed to prerender Dynamic Widget "${widget.id}": ${error instanceof Error ? error.message : String(error)}`
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
logger.info(`Prerendered ${clientWidgets.length} Dynamic Widget placeholder(s)`)
|
|
102
|
+
return results
|
|
103
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { IOSWidgetConfig } from '../../types'
|
|
|
6
6
|
import { generateAssets } from './assets'
|
|
7
7
|
import { generateEntitlements } from './entitlements'
|
|
8
8
|
import { generateInfoPlist } from './infoPlist'
|
|
9
|
+
import { generateIOSDynamicWidgetsManifest } from './manifest'
|
|
9
10
|
import { generateSwiftFiles } from './swift'
|
|
10
11
|
|
|
11
12
|
export interface GenerateWidgetExtensionFilesProps {
|
|
@@ -60,6 +61,12 @@ export const generateWidgetExtensionFiles: ConfigPlugin<GenerateWidgetExtensionF
|
|
|
60
61
|
widgets,
|
|
61
62
|
})
|
|
62
63
|
|
|
64
|
+
// Write the iOS-owned Dynamic Widgets manifest for Metro to consume later.
|
|
65
|
+
generateIOSDynamicWidgetsManifest({
|
|
66
|
+
projectRoot,
|
|
67
|
+
widgets,
|
|
68
|
+
})
|
|
69
|
+
|
|
63
70
|
// Generate entitlements file (may be empty if no groupIdentifier or keychainGroup)
|
|
64
71
|
generateEntitlements({
|
|
65
72
|
targetPath,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
|
|
4
|
+
import { type DynamicWidgetManifest, logger, validateWidgetEntry } from '@use-voltra/expo-plugin'
|
|
5
|
+
|
|
6
|
+
import type { IOSWidgetConfig } from '../../types'
|
|
7
|
+
|
|
8
|
+
const MANIFEST_PATH = path.join('.voltra', 'manifest.ios.json')
|
|
9
|
+
|
|
10
|
+
export interface GenerateIOSDynamicWidgetsManifestOptions {
|
|
11
|
+
projectRoot: string
|
|
12
|
+
widgets?: IOSWidgetConfig[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createIOSDynamicWidgetsManifest(
|
|
16
|
+
projectRoot: string,
|
|
17
|
+
widgets: IOSWidgetConfig[]
|
|
18
|
+
): DynamicWidgetManifest {
|
|
19
|
+
const manifestWidgets: DynamicWidgetManifest['widgets'] = []
|
|
20
|
+
|
|
21
|
+
for (const widget of widgets) {
|
|
22
|
+
if (widget.entry === undefined) {
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
manifestWidgets.push({
|
|
27
|
+
id: widget.id,
|
|
28
|
+
entry: validateWidgetEntry(widget.entry, widget.id, projectRoot),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
version: 1,
|
|
34
|
+
platform: 'ios',
|
|
35
|
+
widgets: manifestWidgets,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function generateIOSDynamicWidgetsManifest(options: GenerateIOSDynamicWidgetsManifestOptions): void {
|
|
40
|
+
const { projectRoot, widgets } = options
|
|
41
|
+
const manifestPath = path.join(projectRoot, MANIFEST_PATH)
|
|
42
|
+
const manifest = createIOSDynamicWidgetsManifest(projectRoot, widgets ?? [])
|
|
43
|
+
|
|
44
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
|
|
45
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
46
|
+
logger.info(`Generated ${MANIFEST_PATH}`)
|
|
47
|
+
}
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants'
|
|
15
15
|
import type { IOSWidgetConfig } from '../../types'
|
|
16
16
|
import { VOLTRA_WIDGET_STRINGS_BASENAME } from '../../utils/fileDiscovery'
|
|
17
|
+
import { detectClientRenderedWidgets, type DetectedIOSWidget } from '../clientRendered'
|
|
18
|
+
import { prerenderClientRenderedWidgets } from '../clientRenderedPrerender'
|
|
17
19
|
|
|
18
20
|
export interface GenerateSwiftFilesOptions {
|
|
19
21
|
targetPath: string
|
|
@@ -43,8 +45,23 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr
|
|
|
43
45
|
renderWidgetToString: RenderWidgetToString
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
+
// Tag each widget with its rendering mode (server vs Dynamic Widget) by inspecting the
|
|
49
|
+
// app.json entry module. See ../clientRendered.ts.
|
|
50
|
+
const detectedWidgets = detectClientRenderedWidgets(widgets || [], projectRoot)
|
|
51
|
+
const clientWidgetCount = detectedWidgets.filter((w) => w.clientRendered).length
|
|
52
|
+
if (clientWidgetCount > 0) {
|
|
53
|
+
logger.info(`Detected ${clientWidgetCount} Dynamic Widget(s) — generating Provider scaffolding`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Prerender widget initial states. Widgets with `initialStatePath` go through the existing
|
|
57
|
+
// multi-family WidgetVariants → JSON path; Dynamic Widgets go through the entry-module path
|
|
58
|
+
// (call the default export with default props + minimal env, run renderVoltraVariantToJson,
|
|
59
|
+
// stringify). Both produce entries in the same map shape so
|
|
60
|
+
// VoltraWidgetInitialStates.swift can read either via the same lookup at runtime.
|
|
61
|
+
const serverWidgets = detectedWidgets.filter((w) => !w.clientRendered)
|
|
62
|
+
const serverStates = await prerenderWidgetState(serverWidgets, projectRoot, renderWidgetToString)
|
|
63
|
+
const clientStates = await prerenderClientRenderedWidgets(detectedWidgets, projectRoot)
|
|
64
|
+
const prerenderedStates = new Map([...serverStates, ...clientStates])
|
|
48
65
|
|
|
49
66
|
syncVoltraWidgetGalleryStrings(targetPath, widgets)
|
|
50
67
|
|
|
@@ -59,7 +76,7 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr
|
|
|
59
76
|
|
|
60
77
|
// Generate the widget bundle Swift file
|
|
61
78
|
const widgetBundleContent =
|
|
62
|
-
|
|
79
|
+
detectedWidgets.length > 0 ? generateWidgetBundleSwift(detectedWidgets) : generateDefaultWidgetBundleSwift()
|
|
63
80
|
|
|
64
81
|
const widgetBundlePath = path.join(targetPath, 'VoltraWidgetBundle.swift')
|
|
65
82
|
fs.writeFileSync(widgetBundlePath, widgetBundleContent)
|
|
@@ -263,9 +280,23 @@ function iosWidgetGalleryLabelSwiftExpr(
|
|
|
263
280
|
}
|
|
264
281
|
|
|
265
282
|
/**
|
|
266
|
-
* Generates Swift code for a single widget struct
|
|
283
|
+
* Generates Swift code for a single widget struct. Dispatches on rendering mode:
|
|
284
|
+
* - server-rendered → `VoltraHomeWidgetProvider` + `VoltraHomeWidgetView` (existing path)
|
|
285
|
+
* - Dynamic Widget → `VoltraClientWidgetProvider` + `VoltraClientWidgetContentView`
|
|
286
|
+
* (the content view internally renders via VoltraHomeWidgetView so the UI layer is
|
|
287
|
+
* identical to the fallback state path — see VoltraClientWidgetRuntime.swift)
|
|
267
288
|
*/
|
|
268
|
-
function
|
|
289
|
+
function widgetUsesAppIntent(widget: DetectedIOSWidget): boolean {
|
|
290
|
+
return widget.clientRendered && !!widget.appIntent && widget.appIntent.parameters.length > 0
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function generateWidgetStruct(widget: DetectedIOSWidget): string {
|
|
294
|
+
// Client-rendered widgets with an appIntent config get a native "Edit Widget" sheet via
|
|
295
|
+
// AppIntentConfiguration; the configured params flow into env.configuration.
|
|
296
|
+
if (widgetUsesAppIntent(widget)) {
|
|
297
|
+
return generateClientAppIntentWidgetCode(widget)
|
|
298
|
+
}
|
|
299
|
+
|
|
269
300
|
const families = widget.supportedFamilies ?? DEFAULT_WIDGET_FAMILIES
|
|
270
301
|
const familiesSwift = families.map((f) => WIDGET_FAMILY_MAP[f]).join(', ')
|
|
271
302
|
|
|
@@ -275,6 +306,27 @@ function generateWidgetStruct(widget: IOSWidgetConfig): string {
|
|
|
275
306
|
const displayNameExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'displayName', widget.displayName)
|
|
276
307
|
const descriptionExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'description', widget.description)
|
|
277
308
|
|
|
309
|
+
const providerAndContent = widget.clientRendered
|
|
310
|
+
? dedent`
|
|
311
|
+
provider: VoltraClientWidgetProvider(
|
|
312
|
+
widgetId: widgetId,
|
|
313
|
+
initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)
|
|
314
|
+
)
|
|
315
|
+
) { entry in
|
|
316
|
+
VoltraClientWidgetContentView(
|
|
317
|
+
entry: entry,
|
|
318
|
+
initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)
|
|
319
|
+
)
|
|
320
|
+
}`
|
|
321
|
+
: dedent`
|
|
322
|
+
provider: VoltraHomeWidgetProvider(
|
|
323
|
+
widgetId: widgetId,
|
|
324
|
+
initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)
|
|
325
|
+
)
|
|
326
|
+
) { entry in
|
|
327
|
+
VoltraHomeWidgetView(entry: entry)
|
|
328
|
+
}`
|
|
329
|
+
|
|
278
330
|
return dedent`
|
|
279
331
|
public struct ${structName}: Widget {
|
|
280
332
|
private let widgetId = "${widget.id}"
|
|
@@ -284,12 +336,100 @@ function generateWidgetStruct(widget: IOSWidgetConfig): string {
|
|
|
284
336
|
public var body: some WidgetConfiguration {
|
|
285
337
|
StaticConfiguration(
|
|
286
338
|
kind: "Voltra_Widget_${widget.id}",
|
|
287
|
-
|
|
288
|
-
|
|
339
|
+
${providerAndContent}
|
|
340
|
+
.configurationDisplayName(${displayNameExpr})
|
|
341
|
+
.description(${descriptionExpr})
|
|
342
|
+
.supportedFamilies([${familiesSwift}])
|
|
343
|
+
.contentMarginsDisabled()
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Generates a Dynamic Widget backed by AppIntentConfiguration (iOS 17+): a
|
|
351
|
+
* WidgetConfigurationIntent (params + code defaults), an AppIntentTimelineProvider that loads the
|
|
352
|
+
* bundle via the shared client runtime, and an AppIntentConfiguration widget. The configured
|
|
353
|
+
* params are passed into the render as env.configuration; the native "Edit Widget" sheet edits them.
|
|
354
|
+
*/
|
|
355
|
+
function generateClientAppIntentWidgetCode(widget: DetectedIOSWidget): string {
|
|
356
|
+
const params = widget.appIntent!.parameters
|
|
357
|
+
const families = widget.supportedFamilies ?? DEFAULT_WIDGET_FAMILIES
|
|
358
|
+
const familiesSwift = families.map((f) => WIDGET_FAMILY_MAP[f]).join(', ')
|
|
359
|
+
const intentName = `VoltraWidget_${widget.id}_Intent`
|
|
360
|
+
const providerName = `VoltraWidget_${widget.id}_ClientProvider`
|
|
361
|
+
const intentTitle = escapeForSwiftStringLiteral(`Configure ${widgetLabelEnglish(widget.displayName)}`)
|
|
362
|
+
const displayNameExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'displayName', widget.displayName)
|
|
363
|
+
const descriptionExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'description', widget.description)
|
|
364
|
+
|
|
365
|
+
const swiftDefault = (p: { default?: string }) => `"${escapeForSwiftStringLiteral(p.default ?? '')}"`
|
|
366
|
+
const dictLiteral = (entries: string[]) => (entries.length > 0 ? `[${entries.join(', ')}]` : '[:]')
|
|
367
|
+
|
|
368
|
+
const paramDecls = params
|
|
369
|
+
.map(
|
|
370
|
+
(p) =>
|
|
371
|
+
` @Parameter(title: "${escapeForSwiftStringLiteral(p.title)}", default: ${swiftDefault(p)})\n var ${
|
|
372
|
+
p.name
|
|
373
|
+
}: String`
|
|
374
|
+
)
|
|
375
|
+
.join('\n\n')
|
|
376
|
+
const initParams = params.map((p) => `${p.name}: String`).join(', ')
|
|
377
|
+
const initBody = params.map((p) => ` self.${p.name} = ${p.name}`).join('\n')
|
|
378
|
+
const configuredDict = dictLiteral(params.map((p) => `"${p.name}": configuration.${p.name}`))
|
|
379
|
+
const defaultDict = dictLiteral(params.map((p) => `"${p.name}": ${swiftDefault(p)}`))
|
|
380
|
+
|
|
381
|
+
return dedent`
|
|
382
|
+
// MARK: - Client-rendered AppIntent widget: ${widget.id}
|
|
383
|
+
|
|
384
|
+
@available(iOS 17.0, *)
|
|
385
|
+
struct ${intentName}: WidgetConfigurationIntent {
|
|
386
|
+
static var title: LocalizedStringResource = "${intentTitle}"
|
|
387
|
+
|
|
388
|
+
${paramDecls}
|
|
389
|
+
|
|
390
|
+
init() {}
|
|
391
|
+
init(${initParams}) {
|
|
392
|
+
${initBody}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
@available(iOS 17.0, *)
|
|
397
|
+
private struct ${providerName}: AppIntentTimelineProvider {
|
|
398
|
+
typealias Intent = ${intentName}
|
|
399
|
+
typealias Entry = VoltraClientWidgetEntry
|
|
400
|
+
|
|
401
|
+
private let widgetId = "${widget.id}"
|
|
402
|
+
|
|
403
|
+
func placeholder(in _: Context) -> VoltraClientWidgetEntry {
|
|
404
|
+
VoltraClientWidgetEntry(date: Date(), widgetId: widgetId, bundleReady: false, configuration: ${defaultDict})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
func snapshot(for configuration: ${intentName}, in _: Context) async -> VoltraClientWidgetEntry {
|
|
408
|
+
await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDict})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
func timeline(for configuration: ${intentName}, in _: Context) async -> Timeline<VoltraClientWidgetEntry> {
|
|
412
|
+
let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDict})
|
|
413
|
+
return Timeline(entries: [entry], policy: .never)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
@available(iOS 17.0, *)
|
|
418
|
+
public struct VoltraWidget_${widget.id}: Widget {
|
|
419
|
+
private let widgetId = "${widget.id}"
|
|
420
|
+
|
|
421
|
+
public init() {}
|
|
422
|
+
|
|
423
|
+
public var body: some WidgetConfiguration {
|
|
424
|
+
AppIntentConfiguration(
|
|
425
|
+
kind: "Voltra_Widget_${widget.id}",
|
|
426
|
+
intent: ${intentName}.self,
|
|
427
|
+
provider: ${providerName}()
|
|
428
|
+
) { entry in
|
|
429
|
+
VoltraClientWidgetContentView(
|
|
430
|
+
entry: entry,
|
|
289
431
|
initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)
|
|
290
432
|
)
|
|
291
|
-
) { entry in
|
|
292
|
-
VoltraHomeWidgetView(entry: entry)
|
|
293
433
|
}
|
|
294
434
|
.configurationDisplayName(${displayNameExpr})
|
|
295
435
|
.description(${descriptionExpr})
|
|
@@ -303,15 +443,25 @@ function generateWidgetStruct(widget: IOSWidgetConfig): string {
|
|
|
303
443
|
/**
|
|
304
444
|
* Generates the VoltraWidgetBundle.swift file content with configured widgets
|
|
305
445
|
*/
|
|
306
|
-
function generateWidgetBundleSwift(widgets:
|
|
446
|
+
function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string {
|
|
307
447
|
// Generate widget structs
|
|
308
|
-
const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n')
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
const
|
|
448
|
+
const widgetStructs = widgets.map((w) => generateWidgetStruct(w)).join('\n\n')
|
|
449
|
+
|
|
450
|
+
// AppIntent widgets are iOS 17+, so their bundle entries are gated behind #available.
|
|
451
|
+
const appIntentWidgets = widgets.filter(widgetUsesAppIntent)
|
|
452
|
+
const plainWidgets = widgets.filter((w) => !widgetUsesAppIntent(w))
|
|
453
|
+
const plainInstances = plainWidgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ')
|
|
454
|
+
const appIntentInstances =
|
|
455
|
+
appIntentWidgets.length > 0
|
|
456
|
+
? `if #available(iOS 17.0, *) {\n ${appIntentWidgets
|
|
457
|
+
.map((w) => `VoltraWidget_${w.id}()`)
|
|
458
|
+
.join('\n ')}\n }`
|
|
459
|
+
: ''
|
|
460
|
+
const widgetInstances = [plainInstances, appIntentInstances].filter(Boolean).join('\n ')
|
|
312
461
|
|
|
313
462
|
const needsFoundation = widgets.some(widgetUsesGalleryLocalization)
|
|
314
463
|
const foundationImport = needsFoundation ? 'import Foundation\n' : ''
|
|
464
|
+
const appIntentsImport = appIntentWidgets.length > 0 ? 'import AppIntents\n' : ''
|
|
315
465
|
|
|
316
466
|
return dedent`
|
|
317
467
|
//
|
|
@@ -321,7 +471,7 @@ function generateWidgetBundleSwift(widgets: IOSWidgetConfig[]): string {
|
|
|
321
471
|
// This file defines which Voltra widgets are available in your app.
|
|
322
472
|
//
|
|
323
473
|
|
|
324
|
-
${foundationImport}import SwiftUI
|
|
474
|
+
${foundationImport}${appIntentsImport}import SwiftUI
|
|
325
475
|
import WidgetKit
|
|
326
476
|
import VoltraWidget
|
|
327
477
|
|
|
@@ -473,4 +623,5 @@ function getSwiftRawStringDelimiter(str: string): string {
|
|
|
473
623
|
|
|
474
624
|
export const __test__ = {
|
|
475
625
|
generateInitialStatesSwift,
|
|
626
|
+
generateWidgetBundleSwift,
|
|
476
627
|
}
|
|
@@ -54,7 +54,7 @@ export const withIOS: ConfigPlugin<WithIOSProps> = (config, props) => {
|
|
|
54
54
|
...(fonts && fonts.length > 0 ? [[withFonts, { fonts, targetName }] as [ConfigPlugin<any>, any]] : []),
|
|
55
55
|
|
|
56
56
|
// 2. Configure Xcode project (creates the target - must run before fonts mod executes)
|
|
57
|
-
[configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget }],
|
|
57
|
+
[configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget, widgets }],
|
|
58
58
|
|
|
59
59
|
// 3. Configure Podfile for widget extension target
|
|
60
60
|
[configurePodfile, { targetName }],
|
|
@@ -4,6 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
|
4
4
|
import { join as joinPath } from 'path'
|
|
5
5
|
|
|
6
6
|
import type { IOSWidgetConfig } from '../types'
|
|
7
|
+
import { detectClientRenderedWidgets } from './clientRendered'
|
|
7
8
|
import { logger } from '@use-voltra/expo-plugin'
|
|
8
9
|
|
|
9
10
|
export interface ConfigureMainAppPlistProps {
|
|
@@ -46,6 +47,27 @@ export const configureWidgetExtensionPlist: ConfigPlugin<ConfigureMainAppPlistPr
|
|
|
46
47
|
|
|
47
48
|
const content = plist.parse(readFileSync(filePath, 'utf8')) as InfoPlist
|
|
48
49
|
|
|
50
|
+
// Dynamic Widgets fetch their JS bundle from Metro at
|
|
51
|
+
// http://localhost:8081 in DEBUG builds. iOS requires an ATS exception for
|
|
52
|
+
// plaintext HTTP, scoped to localhost. The keys are only added when a
|
|
53
|
+
// Dynamic Widget actually exists, so server-only configurations
|
|
54
|
+
// keep their plist minimal.
|
|
55
|
+
if (widgets && widgets.length > 0) {
|
|
56
|
+
const detected = detectClientRenderedWidgets(widgets, config.modRequest.projectRoot)
|
|
57
|
+
const hasClientWidget = detected.some((w) => w.clientRendered)
|
|
58
|
+
if (hasClientWidget) {
|
|
59
|
+
;(content as any)['NSAppTransportSecurity'] = {
|
|
60
|
+
NSAllowsLocalNetworking: true,
|
|
61
|
+
NSExceptionDomains: {
|
|
62
|
+
localhost: {
|
|
63
|
+
NSExceptionAllowsInsecureHTTPLoads: true,
|
|
64
|
+
NSIncludesSubdomains: true,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
// WidgetKit extensions must NOT declare NSExtensionPrincipalClass/MainStoryboard.
|
|
50
72
|
// The @main WidgetBundle in Swift is the entry point.
|
|
51
73
|
const ext = (content as any).NSExtension as Record<string, any> | undefined
|
|
@@ -5,6 +5,99 @@ import type { IOSWidgetExtensionFiles } from '../../types'
|
|
|
5
5
|
|
|
6
6
|
const pbxFile = require('xcode/lib/pbxFile')
|
|
7
7
|
|
|
8
|
+
const WIDGET_BUNDLE_PHASE_NAME = 'Bundle Voltra Dynamic Widgets'
|
|
9
|
+
|
|
10
|
+
// Release-only build phase that bakes each Dynamic Widget's production JS bundle into the
|
|
11
|
+
// extension's resources. Debug builds fetch from Metro (and hot-reload), so this no-ops there. Runs
|
|
12
|
+
// the project's widget bundler with the extension's resources dir as the output, so each
|
|
13
|
+
// voltra-widget-<id>.bundle lands in the .appex (Bundle.main) where the runtime's release loader
|
|
14
|
+
// reads it. SRCROOT is the ios/ dir; the project root is one level up, matching how Expo's main
|
|
15
|
+
// "Bundle React Native code and images" phase resolves things.
|
|
16
|
+
const WIDGET_BUNDLE_SHELL_SCRIPT = `if [[ "$CONFIGURATION" == *Debug* ]]; then
|
|
17
|
+
echo "Voltra: Debug build — Dynamic Widgets load from Metro, skipping bundling"
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
if [[ -f "$SRCROOT/.xcode.env" ]]; then
|
|
22
|
+
source "$SRCROOT/.xcode.env"
|
|
23
|
+
fi
|
|
24
|
+
if [[ -f "$SRCROOT/.xcode.env.local" ]]; then
|
|
25
|
+
source "$SRCROOT/.xcode.env.local"
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
export PROJECT_ROOT="\${PROJECT_ROOT:-$SRCROOT/..}"
|
|
29
|
+
NODE_BINARY="\${NODE_BINARY:-node}"
|
|
30
|
+
|
|
31
|
+
BUNDLER="$("$NODE_BINARY" - "$PROJECT_ROOT" <<'NODE'
|
|
32
|
+
const { createRequire } = require('node:module')
|
|
33
|
+
const path = require('node:path')
|
|
34
|
+
|
|
35
|
+
const projectRoot = process.argv[2]
|
|
36
|
+
const requireFromProject = createRequire(path.join(projectRoot, 'package.json'))
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const resolved = requireFromProject.resolve('@use-voltra/metro/bundle-widgets')
|
|
40
|
+
process.stdout.write(resolved)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(
|
|
43
|
+
'error: Voltra widget bundler could not resolve @use-voltra/metro from ' +
|
|
44
|
+
projectRoot +
|
|
45
|
+
'. Install @use-voltra/metro in the app project so release widgets can be baked.'
|
|
46
|
+
)
|
|
47
|
+
console.error(error && error.message ? error.message : String(error))
|
|
48
|
+
process.exit(1)
|
|
49
|
+
}
|
|
50
|
+
NODE
|
|
51
|
+
)"
|
|
52
|
+
if [[ -z "$BUNDLER" ]]; then
|
|
53
|
+
echo "Voltra: resolver returned an empty string for @use-voltra/metro/bundle-widgets" >&2
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
echo "Voltra: widget bundler resolved to $BUNDLER"
|
|
58
|
+
"$NODE_BINARY" "$BUNDLER" --out-dir "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" --platform ios --project-root "$PROJECT_ROOT"
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Adds (idempotently) the release-only shell-script phase that bakes Dynamic Widget
|
|
63
|
+
* bundles into the extension. Safe to call on every prebuild; only added when absent.
|
|
64
|
+
*/
|
|
65
|
+
export function ensureWidgetBundleScriptPhase(xcodeProject: XcodeProject, targetUuid: string): void {
|
|
66
|
+
const nativeTargets = xcodeProject.pbxNativeTargetSection()
|
|
67
|
+
const target = nativeTargets[targetUuid]
|
|
68
|
+
if (!target) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (!target.buildPhases) {
|
|
72
|
+
target.buildPhases = []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const shellPhases = xcodeProject.hash.project.objects.PBXShellScriptBuildPhase || {}
|
|
76
|
+
const quotedName = `"${WIDGET_BUNDLE_PHASE_NAME}"`
|
|
77
|
+
const alreadyPresent = target.buildPhases.some((entry: any) => shellPhases[entry.value]?.name === quotedName)
|
|
78
|
+
if (alreadyPresent) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
xcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', WIDGET_BUNDLE_PHASE_NAME, targetUuid, {
|
|
83
|
+
shellPath: '/bin/sh',
|
|
84
|
+
shellScript: WIDGET_BUNDLE_SHELL_SCRIPT,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// The phase intentionally re-bakes on every release build (it can't statically enumerate every
|
|
88
|
+
// widget source as an input). Mark it always-out-of-date so Xcode doesn't warn about the missing
|
|
89
|
+
// input/output dependencies.
|
|
90
|
+
const shellPhasesAfter = xcodeProject.hash.project.objects.PBXShellScriptBuildPhase || {}
|
|
91
|
+
for (const key of Object.keys(shellPhasesAfter)) {
|
|
92
|
+
if (/_comment$/.test(key)) {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (shellPhasesAfter[key]?.name === quotedName) {
|
|
96
|
+
shellPhasesAfter[key].alwaysOutOfDate = 1
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
8
101
|
export interface AddBuildPhasesOptions {
|
|
9
102
|
targetUuid: string
|
|
10
103
|
groupName: string
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ConfigPlugin, withXcodeProject } from '@expo/config-plugins'
|
|
2
2
|
import * as path from 'path'
|
|
3
3
|
|
|
4
|
+
import type { IOSWidgetConfig } from '../../types'
|
|
4
5
|
import { getIOSWidgetExtensionFiles } from '../../utils/fileDiscovery'
|
|
5
|
-
import {
|
|
6
|
+
import { detectClientRenderedWidgets } from '../clientRendered'
|
|
7
|
+
import { addBuildPhases, ensureBuildPhases, ensureWidgetBundleScriptPhase } from './buildPhases'
|
|
6
8
|
import { addXCConfigurationList, ensureXCConfigurationList } from './configurationList'
|
|
7
9
|
import { addPbxGroup, ensurePbxGroup } from './groups'
|
|
8
10
|
import { getMainAppTargetSettings } from './mainAppSettings'
|
|
@@ -13,6 +15,7 @@ export interface ConfigureXcodeProjectProps {
|
|
|
13
15
|
targetName: string
|
|
14
16
|
bundleIdentifier: string
|
|
15
17
|
deploymentTarget: string
|
|
18
|
+
widgets?: IOSWidgetConfig[]
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -28,7 +31,7 @@ export interface ConfigureXcodeProjectProps {
|
|
|
28
31
|
* This should run after generateWidgetExtensionFiles so the files exist.
|
|
29
32
|
*/
|
|
30
33
|
export const configureXcodeProject: ConfigPlugin<ConfigureXcodeProjectProps> = (config, props) => {
|
|
31
|
-
const { targetName, bundleIdentifier, deploymentTarget } = props
|
|
34
|
+
const { targetName, bundleIdentifier, deploymentTarget, widgets } = props
|
|
32
35
|
|
|
33
36
|
return withXcodeProject(config, (config) => {
|
|
34
37
|
if (config.modRequest.introspect) {
|
|
@@ -38,6 +41,12 @@ export const configureXcodeProject: ConfigPlugin<ConfigureXcodeProjectProps> = (
|
|
|
38
41
|
const xcodeProject = config.modResults
|
|
39
42
|
const groupName = 'Embed Foundation Extensions'
|
|
40
43
|
|
|
44
|
+
// The release widget-bundling phase is only needed when a widget is a Dynamic Widget (server
|
|
45
|
+
// widgets carry no JS bundle). Detect once so both the create and update paths agree.
|
|
46
|
+
const hasClientRenderedWidgets =
|
|
47
|
+
!!widgets &&
|
|
48
|
+
detectClientRenderedWidgets(widgets, config.modRequest.projectRoot).some((widget) => widget.clientRendered)
|
|
49
|
+
|
|
41
50
|
// Check if target already exists
|
|
42
51
|
const nativeTargets = xcodeProject.pbxNativeTargetSection()
|
|
43
52
|
const existingTargetKey = xcodeProject.findTargetKey(targetName)
|
|
@@ -93,6 +102,10 @@ export const configureXcodeProject: ConfigPlugin<ConfigureXcodeProjectProps> = (
|
|
|
93
102
|
mainTargetUuid: xcodeProject.getFirstTarget().uuid,
|
|
94
103
|
})
|
|
95
104
|
|
|
105
|
+
if (hasClientRenderedWidgets) {
|
|
106
|
+
ensureWidgetBundleScriptPhase(xcodeProject, existingTargetKey)
|
|
107
|
+
}
|
|
108
|
+
|
|
96
109
|
ensurePbxGroup(xcodeProject, {
|
|
97
110
|
targetName,
|
|
98
111
|
widgetFiles,
|
|
@@ -138,6 +151,10 @@ export const configureXcodeProject: ConfigPlugin<ConfigureXcodeProjectProps> = (
|
|
|
138
151
|
widgetFiles,
|
|
139
152
|
})
|
|
140
153
|
|
|
154
|
+
if (hasClientRenderedWidgets) {
|
|
155
|
+
ensureWidgetBundleScriptPhase(xcodeProject, targetUuid)
|
|
156
|
+
}
|
|
157
|
+
|
|
141
158
|
// Add PBX group
|
|
142
159
|
addPbxGroup(xcodeProject, {
|
|
143
160
|
targetName,
|