diff --git a/.gitignore b/.gitignore
index 34294cb3..b103380a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@ android.iml
android/app/libs
android/keystores/debug.keystore
build/
+bundle/
## Node.js
node_modules/
diff --git a/example/app.json b/example/app.json
index 752d545d..a85ab92e 100644
--- a/example/app.json
+++ b/example/app.json
@@ -66,6 +66,26 @@
"intervalMinutes": 15,
"refresh": true
}
+ },
+ {
+ "id": "reactive",
+ "displayName": {
+ "en": "Reactive Weather Widget"
+ },
+ "description": {
+ "en": "Track 2 PoC: city name and colors resolved on-device via AppIntent + JS renderer"
+ },
+ "supportedFamilies": ["systemSmall", "systemMedium"],
+ "initialStatePath": "./widgets/ios/ios-reactive-weather-initial.tsx",
+ "appIntent": {
+ "parameters": [
+ {
+ "name": "city",
+ "title": "City",
+ "default": "New York"
+ }
+ ]
+ }
}
],
"fonts": [
diff --git a/example/server/widget-server.tsx b/example/server/widget-server.tsx
index 043b58c8..19095c1d 100644
--- a/example/server/widget-server.tsx
+++ b/example/server/widget-server.tsx
@@ -13,6 +13,7 @@ import { renderWidgetToString } from '@use-voltra/ios-server'
import { createWidgetUpdateNodeHandler } from '@use-voltra/server'
import React from 'react'
import { IosPortfolioWidget } from '../widgets/ios/IosPortfolioWidget'
+import { IosReactiveWeatherWidget } from '../widgets/ios/IosReactiveWeatherWidget'
import { AndroidMaterialColorsServerWidget } from '../widgets/android/AndroidMaterialColorsWidget'
import { AndroidPortfolioWidget } from '../widgets/android/AndroidPortfolioWidget'
@@ -50,6 +51,14 @@ function generatePortfolioData() {
const handler = createWidgetUpdateNodeHandler({
renderIos: async (req: any) => {
+ if (req.widgetId === 'reactive') {
+ // Track 2 PoC: server renders the widget with appIntentParam('city') →
+ // "{{ appIntent.city }}" preserved in the payload; the extension resolves
+ // it against the current AppIntent parameter values at render time.
+ const content =
+ return { systemSmall: content, systemMedium: content }
+ }
+
if (req.widgetId !== 'portfolio') {
return null
}
@@ -117,6 +126,8 @@ createServer(handler).listen(PORT, () => {
console.log(`\n Portfolio chart:`)
console.log(` iOS: GET http://localhost:${PORT}?widgetId=portfolio&platform=ios&family=systemSmall`)
console.log(` Android: GET http://10.0.2.2:${PORT}?widgetId=portfolio&platform=android`)
+ console.log(`\n Reactive weather (Track 2 PoC — variant-aware payload):`)
+ console.log(` iOS: GET http://localhost:${PORT}?widgetId=reactive&platform=ios&family=systemSmall`)
console.log(`\n Material colors:`)
console.log(` Android: GET http://10.0.2.2:${PORT}?widgetId=material_colors&platform=android`)
console.log(`\n (Android emulator uses 10.0.2.2 to reach the host machine)`)
diff --git a/example/widgets/ios/IosReactiveWeatherWidget.tsx b/example/widgets/ios/IosReactiveWeatherWidget.tsx
new file mode 100644
index 00000000..df49d05d
--- /dev/null
+++ b/example/widgets/ios/IosReactiveWeatherWidget.tsx
@@ -0,0 +1,41 @@
+import { Voltra, appIntentParam } from '@use-voltra/ios'
+
+// Track 2 PoC — demonstrates AppIntent reactivity:
+// - appIntentParam('city'): user-configurable city name via iOS widget settings
+//
+// The server renders this JSX to a compact JSON payload that includes the raw
+// template expressions. At render time inside the widget extension, VoltraJSRenderer
+// resolves them against the current AppIntent parameters — no server push required
+// when the user reconfigures the widget.
+
+export const IosReactiveWeatherWidget = () => (
+
+
+ {appIntentParam('city')}
+
+
+ Reactive Weather
+
+
+ Edit widget to set your city
+
+
+)
diff --git a/example/widgets/ios/ios-reactive-weather-initial.tsx b/example/widgets/ios/ios-reactive-weather-initial.tsx
new file mode 100644
index 00000000..77b12276
--- /dev/null
+++ b/example/widgets/ios/ios-reactive-weather-initial.tsx
@@ -0,0 +1,8 @@
+import React from 'react'
+
+import { IosReactiveWeatherWidget } from './IosReactiveWeatherWidget'
+
+export default {
+ systemSmall: ,
+ systemMedium: ,
+}
diff --git a/package-lock.json b/package-lock.json
index 46eea9dd..38a0d914 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8259,6 +8259,10 @@
"resolved": "packages/ios-client",
"link": true
},
+ "node_modules/@use-voltra/ios-renderer": {
+ "resolved": "packages/ios-renderer",
+ "link": true
+ },
"node_modules/@use-voltra/ios-server": {
"resolved": "packages/ios-server",
"link": true
@@ -22211,6 +22215,96 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "packages/ios-renderer": {
+ "name": "@use-voltra/ios-renderer",
+ "version": "1.4.1",
+ "license": "MIT",
+ "devDependencies": {
+ "@types/jest": "^29.5.14",
+ "@types/node": "^20.19.25",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.3.4"
+ }
+ },
+ "packages/ios-renderer/node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "packages/ios-renderer/node_modules/ts-jest": {
+ "version": "29.4.11",
+ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz",
+ "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bs-logger": "^0.2.6",
+ "fast-json-stable-stringify": "^2.1.0",
+ "handlebars": "^4.7.9",
+ "json5": "^2.2.3",
+ "lodash.memoize": "^4.1.2",
+ "make-error": "^1.3.6",
+ "semver": "^7.8.0",
+ "type-fest": "^4.41.0",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "ts-jest": "cli.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": ">=7.0.0-beta.0 <8",
+ "@jest/transform": "^29.0.0 || ^30.0.0",
+ "@jest/types": "^29.0.0 || ^30.0.0",
+ "babel-jest": "^29.0.0 || ^30.0.0",
+ "jest": "^29.0.0 || ^30.0.0",
+ "jest-util": "^29.0.0 || ^30.0.0",
+ "typescript": ">=4.3 <7"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "@jest/transform": {
+ "optional": true
+ },
+ "@jest/types": {
+ "optional": true
+ },
+ "babel-jest": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jest-util": {
+ "optional": true
+ }
+ }
+ },
+ "packages/ios-renderer/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"packages/ios-server": {
"name": "@use-voltra/ios-server",
"version": "1.4.1",
diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts
index 1a59927c..d82e4712 100644
--- a/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts
+++ b/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts
@@ -2,6 +2,8 @@ import { ConfigPlugin, withDangerousMod } from '@expo/config-plugins'
import * as fs from 'fs'
import * as path from 'path'
+import { logger } from '@use-voltra/expo-plugin'
+
import type { IOSWidgetConfig } from '../../types'
import { generateAssets } from './assets'
import { generateEntitlements } from './entitlements'
@@ -29,6 +31,28 @@ export interface GenerateWidgetExtensionFilesProps {
*
* This should run before configureXcodeProject so the files exist when Xcode project is configured.
*/
+function copyRendererBundle(targetPath: string, projectRoot: string): void {
+ const dest = path.join(targetPath, 'ios-renderer.js')
+ const candidates = [
+ // Standard npm install in the project
+ path.join(projectRoot, 'node_modules', '@use-voltra', 'ios-renderer', 'bundle', 'ios-renderer.js'),
+ // Monorepo: workspace root node_modules (one level above the app)
+ path.join(projectRoot, '..', 'node_modules', '@use-voltra', 'ios-renderer', 'bundle', 'ios-renderer.js'),
+ // Monorepo: packages/ built locally (bundle/ output)
+ path.join(projectRoot, '..', 'packages', 'ios-renderer', 'bundle', 'ios-renderer.js'),
+ ]
+
+ for (const src of candidates) {
+ if (fs.existsSync(src)) {
+ fs.copyFileSync(src, dest)
+ logger.info('Copied ios-renderer.js to widget extension target')
+ return
+ }
+ }
+
+ logger.warn('ios-renderer.js not found — run `npm run build:bundle -w @use-voltra/ios-renderer` then re-run prebuild')
+}
+
export const generateWidgetExtensionFiles: ConfigPlugin = (config, props) => {
const { targetName, widgets, groupIdentifier, keychainGroup, version, buildNumber } = props
@@ -53,6 +77,11 @@ export const generateWidgetExtensionFiles: ConfigPlugin w.appIntent)) {
+ copyRendererBundle(targetPath, projectRoot)
+ }
+
// Generate Swift files (widget bundle, initial states)
await generateSwiftFiles({
targetPath,
diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts
index 2fef4df8..9d734957 100644
--- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts
+++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts
@@ -12,7 +12,7 @@ import {
} from '@use-voltra/expo-plugin'
import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants'
-import type { IOSWidgetConfig } from '../../types'
+import type { AppIntentParameter, IOSWidgetConfig, IOSWidgetFamily } from '../../types'
import { VOLTRA_WIDGET_STRINGS_BASENAME } from '../../utils/fileDiscovery'
export interface GenerateSwiftFilesOptions {
@@ -304,14 +304,31 @@ function generateWidgetStruct(widget: IOSWidgetConfig): string {
* Generates the VoltraWidgetBundle.swift file content with configured widgets
*/
function generateWidgetBundleSwift(widgets: IOSWidgetConfig[]): string {
- // Generate widget structs
- const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n')
+ const staticWidgets = widgets.filter((w) => !w.appIntent)
+ const appIntentWidgets = widgets.filter((w) => w.appIntent)
+
+ const staticStructs = staticWidgets.map(generateWidgetStruct).join('\n\n')
+ const appIntentStructs = appIntentWidgets.map(generateAppIntentWidgetCode).join('\n\n')
+ const widgetStructs = [staticStructs, appIntentStructs].filter(Boolean).join('\n\n')
+
+ // Static widgets are instantiated directly; AppIntent widgets are wrapped in @available
+ const staticInstances = staticWidgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ')
+ const appIntentInstances =
+ appIntentWidgets.length > 0
+ ? dedent`
+ // AppIntent widgets (iOS 17+)
+ if #available(iOS 17.0, *) {
+ ${appIntentWidgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ')}
+ }
+ `
+ : ''
- // Generate widget bundle body entries
- const widgetInstances = widgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ')
+ const widgetInstances = [staticInstances, appIntentInstances].filter(Boolean).join('\n\n ')
const needsFoundation = widgets.some(widgetUsesGalleryLocalization)
+ const needsAppIntents = appIntentWidgets.length > 0
const foundationImport = needsFoundation ? 'import Foundation\n' : ''
+ const appIntentsImport = needsAppIntents ? 'import AppIntents\n' : ''
return dedent`
//
@@ -321,7 +338,7 @@ function generateWidgetBundleSwift(widgets: IOSWidgetConfig[]): string {
// This file defines which Voltra widgets are available in your app.
//
- ${foundationImport}import SwiftUI
+ ${foundationImport}${appIntentsImport}import SwiftUI
import WidgetKit
import VoltraWidget
@@ -471,6 +488,175 @@ function getSwiftRawStringDelimiter(str: string): string {
return '#'.repeat(maxHashes + 1)
}
+// ============================================================================
+// AppIntent Widget Code Generation
+// ============================================================================
+
+function swiftParamDefault(param: AppIntentParameter): string {
+ return param.default !== undefined ? `"${escapeForSwiftStringLiteral(param.default)}"` : '""'
+}
+
+function generateAppIntentStruct(widget: IOSWidgetConfig): string {
+ const params = widget.appIntent!.parameters
+ const intentName = `VoltraWidget_${widget.id}_Intent`
+ const intentTitle = escapeForSwiftStringLiteral(`Configure ${widgetLabelEnglish(widget.displayName)}`)
+
+ const paramDecls = params
+ .map(
+ (p) =>
+ ` @Parameter(title: "${escapeForSwiftStringLiteral(p.title)}", default: ${swiftParamDefault(p)})\n var ${
+ p.name
+ }: String`
+ )
+ .join('\n\n')
+
+ const initParams = params.map((p) => `${p.name}: String`).join(', ')
+ const initBody = params.map((p) => ` self.${p.name} = ${p.name}`).join('\n')
+
+ return dedent`
+ @available(iOS 17.0, *)
+ struct ${intentName}: WidgetConfigurationIntent {
+ static var title: LocalizedStringResource = "${intentTitle}"
+
+ ${paramDecls}
+
+ init() {}
+ init(${initParams}) {
+ ${initBody}
+ }
+ }
+ `
+}
+
+function generateAppIntentEntry(widget: IOSWidgetConfig): string {
+ return dedent`
+ @available(iOS 17.0, *)
+ private struct VoltraWidget_${widget.id}_Entry: TimelineEntry {
+ let date: Date
+ let rawPayloadJSON: String
+ let appIntentParams: [String: String]
+ }
+ `
+}
+
+function generateAppIntentProvider(widget: IOSWidgetConfig): string {
+ const params = widget.appIntent!.parameters
+ const intentName = `VoltraWidget_${widget.id}_Intent`
+ const entryName = `VoltraWidget_${widget.id}_Entry`
+
+ const paramsDict = params.map((p) => `"${p.name}": configuration.${p.name}`).join(', ')
+ const emptyDict = params.map((p) => `"${p.name}": ${swiftParamDefault(p)}`).join(', ')
+
+ return dedent`
+ @available(iOS 17.0, *)
+ private struct VoltraWidget_${widget.id}_Provider: AppIntentTimelineProvider {
+ typealias Intent = ${intentName}
+ typealias Entry = ${entryName}
+
+ private let widgetId = "${widget.id}"
+ private var initialJSON: String {
+ VoltraWidgetInitialStates.getInitialState(for: widgetId).flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
+ }
+
+ func placeholder(in _: Context) -> ${entryName} {
+ ${entryName}(date: Date(), rawPayloadJSON: initialJSON, appIntentParams: [${emptyDict}])
+ }
+
+ func snapshot(for configuration: ${intentName}, in _: Context) async -> ${entryName} {
+ ${entryName}(date: Date(), rawPayloadJSON: initialJSON, appIntentParams: [${paramsDict}])
+ }
+
+ func timeline(for configuration: ${intentName}, in _: Context) async -> Timeline<${entryName}> {
+ let entry = ${entryName}(date: Date(), rawPayloadJSON: initialJSON, appIntentParams: [${paramsDict}])
+ return Timeline(entries: [entry], policy: .never)
+ }
+ }
+ `
+}
+
+function generateAppIntentView(widget: IOSWidgetConfig): string {
+ const entryName = `VoltraWidget_${widget.id}_Entry`
+ // Use a variable so the backslash is a string value, not a raw template character.
+ // dedent processes raw template strings, so `\\.` in source → `\\.` in output (double backslash).
+ const kp = (key: string) => `\\.${key}`
+
+ return dedent`
+ @available(iOS 17.0, *)
+ private struct VoltraWidget_${widget.id}_ReactiveView: View {
+ let entry: ${entryName}
+
+ @Environment(${kp('widgetRenderingMode')}) private var widgetRenderingMode
+ @Environment(${kp('widgetFamily')}) private var widgetFamily
+ @Environment(${kp('showsWidgetContainerBackground')}) private var showsWidgetContainerBackground
+
+ var body: some View {
+ let resolvedJSON = VoltraJSRenderer.resolve(
+ payloadJSON: entry.rawPayloadJSON,
+ appIntentParams: entry.appIntentParams
+ ) ?? entry.rawPayloadJSON
+
+ let rootNode = VoltraReactiveRenderer.extractNode(from: resolvedJSON, family: widgetFamily)
+
+ Voltra(
+ root: rootNode,
+ activityId: "${widget.id}",
+ widget: VoltraWidgetEnvironment(
+ isHomeScreenWidget: true,
+ renderingMode: VoltraReactiveRenderer.voltraRenderingMode(widgetRenderingMode),
+ showsContainerBackground: showsWidgetContainerBackground
+ )
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .modifier(VoltraReactiveContainerBackground())
+ }
+ }
+ `
+}
+
+function generateAppIntentWidgetStruct(widget: IOSWidgetConfig): string {
+ const families: IOSWidgetFamily[] = widget.supportedFamilies ?? DEFAULT_WIDGET_FAMILIES
+ const familiesSwift = families.map((f) => WIDGET_FAMILY_MAP[f]).join(', ')
+ const displayNameExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'displayName', widget.displayName)
+ const descriptionExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'description', widget.description)
+
+ return dedent`
+ @available(iOS 17.0, *)
+ public struct VoltraWidget_${widget.id}: Widget {
+ public init() {}
+
+ public var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: "Voltra_Widget_${widget.id}",
+ intent: VoltraWidget_${widget.id}_Intent.self,
+ provider: VoltraWidget_${widget.id}_Provider()
+ ) { entry in
+ VoltraWidget_${widget.id}_ReactiveView(entry: entry)
+ }
+ .configurationDisplayName(${displayNameExpr})
+ .description(${descriptionExpr})
+ .supportedFamilies([${familiesSwift}])
+ .contentMarginsDisabled()
+ }
+ }
+ `
+}
+
+/**
+ * Generates the full AppIntent widget Swift code for a widget with appIntent config:
+ * Intent struct, timeline entry, provider, view, and widget struct.
+ */
+function generateAppIntentWidgetCode(widget: IOSWidgetConfig): string {
+ return [
+ `// MARK: - AppIntent widget: ${widget.id}`,
+ generateAppIntentStruct(widget),
+ generateAppIntentEntry(widget),
+ generateAppIntentProvider(widget),
+ generateAppIntentView(widget),
+ generateAppIntentWidgetStruct(widget),
+ ].join('\n\n')
+}
+
export const __test__ = {
generateInitialStatesSwift,
+ generateAppIntentWidgetCode,
}
diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts
index da554df2..7e6bd4a0 100644
--- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts
+++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts
@@ -29,8 +29,8 @@ export function addBuildPhases(xcodeProject: XcodeProject, options: AddBuildPhas
const buildPath = `""`
const folderType = 'app_extension'
- const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles
- const resourcePaths = [...assetDirectories, ...localizedStringResources]
+ const { swiftFiles, intentFiles, assetDirectories, localizedStringResources, bundleResources } = widgetFiles
+ const resourcePaths = [...assetDirectories, ...localizedStringResources, ...bundleResources]
// Sources build phase
xcodeProject.addBuildPhase(
@@ -74,8 +74,8 @@ export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBui
const folderType = 'app_extension'
const mainTargetUuid = options.mainTargetUuid ?? xcodeProject.getFirstTarget().uuid
- const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles
- const resourcePaths = [...assetDirectories, ...localizedStringResources]
+ const { swiftFiles, intentFiles, assetDirectories, localizedStringResources, bundleResources } = widgetFiles
+ const resourcePaths = [...assetDirectories, ...localizedStringResources, ...bundleResources]
dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources')
dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase', 'Frameworks')
diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts
index f5f76a24..afe551bb 100644
--- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts
+++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts
@@ -14,8 +14,15 @@ export interface AddPbxGroupOptions {
*/
export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void {
const { targetName, widgetFiles } = options
- const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles, localizedStringResources } =
- widgetFiles
+ const {
+ swiftFiles,
+ intentFiles,
+ assetDirectories,
+ entitlementFiles,
+ plistFiles,
+ localizedStringResources,
+ bundleResources,
+ } = widgetFiles
// Add PBX group with all widget files
const { uuid: pbxGroupUuid } = xcodeProject.addPbxGroup(
@@ -26,6 +33,7 @@ export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOpti
...plistFiles,
...assetDirectories,
...localizedStringResources,
+ ...bundleResources,
],
targetName,
targetName
@@ -47,8 +55,15 @@ export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOpti
*/
export function ensurePbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void {
const { targetName, widgetFiles } = options
- const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles, localizedStringResources } =
- widgetFiles
+ const {
+ swiftFiles,
+ intentFiles,
+ assetDirectories,
+ entitlementFiles,
+ plistFiles,
+ localizedStringResources,
+ bundleResources,
+ } = widgetFiles
const allFiles = [
...swiftFiles,
...intentFiles,
@@ -56,6 +71,7 @@ export function ensurePbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupO
...plistFiles,
...assetDirectories,
...localizedStringResources,
+ ...bundleResources,
]
const existingGroup = xcodeProject.pbxGroupByName(targetName)
diff --git a/packages/ios-client/expo-plugin/src/types.ts b/packages/ios-client/expo-plugin/src/types.ts
index 26f85ce3..71c19c75 100644
--- a/packages/ios-client/expo-plugin/src/types.ts
+++ b/packages/ios-client/expo-plugin/src/types.ts
@@ -14,6 +14,26 @@ export type IOSWidgetFamily =
| 'accessoryRectangular'
| 'accessoryInline'
+/**
+ * A single user-configurable parameter exposed via AppIntent.
+ */
+export interface AppIntentParameter {
+ /** Swift property name and key used in appIntentParam() template expressions */
+ name: string
+ /** Label shown to the user in the widget configuration sheet */
+ title: string
+ /** Default value used before the user configures the widget */
+ default?: string
+}
+
+/**
+ * AppIntent configuration for a reactive widget (iOS 17+).
+ */
+export interface IOSWidgetAppIntentConfig {
+ /** Parameters the user can configure in the widget gallery. */
+ parameters: AppIntentParameter[]
+}
+
/**
* Configuration for a single iOS home screen widget.
*/
@@ -28,6 +48,12 @@ export interface IOSWidgetConfig {
supportedFamilies?: IOSWidgetFamily[]
initialStatePath?: WidgetInitialStatePath
serverUpdate?: IOSWidgetServerUpdateConfig
+ /**
+ * AppIntent configuration for user-configurable widgets (iOS 17+).
+ * When set, the plugin generates an AppIntentConfiguration widget so users can
+ * configure parameters directly in the widget gallery — no server push required.
+ */
+ appIntent?: IOSWidgetAppIntentConfig
}
/**
@@ -52,6 +78,8 @@ export interface IOSWidgetExtensionFiles {
intentFiles: string[]
/** Paths relative to the widget extension root (e.g. en.lproj/VoltraWidgets.strings) */
localizedStringResources: string[]
+ /** JS bundles to embed in the extension (e.g. ios-renderer.js for AppIntent widgets) */
+ bundleResources: string[]
}
/**
diff --git a/packages/ios-client/expo-plugin/src/utils/fileDiscovery.ts b/packages/ios-client/expo-plugin/src/utils/fileDiscovery.ts
index b25ad50a..d6737d22 100644
--- a/packages/ios-client/expo-plugin/src/utils/fileDiscovery.ts
+++ b/packages/ios-client/expo-plugin/src/utils/fileDiscovery.ts
@@ -25,6 +25,7 @@ export function getIOSWidgetExtensionFiles(targetPath: string, targetName: strin
assetDirectories: [],
intentFiles: [],
localizedStringResources: [],
+ bundleResources: [],
}
if (!fs.existsSync(targetPath)) {
@@ -77,6 +78,9 @@ export function getIOSWidgetExtensionFiles(targetPath: string, targetName: strin
case '.intentdefinition':
widgetFiles.intentFiles.push(file)
break
+ case '.js':
+ widgetFiles.bundleResources.push(file)
+ break
}
}
diff --git a/packages/ios-client/ios/shared/VoltraJSRenderer.swift b/packages/ios-client/ios/shared/VoltraJSRenderer.swift
new file mode 100644
index 00000000..f13d3c1a
--- /dev/null
+++ b/packages/ios-client/ios/shared/VoltraJSRenderer.swift
@@ -0,0 +1,84 @@
+import Foundation
+import JavaScriptCore
+
+/// Runs the ios-renderer bundle inside a JavaScriptCore context to resolve AppIntent
+/// template expressions in a Voltra payload before the existing Swift interpreter renders
+/// the result.
+///
+/// The JSContext is initialised lazily and cached for the process lifetime so the bundle
+/// is only evaluated once per extension process.
+public enum VoltraJSRenderer {
+ private static var _context: JSContext?
+ private static let lock = NSLock()
+
+ // MARK: - Public API
+
+ /// Resolves AppIntent template expressions in a raw JSON payload string and returns the
+ /// resolved JSON string, or nil if the bundle is missing or the JS engine fails.
+ ///
+ /// On nil the caller should fall back to the original payload — the existing interpreter
+ /// handles it correctly, it just won't substitute AppIntent values.
+ public static func resolve(
+ payloadJSON: String,
+ appIntentParams: [String: String]
+ ) -> String? {
+ guard let ctx = context else { return nil }
+
+ guard let data = payloadJSON.data(using: .utf8),
+ let payloadObj = try? JSONSerialization.jsonObject(with: data)
+ else {
+ VoltraLogger.widget.error("[VoltraJSRenderer] Failed to parse payload JSON")
+ return nil
+ }
+
+ guard let renderer = ctx.objectForKeyedSubscript("VoltraRenderer"),
+ let resolveFn = renderer.objectForKeyedSubscript("resolve"),
+ !resolveFn.isUndefined
+ else {
+ VoltraLogger.widget.error("[VoltraJSRenderer] VoltraRenderer.resolve not found in context")
+ return nil
+ }
+
+ let result = resolveFn.call(withArguments: [payloadObj, appIntentParams as NSDictionary])
+
+ guard let result,
+ !result.isNull,
+ !result.isUndefined,
+ let resultObj = result.toObject(),
+ JSONSerialization.isValidJSONObject(resultObj),
+ let resultData = try? JSONSerialization.data(withJSONObject: resultObj),
+ let resultJSON = String(data: resultData, encoding: .utf8)
+ else {
+ VoltraLogger.widget.error("[VoltraJSRenderer] Failed to serialise resolved payload")
+ return nil
+ }
+
+ return resultJSON
+ }
+
+ // MARK: - Context lifecycle
+
+ private static var context: JSContext? {
+ lock.lock()
+ defer { lock.unlock() }
+
+ if let existing = _context { return existing }
+
+ guard let bundleURL = Bundle.main.url(forResource: "ios-renderer", withExtension: "js"),
+ let source = try? String(contentsOf: bundleURL, encoding: .utf8)
+ else {
+ VoltraLogger.widget.error("[VoltraJSRenderer] ios-renderer.js not found in extension bundle — rebuild the ios-renderer package")
+ return nil
+ }
+
+ let ctx = JSContext()!
+ ctx.exceptionHandler = { _, exception in
+ VoltraLogger.widget.error("[VoltraJSRenderer] JS exception: \(exception?.toString() ?? "unknown")")
+ }
+ ctx.evaluateScript(source)
+ _context = ctx
+
+ VoltraLogger.widget.info("[VoltraJSRenderer] JSContext initialised")
+ return ctx
+ }
+}
diff --git a/packages/ios-client/ios/shared/VoltraReactiveSupport.swift b/packages/ios-client/ios/shared/VoltraReactiveSupport.swift
new file mode 100644
index 00000000..80cb2dc1
--- /dev/null
+++ b/packages/ios-client/ios/shared/VoltraReactiveSupport.swift
@@ -0,0 +1,59 @@
+import Foundation
+import SwiftUI
+import WidgetKit
+
+/// Shared rendering helpers for AppIntentConfiguration widgets generated by the Voltra config plugin.
+/// Generated widget code calls into these so the boilerplate stays minimal.
+@available(iOS 17.0, *)
+public enum VoltraReactiveRenderer {
+ /// Deserialises the resolved JSON payload, extracts the family-specific element,
+ /// and parses it into a VoltraNode ready for the Swift interpreter.
+ public static func extractNode(from resolvedJSON: String, family: WidgetFamily) -> VoltraNode {
+ guard let data = resolvedJSON.data(using: .utf8),
+ let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let familyContent = root[familyKey(family)] ?? root["systemSmall"],
+ var reconstructed = familyContent as? [String: Any]
+ else { return .empty }
+
+ // Mirror VoltraHomeWidget.reconstructWithSharedData: copy the shared stylesheet ("s")
+ // and shared elements ("e") into the family-specific object so VoltraNode.parse can
+ // resolve style index references and element deduplication.
+ if let stylesheet = root["s"] { reconstructed["s"] = stylesheet }
+ if let elements = root["e"] { reconstructed["e"] = elements }
+
+ guard let familyData = try? JSONSerialization.data(withJSONObject: reconstructed),
+ let familyStr = String(data: familyData, encoding: .utf8),
+ let jsonValue = try? JSONValue.parse(from: familyStr)
+ else { return .empty }
+
+ return VoltraNode.parse(from: jsonValue)
+ }
+
+ public static func familyKey(_ family: WidgetFamily) -> String {
+ switch family {
+ case .systemSmall: return "systemSmall"
+ case .systemMedium: return "systemMedium"
+ case .systemLarge: return "systemLarge"
+ default: return "systemSmall"
+ }
+ }
+
+ public static func voltraRenderingMode(_ mode: WidgetRenderingMode) -> VoltraWidgetRenderingMode {
+ switch mode {
+ case .accented: return .accented
+ case .vibrant: return .vibrant
+ default: return .fullColor
+ }
+ }
+}
+
+/// Applies `.containerBackground(.clear, for: .widget)`.
+/// Used by all generated AppIntentConfiguration widget views (always iOS 17+).
+@available(iOS 17.0, *)
+public struct VoltraReactiveContainerBackground: ViewModifier {
+ public init() {}
+
+ public func body(content: Content) -> some View {
+ content.containerBackground(.clear, for: .widget)
+ }
+}
diff --git a/packages/ios-renderer/jest.config.js b/packages/ios-renderer/jest.config.js
new file mode 100644
index 00000000..8c0fee33
--- /dev/null
+++ b/packages/ios-renderer/jest.config.js
@@ -0,0 +1,14 @@
+/** @type {import('jest').Config} */
+module.exports = {
+ testEnvironment: 'node',
+ testMatch: ['/src/**/*.node.test.ts'],
+ modulePathIgnorePatterns: ['/build', '/bundle'],
+ transform: {
+ '^.+\\.tsx?$': [
+ 'ts-jest',
+ {
+ tsconfig: '/tsconfig.jest.json',
+ },
+ ],
+ },
+}
diff --git a/packages/ios-renderer/package.json b/packages/ios-renderer/package.json
new file mode 100644
index 00000000..e43dd571
--- /dev/null
+++ b/packages/ios-renderer/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@use-voltra/ios-renderer",
+ "version": "1.4.1",
+ "description": "On-device Voltra payload resolver for iOS widget extensions",
+ "main": "build/cjs/index.js",
+ "module": "build/esm/index.js",
+ "types": "build/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./build/types/index.d.ts",
+ "require": "./build/cjs/index.js",
+ "import": "./build/esm/index.js",
+ "default": "./build/esm/index.js"
+ },
+ "./bundle/ios-renderer.js": "./bundle/ios-renderer.js",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "build",
+ "bundle"
+ ],
+ "scripts": {
+ "build": "node ../../scripts/build-package.mjs packages/ios-renderer && node scripts/build-bundle.mjs",
+ "build:bundle": "node scripts/build-bundle.mjs",
+ "clean": "rm -rf build bundle",
+ "lint": "oxlint src",
+ "test": "jest --config jest.config.js",
+ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.14",
+ "@types/node": "^20.19.25",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.3.4"
+ },
+ "keywords": [
+ "voltra",
+ "ios",
+ "widget",
+ "renderer"
+ ],
+ "author": "Saúl Sharma (https://x.com/saul_sharma), Szymon Chmal (https://x.com/chmalszymon)",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/callstackincubator/voltra.git",
+ "directory": "packages/ios-renderer"
+ },
+ "bugs": {
+ "url": "https://github.com/callstackincubator/voltra/issues"
+ },
+ "license": "MIT",
+ "homepage": "https://use-voltra.dev"
+}
diff --git a/packages/ios-renderer/scripts/build-bundle.mjs b/packages/ios-renderer/scripts/build-bundle.mjs
new file mode 100644
index 00000000..965cc536
--- /dev/null
+++ b/packages/ios-renderer/scripts/build-bundle.mjs
@@ -0,0 +1,21 @@
+import { build } from 'esbuild'
+import { fileURLToPath } from 'url'
+import path from 'path'
+import fs from 'fs/promises'
+
+const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const outDir = path.join(packageDir, 'bundle')
+
+await fs.mkdir(outDir, { recursive: true })
+
+await build({
+ entryPoints: [path.join(packageDir, 'src/bundle-entry.ts')],
+ bundle: true,
+ platform: 'neutral', // no Node or browser globals
+ target: ['es2019'], // Hermes and JavaScriptCore both support ES2019
+ outfile: path.join(outDir, 'ios-renderer.js'),
+ minify: true,
+ treeShaking: true,
+})
+
+console.log('ios-renderer bundle written to bundle/ios-renderer.js')
diff --git a/packages/ios-renderer/src/__tests__/resolve.node.test.ts b/packages/ios-renderer/src/__tests__/resolve.node.test.ts
new file mode 100644
index 00000000..40734656
--- /dev/null
+++ b/packages/ios-renderer/src/__tests__/resolve.node.test.ts
@@ -0,0 +1,94 @@
+import { resolve } from '../index'
+import type { AppIntentParams } from '../index'
+
+// Simulates the compact JSON payload the server renderer produces from:
+// {appIntentParam('city')}
+//
+// In practice the payload is rendered by packages/ios/src/widgets/renderer.ts,
+// but since {{ appIntent.X }} expressions pass through the server unchanged
+// (confirmed in renderer.ts → transformProps), we test resolve() directly
+// against a representative payload shape.
+
+const makePayload = (): Record => ({
+ v: 1,
+ systemSmall: {
+ t: 11, // VStack
+ c: [
+ {
+ t: 0, // Text
+ c: '{{ appIntent.city }}',
+ p: { fs: 22, fw: '700' },
+ },
+ {
+ t: 0,
+ c: 'Reactive Weather',
+ p: { fs: 14, mt: 6 },
+ },
+ ],
+ p: { pad: 16, al: 'leading', fl: 1 },
+ },
+ systemMedium: {
+ t: 11,
+ c: [
+ {
+ t: 0,
+ c: '{{ appIntent.city }}',
+ p: { fs: 22, fw: '700' },
+ },
+ ],
+ p: { pad: 16, al: 'leading', fl: 1 },
+ },
+})
+
+const cityParams: AppIntentParams = { city: 'Warsaw' }
+const emptyParams: AppIntentParams = {}
+
+describe('resolve — appIntentParam template substitution', () => {
+ test('replaces {{ appIntent.city }} with the configured city', () => {
+ const result = resolve(makePayload(), cityParams)
+ const small = result['systemSmall'] as any
+ expect(small.c[0].c).toBe('Warsaw')
+ })
+
+ test('replaces template in all families', () => {
+ const result = resolve(makePayload(), cityParams)
+ const medium = result['systemMedium'] as any
+ expect(medium.c[0].c).toBe('Warsaw')
+ })
+
+ test('replaces unknown param with empty string', () => {
+ const result = resolve(makePayload(), emptyParams)
+ const small = result['systemSmall'] as any
+ expect(small.c[0].c).toBe('')
+ })
+})
+
+describe('resolve — passthrough keys', () => {
+ test('v (version) is not traversed', () => {
+ const result = resolve(makePayload(), cityParams)
+ expect(result['v']).toBe(1)
+ })
+
+ test('non-string numeric props are preserved', () => {
+ const result = resolve(makePayload(), cityParams)
+ const small = result['systemSmall'] as any
+ expect(small.c[0].p.fs).toBe(22)
+ })
+})
+
+describe('resolve — end-to-end round-trip', () => {
+ test('resolved payload can be serialised to JSON and back', () => {
+ const result = resolve(makePayload(), cityParams)
+ const json = JSON.stringify(result)
+ const parsed = JSON.parse(json)
+ expect(parsed.systemSmall.c[0].c).toBe('Warsaw')
+ })
+
+ test('same payload, different params, produce different outputs', () => {
+ const warsawResult = resolve(makePayload(), { city: 'Warsaw' })
+ const tokyoResult = resolve(makePayload(), { city: 'Tokyo' })
+ const warsawSmall = warsawResult['systemSmall'] as any
+ const tokyoSmall = tokyoResult['systemSmall'] as any
+ expect(warsawSmall.c[0].c).not.toBe(tokyoSmall.c[0].c)
+ })
+})
diff --git a/packages/ios-renderer/src/bundle-entry.ts b/packages/ios-renderer/src/bundle-entry.ts
new file mode 100644
index 00000000..811bc14c
--- /dev/null
+++ b/packages/ios-renderer/src/bundle-entry.ts
@@ -0,0 +1,5 @@
+import { resolve } from './index'
+
+// Expose for JavaScriptCore / Hermes evaluation in the widget extension.
+// Swift calls: context["VoltraRenderer"].resolve(payload, appIntentParams)
+;(globalThis as unknown as Record)['VoltraRenderer'] = { resolve }
diff --git a/packages/ios-renderer/src/index.ts b/packages/ios-renderer/src/index.ts
new file mode 100644
index 00000000..71d30d02
--- /dev/null
+++ b/packages/ios-renderer/src/index.ts
@@ -0,0 +1,48 @@
+export type AppIntentParams = Record
+
+// Keys whose values are never traversed for resolution.
+// 'v' (version) and 'e' (shared elements / $r refs) are structural — leave untouched.
+// 's' (shared stylesheet) is NOT a passthrough: AppIntent template expressions may
+// appear in style values.
+const PASSTHROUGH_KEYS = new Set(['v', 'e'])
+
+/**
+ * Substitutes {{ appIntent.paramName }} template expressions.
+ * Unknown parameters are replaced with an empty string.
+ */
+function resolveTemplate(value: string, appIntentParams: AppIntentParams): string {
+ return value.replace(/\{\{\s*appIntent\.(\w+)\s*\}\}/g, (_, key: string) => appIntentParams[key] ?? '')
+}
+
+function resolveValue(value: unknown, appIntentParams: AppIntentParams): unknown {
+ if (typeof value === 'string') {
+ return resolveTemplate(value, appIntentParams)
+ }
+ if (Array.isArray(value)) {
+ return value.map((item) => resolveValue(item, appIntentParams))
+ }
+ if (value !== null && typeof value === 'object') {
+ const obj = value as Record
+ // Element refs ($r) are resolved by the Swift layer after resolution — pass through unchanged
+ if ('$r' in obj) return obj
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, resolveValue(v, appIntentParams)]))
+ }
+ return value
+}
+
+/**
+ * Resolves AppIntent template expressions in a Voltra payload, returning a payload
+ * ready for the Swift interpreter.
+ *
+ * The version (v), shared stylesheet (s), and shared elements (e) keys are passed
+ * through unchanged so the Swift interpreter can still resolve element refs and
+ * style indices.
+ */
+export function resolve(payload: Record, appIntentParams: AppIntentParams): Record {
+ return Object.fromEntries(
+ Object.entries(payload).map(([key, value]) => [
+ key,
+ PASSTHROUGH_KEYS.has(key) ? value : resolveValue(value, appIntentParams),
+ ])
+ )
+}
diff --git a/packages/ios-renderer/tsconfig.base.json b/packages/ios-renderer/tsconfig.base.json
new file mode 100644
index 00000000..b8ff6a54
--- /dev/null
+++ b/packages/ios-renderer/tsconfig.base.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2019",
+ "lib": ["ES2019"],
+ "rootDir": "./src",
+ "moduleResolution": "node",
+ "strict": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "skipLibCheck": true,
+ "isolatedModules": true
+ },
+ "include": ["./src"],
+ "exclude": ["**/__tests__/*"]
+}
diff --git a/packages/ios-renderer/tsconfig.cjs.json b/packages/ios-renderer/tsconfig.cjs.json
new file mode 100644
index 00000000..a6b3ca9c
--- /dev/null
+++ b/packages/ios-renderer/tsconfig.cjs.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "CommonJS",
+ "outDir": "./build/cjs",
+ "declaration": false,
+ "sourceMap": true
+ }
+}
diff --git a/packages/ios-renderer/tsconfig.esm.json b/packages/ios-renderer/tsconfig.esm.json
new file mode 100644
index 00000000..2bb18d33
--- /dev/null
+++ b/packages/ios-renderer/tsconfig.esm.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "ES2020",
+ "outDir": "./build/esm",
+ "declaration": false,
+ "sourceMap": true
+ }
+}
diff --git a/packages/ios-renderer/tsconfig.jest.json b/packages/ios-renderer/tsconfig.jest.json
new file mode 100644
index 00000000..771ba8f8
--- /dev/null
+++ b/packages/ios-renderer/tsconfig.jest.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["jest", "node"],
+ "module": "commonjs"
+ },
+ "include": ["./src/**/*.ts"]
+}
diff --git a/packages/ios-renderer/tsconfig.typecheck.json b/packages/ios-renderer/tsconfig.typecheck.json
new file mode 100644
index 00000000..23b12c92
--- /dev/null
+++ b/packages/ios-renderer/tsconfig.typecheck.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "ES2020",
+ "rootDir": "../..",
+ "baseUrl": "../..",
+ "paths": {
+ "voltra": ["packages/voltra/src/index.ts"],
+ "@use-voltra/core": ["packages/core/src/index.ts"],
+ "@use-voltra/ios": ["packages/ios/src/index.ts"],
+ "@use-voltra/ios-renderer": ["packages/ios-renderer/src/index.ts"],
+ "@use-voltra/server": ["packages/server/src/index.ts"]
+ }
+ }
+}
diff --git a/packages/ios-renderer/tsconfig.types.json b/packages/ios-renderer/tsconfig.types.json
new file mode 100644
index 00000000..8ec821ee
--- /dev/null
+++ b/packages/ios-renderer/tsconfig.types.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "ES2020",
+ "outDir": "./build/types",
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "declarationMap": true
+ }
+}
diff --git a/packages/ios-server/src/index.ts b/packages/ios-server/src/index.ts
index 5a15ace5..6d41dee6 100644
--- a/packages/ios-server/src/index.ts
+++ b/packages/ios-server/src/index.ts
@@ -18,7 +18,7 @@ import {
} from '@use-voltra/server'
import type { ReactNode } from 'react'
-export { Voltra } from '@use-voltra/ios'
+export { Voltra, appIntentParam } from '@use-voltra/ios'
export type { LiveActivityVariants, WidgetVariants }
export type {
WidgetRenderRequest,
diff --git a/packages/ios/src/app-intent.ts b/packages/ios/src/app-intent.ts
new file mode 100644
index 00000000..7dbdf87a
--- /dev/null
+++ b/packages/ios/src/app-intent.ts
@@ -0,0 +1,14 @@
+/**
+ * Returns a template expression that the ios-renderer JS resolver will replace
+ * at render time with the widget's AppIntent parameter value.
+ *
+ * Usage in a widget JSX component:
+ * {appIntentParam('city')}
+ *
+ * This string passes through the server renderer unchanged and is resolved
+ * inside the widget extension process — no server push required when the
+ * user reconfigures the widget.
+ */
+export function appIntentParam(name: string): string {
+ return `{{ appIntent.${name} }}`
+}
diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts
index 98b89a67..f5d369aa 100644
--- a/packages/ios/src/index.ts
+++ b/packages/ios/src/index.ts
@@ -5,6 +5,7 @@ export {
COMPONENT_ID_TO_NAME,
COMPONENT_NAME_TO_ID,
} from './payload/component-ids.js'
+export { appIntentParam } from './app-intent.js'
export { renderLiveActivityToJson, renderLiveActivityToString } from './live-activity/renderer.js'
export type {
DismissalPolicy,