From 6ac2e1e34a9d12d6a878c188cc08fe3ffefcb4a5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 15:01:33 +0200 Subject: [PATCH 1/2] docs: add Metro setup to Dynamic Widget guides --- .../v2/android/development/dynamic-widgets.md | 19 +++++++++++++++++++ .../v2/ios/development/dynamic-widgets.md | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/website/docs/v2/android/development/dynamic-widgets.md b/website/docs/v2/android/development/dynamic-widgets.md index 64fecc4d..248b75d9 100644 --- a/website/docs/v2/android/development/dynamic-widgets.md +++ b/website/docs/v2/android/development/dynamic-widgets.md @@ -16,6 +16,25 @@ Your Dynamic Widget can react to: When you change `app.json`, run Expo Prebuild or Voltra Apply so the updated Dynamic Widget configuration is available on device. If you change only the Dynamic Widget JS, reopen the app in development and the Dynamic Widget updates automatically. +## Set up Metro + +Dynamic Widgets require `@use-voltra/metro` in the app project. Install it alongside the Android packages: + +```sh +npm install @use-voltra/metro +``` + +Wrap the app's existing Metro config with `withVoltra`: + +```js title="metro.config.js" +const { getDefaultConfig } = require('expo/metro-config') +const { withVoltra } = require('@use-voltra/metro') + +const config = getDefaultConfig(__dirname) + +module.exports = withVoltra(config) +``` + ## How to use it 1. Add an Android Dynamic Widget declaration to `app.json` with an `id`, an `entry`, and any Dynamic Widget metadata you need. diff --git a/website/docs/v2/ios/development/dynamic-widgets.md b/website/docs/v2/ios/development/dynamic-widgets.md index cf742771..019bda91 100644 --- a/website/docs/v2/ios/development/dynamic-widgets.md +++ b/website/docs/v2/ios/development/dynamic-widgets.md @@ -17,6 +17,25 @@ That means your Dynamic Widget can react to: When you change `app.json`, run Expo Prebuild or Voltra Apply so the updated widget configuration is available on device. If you change only the widget JS, reopen the app in development and the widget updates automatically. +## Set up Metro + +Dynamic Widgets require `@use-voltra/metro` in the app project. Install it alongside the iOS packages: + +```sh +npm install @use-voltra/metro +``` + +Wrap the app's existing Metro config with `withVoltra`: + +```js title="metro.config.js" +const { getDefaultConfig } = require('expo/metro-config') +const { withVoltra } = require('@use-voltra/metro') + +const config = getDefaultConfig(__dirname) + +module.exports = withVoltra(config) +``` + ## How to use it 1. Add an iOS widget declaration to `app.json` with an `id`, an `entry`, and any widget metadata you need. From e77f80f28bc3072c1485374ccde995c93e880536 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 15:40:35 +0200 Subject: [PATCH 2/2] fix: derive Voltra version from package metadata --- .changeset/dynamic-voltra-version.md | 8 ++++++ packages/android-client/android/build.gradle | 2 +- .../clientRenderedPrerender.node.test.ts | 7 +++++ .../android/files/clientRenderedPrerender.ts | 13 ++++++--- .../cli/src/dependencies/platformPackages.ts | 11 ++++++++ .../cli/src/platforms/android/generated.ts | 22 ++++++++++----- packages/cli/src/platforms/ios/generated.ts | 27 ++++++++++++------- .../cli/src/platforms/shared/widgetModule.ts | 14 +++++----- packages/cli/test/cli.test.js | 14 +++++++--- packages/expo-plugin/src/index.ts | 1 + .../src/utils/packageVersion.node.test.ts | 24 +++++++++++++++++ .../expo-plugin/src/utils/packageVersion.ts | 19 +++++++++++++ .../clientRenderedPrerender.node.test.ts | 7 +++++ .../src/ios-widget/clientRenderedPrerender.ts | 13 ++++++--- .../expo-plugin/src/ios-widget/files/index.ts | 5 +++- .../ios-widget/files/infoPlist.node.test.ts | 21 +++++++++++++++ .../src/ios-widget/files/infoPlist.ts | 20 +++++++++++--- .../ios/shared/VoltraConstants.swift | 1 + .../target/VoltraClientWidgetRuntime.swift | 4 ++- 19 files changed, 193 insertions(+), 40 deletions(-) create mode 100644 .changeset/dynamic-voltra-version.md create mode 100644 packages/expo-plugin/src/utils/packageVersion.node.test.ts create mode 100644 packages/expo-plugin/src/utils/packageVersion.ts create mode 100644 packages/ios-client/expo-plugin/src/ios-widget/files/infoPlist.node.test.ts diff --git a/.changeset/dynamic-voltra-version.md b/.changeset/dynamic-voltra-version.md new file mode 100644 index 00000000..300f0081 --- /dev/null +++ b/.changeset/dynamic-voltra-version.md @@ -0,0 +1,8 @@ +--- +'@use-voltra/android-client': patch +'@use-voltra/expo-plugin': patch +'@use-voltra/ios-client': patch +'voltra': patch +--- + +Dynamic Widget build environments now report the installed Voltra client package version instead of a stale hardcoded version. diff --git a/packages/android-client/android/build.gradle b/packages/android-client/android/build.gradle index c798c2bd..8888a389 100644 --- a/packages/android-client/android/build.gradle +++ b/packages/android-client/android/build.gradle @@ -44,7 +44,7 @@ android { minSdkVersion safeExtGet("minSdkVersion", 31) targetSdkVersion safeExtGet("targetSdkVersion", 36) versionCode 1 - versionName "0.1.0" + versionName voltraVersion buildConfigField "String", "VOLTRA_VERSION", "\"${voltraVersion}\"" // Standalone Hermes runtime for Dynamic Widgets, via custom JNI. diff --git a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.node.test.ts b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.node.test.ts index 5da00c3d..6bcaf6da 100644 --- a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.node.test.ts +++ b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.node.test.ts @@ -33,12 +33,18 @@ describe('prerenderClientRenderedAndroidWidgets', () => { it('evaluates the entry default export for a Dynamic Widget placeholder', async () => { const { projectRoot, cleanup } = makeTempProject({ + 'package.json': '{}\n', + 'node_modules/@use-voltra/android-client/package.json': JSON.stringify({ + name: '@use-voltra/android-client', + version: '9.8.7', + }), 'widgets/Dynamic.tsx': ` export default function NotTheWidgetId(props, env) { return { source: 'entry', widgetFamily: env.widgetFamily, hasProps: typeof props === 'object', + voltraVersion: env.build.voltraVersion, } } `, @@ -61,6 +67,7 @@ describe('prerenderClientRenderedAndroidWidgets', () => { expect(renderAndroidVariantToJson).toHaveBeenCalledTimes(1) expect(result.get('dynamic_widget')?.get('__default')).toContain('"source":"entry"') expect(result.get('dynamic_widget')?.get('__default')).toContain('"widgetFamily":"200x200"') + expect(result.get('dynamic_widget')?.get('__default')).toContain('"voltraVersion":"9.8.7"') } finally { cleanup() } diff --git a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts index 58965685..f8fc0fb9 100644 --- a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts +++ b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts @@ -1,4 +1,9 @@ -import { evaluateWidgetModuleExports, logger, type PrerenderedWidgetStates } from '@use-voltra/expo-plugin' +import { + evaluateWidgetModuleExports, + logger, + resolveInstalledPackageVersion, + type PrerenderedWidgetStates, +} from '@use-voltra/expo-plugin' import type { DetectedAndroidWidget } from '../clientRendered' @@ -17,7 +22,7 @@ import type { DetectedAndroidWidget } from '../clientRendered' const SINGLE_LOCALE_KEY = '__default' -function buildPlaceholderEnv(): Record { +function buildPlaceholderEnv(voltraVersion: string): Record { return { date: Date.now(), widgetFamily: '200x200', @@ -28,7 +33,7 @@ function buildPlaceholderEnv(): Record { isDev: false, metroUrl: null, appVersion: 'unknown', - voltraVersion: '1.4.1', + voltraVersion, }, } } @@ -52,7 +57,7 @@ export async function prerenderClientRenderedAndroidWidgets( renderAndroidVariantToJson: (element: unknown) => unknown } - const placeholderEnv = buildPlaceholderEnv() + const placeholderEnv = buildPlaceholderEnv(resolveInstalledPackageVersion(projectRoot, '@use-voltra/android-client')) for (const widget of clientWidgets) { try { diff --git a/packages/cli/src/dependencies/platformPackages.ts b/packages/cli/src/dependencies/platformPackages.ts index 6683e086..be50afe2 100644 --- a/packages/cli/src/dependencies/platformPackages.ts +++ b/packages/cli/src/dependencies/platformPackages.ts @@ -36,6 +36,17 @@ export function requirePlatformPackage(projectRoot: string, platform: return createProjectRequire(projectRoot)(getPlatformPackageName(platform)) as TPackage } +export function getPlatformClientPackageVersion(projectRoot: string, platform: VoltraPlatform): string { + const packageName = getPlatformClientPackageName(platform) + const manifest = createProjectRequire(projectRoot)(`${packageName}/package.json`) as { version?: unknown } + + if (typeof manifest.version !== 'string' || manifest.version.length === 0) { + throw new Error(`Package ${packageName} does not declare a valid version.`) + } + + return manifest.version +} + export function getMissingPlatformPackageMessage( platform: VoltraPlatform, packageNames = getRequiredPlatformPackageNames(platform) diff --git a/packages/cli/src/platforms/android/generated.ts b/packages/cli/src/platforms/android/generated.ts index 2ffdc381..94ea278d 100644 --- a/packages/cli/src/platforms/android/generated.ts +++ b/packages/cli/src/platforms/android/generated.ts @@ -4,11 +4,11 @@ import path from 'node:path' import { createRequire } from 'node:module' import { vdConvert } from 'vd-tool' -import { requirePlatformPackage } from '../../dependencies/platformPackages' +import { getPlatformClientPackageVersion, requirePlatformPackage } from '../../dependencies/platformPackages' import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite' import { normalizeRelativePath, toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' -import { DYNAMIC_WIDGET_BUILD_INFO, evaluateWidgetModuleExports } from '../shared/widgetModule' +import { createDynamicWidgetBuildInfo, evaluateWidgetModuleExports } from '../shared/widgetModule' import type { AndroidProjectDiscovery } from '../../discovery/android' import type { @@ -76,6 +76,7 @@ type DetectedAndroidWidget = export async function generateAndroidFiles(options: GenerateAndroidFilesOptions): Promise { const { projectRoot, android, discovery } = options + const voltraVersion = getPlatformClientPackageVersion(projectRoot, 'android') const resourceRoot = path.join(discovery.appModuleRoot, 'src', 'main') const detectedWidgets = detectClientRenderedWidgets(projectRoot, android.widgets) const changes: ReportedChange[] = [] @@ -104,7 +105,12 @@ export async function generateAndroidFiles(options: GenerateAndroidFilesOptions) const fontFiles = await copyAndroidFonts(projectRoot, resourceRoot, android.fonts) mergeResult(fontFiles, changes, warnings, generatedFiles) - const initialStateFiles = await generateAndroidInitialStates(projectRoot, resourceRoot, detectedWidgets) + const initialStateFiles = await generateAndroidInitialStates( + projectRoot, + resourceRoot, + detectedWidgets, + voltraVersion + ) mergeResult(initialStateFiles, changes, warnings, generatedFiles) const configDefaultsResult = await generateAndroidConfigDefaults(projectRoot, resourceRoot, detectedWidgets) @@ -409,7 +415,8 @@ async function copyAndroidFonts( async function generateAndroidInitialStates( projectRoot: string, resourceRoot: string, - widgets: DetectedAndroidWidget[] + widgets: DetectedAndroidWidget[], + voltraVersion: string ): Promise { const serverWidgets = widgets.filter( (widget): widget is Extract => !widget.clientRendered @@ -420,7 +427,7 @@ async function generateAndroidInitialStates( prerenderableServerWidgets, loadAndroidWidgetRenderer(projectRoot) ) - const clientStates = await prerenderClientRenderedAndroidWidgets(projectRoot, widgets) + const clientStates = await prerenderClientRenderedAndroidWidgets(projectRoot, widgets, voltraVersion) const prerenderedStates = new Map([...serverStates, ...clientStates]) if (prerenderedStates.size === 0) { @@ -568,7 +575,8 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedAndroidWidget async function prerenderClientRenderedAndroidWidgets( projectRoot: string, - widgets: DetectedAndroidWidget[] + widgets: DetectedAndroidWidget[], + voltraVersion: string ): Promise { const clientWidgets = widgets.filter( (widget): widget is Extract => widget.clientRendered @@ -585,7 +593,7 @@ async function prerenderClientRenderedAndroidWidgets( colorScheme: 'light', locale: 'en-US', configuration: undefined, - build: DYNAMIC_WIDGET_BUILD_INFO, + build: createDynamicWidgetBuildInfo(voltraVersion), } const prerenderedStates: PrerenderedWidgetStates = new Map() diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts index 5906aecb..9c6d5539 100644 --- a/packages/cli/src/platforms/ios/generated.ts +++ b/packages/cli/src/platforms/ios/generated.ts @@ -3,11 +3,11 @@ import fsPromises from 'node:fs/promises' import path from 'node:path' import { createRequire } from 'node:module' -import { requirePlatformPackage } from '../../dependencies/platformPackages' +import { getPlatformClientPackageVersion, requirePlatformPackage } from '../../dependencies/platformPackages' import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite' import { normalizeRelativePath, toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' -import { DYNAMIC_WIDGET_BUILD_INFO, evaluateWidgetModuleExports } from '../shared/widgetModule' +import { createDynamicWidgetBuildInfo, evaluateWidgetModuleExports } from '../shared/widgetModule' import { buildPlistXml, parsePlistFile } from './plist' import { resolveIOSWidgetTargetName } from './targetName' @@ -152,6 +152,7 @@ function createGeneratedFilesError(message: string): IOSGeneratedFilesError { export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promise { const { projectRoot, ios, discovery } = options + const voltraVersion = getPlatformClientPackageVersion(projectRoot, 'ios') const targetName = resolveIOSWidgetTargetName(ios, discovery) const targetPath = path.join(discovery.iosRoot, targetName) const detectedWidgets = detectClientRenderedWidgets(projectRoot, ios.widgets) @@ -176,7 +177,8 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis targetName, ios, mainAppMetadata, - detectedWidgets + detectedWidgets, + voltraVersion ) mergeSingleResult(infoPlistResult, changes, generatedFiles) @@ -189,7 +191,7 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis const fontsResult = await copyIOSFonts(projectRoot, targetPath, ios.fonts) mergeResult(fontsResult, changes, warnings, generatedFiles) - const initialStatesResult = await generateInitialStatesSwift(projectRoot, detectedWidgets) + const initialStatesResult = await generateInitialStatesSwift(projectRoot, detectedWidgets, voltraVersion) mergeSingleResult( await writeGeneratedTextFile( projectRoot, @@ -225,7 +227,8 @@ async function generateInfoPlistFile( targetName: string, ios: NormalizedVoltraIOSConfig, mainAppMetadata: MainAppMetadata, - widgets: DetectedIOSWidget[] + widgets: DetectedIOSWidget[], + voltraVersion: string ): Promise { const plistPath = path.join(targetPath, 'Info.plist') const fontNames = ios.fonts.map((fontPath) => path.basename(fontPath)).sort() @@ -257,6 +260,7 @@ async function generateInfoPlistFile( UIAppFonts: fontNames.length > 0 ? fontNames : undefined, Voltra_AppGroupIdentifier: ios.groupIdentifier, Voltra_KeychainGroup: ios.keychainGroup, + Voltra_Version: voltraVersion, Voltra_WidgetServerIntervals: Object.keys(serverIntervals).length > 0 ? serverIntervals : undefined, Voltra_WidgetServerRefresh: Object.keys(serverRefresh).length > 0 ? serverRefresh : undefined, NSAppTransportSecurity: createWidgetAppTransportSecurity(hasClientRenderedWidget, serverWidgets.length > 0), @@ -435,7 +439,11 @@ async function readMainAppMetadata(infoPlistPath: string): Promise { +async function generateInitialStatesSwift( + projectRoot: string, + widgets: DetectedIOSWidget[], + voltraVersion: string +): Promise { const serverWidgets = widgets.filter( (widget): widget is Extract => !widget.clientRendered ) @@ -445,7 +453,7 @@ async function generateInitialStatesSwift(projectRoot: string, widgets: Detected prerenderableServerWidgets, loadIOSWidgetRenderer(projectRoot) ) - const clientStates = await prerenderClientRenderedWidgets(projectRoot, widgets) + const clientStates = await prerenderClientRenderedWidgets(projectRoot, widgets, voltraVersion) const prerenderedStates = new Map([...serverStates, ...clientStates]) if (prerenderedStates.size === 0) { @@ -864,7 +872,8 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedIOSWidgetConf async function prerenderClientRenderedWidgets( projectRoot: string, - widgets: DetectedIOSWidget[] + widgets: DetectedIOSWidget[], + voltraVersion: string ): Promise { const clientWidgets = widgets.filter( (widget): widget is Extract => widget.clientRendered @@ -883,7 +892,7 @@ async function prerenderClientRenderedWidgets( widgetRenderingMode: 'fullColor', showsWidgetContainerBackground: true, configuration: undefined, - build: DYNAMIC_WIDGET_BUILD_INFO, + build: createDynamicWidgetBuildInfo(voltraVersion), } const prerenderedStates: PrerenderedWidgetStates = new Map() diff --git a/packages/cli/src/platforms/shared/widgetModule.ts b/packages/cli/src/platforms/shared/widgetModule.ts index 0ddc8213..2402377b 100644 --- a/packages/cli/src/platforms/shared/widgetModule.ts +++ b/packages/cli/src/platforms/shared/widgetModule.ts @@ -11,14 +11,16 @@ export interface WidgetModuleBuildInfo { isDev: false metroUrl: null appVersion: 'unknown' - voltraVersion: '1.4.1' + voltraVersion: string } -export const DYNAMIC_WIDGET_BUILD_INFO: WidgetModuleBuildInfo = { - isDev: false, - metroUrl: null, - appVersion: 'unknown', - voltraVersion: '1.4.1', +export function createDynamicWidgetBuildInfo(voltraVersion: string): WidgetModuleBuildInfo { + return { + isDev: false, + metroUrl: null, + appVersion: 'unknown', + voltraVersion, + } } export function evaluateWidgetModuleExports( diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index f3cc95b5..6bda4f00 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -15,10 +15,10 @@ function loadIosMainAppEntitlementsModule() { return require(path.join(packageRoot, 'build/cjs/platforms/ios/mainAppEntitlements.js')) } -function writeFakePackage(projectRoot, packageName) { +function writeFakePackage(projectRoot, packageName, version = '0.0.0') { const packagePath = path.join(projectRoot, 'node_modules', ...packageName.split('/'), 'package.json') fs.mkdirSync(path.dirname(packagePath), { recursive: true }) - fs.writeFileSync(packagePath, `${JSON.stringify({ name: packageName, version: '0.0.0' }, null, 2)}\n`) + fs.writeFileSync(packagePath, `${JSON.stringify({ name: packageName, version }, null, 2)}\n`) } function writeFakeModule(projectRoot, packageName, source) { @@ -453,12 +453,13 @@ test('generateIOSFiles writes Dynamic Widget manifest and AppIntent Swift scaffo } ` ) + writeFakePackage(tempDir, '@use-voltra/ios-client', '9.8.7') fs.mkdirSync(path.dirname(legacyStatePath), { recursive: true }) fs.writeFileSync(legacyStatePath, `module.exports = { systemSmall: { title: 'Legacy' } }\n`) fs.writeFileSync( dynamicEntryPath, `module.exports = function Widget(_props, env) { - return { family: env.widgetFamily, label: env.configuration ?? null } + return { family: env.widgetFamily, label: env.configuration ?? null, version: env.build.voltraVersion } } ` ) @@ -525,7 +526,9 @@ test('generateIOSFiles writes Dynamic Widget manifest and AppIntent Swift scaffo const initialStatesSwift = fs.readFileSync(path.join(result.targetPath, 'VoltraWidgetInitialStates.swift'), 'utf8') assert.match(initialStatesSwift, /"legacy"/) assert.match(initialStatesSwift, /"dynamic"/) + assert.match(initialStatesSwift, /9\.8\.7/) const infoPlist = fs.readFileSync(path.join(result.targetPath, 'Info.plist'), 'utf8') + assert.match(infoPlist, /Voltra_Version<\/key>\s*9\.8\.7<\/string>/) assert.match(infoPlist, /Voltra_WidgetServerUrls/) assert.match(infoPlist, /https:\/\/example\.com\/widget/) assert.match(infoPlist, /NSAppTransportSecurity/) @@ -558,12 +561,13 @@ test('generateAndroidFiles writes Dynamic Widget manifest, client receiver, and } ` ) + writeFakePackage(tempDir, '@use-voltra/android-client', '9.8.7') fs.mkdirSync(path.dirname(legacyStatePath), { recursive: true }) fs.writeFileSync(legacyStatePath, `module.exports = { small: { title: 'Legacy' } }\n`) fs.writeFileSync( dynamicEntryPath, `module.exports = function Widget(_props, env) { - return { family: env.widgetFamily, label: env.configuration ?? null } + return { family: env.widgetFamily, label: env.configuration ?? null, version: env.build.voltraVersion } } ` ) @@ -630,6 +634,7 @@ test('generateAndroidFiles writes Dynamic Widget manifest, client receiver, and ) assert.ok(initialStates.legacy) assert.ok(initialStates.dynamic) + assert.equal(initialStates.dynamic.element.version, '9.8.7') assert.ok(result.files.includes('.voltra/manifest.android.json')) }) @@ -654,6 +659,7 @@ test('Dynamic Widget entry must default-export a function or component', async ( } ` ) + writeFakePackage(tempDir, '@use-voltra/ios-client', '9.8.7') fs.mkdirSync(path.join(tempDir, 'widgets'), { recursive: true }) fs.writeFileSync(path.join(tempDir, 'widgets', 'bad.js'), 'module.exports = { nope: true }\n') writeInfoPlist(infoPlistPath) diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts index 8968f78f..a07e0e44 100644 --- a/packages/expo-plugin/src/index.ts +++ b/packages/expo-plugin/src/index.ts @@ -20,6 +20,7 @@ export { addApplicationGroupsEntitlement } from './utils/entitlements' export { resolveFontPaths } from './utils/fonts' export { normalizeLocaleTag, pickLocalizedValue } from './utils/localePick' export { logger } from './utils/logger' +export { resolveInstalledPackageVersion } from './utils/packageVersion' export type { PrerenderableWidget, PrerenderedWidgetStates, WidgetRenderer } from './utils/prerender' export { evaluateWidgetModule, evaluateWidgetModuleExports, prerenderWidgetState } from './utils/prerender' export { isWidgetLocalizedMap, widgetLabelEnglish } from './utils/widgetLabel' diff --git a/packages/expo-plugin/src/utils/packageVersion.node.test.ts b/packages/expo-plugin/src/utils/packageVersion.node.test.ts new file mode 100644 index 00000000..5cf847fa --- /dev/null +++ b/packages/expo-plugin/src/utils/packageVersion.node.test.ts @@ -0,0 +1,24 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import { resolveInstalledPackageVersion } from './packageVersion' + +describe('resolveInstalledPackageVersion', () => { + it('reads the version from the package installed in the project', () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-package-version-')) + const packageDir = path.join(projectRoot, 'node_modules', '@use-voltra', 'ios-client') + fs.mkdirSync(packageDir, { recursive: true }) + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}\n') + fs.writeFileSync( + path.join(packageDir, 'package.json'), + `${JSON.stringify({ name: '@use-voltra/ios-client', version: '9.8.7' })}\n` + ) + + try { + expect(resolveInstalledPackageVersion(projectRoot, '@use-voltra/ios-client')).toBe('9.8.7') + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/expo-plugin/src/utils/packageVersion.ts b/packages/expo-plugin/src/utils/packageVersion.ts new file mode 100644 index 00000000..6ce8a4ff --- /dev/null +++ b/packages/expo-plugin/src/utils/packageVersion.ts @@ -0,0 +1,19 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { createRequire } from 'node:module' + +interface PackageManifest { + version?: unknown +} + +export function resolveInstalledPackageVersion(projectRoot: string, packageName: string): string { + const projectRequire = createRequire(path.join(projectRoot, 'package.json')) + const packageJsonPath = projectRequire.resolve(`${packageName}/package.json`) + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageManifest + + if (typeof manifest.version !== 'string' || manifest.version.length === 0) { + throw new Error(`Package ${packageName} does not declare a valid version in ${packageJsonPath}.`) + } + + return manifest.version +} diff --git a/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.node.test.ts index 279bf691..e8016169 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.node.test.ts @@ -33,12 +33,18 @@ describe('prerenderClientRenderedWidgets', () => { it('evaluates the entry default export for a Dynamic Widget placeholder', async () => { const { projectRoot, cleanup } = makeTempProject({ + 'package.json': '{}\n', + 'node_modules/@use-voltra/ios-client/package.json': JSON.stringify({ + name: '@use-voltra/ios-client', + version: '9.8.7', + }), 'widgets/Dynamic.tsx': ` export default function NotTheWidgetId(props, env) { return { source: 'entry', widgetFamily: env.widgetFamily, hasProps: typeof props === 'object', + voltraVersion: env.build.voltraVersion, } } `, @@ -59,6 +65,7 @@ describe('prerenderClientRenderedWidgets', () => { expect(renderVoltraVariantToJson).toHaveBeenCalledTimes(1) expect(result.get('dynamic_widget')?.get('__default')).toContain('"source":"entry"') expect(result.get('dynamic_widget')?.get('__default')).toContain('"widgetFamily":"systemMedium"') + expect(result.get('dynamic_widget')?.get('__default')).toContain('"voltraVersion":"9.8.7"') } finally { cleanup() } diff --git a/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts b/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts index 81d5fd28..0a3db79e 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts @@ -1,4 +1,9 @@ -import { evaluateWidgetModuleExports, logger, type PrerenderedWidgetStates } from '@use-voltra/expo-plugin' +import { + evaluateWidgetModuleExports, + logger, + resolveInstalledPackageVersion, + type PrerenderedWidgetStates, +} from '@use-voltra/expo-plugin' import type { DetectedIOSWidget } from './clientRendered' @@ -31,7 +36,7 @@ const SINGLE_LOCALE_KEY = '__default' * mirror `WidgetEnvironment` from packages/core/src/widget-environment.ts so the widget * function sees the same shape it gets at runtime. */ -function buildPlaceholderEnv(): Record { +function buildPlaceholderEnv(voltraVersion: string): Record { return { date: Date.now(), widgetFamily: 'systemMedium', @@ -44,7 +49,7 @@ function buildPlaceholderEnv(): Record { isDev: false, metroUrl: null, appVersion: 'unknown', - voltraVersion: '1.4.1', + voltraVersion, }, } } @@ -75,7 +80,7 @@ export async function prerenderClientRenderedWidgets( renderVoltraVariantToJson: (element: unknown) => unknown } - const placeholderEnv = buildPlaceholderEnv() + const placeholderEnv = buildPlaceholderEnv(resolveInstalledPackageVersion(projectRoot, '@use-voltra/ios-client')) for (const widget of clientWidgets) { try { 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 f4d9f734..d2bfe643 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 { resolveInstalledPackageVersion } from '@use-voltra/expo-plugin' + import type { IOSWidgetConfig } from '../../types' import { generateAssets } from './assets' import { generateEntitlements } from './entitlements' @@ -42,6 +44,7 @@ export const generateWidgetExtensionFiles: ConfigPlugin { + it('writes the app and Voltra package versions separately', () => { + const targetPath = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-widget-plist-')) + + try { + generateInfoPlist(targetPath, 'ExampleWidget', '1.2.3', '42', '9.8.7') + + const plist = fs.readFileSync(path.join(targetPath, 'Info.plist'), 'utf8') + expect(plist).toContain('CFBundleShortVersionString\n\t1.2.3') + expect(plist).toContain('Voltra_Version\n\t9.8.7') + } finally { + fs.rmSync(targetPath, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/infoPlist.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/infoPlist.ts index c4ee05f2..b244129b 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/infoPlist.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/infoPlist.ts @@ -9,7 +9,12 @@ import { logger } from '@use-voltra/expo-plugin' * This includes version information and the required WidgetKit extension point. * Version keys are written directly to the plist following the Expo pattern. */ -function generateInfoPlistContent(targetName: string, version: string, buildNumber: string): string { +function generateInfoPlistContent( + targetName: string, + version: string, + buildNumber: string, + voltraVersion: string +): string { return ` @@ -32,6 +37,8 @@ function generateInfoPlistContent(targetName: string, version: string, buildNumb ${version} CFBundleVersion ${buildNumber} + Voltra_Version + ${voltraVersion} NSExtension NSExtensionPointIdentifier @@ -49,9 +56,16 @@ function generateInfoPlistContent(targetName: string, version: string, buildNumb * @param targetName - The widget extension target name (used for CFBundleDisplayName) * @param version - The app version (CFBundleShortVersionString) * @param buildNumber - The build number (CFBundleVersion) + * @param voltraVersion - The installed @use-voltra/ios-client package version */ -export function generateInfoPlist(targetPath: string, targetName: string, version: string, buildNumber: string): void { +export function generateInfoPlist( + targetPath: string, + targetName: string, + version: string, + buildNumber: string, + voltraVersion: string +): void { const infoPlistPath = path.join(targetPath, 'Info.plist') - fs.writeFileSync(infoPlistPath, generateInfoPlistContent(targetName, version, buildNumber)) + fs.writeFileSync(infoPlistPath, generateInfoPlistContent(targetName, version, buildNumber, voltraVersion)) logger.info('Generated Info.plist') } diff --git a/packages/ios-client/ios/shared/VoltraConstants.swift b/packages/ios-client/ios/shared/VoltraConstants.swift index 8b42e725..a3b3a3b4 100644 --- a/packages/ios-client/ios/shared/VoltraConstants.swift +++ b/packages/ios-client/ios/shared/VoltraConstants.swift @@ -50,4 +50,5 @@ public enum VoltraStorageKeys { public static let appGroupIdentifier = "Voltra_AppGroupIdentifier" public static let legacyAppGroupIdentifier = "AppGroupIdentifier" public static let keychainGroup = "Voltra_KeychainGroup" + public static let voltraVersion = "Voltra_Version" } diff --git a/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift b/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift index c4e24cce..3a98cca4 100644 --- a/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift +++ b/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift @@ -238,6 +238,8 @@ public enum VoltraClientWidgetEnvBuilder { ) -> String { let timestampMs = Int(date.timeIntervalSince1970 * 1000) let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let voltraVersion = + Bundle.main.object(forInfoDictionaryKey: VoltraStorageKeys.voltraVersion) as? String ?? "unknown" #if DEBUG let isDev = true @@ -253,7 +255,7 @@ public enum VoltraClientWidgetEnvBuilder { "isDev": \(isDev), "metroUrl": \(metroUrlLiteral), "appVersion": \(jsonString(appVersion)), - "voltraVersion": \(jsonString("1.4.1")) + "voltraVersion": \(jsonString(voltraVersion)) } """