Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/dynamic-voltra-version.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/android-client/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
`,
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -17,7 +22,7 @@ import type { DetectedAndroidWidget } from '../clientRendered'

const SINGLE_LOCALE_KEY = '__default'

function buildPlaceholderEnv(): Record<string, unknown> {
function buildPlaceholderEnv(voltraVersion: string): Record<string, unknown> {
return {
date: Date.now(),
widgetFamily: '200x200',
Expand All @@ -28,7 +33,7 @@ function buildPlaceholderEnv(): Record<string, unknown> {
isDev: false,
metroUrl: null,
appVersion: 'unknown',
voltraVersion: '1.4.1',
voltraVersion,
},
}
}
Expand All @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/dependencies/platformPackages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export function requirePlatformPackage<TPackage>(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)
Expand Down
22 changes: 15 additions & 7 deletions packages/cli/src/platforms/android/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -76,6 +76,7 @@ type DetectedAndroidWidget =

export async function generateAndroidFiles(options: GenerateAndroidFilesOptions): Promise<GenerateAndroidFilesResult> {
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[] = []
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -409,7 +415,8 @@ async function copyAndroidFonts(
async function generateAndroidInitialStates(
projectRoot: string,
resourceRoot: string,
widgets: DetectedAndroidWidget[]
widgets: DetectedAndroidWidget[],
voltraVersion: string
): Promise<GenerateAndroidFilesResult> {
const serverWidgets = widgets.filter(
(widget): widget is Extract<DetectedAndroidWidget, { clientRendered: false }> => !widget.clientRendered
Expand All @@ -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) {
Expand Down Expand Up @@ -568,7 +575,8 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedAndroidWidget

async function prerenderClientRenderedAndroidWidgets(
projectRoot: string,
widgets: DetectedAndroidWidget[]
widgets: DetectedAndroidWidget[],
voltraVersion: string
): Promise<PrerenderedWidgetStates> {
const clientWidgets = widgets.filter(
(widget): widget is Extract<DetectedAndroidWidget, { clientRendered: true }> => widget.clientRendered
Expand All @@ -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()

Expand Down
27 changes: 18 additions & 9 deletions packages/cli/src/platforms/ios/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -152,6 +152,7 @@ function createGeneratedFilesError(message: string): IOSGeneratedFilesError {

export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promise<GenerateIOSFilesResult> {
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)
Expand All @@ -176,7 +177,8 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis
targetName,
ios,
mainAppMetadata,
detectedWidgets
detectedWidgets,
voltraVersion
)
mergeSingleResult(infoPlistResult, changes, generatedFiles)

Expand All @@ -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,
Expand Down Expand Up @@ -225,7 +227,8 @@ async function generateInfoPlistFile(
targetName: string,
ios: NormalizedVoltraIOSConfig,
mainAppMetadata: MainAppMetadata,
widgets: DetectedIOSWidget[]
widgets: DetectedIOSWidget[],
voltraVersion: string
): Promise<GeneratedFileResult> {
const plistPath = path.join(targetPath, 'Info.plist')
const fontNames = ios.fonts.map((fontPath) => path.basename(fontPath)).sort()
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -435,7 +439,11 @@ async function readMainAppMetadata(infoPlistPath: string): Promise<MainAppMetada
}
}

async function generateInitialStatesSwift(projectRoot: string, widgets: DetectedIOSWidget[]): Promise<string> {
async function generateInitialStatesSwift(
projectRoot: string,
widgets: DetectedIOSWidget[],
voltraVersion: string
): Promise<string> {
const serverWidgets = widgets.filter(
(widget): widget is Extract<DetectedIOSWidget, { clientRendered: false }> => !widget.clientRendered
)
Expand All @@ -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) {
Expand Down Expand Up @@ -864,7 +872,8 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedIOSWidgetConf

async function prerenderClientRenderedWidgets(
projectRoot: string,
widgets: DetectedIOSWidget[]
widgets: DetectedIOSWidget[],
voltraVersion: string
): Promise<PrerenderedWidgetStates> {
const clientWidgets = widgets.filter(
(widget): widget is Extract<DetectedIOSWidget, { clientRendered: true }> => widget.clientRendered
Expand All @@ -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()

Expand Down
14 changes: 8 additions & 6 deletions packages/cli/src/platforms/shared/widgetModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 10 additions & 4 deletions packages/cli/test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }
}
`
)
Expand Down Expand Up @@ -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, /<key>Voltra_Version<\/key>\s*<string>9\.8\.7<\/string>/)
assert.match(infoPlist, /Voltra_WidgetServerUrls/)
assert.match(infoPlist, /https:\/\/example\.com\/widget/)
assert.match(infoPlist, /NSAppTransportSecurity/)
Expand Down Expand Up @@ -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 }
}
`
)
Expand Down Expand Up @@ -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'))
})

Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/expo-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
24 changes: 24 additions & 0 deletions packages/expo-plugin/src/utils/packageVersion.node.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
})
Loading
Loading