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,