@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
@@ -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
- // Prerender widget initial states if any widgets have initialStatePath configured
47
- const prerenderedStates = await prerenderWidgetState(widgets || [], projectRoot, renderWidgetToString)
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
- widgets && widgets.length > 0 ? generateWidgetBundleSwift(widgets) : generateDefaultWidgetBundleSwift()
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 generateWidgetStruct(widget: IOSWidgetConfig): string {
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
- provider: VoltraHomeWidgetProvider(
288
- widgetId: widgetId,
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: IOSWidgetConfig[]): string {
446
+ function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string {
307
447
  // Generate widget structs
308
- const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n')
309
-
310
- // Generate widget bundle body entries
311
- const widgetInstances = widgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ')
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 { addBuildPhases, ensureBuildPhases } from './buildPhases'
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,